feat(case): persist source audio for learning detail

This commit is contained in:
2026-07-14 05:51:45 +08:00
parent 9bbfd575a3
commit 9ace0cb5c9
10 changed files with 133 additions and 20 deletions
@@ -10,7 +10,8 @@ public final class AihrCaseDto {
public record UploadRequest(String fileName, String projectExtOrgId) { public record UploadRequest(String fileName, String projectExtOrgId) {
} }
public record UploadResponse(String caseId, String fileName, String transcript, List<TagResponse> tags, String source) { public record UploadResponse(String caseId, String fileName, String transcript, List<TagResponse> tags, String source,
String mediaUrl, Long mediaOssId) {
} }
public record OrganizeRequest(String caseId) { public record OrganizeRequest(String caseId) {
@@ -38,6 +39,7 @@ public final class AihrCaseDto {
} }
public record DetailResponse(String caseId, String fileName, String title, String type, String status, public record DetailResponse(String caseId, String fileName, String title, String type, String status,
String time, String owner, String transcript, String summary) { String time, String owner, String transcript, String summary,
String mediaUrl, Long mediaOssId) {
} }
} }
@@ -13,6 +13,8 @@ import org.dromara.aihr.domain.AihrCaseDto.RecordResponse;
import org.dromara.aihr.domain.AihrCaseDto.SummaryResponse; import org.dromara.aihr.domain.AihrCaseDto.SummaryResponse;
import org.dromara.aihr.domain.AihrCaseDto.TagResponse; import org.dromara.aihr.domain.AihrCaseDto.TagResponse;
import org.dromara.aihr.domain.AihrCaseDto.UploadResponse; import org.dromara.aihr.domain.AihrCaseDto.UploadResponse;
import org.dromara.system.domain.vo.SysOssVo;
import org.dromara.system.service.ISysOssService;
import org.springframework.dao.DataAccessException; import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -40,6 +42,7 @@ public class AihrCaseService {
private final AihrModelSeedService modelService; private final AihrModelSeedService modelService;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
private final ISysOssService ossService;
private final Map<String, CaseState> cases = new ConcurrentHashMap<>(); private final Map<String, CaseState> cases = new ConcurrentHashMap<>();
private volatile boolean tableReady; private volatile boolean tableReady;
@@ -54,11 +57,13 @@ public class AihrCaseService {
String transcript = speechService.transcribe(file.getBytes(), fileName, contentType) String transcript = speechService.transcribe(file.getBytes(), fileName, contentType)
.orElseThrow(() -> new IllegalStateException("ASR 未配置或转写失败")); .orElseThrow(() -> new IllegalStateException("ASR 未配置或转写失败"));
transcript = maskSensitiveText(transcript); transcript = maskSensitiveText(transcript);
MediaRef media = storeSourceAudio(file);
String caseId = "case-" + UUID.randomUUID(); String caseId = "case-" + UUID.randomUUID();
CaseState state = new CaseState(caseId, fileName, scopedProject, transcript, tagsFromText(transcript), null); CaseState state = new CaseState(caseId, fileName, scopedProject, transcript, tagsFromText(transcript), null,
media.ossId(), media.url());
cases.put(caseId, state); cases.put(caseId, state);
saveCase(state, "已转写", caseTitle(List.of(), fileName), transcript); saveCase(state, "已转写", caseTitle(List.of(), fileName), transcript);
return new UploadResponse(caseId, fileName, transcript, state.tags(), "real-asr"); return new UploadResponse(caseId, fileName, transcript, state.tags(), "real-asr", media.url(), media.ossId());
} catch (IllegalStateException e) { } catch (IllegalStateException e) {
throw e; throw e;
} catch (Exception e) { } catch (Exception e) {
@@ -70,7 +75,8 @@ public class AihrCaseService {
public OrganizeResponse organize(OrganizeRequest request, List<String> projectScopes) { public OrganizeResponse organize(OrganizeRequest request, List<String> projectScopes) {
CaseState state = requireCase(request == null ? null : request.caseId(), projectScopes); CaseState state = requireCase(request == null ? null : request.caseId(), projectScopes);
CaseSummary summary = organizeWithModel(state).orElseGet(() -> localSummary(state)); CaseSummary summary = organizeWithModel(state).orElseGet(() -> localSummary(state));
CaseState next = new CaseState(state.id(), state.fileName(), state.projectExtOrgId(), state.transcript(), summary.tags(), summary.summary()); CaseState next = new CaseState(state.id(), state.fileName(), state.projectExtOrgId(), state.transcript(), summary.tags(), summary.summary(),
state.mediaOssId(), state.mediaUrl());
cases.put(state.id(), next); cases.put(state.id(), next);
saveCase(next, "已整理", caseTitle(summary.summary(), state.fileName()), summary.aiSummary()); saveCase(next, "已整理", caseTitle(summary.summary(), state.fileName()), summary.aiSummary());
return new OrganizeResponse(state.id(), summary.summary(), summary.tags(), summary.aiSummary(), summary.source()); return new OrganizeResponse(state.id(), summary.summary(), summary.tags(), summary.aiSummary(), summary.source());
@@ -142,7 +148,7 @@ public class AihrCaseService {
args.addAll(projectScopes); args.addAll(projectScopes);
} }
String sql = """ String sql = """
SELECT case_id, file_name, title, type, status, update_time, owner, transcript, summary SELECT case_id, file_name, title, type, status, update_time, owner, transcript, summary, media_url, media_oss_id
FROM aihr_case_record FROM aihr_case_record
WHERE tenant_id = '000000' AND case_id = ? WHERE tenant_id = '000000' AND case_id = ?
""" + projectWhere + " LIMIT 1"; """ + projectWhere + " LIMIT 1";
@@ -155,7 +161,9 @@ public class AihrCaseService {
rs.getTimestamp("update_time") == null ? "" : rs.getTimestamp("update_time").toLocalDateTime().format(TIME_FORMAT), rs.getTimestamp("update_time") == null ? "" : rs.getTimestamp("update_time").toLocalDateTime().format(TIME_FORMAT),
firstNonBlank(rs.getString("owner"), "培训组"), firstNonBlank(rs.getString("owner"), "培训组"),
firstNonBlank(rs.getString("transcript"), ""), firstNonBlank(rs.getString("transcript"), ""),
firstNonBlank(rs.getString("summary"), "") firstNonBlank(rs.getString("summary"), ""),
firstNonBlank(rs.getString("media_url"), ""),
rs.getObject("media_oss_id", Long.class)
), args.toArray()).stream().findFirst().orElse(null); ), args.toArray()).stream().findFirst().orElse(null);
} }
@@ -210,8 +218,8 @@ public class AihrCaseService {
ensureCaseTable(); ensureCaseTable();
jdbcTemplate.update(""" jdbcTemplate.update("""
INSERT INTO aihr_case_record INSERT INTO aihr_case_record
(tenant_id, case_id, file_name, project_ext_org_id, title, type, status, transcript, summary, owner, create_time, update_time) (tenant_id, case_id, file_name, project_ext_org_id, title, type, status, transcript, summary, media_oss_id, media_url, owner, create_time, update_time)
VALUES ('000000', ?, ?, ?, ?, ?, ?, ?, ?, '培训组', now(), now()) VALUES ('000000', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '培训组', now(), now())
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
file_name = VALUES(file_name), file_name = VALUES(file_name),
project_ext_org_id = VALUES(project_ext_org_id), project_ext_org_id = VALUES(project_ext_org_id),
@@ -220,6 +228,8 @@ public class AihrCaseService {
status = VALUES(status), status = VALUES(status),
transcript = VALUES(transcript), transcript = VALUES(transcript),
summary = VALUES(summary), summary = VALUES(summary),
media_oss_id = VALUES(media_oss_id),
media_url = VALUES(media_url),
update_time = now() update_time = now()
""", """,
state.id(), state.id(),
@@ -229,7 +239,9 @@ public class AihrCaseService {
primaryTag(state.tags()), primaryTag(state.tags()),
status, status,
truncate(maskSensitiveText(state.transcript()), 4000), truncate(maskSensitiveText(state.transcript()), 4000),
truncate(maskSensitiveText(summary), 1000) truncate(maskSensitiveText(summary), 1000),
state.mediaOssId(),
truncate(state.mediaUrl(), 500)
); );
} }
@@ -253,6 +265,8 @@ public class AihrCaseService {
`status` varchar(30) DEFAULT '已转写' COMMENT '状态', `status` varchar(30) DEFAULT '已转写' COMMENT '状态',
`transcript` text DEFAULT NULL COMMENT 'ASR转写', `transcript` text DEFAULT NULL COMMENT 'ASR转写',
`summary` varchar(1000) DEFAULT NULL COMMENT '摘要', `summary` varchar(1000) DEFAULT NULL COMMENT '摘要',
`media_oss_id` bigint DEFAULT NULL COMMENT '原始音频OSS文件ID',
`media_url` varchar(500) DEFAULT NULL COMMENT '原始音频访问地址',
`owner` varchar(80) DEFAULT '培训组' COMMENT '负责人', `owner` varchar(80) DEFAULT '培训组' COMMENT '负责人',
`create_time` datetime DEFAULT NULL COMMENT '创建时间', `create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间', `update_time` datetime DEFAULT NULL COMMENT '更新时间',
@@ -261,6 +275,8 @@ public class AihrCaseService {
KEY `idx_aihr_case_record_status` (`tenant_id`, `status`, `update_time`) KEY `idx_aihr_case_record_status` (`tenant_id`, `status`, `update_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 案例沉淀记录'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 案例沉淀记录';
"""); """);
addCaseColumn("media_oss_id", "ALTER TABLE aihr_case_record ADD COLUMN media_oss_id bigint DEFAULT NULL COMMENT '原始音频OSS文件ID'");
addCaseColumn("media_url", "ALTER TABLE aihr_case_record ADD COLUMN media_url varchar(500) DEFAULT NULL COMMENT '原始音频访问地址'");
tableReady = true; tableReady = true;
} }
} }
@@ -368,6 +384,7 @@ public class AihrCaseService {
ensureCaseTable(); ensureCaseTable();
List<CaseState> persisted = jdbcTemplate.query(""" List<CaseState> persisted = jdbcTemplate.query("""
SELECT case_id, file_name, project_ext_org_id, transcript, type SELECT case_id, file_name, project_ext_org_id, transcript, type
, media_oss_id, media_url
FROM aihr_case_record FROM aihr_case_record
WHERE tenant_id = '000000' AND case_id = ? WHERE tenant_id = '000000' AND case_id = ?
LIMIT 1 LIMIT 1
@@ -377,7 +394,9 @@ public class AihrCaseService {
firstNonBlank(rs.getString("project_ext_org_id"), ""), firstNonBlank(rs.getString("project_ext_org_id"), ""),
firstNonBlank(rs.getString("transcript"), ""), firstNonBlank(rs.getString("transcript"), ""),
tagsFromText(firstNonBlank(rs.getString("type"), "现场案例")), tagsFromText(firstNonBlank(rs.getString("type"), "现场案例")),
null null,
rs.getObject("media_oss_id", Long.class),
firstNonBlank(rs.getString("media_url"), "")
), normalizedCaseId); ), normalizedCaseId);
state = persisted.stream().findFirst().orElse(null); state = persisted.stream().findFirst().orElse(null);
if (state != null) { if (state != null) {
@@ -397,6 +416,23 @@ public class AihrCaseService {
return String.join(",", Collections.nCopies(size, "?")); return String.join(",", Collections.nCopies(size, "?"));
} }
private MediaRef storeSourceAudio(MultipartFile file) {
// Unit tests can construct this service without the system OSS bean; production always wires it.
if (ossService == null) {
return new MediaRef(null, "");
}
SysOssVo oss = ossService.upload(file);
return new MediaRef(oss.getOssId(), firstNonBlank(oss.getUrl(), ""));
}
private void addCaseColumn(String column, String sql) {
try {
jdbcTemplate.execute(sql);
} catch (DataAccessException e) {
log.debug("case table column already exists or cannot be altered: {}", column);
}
}
private static List<TagResponse> tagsFromText(String text) { private static List<TagResponse> tagsFromText(String text) {
String source = clean(text); String source = clean(text);
List<TagResponse> tags = new ArrayList<>(); List<TagResponse> tags = new ArrayList<>();
@@ -477,10 +513,15 @@ public class AihrCaseService {
String projectExtOrgId, String projectExtOrgId,
String transcript, String transcript,
List<TagResponse> tags, List<TagResponse> tags,
List<SummaryResponse> summary List<SummaryResponse> summary,
Long mediaOssId,
String mediaUrl
) { ) {
} }
private record MediaRef(Long ossId, String url) {
}
private record CaseSummary(List<SummaryResponse> summary, List<TagResponse> tags, String aiSummary, String source) { private record CaseSummary(List<SummaryResponse> summary, List<TagResponse> tags, String aiSummary, String source) {
} }
} }
@@ -2,6 +2,8 @@ package org.dromara.aihr.service;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.aihr.domain.AihrCaseDto.UploadResponse; import org.dromara.aihr.domain.AihrCaseDto.UploadResponse;
import org.dromara.system.domain.vo.SysOssVo;
import org.dromara.system.service.ISysOssService;
import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
@@ -32,7 +34,7 @@ class AihrCaseServiceTest {
} }
}; };
CaseJdbcTemplate jdbcTemplate = new CaseJdbcTemplate(); CaseJdbcTemplate jdbcTemplate = new CaseJdbcTemplate();
AihrCaseService service = new AihrCaseService(speechService, null, new ObjectMapper(), jdbcTemplate); AihrCaseService service = new AihrCaseService(speechService, null, new ObjectMapper(), jdbcTemplate, null);
UploadResponse response = service.upload(new MockMultipartFile("file", "case.webm", "audio/webm", new byte[]{1}), "P1", null); UploadResponse response = service.upload(new MockMultipartFile("file", "case.webm", "audio/webm", new byte[]{1}), "P1", null);
@@ -44,11 +46,36 @@ class AihrCaseServiceTest {
assertFalse(response.transcript().contains("2-304")); assertFalse(response.transcript().contains("2-304"));
} }
@Test
@Tag("dev")
void uploadPersistsSourceAudioReferenceWhenOssIsAvailable() {
AihrSpeechService speechService = new AihrSpeechService(null, new ObjectMapper()) {
@Override
public Optional<String> transcribe(byte[] audio, String filename, String contentType) {
return Optional.of("现场投诉已完成跟进");
}
};
SysOssVo oss = new SysOssVo();
oss.setOssId(123L);
oss.setUrl("https://oss.local/cases/case.webm");
ISysOssService ossService = mock(ISysOssService.class);
when(ossService.upload(org.mockito.ArgumentMatchers.any(org.springframework.web.multipart.MultipartFile.class))).thenReturn(oss);
CaseJdbcTemplate jdbcTemplate = new CaseJdbcTemplate();
AihrCaseService service = new AihrCaseService(speechService, null, new ObjectMapper(), jdbcTemplate, ossService);
UploadResponse response = service.upload(new MockMultipartFile("file", "case.webm", "audio/webm", new byte[]{1}), "P1", null);
assertEquals(123L, response.mediaOssId());
assertEquals("https://oss.local/cases/case.webm", response.mediaUrl());
assertEquals(123L, jdbcTemplate.updateArgs[8]);
assertEquals("https://oss.local/cases/case.webm", jdbcTemplate.updateArgs[9]);
}
@Test @Test
@Tag("dev") @Tag("dev")
void curateCanReloadCaseAfterServiceRestart() { void curateCanReloadCaseAfterServiceRestart() {
PersistentCaseJdbcTemplate jdbcTemplate = new PersistentCaseJdbcTemplate(); PersistentCaseJdbcTemplate jdbcTemplate = new PersistentCaseJdbcTemplate();
AihrCaseService service = new AihrCaseService(null, null, new ObjectMapper(), jdbcTemplate); AihrCaseService service = new AihrCaseService(null, null, new ObjectMapper(), jdbcTemplate, null);
var response = service.curate(new org.dromara.aihr.domain.AihrCaseDto.CurateRequest( var response = service.curate(new org.dromara.aihr.domain.AihrCaseDto.CurateRequest(
"persisted-case", "可复用培训案例", 1 "persisted-case", "可复用培训案例", 1
@@ -65,7 +92,7 @@ class AihrCaseServiceTest {
@Tag("dev") @Tag("dev")
void detailReturnsPersistedCaseForAuthorizedProject() { void detailReturnsPersistedCaseForAuthorizedProject() {
PersistentCaseJdbcTemplate jdbcTemplate = new PersistentCaseJdbcTemplate(); PersistentCaseJdbcTemplate jdbcTemplate = new PersistentCaseJdbcTemplate();
AihrCaseService service = new AihrCaseService(null, null, new ObjectMapper(), jdbcTemplate); AihrCaseService service = new AihrCaseService(null, null, new ObjectMapper(), jdbcTemplate, null);
var detail = service.detail("persisted-case", List.of("P1")); var detail = service.detail("persisted-case", List.of("P1"));
@@ -86,7 +113,7 @@ class AihrCaseServiceTest {
@Test @Test
@Tag("dev") @Tag("dev")
void systemUploadRequiresExplicitProject() { void systemUploadRequiresExplicitProject() {
AihrCaseService service = new AihrCaseService(null, null, new ObjectMapper(), new CaseJdbcTemplate()); AihrCaseService service = new AihrCaseService(null, null, new ObjectMapper(), new CaseJdbcTemplate(), null);
assertThrows(IllegalArgumentException.class, () -> service.upload( assertThrows(IllegalArgumentException.class, () -> service.upload(
new MockMultipartFile("file", "case.webm", "audio/webm", new byte[]{1}), "", null new MockMultipartFile("file", "case.webm", "audio/webm", new byte[]{1}), "", null
@@ -1,6 +1,27 @@
-- 物业 AI HR 对练记录最小表结构。 -- 物业 AI HR 对练记录最小表结构。
-- ponytail: one session table covers history, review list and profile aggregation for MVP. -- ponytail: one session table covers history, review list and profile aggregation for MVP.
CREATE TABLE IF NOT EXISTS `aihr_case_record` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
`case_id` varchar(80) NOT NULL COMMENT '案例ID',
`file_name` varchar(255) DEFAULT NULL COMMENT '来源文件',
`project_ext_org_id` varchar(100) DEFAULT NULL COMMENT '项目外部组织ID',
`title` varchar(120) DEFAULT NULL COMMENT '标题',
`type` varchar(80) DEFAULT NULL COMMENT '案例类型',
`status` varchar(30) DEFAULT '已转写' COMMENT '状态',
`transcript` text DEFAULT NULL COMMENT 'ASR转写',
`summary` varchar(1000) DEFAULT NULL COMMENT '摘要',
`media_oss_id` bigint DEFAULT NULL COMMENT '原始音频OSS文件ID',
`media_url` varchar(500) DEFAULT NULL COMMENT '原始音频访问地址',
`owner` varchar(80) DEFAULT '培训组' COMMENT '负责人',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_case_record_case` (`tenant_id`, `case_id`),
KEY `idx_aihr_case_record_status` (`tenant_id`, `status`, `update_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 案例沉淀记录';
CREATE TABLE IF NOT EXISTS `aihr_practice_session` ( CREATE TABLE IF NOT EXISTS `aihr_practice_session` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
+1 -1
View File
@@ -10,7 +10,7 @@
| 三角色对练 `/train/practice` | `POST /api/train/practice/start`、`/turn`、`/finish` | 已接入编排 API;数据库启用 chat 模型后,`/turn` 客户回复按人设走真 LLM 生成(seed 剧本作剧情锚点),`/finish` 走单次 temperature=0 结构化评分(4 维分+导师改写+点评);模型未配置或调用失败自动回退 seed,契约不变 | | 三角色对练 `/train/practice` | `POST /api/train/practice/start`、`/turn`、`/finish` | 已接入编排 API;数据库启用 chat 模型后,`/turn` 客户回复按人设走真 LLM 生成(seed 剧本作剧情锚点),`/finish` 走单次 temperature=0 结构化评分(4 维分+导师改写+点评);模型未配置或调用失败自动回退 seed,契约不变 |
| 正式试点数据导出 | `GET /api/train/practice/export?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD` | 起止日期必填且包含结束日;只统计窗口内能通过唯一手机号或外部 ID 映射到在职组织快照的正式会话,排除重复手机号和身份碰撞。完训定义为每人至少 10 次,校准必须关联同一窗口内正式会话;CSV 同时给出校准命中数、SOP 可用数、满意度响应数/平均分,以及明细级 AI 分、人工校准分、校准人、校准时间、最终采用分、满意度分和意见,避免用四舍五入后的比率反推门禁状态;汇总和明细均携带正式人员及项目口径,不混入历史 seed/开发身份 | | 正式试点数据导出 | `GET /api/train/practice/export?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD` | 起止日期必填且包含结束日;只统计窗口内能通过唯一手机号或外部 ID 映射到在职组织快照的正式会话,排除重复手机号和身份碰撞。完训定义为每人至少 10 次,校准必须关联同一窗口内正式会话;CSV 同时给出校准命中数、SOP 可用数、满意度响应数/平均分,以及明细级 AI 分、人工校准分、校准人、校准时间、最终采用分、满意度分和意见,避免用四舍五入后的比率反推门禁状态;汇总和明细均携带正式人员及项目口径,不混入历史 seed/开发身份 |
| 对练语音 | `POST /api/ai/asr`(multipart 字段 `file`,≤5MB)、`POST /api/ai/tts`(JSON `{text≤300字, voice}`,返回 `{audioUrl}` base64 dataURL)、`GET /api/aihr/mobile/oss/{ossId}` | 走 OpenAI-compatible audio 接口(如硅基流动 SenseVoice/CosyVoice2);模型管理需启用 `category=asr/tts` 配置;移动端优先用浏览器录音,`getUserMedia/MediaRecorder` 不可用或麦克风权限失败时,用 `audio/*` file input 选择/录制音频后继续调同一 ASR 接口;ASR/TTS 未配置或失败返回 fail,前端降级文本;音频下载按员工本人或主管项目范围授权,系统管理端保持后台访问 | | 对练语音 | `POST /api/ai/asr`(multipart 字段 `file`,≤5MB)、`POST /api/ai/tts`(JSON `{text≤300字, voice}`,返回 `{audioUrl}` base64 dataURL)、`GET /api/aihr/mobile/oss/{ossId}` | 走 OpenAI-compatible audio 接口(如硅基流动 SenseVoice/CosyVoice2);模型管理需启用 `category=asr/tts` 配置;移动端优先用浏览器录音,`getUserMedia/MediaRecorder` 不可用或麦克风权限失败时,用 `audio/*` file input 选择/录制音频后继续调同一 ASR 接口;ASR/TTS 未配置或失败返回 fail,前端降级文本;音频下载按员工本人或主管项目范围授权,系统管理端保持后台访问 |
| 案例沉淀 `/knowledge/cases` | `POST /api/knowledge/case/upload`、`/organize`、`/curate`、`GET /records`、`GET /records/{caseId}` | `/upload` 改为 multipart 真实语音上传并走 ASR;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化;APP 用户的项目范围从 `aihr_org_snapshot` 登录身份解析,上传、整理、入库、列表和详情均按项目范围校验,未完成正式组织映射时安全拒绝,不接受前端伪造项目范围;移动端可查看持久化转写/摘要详情;管理端系统用户保留全局运营视图;视频样片、学习点和主管点评仍未接入 | | 案例沉淀 `/knowledge/cases` | `POST /api/knowledge/case/upload`、`/organize`、`/curate`、`GET /records`、`GET /records/{caseId}` | `/upload` 改为 multipart 真实语音上传并走 ASR,成功后原始音频写入 `sys_oss`,案例记录保存 `mediaOssId/mediaUrl`;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化;APP 用户的项目范围从 `aihr_org_snapshot` 登录身份解析,上传、整理、入库、列表和详情均按项目范围校验,未完成正式组织映射时安全拒绝,不接受前端伪造项目范围;移动端可查看持久化转写/摘要并回放原始音频;管理端系统用户保留全局运营视图;预渲染视频样片、学习点和主管点评仍未接入 |
| SOP知识库 `/knowledge/sop` | `POST /api/knowledge/search`、`POST /api/knowledge/answer-feedback`、`GET /api/knowledge/position-sop`、`POST /api/knowledge/doc/upload` | 已接入 MySQL Fulltext + Qdrant 混合召回、岗位学习适配摘要、OSS-first 文档上传、txt/md/PDF/Word/Excel/PPT 解析和 embedding 写入,失败回退 seed;搜索返回 `reviewId` 与 `promptVersion`,员工反馈回传并保存该评审批次,SOP 人工评审记录同时保留答案生成提示词版本,管理端可继续复核;`position-sop` 目前只返回一期生活顾问学习导航,不代表正式上岗资格 | | SOP知识库 `/knowledge/sop` | `POST /api/knowledge/search`、`POST /api/knowledge/answer-feedback`、`GET /api/knowledge/position-sop`、`POST /api/knowledge/doc/upload` | 已接入 MySQL Fulltext + Qdrant 混合召回、岗位学习适配摘要、OSS-first 文档上传、txt/md/PDF/Word/Excel/PPT 解析和 embedding 写入,失败回退 seed;搜索返回 `reviewId` 与 `promptVersion`,员工反馈回传并保存该评审批次,SOP 人工评审记录同时保留答案生成提示词版本,管理端可继续复核;`position-sop` 目前只返回一期生活顾问学习导航,不代表正式上岗资格 |
| 资料处理 `/knowledge/processing` | `GET /api/knowledge/processing/overview`、`POST /api/knowledge/doc/upload-async`、`GET /api/knowledge/doc/upload-items`、`POST /api/knowledge/doc/upload-items/{id}/retry`、`POST /api/knowledge/doc/import-local-task`、`GET /api/knowledge/doc/import-tasks`、`POST /api/knowledge/doc/import-tasks/{id}/cancel` | 已接入解析任务状态聚合;**批量上传走异步队列**:接口只暂存+入队即秒回,后台 worker(并发 2)逐条解析/归类/向量化;ZIP 在 worker 内安全解压后把支持的子文件继续入同一批次队列,页面按批次轮询进度、失败可单文件重试;服务端目录导入、进度轮询和任务取消保留,失败回退 seed | | 资料处理 `/knowledge/processing` | `GET /api/knowledge/processing/overview`、`POST /api/knowledge/doc/upload-async`、`GET /api/knowledge/doc/upload-items`、`POST /api/knowledge/doc/upload-items/{id}/retry`、`POST /api/knowledge/doc/import-local-task`、`GET /api/knowledge/doc/import-tasks`、`POST /api/knowledge/doc/import-tasks/{id}/cancel` | 已接入解析任务状态聚合;**批量上传走异步队列**:接口只暂存+入队即秒回,后台 worker(并发 2)逐条解析/归类/向量化;ZIP 在 worker 内安全解压后把支持的子文件继续入同一批次队列,页面按批次轮询进度、失败可单文件重试;服务端目录导入、进度轮询和任务取消保留,失败回退 seed |
| 组织人员同步 | `POST /api/aihr/org/sync` | 从开放组织同步系统的 `/api/open/v1/sync/snapshot` 拉取 `company/department/employee` 快照,分页参数使用 `limit`;员工手机号只落 `person_phone` 用于移动端身份映射,不在组织列表响应暴露;岗位识别 `position/job_title/post/job_name/role/title` 等字段。默认 `replaceExisting=true`,写入前必须先用 `{"dryRun":true}`;dry-run 不访问本地快照表、不执行 DDL/写库,返回 `phoneLinked/maskedPhone/suspectText/warnings` 且 `syncedCount=0`。非 dry-run 覆盖写入遇到员工被跳过、手机号不完整、脱敏手机号或疑似乱码时默认拒绝,只有确认 dry-run 结果后显式传 `allowPartialReplace=true` 才允许覆盖;重复 `ext_party_id` 始终拒绝写入,因为数据库唯一键会折叠重复身份;`replaceExisting=false` 不触发不完整快照覆盖闸门,但仍拒绝重复身份。2026-07-10 源接口实测 3474 人仅 1 个可用手机号、3473 个脱敏手机号、8 条疑似乱码,因此未执行覆盖同步,正式试点需上游先开放至少 20 名试点人员手机号 | | 组织人员同步 | `POST /api/aihr/org/sync` | 从开放组织同步系统的 `/api/open/v1/sync/snapshot` 拉取 `company/department/employee` 快照,分页参数使用 `limit`;员工手机号只落 `person_phone` 用于移动端身份映射,不在组织列表响应暴露;岗位识别 `position/job_title/post/job_name/role/title` 等字段。默认 `replaceExisting=true`,写入前必须先用 `{"dryRun":true}`;dry-run 不访问本地快照表、不执行 DDL/写库,返回 `phoneLinked/maskedPhone/suspectText/warnings` 且 `syncedCount=0`。非 dry-run 覆盖写入遇到员工被跳过、手机号不完整、脱敏手机号或疑似乱码时默认拒绝,只有确认 dry-run 结果后显式传 `allowPartialReplace=true` 才允许覆盖;重复 `ext_party_id` 始终拒绝写入,因为数据库唯一键会折叠重复身份;`replaceExisting=false` 不触发不完整快照覆盖闸门,但仍拒绝重复身份。2026-07-10 源接口实测 3474 人仅 1 个可用手机号、3473 个脱敏手机号、8 条疑似乱码,因此未执行覆盖同步,正式试点需上游先开放至少 20 名试点人员手机号 |
+1 -1
View File
@@ -18,7 +18,7 @@
| 候选人面试与 HR 复核 | `mobile-uni/src/pages/candidate/*`、`frontend/src/views/recruit/interview.vue` | 工程闭环已在位;候选人资料移动端上传/列表现在只接受 APP 候选人本人身份,HR 继续通过独立审核接口处理;需继续保留人工复核证据 | | 候选人面试与 HR 复核 | `mobile-uni/src/pages/candidate/*`、`frontend/src/views/recruit/interview.vue` | 工程闭环已在位;候选人资料移动端上传/列表现在只接受 APP 候选人本人身份,HR 继续通过独立审核接口处理;需继续保留人工复核证据 |
| SOP/RAG/资料处理 | `frontend/src/views/knowledge/*`、`backend/.../AihrSopSeedService.java` | 搜索、引用、反馈、异步处理和评审入口已在位 | | SOP/RAG/资料处理 | `frontend/src/views/knowledge/*`、`backend/.../AihrSopSeedService.java` | 搜索、引用、反馈、异步处理和评审入口已在位 |
| 对练/每日题/主管带教 | `backend/.../AihrPracticeSeedService.java`、`mobile-uni/src/pages/user/practice` | 每日三题现支持文字或录音转写,语音 `audioUrl/ossId` 随作业提交并持久化;普通陪练、即时反馈、低分再练、错题归因和主管带教已在位,正式窗口仍需验证真实音频与低分样本 | | 对练/每日题/主管带教 | `backend/.../AihrPracticeSeedService.java`、`mobile-uni/src/pages/user/practice` | 每日三题现支持文字或录音转写,语音 `audioUrl/ossId` 随作业提交并持久化;普通陪练、即时反馈、低分再练、错题归因和主管带教已在位,正式窗口仍需验证真实音频与低分样本 |
| 案例上传/整理/筛选 | `backend/.../AihrCaseService.java`、`mobile-uni/src/pages/user/cases`、`frontend/src/views/knowledge/cases.vue` | 上传与整理已在位,案例状态在内存缺失时可从 `aihr_case_record` 回读恢复;APP 用户的读取按项目范围校验,写入接口已增加“主管/项目经理/系统用户”门禁,普通员工不能提交、整理或入库案例;本轮补上管理端上传的项目选择与后端无项目拒绝,避免案例落成员工不可见的无范围记录;当前仓库仍没有可用预渲染视频样片资产,也没有稳定的案例详情/学习点模型,不能用假播放器宣称案例学习闭环完成 | | 案例上传/整理/筛选 | `backend/.../AihrCaseService.java`、`mobile-uni/src/pages/user/cases`、`frontend/src/views/knowledge/cases.vue` | 上传与整理已在位,案例状态在内存缺失时可从 `aihr_case_record` 回读恢复;APP 用户的读取按项目范围校验,写入接口已增加“主管/项目经理/系统用户”门禁,普通员工不能提交、整理或入库案例;原始音频现在上传到 `sys_oss` 并在 `aihr_case_record.media_oss_id/media_url` 留痕,员工详情可回放;本轮补上管理端上传的项目选择与后端无项目拒绝,避免案例落成员工不可见的无范围记录;当前仓库仍没有可用预渲染视频样片资产,也没有稳定的学习点/主管点评模型,不能用音频回放冒充完整案例学习闭环 |
## 3. 未完成项与优先级 ## 3. 未完成项与优先级
+1 -1
View File
@@ -18,7 +18,7 @@
| 2 | uni-app 三端 `https://wygj-mobile-uni.localhost/h5/#/pages/user/today/index`、`/pages/candidate/index/index`、`/pages/supervisor/index/index` | 打开后先手机号登录;员工端从今日页进入问师傅、练习、学习、案例素材和个人页;候选首页按真实状态进入独立 `/interview/index`、`/materials/index`、`/progress/index`、`/study/index`;主管首页进入独立团队画像、指派、复盘列表和复盘详情 | 员工端显示训练评分、历史和画像;候选面试页可真实拉题/答题/评分,资料页显示审核三态,进度页展示办理步骤,预习页从正式知识库返回依据;主管端待复盘列表、复盘详情、专项指派和团队范围均为独立页面且普通员工无主管权限 | | 2 | uni-app 三端 `https://wygj-mobile-uni.localhost/h5/#/pages/user/today/index`、`/pages/candidate/index/index`、`/pages/supervisor/index/index` | 打开后先手机号登录;员工端从今日页进入问师傅、练习、学习、案例素材和个人页;候选首页按真实状态进入独立 `/interview/index`、`/materials/index`、`/progress/index`、`/study/index`;主管首页进入独立团队画像、指派、复盘列表和复盘详情 | 员工端显示训练评分、历史和画像;候选面试页可真实拉题/答题/评分,资料页显示审核三态,进度页展示办理步骤,预习页从正式知识库返回依据;主管端待复盘列表、复盘详情、专项指派和团队范围均为独立页面且普通员工无主管权限 |
| 3 | AI面试 `/recruit/interview` | 生成题目 → 填满参考回答或输入真实回答 → 完成评分 | 配置 chat 模型时真实生成题目和评分;未配置时用本地 Rubric 估分,不返回固定候选人分数;应看到新增面试记录 | | 3 | AI面试 `/recruit/interview` | 生成题目 → 填满参考回答或输入真实回答 → 完成评分 | 配置 chat 模型时真实生成题目和评分;未配置时用本地 Rubric 估分,不返回固定候选人分数;应看到新增面试记录 |
| 4 | 三角色对练 `/train/practice` | 开始对练 → 填入参考回复或输入真实回复 → 继续一轮 → 结束并评分 | 已完成闭环、导师改写、新增对练记录;数据库已启用 chat 模型时,客户回复与评分为真实 LLM 生成(回复不再是固定台词,分数随话术变化),未配置时使用本地剧本兜底 | | 4 | 三角色对练 `/train/practice` | 开始对练 → 填入参考回复或输入真实回复 → 继续一轮 → 结束并评分 | 已完成闭环、导师改写、新增对练记录;数据库已启用 chat 模型时,客户回复与评分为真实 LLM 生成(回复不再是固定台词,分数随话术变化),未配置时使用本地剧本兜底 |
| 5 | 案例沉淀 `/knowledge/cases` | 上传真实语音 → ASR 转写 → AI 整理 → 送审 → 入库 → 查看案例详情 | 已完成语音转写、整理、入库和按项目权限查看持久化转写/摘要详情;稳定媒体 URL、视频样片、学习点和主管点评尚未形成,不把“查看样片”作为当前完成证据 | | 5 | 案例沉淀 `/knowledge/cases` | 上传真实语音 → ASR 转写 → AI 整理 → 送审 → 入库 → 查看案例详情/回放原始音频 | 已完成语音转写、原始音频 OSS 留痕、整理、入库和按项目权限查看持久化转写/摘要详情;预渲染视频样片、学习点和主管点评尚未形成,不把音频回放当作“查看样片”完成证据 |
| 6 | SOP知识库 `/knowledge/sop` | 上传 txt/md/PDF/Word/Excel/PPT 文档或使用 seed 文档 → 检索 → 生成训练题 | 已完成闭环、引用 SOP 原文片段、训练题已生成 | | 6 | SOP知识库 `/knowledge/sop` | 上传 txt/md/PDF/Word/Excel/PPT 文档或使用 seed 文档 → 检索 → 生成训练题 | 已完成闭环、引用 SOP 原文片段、训练题已生成 |
| 7 | 资料处理 `/knowledge/processing` | 打开页面 → 查看解析任务 → 查看处理链路 → 可选点“服务端导入”导入 `.data/import` 下少量样例 | 可看到资料总量、等待/解析中/完成/失败、片段数、向量化状态和事件列表 | | 7 | 资料处理 `/knowledge/processing` | 打开页面 → 查看解析任务 → 查看处理链路 → 可选点“服务端导入”导入 `.data/import` 下少量样例 | 可看到资料总量、等待/解析中/完成/失败、片段数、向量化状态和事件列表 |
+15 -1
View File
@@ -104,7 +104,11 @@
<text class="case-content">{{ caseDetail.transcript || '暂无转写内容' }}</text> <text class="case-content">{{ caseDetail.transcript || '暂无转写内容' }}</text>
<text class="detail-label">案例摘要</text> <text class="detail-label">案例摘要</text>
<text class="case-note">{{ caseDetail.summary || '暂无案例摘要' }}</text> <text class="case-note">{{ caseDetail.summary || '暂无案例摘要' }}</text>
<text class="case-note">当前版本提供转写、摘要和状态详情;视频样片、学习点与主管点评待正式媒体和业务字段接入。</text> <view v-if="caseDetail.mediaUrl" class="case-media-block">
<text class="detail-label">原始音频</text>
<audio class="case-audio" :src="caseDetail.mediaUrl" controls />
</view>
<text class="case-note">当前版本已保留原始音频回放;预渲染视频样片、学习点与主管点评待正式媒体和业务字段接入。</text>
</view> </view>
</view> </view>
</template> </template>
@@ -369,6 +373,16 @@ onShow(() => {
font-weight: 700; font-weight: 700;
} }
.case-media-block {
display: flex;
flex-direction: column;
gap: 8px;
}
.case-audio {
width: 100%;
}
.tag-row { .tag-row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
+4
View File
@@ -386,6 +386,8 @@ export interface CaseDetailResponse {
owner: string; owner: string;
transcript: string; transcript: string;
summary: string; summary: string;
mediaUrl?: string;
mediaOssId?: number;
} }
export interface CaseUploadResponse { export interface CaseUploadResponse {
@@ -394,6 +396,8 @@ export interface CaseUploadResponse {
transcript: string; transcript: string;
tags: CaseTag[]; tags: CaseTag[];
source: string; source: string;
mediaUrl?: string;
mediaOssId?: number;
} }
export interface CaseOrganizeResponse { export interface CaseOrganizeResponse {
+4
View File
@@ -452,6 +452,10 @@ contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "aihr_knowledge_gap" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "aihr_knowledge_gap"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java "仅项目负责人或主管可提交案例素材" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java "仅项目负责人或主管可提交案例素材"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java "position_level IN ('主管', '项目经理')" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java "position_level IN ('主管', '项目经理')"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java "media_oss_id"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java "storeSourceAudio"
contains mobile-uni/src/pages/user/cases/index.vue "case-audio"
contains backend/script/sql/aihr_practice_mysql8.sql "aihr_case_record"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "prompt_version" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "prompt_version"
contains backend/script/sql/aihr_practice_mysql8.sql "prompt_version" contains backend/script/sql/aihr_practice_mysql8.sql "prompt_version"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "review_id" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "review_id"