feat(aihr): complete case learning review loop
This commit is contained in:
+10
@@ -14,6 +14,7 @@ import org.dromara.aihr.domain.AihrCaseDto.DetailResponse;
|
||||
import org.dromara.aihr.domain.AihrCaseDto.OrganizeRequest;
|
||||
import org.dromara.aihr.domain.AihrCaseDto.OrganizeResponse;
|
||||
import org.dromara.aihr.domain.AihrCaseDto.RecordResponse;
|
||||
import org.dromara.aihr.domain.AihrCaseDto.ReviewRequest;
|
||||
import org.dromara.aihr.domain.AihrCaseDto.UploadResponse;
|
||||
import org.dromara.aihr.service.AihrCaseService;
|
||||
import org.dromara.common.core.domain.R;
|
||||
@@ -84,6 +85,15 @@ public class AihrCaseController {
|
||||
return detail == null ? R.fail("案例不存在或无权访问") : R.ok(detail);
|
||||
}
|
||||
|
||||
@PostMapping("/records/{caseId}/review")
|
||||
public R<Void> review(@PathVariable String caseId, @RequestBody ReviewRequest request) {
|
||||
if (!canManageCases()) {
|
||||
return R.fail("仅项目负责人或主管可提交案例点评");
|
||||
}
|
||||
caseService.review(caseId, request, currentProjectScopes());
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
@GetMapping("/capabilities")
|
||||
public R<CapabilitiesResponse> capabilities() {
|
||||
return R.ok(new CapabilitiesResponse(canManageCases(), canViewCases()));
|
||||
|
||||
+7
-2
@@ -17,12 +17,16 @@ public final class AihrCaseDto {
|
||||
public record OrganizeRequest(String caseId) {
|
||||
}
|
||||
|
||||
public record OrganizeResponse(String caseId, List<SummaryResponse> summary, List<TagResponse> tags, String aiSummary, String source) {
|
||||
public record OrganizeResponse(String caseId, List<SummaryResponse> summary, List<TagResponse> tags,
|
||||
List<String> learningPoints, String aiSummary, String source) {
|
||||
}
|
||||
|
||||
public record CurateRequest(String caseId, String criteria, Integer limit) {
|
||||
}
|
||||
|
||||
public record ReviewRequest(String comment) {
|
||||
}
|
||||
|
||||
public record CurateResponse(List<String> selectedCaseIds, List<RecordResponse> records, String sampleHint) {
|
||||
}
|
||||
|
||||
@@ -43,6 +47,7 @@ public final class AihrCaseDto {
|
||||
|
||||
public record DetailResponse(String caseId, String fileName, String title, String type, String status,
|
||||
String time, String owner, String transcript, String summary,
|
||||
String mediaUrl, Long mediaOssId) {
|
||||
String mediaUrl, Long mediaOssId, List<String> learningPoints,
|
||||
String supervisorComment) {
|
||||
}
|
||||
}
|
||||
|
||||
+86
-9
@@ -12,6 +12,7 @@ import org.dromara.aihr.domain.AihrCaseDto.DetailResponse;
|
||||
import org.dromara.aihr.domain.AihrCaseDto.OrganizeRequest;
|
||||
import org.dromara.aihr.domain.AihrCaseDto.OrganizeResponse;
|
||||
import org.dromara.aihr.domain.AihrCaseDto.RecordResponse;
|
||||
import org.dromara.aihr.domain.AihrCaseDto.ReviewRequest;
|
||||
import org.dromara.aihr.domain.AihrCaseDto.SummaryResponse;
|
||||
import org.dromara.aihr.domain.AihrCaseDto.TagResponse;
|
||||
import org.dromara.aihr.domain.AihrCaseDto.UploadResponse;
|
||||
@@ -73,7 +74,7 @@ public class AihrCaseService {
|
||||
mediaOssId = media.ossId();
|
||||
caseId = "case-" + UUID.randomUUID();
|
||||
CaseState state = new CaseState(caseId, fileName, scopedProject, transcript, tagsFromText(transcript), null,
|
||||
media.ossId(), media.url(), currentCaseOwner());
|
||||
List.of(), "", media.ossId(), media.url(), currentCaseOwner());
|
||||
cases.put(caseId, state);
|
||||
saveCase(state, "已转写", caseTitle(List.of(), fileName), transcript);
|
||||
return new UploadResponse(caseId, fileName, transcript, state.tags(), "real-asr", media.url(), media.ossId());
|
||||
@@ -90,11 +91,12 @@ public class AihrCaseService {
|
||||
public OrganizeResponse organize(OrganizeRequest request, List<String> projectScopes) {
|
||||
CaseState state = requireCase(request == null ? null : request.caseId(), projectScopes);
|
||||
CaseSummary summary = organizeWithModel(state).orElseGet(() -> localSummary(state));
|
||||
List<String> learningPoints = learningPointsFromSummary(summary.summary());
|
||||
CaseState next = new CaseState(state.id(), state.fileName(), state.projectExtOrgId(), state.transcript(), summary.tags(), summary.summary(),
|
||||
state.mediaOssId(), state.mediaUrl(), state.owner());
|
||||
learningPoints, state.supervisorComment(), state.mediaOssId(), state.mediaUrl(), state.owner());
|
||||
cases.put(state.id(), next);
|
||||
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(), learningPoints, summary.aiSummary(), summary.source());
|
||||
}
|
||||
|
||||
public CurateResponse curate(CurateRequest request, List<String> projectScopes) {
|
||||
@@ -117,6 +119,19 @@ public class AihrCaseService {
|
||||
return new CurateResponse(List.of(state.id()), List.of(record), "已按真实转写内容生成培训案例,已进入案例库。");
|
||||
}
|
||||
|
||||
public void review(String caseId, ReviewRequest request, List<String> projectScopes) {
|
||||
CaseState state = requireCase(caseId, projectScopes);
|
||||
String comment = truncate(maskSensitiveText(request == null ? "" : request.comment()), 1000);
|
||||
jdbcTemplate.update("""
|
||||
UPDATE aihr_case_record
|
||||
SET supervisor_comment = ?, update_time = now()
|
||||
WHERE tenant_id = ? AND case_id = ?
|
||||
""", comment, tenantId(), state.id());
|
||||
cases.computeIfPresent(state.id(), (id, current) -> new CaseState(current.id(), current.fileName(), current.projectExtOrgId(),
|
||||
current.transcript(), current.tags(), current.summary(), current.learningPoints(), comment,
|
||||
current.mediaOssId(), current.mediaUrl(), current.owner()));
|
||||
}
|
||||
|
||||
public List<RecordResponse> records(Integer limit, List<String> projectScopes) {
|
||||
ensureCaseTable();
|
||||
if (projectScopes != null && projectScopes.isEmpty()) {
|
||||
@@ -166,7 +181,8 @@ public class AihrCaseService {
|
||||
}
|
||||
String publishedWhere = projectScopes == null ? "" : " AND status = '已入库'";
|
||||
String sql = """
|
||||
SELECT case_id, file_name, title, type, status, update_time, owner, transcript, summary, media_url, media_oss_id
|
||||
SELECT case_id, file_name, title, type, status, update_time, owner, transcript, summary, media_url, media_oss_id,
|
||||
learning_points, supervisor_comment
|
||||
FROM aihr_case_record
|
||||
WHERE tenant_id = ? AND case_id = ?
|
||||
""" + projectWhere + publishedWhere + " LIMIT 1";
|
||||
@@ -184,7 +200,9 @@ public class AihrCaseService {
|
||||
firstNonBlank(rs.getString("transcript"), ""),
|
||||
firstNonBlank(rs.getString("summary"), ""),
|
||||
resolveMediaUrl(mediaOssId, rs.getString("media_url")),
|
||||
mediaOssId
|
||||
mediaOssId,
|
||||
parseLearningPoints(rs.getString("learning_points")),
|
||||
firstNonBlank(rs.getString("supervisor_comment"), "")
|
||||
);
|
||||
}, args.toArray()).stream().findFirst().orElse(null);
|
||||
}
|
||||
@@ -240,8 +258,8 @@ public class AihrCaseService {
|
||||
ensureCaseTable();
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO aihr_case_record
|
||||
(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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now())
|
||||
(tenant_id, case_id, file_name, project_ext_org_id, title, type, status, transcript, summary, learning_points, supervisor_comment, media_oss_id, media_url, owner, create_time, update_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
file_name = VALUES(file_name),
|
||||
project_ext_org_id = VALUES(project_ext_org_id),
|
||||
@@ -250,6 +268,8 @@ public class AihrCaseService {
|
||||
status = VALUES(status),
|
||||
transcript = VALUES(transcript),
|
||||
summary = VALUES(summary),
|
||||
learning_points = VALUES(learning_points),
|
||||
supervisor_comment = COALESCE(NULLIF(VALUES(supervisor_comment), ''), supervisor_comment),
|
||||
media_oss_id = VALUES(media_oss_id),
|
||||
media_url = VALUES(media_url),
|
||||
update_time = now()
|
||||
@@ -263,6 +283,8 @@ public class AihrCaseService {
|
||||
status,
|
||||
truncate(maskSensitiveText(state.transcript()), 4000),
|
||||
truncate(maskSensitiveText(summary), 1000),
|
||||
learningPointsJson(state.learningPoints()),
|
||||
firstNonBlank(state.supervisorComment(), ""),
|
||||
state.mediaOssId(),
|
||||
truncate(state.mediaUrl(), 500),
|
||||
firstNonBlank(state.owner(), "培训组")
|
||||
@@ -289,6 +311,8 @@ public class AihrCaseService {
|
||||
`status` varchar(30) DEFAULT '已转写' COMMENT '状态',
|
||||
`transcript` text DEFAULT NULL COMMENT 'ASR转写',
|
||||
`summary` varchar(1000) DEFAULT NULL COMMENT '摘要',
|
||||
`learning_points` text DEFAULT NULL COMMENT '案例学习点JSON',
|
||||
`supervisor_comment` 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 '负责人',
|
||||
@@ -301,6 +325,8 @@ public class AihrCaseService {
|
||||
""");
|
||||
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 '原始音频访问地址'");
|
||||
addCaseColumn("learning_points", "ALTER TABLE aihr_case_record ADD COLUMN learning_points text DEFAULT NULL COMMENT '案例学习点JSON'");
|
||||
addCaseColumn("supervisor_comment", "ALTER TABLE aihr_case_record ADD COLUMN supervisor_comment varchar(1000) DEFAULT NULL COMMENT '主管点评'");
|
||||
tableReady = true;
|
||||
}
|
||||
}
|
||||
@@ -411,8 +437,8 @@ public class AihrCaseService {
|
||||
if (state == null && !normalizedCaseId.isBlank()) {
|
||||
ensureCaseTable();
|
||||
List<CaseState> persisted = jdbcTemplate.query("""
|
||||
SELECT case_id, file_name, project_ext_org_id, transcript, type
|
||||
, media_oss_id, media_url, owner
|
||||
SELECT case_id, file_name, project_ext_org_id, transcript, type,
|
||||
media_oss_id, media_url, learning_points, supervisor_comment, owner
|
||||
FROM aihr_case_record
|
||||
WHERE tenant_id = ? AND case_id = ?
|
||||
LIMIT 1
|
||||
@@ -423,6 +449,8 @@ public class AihrCaseService {
|
||||
firstNonBlank(rs.getString("transcript"), ""),
|
||||
tagsFromText(firstNonBlank(rs.getString("type"), "现场案例")),
|
||||
null,
|
||||
parseLearningPoints(rs.getString("learning_points")),
|
||||
firstNonBlank(rs.getString("supervisor_comment"), ""),
|
||||
rs.getObject("media_oss_id", Long.class),
|
||||
firstNonBlank(rs.getString("media_url"), ""),
|
||||
firstNonBlank(rs.getString("owner"), "培训组")
|
||||
@@ -526,6 +554,53 @@ public class AihrCaseService {
|
||||
return tags.isEmpty() ? "现场案例" : tags.get(0).text();
|
||||
}
|
||||
|
||||
static List<String> learningPointsFromSummary(List<SummaryResponse> summary) {
|
||||
if (summary == null || summary.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return summary.stream()
|
||||
.filter(item -> item != null && !clean(item.text()).isBlank())
|
||||
.filter(item -> Set.of("处理", "结果", "亮点").contains(clean(item.label())))
|
||||
.map(item -> clean(item.label()) + ":" + truncate(maskSensitiveText(item.text()), 160))
|
||||
.limit(5)
|
||||
.toList();
|
||||
}
|
||||
|
||||
private List<String> parseLearningPoints(String value) {
|
||||
String json = clean(value);
|
||||
if (json.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(json);
|
||||
if (!node.isArray()) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> points = new ArrayList<>();
|
||||
node.forEach(item -> {
|
||||
String point = clean(item.asText(""));
|
||||
if (!point.isBlank() && points.size() < 5) {
|
||||
points.add(maskSensitiveText(point));
|
||||
}
|
||||
});
|
||||
return points;
|
||||
} catch (Exception e) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private String learningPointsJson(List<String> points) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(points == null ? List.of() : points.stream()
|
||||
.filter(point -> !clean(point).isBlank())
|
||||
.map(point -> truncate(maskSensitiveText(point), 180))
|
||||
.limit(5)
|
||||
.toList());
|
||||
} catch (Exception e) {
|
||||
return "[]";
|
||||
}
|
||||
}
|
||||
|
||||
private static String tagType(int index) {
|
||||
return switch (index % 3) {
|
||||
case 0 -> "pill-success";
|
||||
@@ -592,6 +667,8 @@ public class AihrCaseService {
|
||||
String transcript,
|
||||
List<TagResponse> tags,
|
||||
List<SummaryResponse> summary,
|
||||
List<String> learningPoints,
|
||||
String supervisorComment,
|
||||
Long mediaOssId,
|
||||
String mediaUrl,
|
||||
String owner
|
||||
|
||||
+35
-2
@@ -2,6 +2,7 @@ package org.dromara.aihr.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.dromara.aihr.domain.AihrCaseDto.UploadResponse;
|
||||
import org.dromara.aihr.domain.AihrCaseDto.ReviewRequest;
|
||||
import org.dromara.system.domain.vo.SysOssVo;
|
||||
import org.dromara.system.service.ISysOssService;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
@@ -70,8 +71,40 @@ class AihrCaseServiceTest {
|
||||
|
||||
assertEquals(123L, response.mediaOssId());
|
||||
assertEquals("https://oss.local/cases/case.webm", response.mediaUrl());
|
||||
assertEquals(123L, jdbcTemplate.updateArgs[9]);
|
||||
assertEquals("https://oss.local/cases/case.webm", jdbcTemplate.updateArgs[10]);
|
||||
assertEquals(123L, jdbcTemplate.updateArgs[11]);
|
||||
assertEquals("https://oss.local/cases/case.webm", jdbcTemplate.updateArgs[12]);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("dev")
|
||||
void learningPointsUseOnlyActionResultAndHighlightSummaryItems() {
|
||||
List<String> points = AihrCaseService.learningPointsFromSummary(List.of(
|
||||
new org.dromara.aihr.domain.AihrCaseDto.SummaryResponse("背景", "业主提出问题"),
|
||||
new org.dromara.aihr.domain.AihrCaseDto.SummaryResponse("处理", "确认责任人和回访时限"),
|
||||
new org.dromara.aihr.domain.AihrCaseDto.SummaryResponse("结果", "问题已跟进"),
|
||||
new org.dromara.aihr.domain.AihrCaseDto.SummaryResponse("亮点", "记录了后续节点")
|
||||
));
|
||||
|
||||
assertEquals(List.of("处理:确认责任人和回访时限", "结果:问题已跟进", "亮点:记录了后续节点"), points);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("dev")
|
||||
void supervisorReviewMasksSensitiveCommentBeforePersisting() {
|
||||
AihrSpeechService speechService = new AihrSpeechService(null, new ObjectMapper()) {
|
||||
@Override
|
||||
public Optional<String> transcribe(byte[] audio, String filename, String contentType) {
|
||||
return Optional.of("现场投诉已完成跟进");
|
||||
}
|
||||
};
|
||||
CaseJdbcTemplate jdbcTemplate = new CaseJdbcTemplate();
|
||||
AihrCaseService service = new AihrCaseService(speechService, null, new ObjectMapper(), jdbcTemplate, null);
|
||||
UploadResponse upload = service.upload(new MockMultipartFile("file", "case.webm", "audio/webm", new byte[]{1}), "P1", null);
|
||||
|
||||
service.review(upload.caseId(), new ReviewRequest("请联系13900001111并查看2-304"), null);
|
||||
|
||||
assertEquals("请联系1390****1111并查看2-****", jdbcTemplate.updateArgs[0]);
|
||||
assertEquals(upload.caseId(), jdbcTemplate.updateArgs[2]);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -12,6 +12,8 @@ CREATE TABLE IF NOT EXISTS `aihr_case_record` (
|
||||
`status` varchar(30) DEFAULT '已转写' COMMENT '状态',
|
||||
`transcript` text DEFAULT NULL COMMENT 'ASR转写',
|
||||
`summary` varchar(1000) DEFAULT NULL COMMENT '摘要',
|
||||
`learning_points` text DEFAULT NULL COMMENT '案例学习点JSON',
|
||||
`supervisor_comment` 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 '负责人',
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
-- AIHR 案例学习点/主管点评迁移(MySQL 8.x)
|
||||
-- 仅补齐案例学习字段,不写入业务内容;可重复执行。
|
||||
|
||||
SET @aihr_case_table_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'aihr_case_record'
|
||||
);
|
||||
|
||||
SET @aihr_learning_points_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'aihr_case_record'
|
||||
AND COLUMN_NAME = 'learning_points'
|
||||
);
|
||||
SET @aihr_learning_points_ddl := IF(
|
||||
@aihr_case_table_exists = 1 AND @aihr_learning_points_exists = 0,
|
||||
'ALTER TABLE aihr_case_record ADD COLUMN `learning_points` text DEFAULT NULL COMMENT ''案例学习点JSON'' AFTER `summary`',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE aihr_learning_points_stmt FROM @aihr_learning_points_ddl;
|
||||
EXECUTE aihr_learning_points_stmt;
|
||||
DEALLOCATE PREPARE aihr_learning_points_stmt;
|
||||
|
||||
SET @aihr_supervisor_comment_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'aihr_case_record'
|
||||
AND COLUMN_NAME = 'supervisor_comment'
|
||||
);
|
||||
SET @aihr_supervisor_comment_ddl := IF(
|
||||
@aihr_case_table_exists = 1 AND @aihr_supervisor_comment_exists = 0,
|
||||
'ALTER TABLE aihr_case_record ADD COLUMN `supervisor_comment` varchar(1000) DEFAULT NULL COMMENT ''主管点评'' AFTER `learning_points`',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE aihr_supervisor_comment_stmt FROM @aihr_supervisor_comment_ddl;
|
||||
EXECUTE aihr_supervisor_comment_stmt;
|
||||
DEALLOCATE PREPARE aihr_supervisor_comment_stmt;
|
||||
@@ -11,7 +11,7 @@
|
||||
| 三角色对练 `/train/practice` | `POST /api/train/practice/start`、`/turn`、`/finish` | 已接入编排 API;数据库启用 chat 模型后,`/turn` 客户回复按人设走真 LLM 生成(seed 剧本作剧情锚点),`/finish` 走单次 temperature=0 结构化评分(4 维分+导师改写+点评);移动端 `turn/finish` 校验当前手机号与启动会话归属,未知或他人会话拒绝;模型未配置或调用失败自动回退 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}`,成功返回 OSS `audioUrl + ossId`,OSS 上传失败才回退 `inlineAudioUrl` dataURL)、`GET /api/aihr/mobile/oss/{ossId}` | 走 OpenAI-compatible audio 接口(如硅基流动 SenseVoice/CosyVoice2);模型管理需启用 `category=asr/tts` 配置;移动端优先用浏览器录音,`getUserMedia/MediaRecorder` 不可用或麦克风权限失败时,用 `audio/*` file input 选择/录制音频后继续调同一 ASR 接口;ASR/TTS 未配置或失败返回 fail,前端降级文本;音频下载按员工本人或主管项目范围授权,系统管理端保持后台访问;正常 TTS 响应不重复携带完整 base64 音频 |
|
||||
| 案例沉淀 `/knowledge/cases` | `GET /api/knowledge/case/capabilities`、`POST /api/knowledge/case/upload`、`/organize`、`/curate`、`GET /records`、`GET /records/{caseId}` | `/capabilities` 返回服务端判定的案例提交/查看能力,移动端不再向普通员工展示无权提交的素材表单;`/upload` 改为 multipart 真实语音上传并走 ASR,服务端只接受 MP3/WAV/M4A/WebM/OGG/AAC/FLAC,成功后原始音频写入 `sys_oss`,案例记录保存 `mediaOssId/mediaUrl`;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化;APP 用户的项目范围从 `aihr_org_snapshot` 登录身份解析,上传、整理、入库、列表和详情均按项目范围校验,未完成正式组织映射时安全拒绝,不接受前端伪造项目范围;员工列表/详情只返回 `已入库` 案例,管理端系统用户保留全局运营视图;移动端和管理端案例详情均可回放原始音频;预渲染视频样片、学习点和主管点评仍未接入 |
|
||||
| 案例沉淀 `/knowledge/cases` | `GET /api/knowledge/case/capabilities`、`POST /api/knowledge/case/upload`、`/organize`、`/curate`、`GET /records`、`GET /records/{caseId}`、`POST /records/{caseId}/review` | `/capabilities` 返回服务端判定的案例提交/查看能力,移动端不再向普通员工展示无权提交的素材表单;`/upload` 改为 multipart 真实语音上传并走 ASR,服务端只接受 MP3/WAV/M4A/WebM/OGG/AAC/FLAC,成功后原始音频写入 `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` 目前只返回一期生活顾问学习导航,不代表正式上岗资格 |
|
||||
| 资料处理 `/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` 等字段。`dryRun` 必须显式传入 `true`(预检)或 `false`(写入),省略或传 `null` 直接拒绝,避免空请求意外写库;默认 `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 名试点人员手机号 |
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
- 2026-07-14 23:16 生产训练 schema 只读复核:生产缺少 `aihr_practice_session` 的 `dim_task_completion/dim_response_timeliness/response_latency_ms`、`aihr_practice_assignment` 的 `score/score_mode`、SOP 评审的 `prompt_version/requester_ext_party_id` 和组织快照 `hire_date`;对应版本化迁移已存在,但尚未在线上执行。严格门禁现增加会话与每日题字段计数,避免发布后依赖运行时懒迁移。
|
||||
- 2026-07-14 23:18 生产 Prompt 内容只读复核:生产 `aihr_prompt_template` 表存在但内置模板数量为 `0`;现新增 `aihr_20260714_prompt_template_seed_mysql8.sql`,按唯一键幂等补齐 6 个内置模板,不覆盖同 code/output_format/version 的自定义模板。未写入生产数据。
|
||||
- 2026-07-14 23:25 生产迁移顺序固化:新增 `docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md`,明确场景/Rubric 基础表、场景与 SOP/Prompt 内置内容、音频/案例媒体、五维与字段迁移的执行顺序,并提供迁移后只读 schema/模板核验 SQL;未执行生产写入,也不把 seed/schema 完成等同于正式试点通过。
|
||||
- 2026-07-14 案例学习闭环补齐:案例整理结果现在从真实摘要提取并持久化 `learning_points`,主管可通过 `/api/knowledge/case/records/{caseId}/review` 提交脱敏点评,管理端和员工端详情可查看;新增 `aihr_20260714_case_learning_mysql8.sql`,未新增视频占位资源,也未修改生产数据。
|
||||
|
||||
更新时间:2026-07-14
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aih
|
||||
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260714_prompt_template_seed_mysql8.sql
|
||||
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260714_practice_evidence_mysql8.sql
|
||||
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260714_case_media_mysql8.sql
|
||||
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260714_case_learning_mysql8.sql
|
||||
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260714_practice_five_dimensions_mysql8.sql
|
||||
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260714_daily_drill_score_mode_mysql8.sql
|
||||
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260714_sop_requester_identity_mysql8.sql
|
||||
@@ -56,7 +57,7 @@ SELECT table_name, column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND (
|
||||
(table_name = 'aihr_case_record' AND column_name IN ('media_oss_id', 'media_url'))
|
||||
(table_name = 'aihr_case_record' AND column_name IN ('media_oss_id', 'media_url', 'learning_points', 'supervisor_comment'))
|
||||
OR (table_name = 'aihr_practice_session' AND column_name IN ('dim_task_completion', 'dim_response_timeliness', 'response_latency_ms'))
|
||||
OR (table_name = 'aihr_practice_assignment' AND column_name IN ('score', 'score_mode'))
|
||||
OR (table_name = 'aihr_sop_answer_review' AND column_name IN ('prompt_version', 'requester_ext_party_id'))
|
||||
|
||||
@@ -38,6 +38,8 @@ export type CaseDetailResponse = {
|
||||
summary: string;
|
||||
mediaUrl?: string;
|
||||
mediaOssId?: number | null;
|
||||
learningPoints: string[];
|
||||
supervisorComment: string;
|
||||
};
|
||||
|
||||
export type CaseUploadResponse = {
|
||||
@@ -52,6 +54,7 @@ export type CaseOrganizeResponse = {
|
||||
caseId: string;
|
||||
summary: CaseSummaryItem[];
|
||||
tags: CaseTag[];
|
||||
learningPoints: string[];
|
||||
aiSummary: string;
|
||||
source: string;
|
||||
};
|
||||
@@ -102,3 +105,12 @@ export function getCaseDetail(caseId: string): Promise<ApiResult<CaseDetailRespo
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
export function reviewKnowledgeCase(caseId: string, comment: string): Promise<ApiResult<void>> {
|
||||
return request({
|
||||
url: `/api/knowledge/case/records/${encodeURIComponent(caseId)}/review`,
|
||||
method: 'post',
|
||||
data: { comment },
|
||||
headers: { repeatSubmit: false }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -144,11 +144,24 @@
|
||||
<strong>案例摘要</strong>
|
||||
<p>{{ caseDetail.summary || '暂无案例摘要' }}</p>
|
||||
</div>
|
||||
<div v-if="caseDetail.learningPoints?.length" class="detail-block">
|
||||
<strong>学习点</strong>
|
||||
<ul class="learning-points">
|
||||
<li v-for="point in caseDetail.learningPoints" :key="point">{{ point }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="detail-block">
|
||||
<strong>主管点评</strong>
|
||||
<el-input v-model="supervisorComment" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="记录本案例可复用的带教建议" />
|
||||
<div class="detail-actions">
|
||||
<el-button type="danger" :loading="reviewSaving" :disabled="!caseDetail.caseId" @click="submitSupervisorReview">保存点评</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="caseDetail.mediaUrl" class="detail-block">
|
||||
<strong>原始音频</strong>
|
||||
<audio class="case-audio" :src="caseDetail.mediaUrl" controls preload="metadata" />
|
||||
</div>
|
||||
<el-alert title="当前版本提供转写、摘要和状态详情;视频样片、学习点与主管点评待正式媒体和业务字段接入。" type="info" :closable="false" />
|
||||
<el-alert title="当前版本提供转写、摘要、学习点和主管点评;视频样片仍待正式媒体资源接入。" type="info" :closable="false" />
|
||||
</template>
|
||||
<el-empty v-else description="暂无详情" />
|
||||
</el-dialog>
|
||||
@@ -165,6 +178,7 @@ import {
|
||||
getCaseDetail,
|
||||
getCaseRecords,
|
||||
organizeKnowledgeCase,
|
||||
reviewKnowledgeCase,
|
||||
uploadCaseAudio,
|
||||
type CaseDetailResponse,
|
||||
type CaseRecord,
|
||||
@@ -192,6 +206,8 @@ const recordsLoading = ref(true);
|
||||
const recordsFallback = ref(false);
|
||||
const detailVisible = ref(false);
|
||||
const caseDetail = ref<CaseDetailResponse | null>(null);
|
||||
const supervisorComment = ref('');
|
||||
const reviewSaving = ref(false);
|
||||
const projectExtOrgId = ref('');
|
||||
const projectOptions = ref<OrgProjectOption[]>([]);
|
||||
const projectsLoading = ref(false);
|
||||
@@ -301,12 +317,27 @@ async function openCaseDetail(record: CaseRecord) {
|
||||
try {
|
||||
const { data } = await getCaseDetail(record.caseId);
|
||||
caseDetail.value = data;
|
||||
supervisorComment.value = data.supervisorComment || '';
|
||||
detailVisible.value = true;
|
||||
} catch {
|
||||
ElMessage.error('案例详情加载失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function submitSupervisorReview() {
|
||||
if (!caseDetail.value || reviewSaving.value) return;
|
||||
reviewSaving.value = true;
|
||||
try {
|
||||
await reviewKnowledgeCase(caseDetail.value.caseId, supervisorComment.value.trim());
|
||||
caseDetail.value.supervisorComment = supervisorComment.value.trim();
|
||||
ElMessage.success('主管点评已保存');
|
||||
} catch {
|
||||
ElMessage.error('主管点评保存失败');
|
||||
} finally {
|
||||
reviewSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAudioPicked(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
@@ -713,6 +744,19 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.learning-points {
|
||||
margin: 8px 0 0;
|
||||
padding-left: 20px;
|
||||
color: #445064;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.detail-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.case-audio {
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
@@ -58,6 +58,13 @@
|
||||
<text class="summary-label">{{ item.label }}</text>
|
||||
<text class="summary-text">{{ item.text }}</text>
|
||||
</view>
|
||||
<view v-if="organizeResult.learningPoints?.length" class="case-learning-block">
|
||||
<text class="detail-label">学习点</text>
|
||||
<view v-for="point in organizeResult.learningPoints" :key="point" class="learning-point">
|
||||
<text class="learning-bullet">•</text>
|
||||
<text class="case-note">{{ point }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="case-note">{{ organizeResult.aiSummary }}</text>
|
||||
</view>
|
||||
|
||||
@@ -109,6 +116,17 @@
|
||||
<text class="case-content">{{ caseDetail.transcript || '暂无转写内容' }}</text>
|
||||
<text class="detail-label">案例摘要</text>
|
||||
<text class="case-note">{{ caseDetail.summary || '暂无案例摘要' }}</text>
|
||||
<view v-if="caseDetail.learningPoints?.length" class="case-learning-block">
|
||||
<text class="detail-label">学习点</text>
|
||||
<view v-for="point in caseDetail.learningPoints" :key="point" class="learning-point">
|
||||
<text class="learning-bullet">•</text>
|
||||
<text class="case-note">{{ point }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="caseDetail.supervisorComment" class="case-learning-block">
|
||||
<text class="detail-label">主管点评</text>
|
||||
<text class="case-note">{{ caseDetail.supervisorComment }}</text>
|
||||
</view>
|
||||
<view v-if="caseDetail.mediaUrl" class="case-media-block">
|
||||
<text class="detail-label">原始音频</text>
|
||||
<audio class="case-audio" :src="caseDetail.mediaUrl" controls />
|
||||
@@ -401,6 +419,23 @@ onShow(() => {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.case-learning-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.learning-point {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.learning-bullet {
|
||||
color: #c0392b;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.case-audio {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -404,6 +404,8 @@ export interface CaseDetailResponse {
|
||||
summary: string;
|
||||
mediaUrl?: string;
|
||||
mediaOssId?: number;
|
||||
learningPoints: string[];
|
||||
supervisorComment: string;
|
||||
}
|
||||
|
||||
export interface CaseUploadResponse {
|
||||
@@ -420,6 +422,7 @@ export interface CaseOrganizeResponse {
|
||||
caseId: string;
|
||||
summary: CaseSummaryItem[];
|
||||
tags: CaseTag[];
|
||||
learningPoints: string[];
|
||||
aiSummary: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ check_pilot_samples() {
|
||||
[[ "$(pilot_sql_scalar "SELECT DATE('$pilot_end') IS NOT NULL")" == "1" ]] || fail "invalid AIHR_PILOT_END_DATE: $pilot_end"
|
||||
[[ "$pilot_start" > "$pilot_end" ]] && fail "pilot start date must not be after end date"
|
||||
|
||||
local scenario_count scenario_service_promotion_count scenario_daily_service_count rubric_expected_count rubric_five_dimension_count completed_people pilot_people org_active org_phone_linked org_hire_date_linked org_pilot_people org_pilot_projects org_completed_people audio_table_count audio_upload_table_count case_media_schema_count practice_session_schema_count daily_drill_schema_count prompt_version_schema_count requester_identity_schema_count hire_date_schema_count sop_document_count case_stored_count case_unmasked_count
|
||||
local scenario_count scenario_service_promotion_count scenario_daily_service_count rubric_expected_count rubric_five_dimension_count completed_people pilot_people org_active org_phone_linked org_hire_date_linked org_pilot_people org_pilot_projects org_completed_people audio_table_count audio_upload_table_count case_media_schema_count case_learning_schema_count practice_session_schema_count daily_drill_schema_count prompt_version_schema_count requester_identity_schema_count hire_date_schema_count sop_document_count case_stored_count case_unmasked_count
|
||||
local calibration_stats calibration_count calibration_matched sop_total sop_usable sop_pending satisfaction_count satisfaction_average
|
||||
local org_identity_cte="
|
||||
WITH active_org AS (
|
||||
@@ -142,6 +142,7 @@ check_pilot_samples() {
|
||||
audio_table_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'aihr_practice_audio'")"
|
||||
audio_upload_table_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'aihr_practice_audio_upload'")"
|
||||
case_media_schema_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'aihr_case_record' AND column_name IN ('media_oss_id','media_url')")"
|
||||
case_learning_schema_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'aihr_case_record' AND column_name IN ('learning_points','supervisor_comment')")"
|
||||
practice_session_schema_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'aihr_practice_session' AND column_name IN ('dim_task_completion','dim_response_timeliness','response_latency_ms','annotations_json','review_advice','incentive_point','satisfaction_score','satisfaction_comment')")"
|
||||
daily_drill_schema_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'aihr_practice_assignment' AND column_name IN ('answer_text','feedback','correct','finished_time','score','score_mode')")"
|
||||
prompt_version_schema_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'aihr_sop_answer_review' AND column_name = 'prompt_version'")"
|
||||
@@ -300,7 +301,7 @@ check_pilot_samples() {
|
||||
AND s.finished_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY)
|
||||
)
|
||||
SELECT COALESCE(ROUND(AVG(satisfaction_score), 1), 0) FROM formal_sessions WHERE satisfaction_score IS NOT NULL")"
|
||||
echo "OK: pilot gates window=${pilot_start}..${pilot_end} scenarios=${scenario_count}/12 rubric_five_dimensions=${rubric_five_dimension_count}/${rubric_expected_count} core_flow=service_promotion:${scenario_service_promotion_count} daily_service:${scenario_daily_service_count} sop_docs=${sop_document_count}/5 stored_cases=${case_stored_count}/20 case_unmasked=${case_unmasked_count} ten_sessions=${completed_people}/${pilot_people} org_identity=${org_phone_linked}/${org_active} org_hire_date=${org_hire_date_linked}/${org_active} org_pilot=${org_pilot_people}/20 projects=${org_pilot_projects}/1-2 org_ten_sessions=${org_completed_people}/${org_pilot_people} calibration=${calibration_count}/20 consistency=${calibration_matched}/${calibration_count} sop_usable=${sop_usable}/${sop_total} sop_pending=${sop_pending} satisfaction=${satisfaction_average}/5 responses=${satisfaction_count} audio_schema=${audio_table_count} audio_upload_schema=${audio_upload_table_count} case_media_schema=${case_media_schema_count}/2 practice_session_schema=${practice_session_schema_count}/8 daily_drill_schema=${daily_drill_schema_count}/6 prompt_version_schema=${prompt_version_schema_count} requester_identity_schema=${requester_identity_schema_count} hire_date_schema=${hire_date_schema_count}"
|
||||
echo "OK: pilot gates window=${pilot_start}..${pilot_end} scenarios=${scenario_count}/12 rubric_five_dimensions=${rubric_five_dimension_count}/${rubric_expected_count} core_flow=service_promotion:${scenario_service_promotion_count} daily_service:${scenario_daily_service_count} sop_docs=${sop_document_count}/5 stored_cases=${case_stored_count}/20 case_unmasked=${case_unmasked_count} ten_sessions=${completed_people}/${pilot_people} org_identity=${org_phone_linked}/${org_active} org_hire_date=${org_hire_date_linked}/${org_active} org_pilot=${org_pilot_people}/20 projects=${org_pilot_projects}/1-2 org_ten_sessions=${org_completed_people}/${org_pilot_people} calibration=${calibration_count}/20 consistency=${calibration_matched}/${calibration_count} sop_usable=${sop_usable}/${sop_total} sop_pending=${sop_pending} satisfaction=${satisfaction_average}/5 responses=${satisfaction_count} audio_schema=${audio_table_count} audio_upload_schema=${audio_upload_table_count} case_media_schema=${case_media_schema_count}/2 case_learning_schema=${case_learning_schema_count}/2 practice_session_schema=${practice_session_schema_count}/8 daily_drill_schema=${daily_drill_schema_count}/6 prompt_version_schema=${prompt_version_schema_count} requester_identity_schema=${requester_identity_schema_count} hire_date_schema=${hire_date_schema_count}"
|
||||
|
||||
[[ "${AIHR_PILOT_STRICT:-false}" != "true" ]] || {
|
||||
(( scenario_count >= 12 )) || fail "pilot enabled scenarios ${scenario_count}/12"
|
||||
@@ -313,6 +314,7 @@ check_pilot_samples() {
|
||||
(( audio_table_count == 1 )) || fail "pilot audio schema unavailable (aihr_practice_audio missing)"
|
||||
(( audio_upload_table_count == 1 )) || fail "pilot audio staging schema unavailable (aihr_practice_audio_upload missing)"
|
||||
(( case_media_schema_count == 2 )) || fail "pilot case media schema unavailable (aihr_case_record.media_oss_id/media_url missing)"
|
||||
(( case_learning_schema_count == 2 )) || fail "pilot case learning schema incomplete (aihr_case_record.learning_points/supervisor_comment missing)"
|
||||
(( practice_session_schema_count == 8 )) || fail "pilot practice session schema incomplete (${practice_session_schema_count}/8)"
|
||||
(( daily_drill_schema_count == 6 )) || fail "pilot daily drill schema incomplete (${daily_drill_schema_count}/6)"
|
||||
(( prompt_version_schema_count == 1 )) || fail "pilot RAG traceability schema unavailable (aihr_sop_answer_review.prompt_version missing)"
|
||||
@@ -447,6 +449,10 @@ contains frontend/src/views/knowledge/cases.vue "案例所属项目"
|
||||
contains frontend/src/views/knowledge/cases.vue "请先选择案例所属项目"
|
||||
contains backend/script/sql/update/aihr_20260714_case_media_mysql8.sql "media_oss_id"
|
||||
contains backend/script/sql/update/aihr_20260714_case_media_mysql8.sql "media_url"
|
||||
contains backend/script/sql/update/aihr_20260714_case_learning_mysql8.sql "learning_points"
|
||||
contains backend/script/sql/update/aihr_20260714_case_learning_mysql8.sql "supervisor_comment"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java "/records/{caseId}/review"
|
||||
contains frontend/src/api/aihr/case.ts "reviewKnowledgeCase"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java "系统用户上传案例必须明确所属项目"
|
||||
contains frontend/src/views/index.vue "核心流程覆盖待内容确认"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrDashboardService.java "核心流程覆盖待内容确认"
|
||||
|
||||
@@ -37,6 +37,11 @@ mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260714_case_media_mysq
|
||||
case_media_columns="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(column_name ORDER BY ordinal_position) FROM information_schema.columns WHERE table_schema='$DB' AND table_name='aihr_case_record' AND column_name IN ('media_oss_id','media_url');")"
|
||||
test "$case_media_columns" = 'media_oss_id,media_url'
|
||||
|
||||
mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260714_case_learning_mysql8.sql"
|
||||
mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260714_case_learning_mysql8.sql"
|
||||
case_learning_columns="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(column_name ORDER BY ordinal_position) FROM information_schema.columns WHERE table_schema='$DB' AND table_name='aihr_case_record' AND column_name IN ('learning_points','supervisor_comment');")"
|
||||
test "$case_learning_columns" = 'learning_points,supervisor_comment'
|
||||
|
||||
mysql "$DB" <<'SQL'
|
||||
CREATE TABLE aihr_prompt_template (
|
||||
id bigint NOT NULL AUTO_INCREMENT,
|
||||
@@ -93,4 +98,4 @@ mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260714_org_hire_date_m
|
||||
org_columns="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(column_name ORDER BY ordinal_position) FROM information_schema.columns WHERE table_schema='$DB' AND table_name='aihr_org_snapshot' AND column_name='hire_date';")"
|
||||
test "$org_columns" = 'hire_date'
|
||||
|
||||
echo "PASS: AIHR legacy schema migrations are idempotent, including case media fields"
|
||||
echo "PASS: AIHR legacy schema migrations are idempotent, including case media and learning fields"
|
||||
|
||||
@@ -69,7 +69,7 @@ CREATE TABLE aihr_practice_scenario (tenant_id varchar(20), position varchar(80)
|
||||
CREATE TABLE aihr_knowledge_attach (tenant_id varchar(20), type varchar(50), status tinyint);
|
||||
CREATE TABLE aihr_case_record (
|
||||
tenant_id varchar(20), status varchar(30), title varchar(120), transcript text, summary varchar(1000),
|
||||
media_oss_id bigint, media_url varchar(500)
|
||||
media_oss_id bigint, media_url varchar(500), learning_points text, supervisor_comment varchar(1000)
|
||||
);
|
||||
CREATE TABLE aihr_practice_session (
|
||||
tenant_id varchar(20), session_id varchar(100), ext_party_id varchar(100), mode varchar(30), finished_time datetime,
|
||||
@@ -177,6 +177,7 @@ pilot_output="$(AIHR_PILOT_START_DATE=2026-07-07 AIHR_PILOT_END_DATE=2026-07-10
|
||||
[[ "$pilot_output" == *'sop_docs=5/5 stored_cases=20/20'* ]] || { echo "FAIL: BRD content quantity gates: $pilot_output" >&2; exit 1; }
|
||||
[[ "$pilot_output" == *'case_unmasked=0'* ]] || { echo "FAIL: redacted case content gate: $pilot_output" >&2; exit 1; }
|
||||
[[ "$pilot_output" == *'case_media_schema=2/2'* ]] || { echo "FAIL: case media schema gate: $pilot_output" >&2; exit 1; }
|
||||
[[ "$pilot_output" == *'case_learning_schema=2/2'* ]] || { echo "FAIL: case learning schema gate: $pilot_output" >&2; exit 1; }
|
||||
[[ "$pilot_output" == *'practice_session_schema=8/8 daily_drill_schema=6/6'* ]] || { echo "FAIL: practice schema gates: $pilot_output" >&2; exit 1; }
|
||||
[[ "$pilot_output" == *'sop_usable=1/1 sop_pending=1'* ]] || { echo "FAIL: SOP window: $pilot_output" >&2; exit 1; }
|
||||
[[ "$pilot_output" == *'satisfaction=5.0/5 responses=19'* ]] || { echo "FAIL: safe satisfaction identity scope: $pilot_output" >&2; exit 1; }
|
||||
|
||||
Reference in New Issue
Block a user