feat(aihr): export human calibration evidence
This commit is contained in:
+17
-3
@@ -187,8 +187,16 @@ public class AihrPracticeSeedService {
|
||||
(SELECT GROUP_CONCAT(DISTINCT o.project_code ORDER BY o.project_code SEPARATOR ';')
|
||||
FROM safe_org o WHERE o.ext_party_id = s.formal_ext_party_id) AS project_codes,
|
||||
s.session_id, s.trainee_name, s.scenario_name, s.total_score, s.status,
|
||||
s.finished_time, s.summary, s.review_advice, s.incentive_point
|
||||
s.finished_time, s.summary, s.review_advice, s.incentive_point,
|
||||
s.total_score AS ai_score,
|
||||
c.original_score AS calibration_original_score,
|
||||
c.corrected_score AS human_score,
|
||||
c.reviewer AS calibration_reviewer,
|
||||
c.create_time AS calibration_time,
|
||||
COALESCE(c.corrected_score, s.total_score) AS final_adopted_score
|
||||
FROM formal_sessions s
|
||||
LEFT JOIN latest_calibration l ON l.session_id = s.session_id
|
||||
LEFT JOIN aihr_practice_calibration c ON c.id = l.id
|
||||
ORDER BY s.finished_time DESC, s.id DESC
|
||||
""";
|
||||
|
||||
@@ -1056,7 +1064,7 @@ public class AihrPracticeSeedService {
|
||||
appendCsvRow(csv, "sop_answer_usable_ready", sopReady);
|
||||
appendCsvRow(csv, "pilot_metric_ready", metrics.scenarioCount() >= 12 && completionReady && calibrationReady && sopReady);
|
||||
csv.append('\n');
|
||||
appendCsvRow(csv, "formal_ext_party_id", "project_codes", "session_id", "trainee", "scene", "score", "status", "finished_time", "summary", "review_advice", "incentive_point");
|
||||
appendCsvRow(csv, "formal_ext_party_id", "project_codes", "session_id", "trainee", "scene", "score", "status", "finished_time", "summary", "review_advice", "incentive_point", "ai_score", "calibration_original_score", "human_score", "calibration_reviewer", "calibration_time", "final_adopted_score");
|
||||
sessionRows.forEach(row -> appendCsvRow(
|
||||
csv,
|
||||
row.get("formal_ext_party_id"),
|
||||
@@ -1069,7 +1077,13 @@ public class AihrPracticeSeedService {
|
||||
row.get("finished_time"),
|
||||
row.get("summary"),
|
||||
row.get("review_advice"),
|
||||
row.get("incentive_point")
|
||||
row.get("incentive_point"),
|
||||
row.get("ai_score"),
|
||||
row.get("calibration_original_score"),
|
||||
row.get("human_score"),
|
||||
row.get("calibration_reviewer"),
|
||||
row.get("calibration_time"),
|
||||
row.get("final_adopted_score")
|
||||
));
|
||||
return csv.toString();
|
||||
}
|
||||
|
||||
+11
-1
@@ -82,8 +82,12 @@ public class AihrPracticeSeedServiceTest {
|
||||
assertTrue(jdbcTemplate.metricsSql.contains("HAVING COUNT(*) >= 10"));
|
||||
assertTrue(jdbcTemplate.metricsSql.contains("JOIN formal_sessions s ON s.session_id = c.session_id"));
|
||||
assertTrue(jdbcTemplate.sessionRowsSql.contains("JOIN org_identity o ON o.identity_key = s.ext_party_id"));
|
||||
assertTrue(jdbcTemplate.sessionRowsSql.contains("LEFT JOIN latest_calibration l ON l.session_id = s.session_id"));
|
||||
assertTrue(csv.contains("\"training_count\",\"1\""));
|
||||
assertTrue(csv.contains("\"FORMAL-1\",\"P1\",\"session-1\""));
|
||||
assertTrue(csv.contains("\"ai_score\",\"calibration_original_score\",\"human_score\""));
|
||||
assertTrue(csv.contains("\"3\",\"86\",\"86\",\"78\",\"主管甲\""));
|
||||
assertTrue(csv.contains("\"78\"\n"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -618,7 +622,13 @@ public class AihrPracticeSeedServiceTest {
|
||||
Map.entry("finished_time", Timestamp.valueOf("2026-07-08 10:00:00")),
|
||||
Map.entry("summary", "正式试点记录"),
|
||||
Map.entry("review_advice", "保持回访"),
|
||||
Map.entry("incentive_point", 3)
|
||||
Map.entry("incentive_point", 3),
|
||||
Map.entry("ai_score", 86),
|
||||
Map.entry("calibration_original_score", 86),
|
||||
Map.entry("human_score", 78),
|
||||
Map.entry("calibration_reviewer", "主管甲"),
|
||||
Map.entry("calibration_time", Timestamp.valueOf("2026-07-08 11:00:00")),
|
||||
Map.entry("final_adopted_score", 78)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|---|---|---|
|
||||
| AI面试 `/recruit/interview` | `POST /api/recruit/interview/start`、`/answer`、`/finish` | 已接入真实模型优先链路:配置 chat 模型后 `/start` 动态生成面试题,`/finish` 按前端提交的真实回答做结构化评分;未配置模型时使用本地 Rubric 估分,不返回固定候选人分数 |
|
||||
| 三角色对练 `/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 可用数,避免用四舍五入后的比率反推门禁状态;汇总和明细均携带正式人员及项目口径,不混入历史 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) | 走 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` | `/upload` 改为 multipart 真实语音上传并走 ASR;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化;不再用固定样例转写冒充成功 |
|
||||
| SOP知识库 `/knowledge/sop` | `POST /api/knowledge/search`、`GET /api/knowledge/position-sop`、`POST /api/knowledge/doc/upload` | 已接入 MySQL Fulltext + Qdrant 混合召回、岗位学习适配摘要、OSS-first 文档上传、txt/md/PDF/Word/Excel/PPT 解析和 embedding 写入,失败回退 seed;`position-sop` 目前只返回一期生活顾问学习导航,不代表正式上岗资格 |
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
|---|---|---|---|
|
||||
| 正式主管身份、项目范围和数据权限 | 5.4、L1-B、G3 | 代码已有 `aihr_org_snapshot`、岗位/项目过滤和主管接口;移动端角色查询失败已改为安全降级员工端,但正式上游快照的手机号关联仍不足,线上视觉测试不能替代真实主管身份验证 | 用正式组织同步 dry-run 先核对项目、岗位、`ext_party_id`、手机号;只在上游数据合规后执行正式同步 |
|
||||
| M5 正式试点证据 | L1-B/C/D、G1/G3/G4 | 本地严格门禁和导出能力已存在;本地样本不能代表正式窗口,不能用 seed/烟测数据替代 | 固定 1–2 个住宅项目和窗口,按唯一在职身份采集训练、人工校准、SOP 评审、完训率和满意度 |
|
||||
| AI 分数与人工复核边界 | 4.1、4.5、G1/G2 | 面试和对练已有人工复核/校准入口;仍需在正式试点报告中保留“AI 建议、人工可否决”的证据 | 导出中保留 AI 分、人工分、复核人、复核时间和最终采用值 |
|
||||
| AI 分数与人工复核边界 | 4.1、4.5、G1/G2 | 面试和对练已有人工复核/校准入口;正式试点 CSV 已补明细级 AI 分、人工校准分、校准人、校准时间和最终采用分;正式试点仍需用真实窗口验证人工可否决证据 | 在正式试点报告中抽查校准记录与最终采用值,确认人工分可覆盖 AI 建议且不直接挂钩绩效/晋升 |
|
||||
|
||||
### P1:一期 BRD 功能仍未形成完整业务闭环
|
||||
|
||||
|
||||
@@ -346,6 +346,8 @@ contains frontend/src/views/train/practice.vue "导出试点CSV"
|
||||
contains frontend/src/views/train/reviews.vue "导出试点 CSV"
|
||||
contains frontend/src/views/train/reviews.vue "人工校准总分"
|
||||
contains frontend/src/api/aihr/practice.ts "/api/train/practice/export"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "final_adopted_score"
|
||||
contains backend/script/sql/aihr_practice_mysql8.sql "aihr_practice_calibration"
|
||||
contains frontend/src/api/aihr/practice.ts '/api/train/practice/scenarios/${id}/enabled'
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java "/scenarios/{id}/enabled"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java "String position"
|
||||
|
||||
@@ -83,7 +83,13 @@ const DETAIL_HEADERS = [
|
||||
'finished_time',
|
||||
'summary',
|
||||
'review_advice',
|
||||
'incentive_point'
|
||||
'incentive_point',
|
||||
'ai_score',
|
||||
'calibration_original_score',
|
||||
'human_score',
|
||||
'calibration_reviewer',
|
||||
'calibration_time',
|
||||
'final_adopted_score'
|
||||
];
|
||||
|
||||
function fail(message) {
|
||||
@@ -280,6 +286,23 @@ export function verifyPilotExportContract({ csv, startDate, endDate, contentDisp
|
||||
}
|
||||
const score = Number(row[5]);
|
||||
if (!Number.isFinite(score) || score < 0 || score > 100) fail(`detail score ${row[5] || '<missing>'} is invalid`);
|
||||
if (Number(row[11]) !== score) fail(`detail ai_score ${row[11] || '<missing>'} does not match score ${row[5]}`);
|
||||
const calibrationOriginal = row[12] === '' ? null : Number(row[12]);
|
||||
const humanScore = row[13] === '' ? null : Number(row[13]);
|
||||
for (const [label, value] of [['calibration_original_score', calibrationOriginal], ['human_score', humanScore]]) {
|
||||
if (value !== null && (!Number.isFinite(value) || value < 0 || value > 100)) {
|
||||
fail(`detail ${label} ${value} is invalid`);
|
||||
}
|
||||
}
|
||||
const finalAdopted = Number(row[16]);
|
||||
if (!Number.isFinite(finalAdopted) || finalAdopted < 0 || finalAdopted > 100) {
|
||||
fail(`detail final_adopted_score ${row[16] || '<missing>'} is invalid`);
|
||||
}
|
||||
if (humanScore === null) {
|
||||
if (row[14] || row[15] || finalAdopted !== score) fail('detail without human score has inconsistent calibration evidence');
|
||||
} else if (!row[14] || !row[15] || finalAdopted !== humanScore) {
|
||||
fail('detail human score is missing reviewer/time or final adopted score');
|
||||
}
|
||||
scores.push(score);
|
||||
if (row[6] === '已复盘') reviewed += 1;
|
||||
if (score < 80 || row[6] === '待复盘') alerts += 1;
|
||||
|
||||
@@ -44,8 +44,8 @@ function validCsv() {
|
||||
pilot_metric_ready: 'false'
|
||||
};
|
||||
const lines = ['metric,value', ...Object.entries(metrics).map(([key, value]) => `${key},${value}`)];
|
||||
lines.push('', 'formal_ext_party_id,project_codes,session_id,trainee,scene,score,status,finished_time,summary,review_advice,incentive_point');
|
||||
lines.push('FORMAL-1,P1,session-1,员工甲,投诉处理,86,已复盘,2026-07-08 10:00:00,正式记录,保持回访,3');
|
||||
lines.push('', 'formal_ext_party_id,project_codes,session_id,trainee,scene,score,status,finished_time,summary,review_advice,incentive_point,ai_score,calibration_original_score,human_score,calibration_reviewer,calibration_time,final_adopted_score');
|
||||
lines.push('FORMAL-1,P1,session-1,员工甲,投诉处理,86,已复盘,2026-07-08 10:00:00,正式记录,保持回访,3,86,86,78,主管甲,2026-07-08 11:00:00,78');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user