fix(aihr): add semantic daily drill scoring fallback

This commit is contained in:
2026-07-14 15:53:04 +08:00
parent 14d91f86a9
commit 4d4d911bad
11 changed files with 164 additions and 15 deletions
@@ -271,6 +271,7 @@ public final class AihrPracticeDto {
String feedback, String feedback,
Boolean correct, Boolean correct,
Integer score, Integer score,
String scoreMode,
String audioUrl, String audioUrl,
Long audioOssId Long audioOssId
) { ) {
@@ -297,6 +298,7 @@ public final class AihrPracticeDto {
Integer score, Integer score,
String feedback, String feedback,
String referenceAnswer, String referenceAnswer,
String scoreMode,
String audioUrl, String audioUrl,
Long audioOssId Long audioOssId
) { ) {
@@ -22,6 +22,7 @@ public class AihrPracticeLlmService {
private static final String PROMPT_VERSION = "practice-p0-v1"; private static final String PROMPT_VERSION = "practice-p0-v1";
private static final double CUSTOMER_TEMPERATURE = 0.7; private static final double CUSTOMER_TEMPERATURE = 0.7;
private static final double SCORE_TEMPERATURE = 0.0; private static final double SCORE_TEMPERATURE = 0.0;
private static final double DAILY_DRILL_TEMPERATURE = 0.0;
private static final int MAX_CUSTOMER_CHARS = 80; private static final int MAX_CUSTOMER_CHARS = 80;
private final AihrModelSeedService modelService; private final AihrModelSeedService modelService;
@@ -53,6 +54,9 @@ public class AihrPracticeLlmService {
) { ) {
} }
public record DailyDrillAssessment(int score, String feedback) {
}
/** /**
* 生成 AI 客户(业主)的下一句回应。nextBeat 为 seed 剧本中下一轮台词,作为剧情推进锚点。 * 生成 AI 客户(业主)的下一句回应。nextBeat 为 seed 剧本中下一轮台词,作为剧情推进锚点。
*/ */
@@ -159,6 +163,29 @@ public class AihrPracticeLlmService {
return modelService.tryChat(system, user, SCORE_TEMPERATURE).flatMap(this::parseScore); return modelService.tryChat(system, user, SCORE_TEMPERATURE).flatMap(this::parseScore);
} }
/**
* 每日三题的语义评估。模型未配置、成本闸门关闭或结构化结果解析失败时由调用方回退规则评分。
*/
public Optional<DailyDrillAssessment> scoreDailyDrill(String question, String referenceAnswer, String answer) {
if (answer == null || answer.isBlank()) {
return Optional.empty();
}
String system = """
你是物业行业新员工训练教练,请评估员工对每日微场景的处理话术。
只根据员工实际回答评分,不要臆造员工没有说过的动作。
评分标准:0-59为未覆盖关键动作,60-74为部分覆盖,75-89为基本完整,90-100为完整且表达清晰。
只输出 JSON,不要输出其他文字或 markdown:
{"score":0,"feedback":"不超过80字的可执行反馈"}
""";
String user = """
题目:%s
参考要点:%s
员工回答:%s
""".formatted(question, referenceAnswer, answer);
return modelService.tryChat(system, user, DAILY_DRILL_TEMPERATURE)
.flatMap(this::parseDailyDrillAssessment);
}
private Optional<PracticeScore> parseScore(String content) { private Optional<PracticeScore> parseScore(String content) {
try { try {
JsonNode root = objectMapper.readTree(extractJson(content)); JsonNode root = objectMapper.readTree(extractJson(content));
@@ -184,6 +211,25 @@ public class AihrPracticeLlmService {
} }
} }
private Optional<DailyDrillAssessment> parseDailyDrillAssessment(String content) {
try {
JsonNode root = objectMapper.readTree(extractJson(content));
JsonNode scoreNode = root.path("score");
if (!scoreNode.isNumber() && !scoreNode.isTextual()) {
return Optional.empty();
}
int score = clampScore(scoreNode.asInt());
String feedback = truncateText(root.path("feedback").asText(""), 300);
if (feedback.isBlank()) {
return Optional.empty();
}
return Optional.of(new DailyDrillAssessment(score, feedback));
} catch (Exception e) {
log.warn("daily drill score parse failed, falls back to rule score(处理错误已隐藏)");
return Optional.empty();
}
}
private Optional<PracticeTurn> parseTurn(String content, int defaultEmotion, int defaultTrust, String defaultCoachHint) { private Optional<PracticeTurn> parseTurn(String content, int defaultEmotion, int defaultTrust, String defaultCoachHint) {
try { try {
JsonNode root = objectMapper.readTree(extractJson(content)); JsonNode root = objectMapper.readTree(extractJson(content));
@@ -45,6 +45,7 @@ import org.dromara.aihr.domain.AihrPracticeDto.TurnRequest;
import org.dromara.aihr.domain.AihrPracticeDto.TurnResponse; import org.dromara.aihr.domain.AihrPracticeDto.TurnResponse;
import org.dromara.aihr.domain.AihrPracticeDto.TeamMemberResponse; import org.dromara.aihr.domain.AihrPracticeDto.TeamMemberResponse;
import org.dromara.aihr.service.AihrPracticeLlmService.PracticeTurn; import org.dromara.aihr.service.AihrPracticeLlmService.PracticeTurn;
import org.dromara.aihr.service.AihrPracticeLlmService.DailyDrillAssessment;
import org.dromara.common.core.exception.ServiceException; import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.satoken.utils.LoginHelper; import org.dromara.common.satoken.utils.LoginHelper;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -1053,7 +1054,7 @@ public class AihrPracticeSeedService {
args.add(limit); args.add(limit);
String sql = """ String sql = """
SELECT id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time, SELECT id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time,
answer_text, answer_audio_url, answer_audio_oss_id, feedback, correct, score answer_text, answer_audio_url, answer_audio_oss_id, feedback, correct, score, score_mode
FROM aihr_practice_assignment FROM aihr_practice_assignment
%s %s
ORDER BY create_time DESC, id DESC ORDER BY create_time DESC, id DESC
@@ -1826,7 +1827,7 @@ public class AihrPracticeSeedService {
ensureDailyDrills(party); ensureDailyDrills(party);
return jdbcTemplate.query(""" return jdbcTemplate.query("""
SELECT id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time, SELECT id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time,
answer_text, answer_audio_url, answer_audio_oss_id, feedback, correct, score answer_text, answer_audio_url, answer_audio_oss_id, feedback, correct, score, score_mode
FROM aihr_practice_assignment FROM aihr_practice_assignment
WHERE tenant_id = ? WHERE tenant_id = ?
AND (? = '' OR ext_party_id = ?) AND (? = '' OR ext_party_id = ?)
@@ -1864,7 +1865,7 @@ public class AihrPracticeSeedService {
args.add(normalizeLimit(limit)); args.add(normalizeLimit(limit));
return jdbcTemplate.query(""" return jdbcTemplate.query("""
SELECT id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time, SELECT id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time,
answer_text, answer_audio_url, answer_audio_oss_id, feedback, correct, score answer_text, answer_audio_url, answer_audio_oss_id, feedback, correct, score, score_mode
FROM aihr_practice_assignment FROM aihr_practice_assignment
WHERE tenant_id = ? WHERE tenant_id = ?
AND ext_party_id IN (%s) AND ext_party_id IN (%s)
@@ -1993,13 +1994,13 @@ public class AihrPracticeSeedService {
""", tenantId(), extPartyId, traineeName, scenario.id(), scenario.name(), source, reason, createdAt, createdAt); """, tenantId(), extPartyId, traineeName, scenario.id(), scenario.name(), source, reason, createdAt, createdAt);
List<PracticeAssignmentResponse> rows = jdbcTemplate.query(""" List<PracticeAssignmentResponse> rows = jdbcTemplate.query("""
SELECT id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time, SELECT id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time,
answer_text, answer_audio_url, answer_audio_oss_id, feedback, correct, score answer_text, answer_audio_url, answer_audio_oss_id, feedback, correct, score, score_mode
FROM aihr_practice_assignment FROM aihr_practice_assignment
WHERE tenant_id = ? AND ext_party_id = ? AND scenario_id = ? AND source = ? AND reason = ? WHERE tenant_id = ? AND ext_party_id = ? AND scenario_id = ? AND source = ? AND reason = ?
ORDER BY id DESC ORDER BY id DESC
LIMIT 1 LIMIT 1
""", this::mapAssignment, tenantId(), extPartyId, scenario.id(), source, reason); """, this::mapAssignment, tenantId(), extPartyId, scenario.id(), source, reason);
return rows.isEmpty() ? new PracticeAssignmentResponse(null, extPartyId, traineeName, scenario.id(), scenario.name(), source, reason, "待训练", now.format(TIME_FORMATTER), null, null, null, null, null, null, null, null) : rows.get(0); return rows.isEmpty() ? new PracticeAssignmentResponse(null, extPartyId, traineeName, scenario.id(), scenario.name(), source, reason, "待训练", now.format(TIME_FORMATTER), null, null, null, null, null, null, null, null, null) : rows.get(0);
} }
public PracticeAssignmentResponse createAssignment(PracticeAssignmentRequest request, String supervisorExtPartyId) { public PracticeAssignmentResponse createAssignment(PracticeAssignmentRequest request, String supervisorExtPartyId) {
@@ -2061,7 +2062,7 @@ public class AihrPracticeSeedService {
String answer = maskSensitiveText(firstNonBlank(request == null ? null : request.answer(), "")); String answer = maskSensitiveText(firstNonBlank(request == null ? null : request.answer(), ""));
String audioUrl = firstNonBlank(request == null ? null : request.audioUrl(), ""); String audioUrl = firstNonBlank(request == null ? null : request.audioUrl(), "");
Long audioOssId = request == null ? null : request.audioOssId(); Long audioOssId = request == null ? null : request.audioOssId();
DailyDrillScore score = scoreDailyDrill(answer, parts.referenceAnswer()); DailyDrillScore score = scoreDailyDrill(parts.question(), answer, parts.referenceAnswer());
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
int updated = jdbcTemplate.update(""" int updated = jdbcTemplate.update("""
UPDATE aihr_practice_assignment UPDATE aihr_practice_assignment
@@ -2072,6 +2073,7 @@ public class AihrPracticeSeedService {
feedback = ?, feedback = ?,
correct = ?, correct = ?,
score = ?, score = ?,
score_mode = ?,
finished_time = ?, finished_time = ?,
update_time = ? update_time = ?
WHERE tenant_id = ? AND id = ? AND source = 'daily' WHERE tenant_id = ? AND id = ? AND source = 'daily'
@@ -2084,6 +2086,7 @@ public class AihrPracticeSeedService {
score.feedback(), score.feedback(),
score.correct() ? 1 : 0, score.correct() ? 1 : 0,
score.score(), score.score(),
score.mode(),
Timestamp.valueOf(now), Timestamp.valueOf(now),
Timestamp.valueOf(now), Timestamp.valueOf(now),
tenantId(), tenantId(),
@@ -2092,7 +2095,7 @@ public class AihrPracticeSeedService {
if (updated == 0) { if (updated == 0) {
return null; return null;
} }
return new DailyDrillAnswerResponse(id, "已完成", score.correct(), score.score(), score.feedback(), parts.referenceAnswer(), audioUrl, audioOssId); return new DailyDrillAnswerResponse(id, "已完成", score.correct(), score.score(), score.feedback(), parts.referenceAnswer(), score.mode(), audioUrl, audioOssId);
} }
public List<PromptTemplateResponse> promptTemplates(String category, String code, Boolean enabled) { public List<PromptTemplateResponse> promptTemplates(String category, String code, Boolean enabled) {
@@ -2674,6 +2677,7 @@ public class AihrPracticeSeedService {
"daily".equals(source) ? stripReferenceAnswer(rs.getString("feedback")) : rs.getString("feedback"), "daily".equals(source) ? stripReferenceAnswer(rs.getString("feedback")) : rs.getString("feedback"),
correctValue == null ? null : rs.getBoolean("correct"), correctValue == null ? null : rs.getBoolean("correct"),
rs.getObject("score") == null ? null : rs.getInt("score"), rs.getObject("score") == null ? null : rs.getInt("score"),
firstNonBlank(rs.getString("score_mode"), "RULE_FALLBACK"),
rs.getString("answer_audio_url"), rs.getString("answer_audio_url"),
rs.getObject("answer_audio_oss_id") == null ? null : rs.getLong("answer_audio_oss_id") rs.getObject("answer_audio_oss_id") == null ? null : rs.getLong("answer_audio_oss_id")
); );
@@ -2704,10 +2708,22 @@ public class AihrPracticeSeedService {
return new DailyDrillParts(question, reference); return new DailyDrillParts(question, reference);
} }
private DailyDrillScore scoreDailyDrill(String answer, String reference) { private DailyDrillScore scoreDailyDrill(String question, String answer, String reference) {
if (isBlank(answer)) { if (isBlank(answer)) {
return new DailyDrillScore(false, 0, "请先写出你的处理话术,再对照参考答案补齐关键动作。"); return new DailyDrillScore(false, 0, "请先写出你的处理话术,再对照参考答案补齐关键动作。", "RULE_FALLBACK");
} }
if (practiceLlmService != null) {
Optional<DailyDrillAssessment> assessment = practiceLlmService.scoreDailyDrill(question, reference, answer);
if (assessment.isPresent()) {
DailyDrillAssessment value = assessment.get();
int score = Math.max(0, Math.min(100, value.score()));
return new DailyDrillScore(score >= 70, score, value.feedback(), "LLM");
}
}
return scoreDailyDrillFallback(answer, reference);
}
private DailyDrillScore scoreDailyDrillFallback(String answer, String reference) {
List<String> required = dailyDrillKeywords(reference); List<String> required = dailyDrillKeywords(reference);
List<String> hit = new ArrayList<>(); List<String> hit = new ArrayList<>();
List<String> missing = new ArrayList<>(); List<String> missing = new ArrayList<>();
@@ -2727,7 +2743,7 @@ public class AihrPracticeSeedService {
String feedback = (correct ? "回答方向正确。" : "还缺关键动作。") String feedback = (correct ? "回答方向正确。" : "还缺关键动作。")
+ (hit.isEmpty() ? "" : "已命中:" + String.join("、", hit) + "。") + (hit.isEmpty() ? "" : "已命中:" + String.join("、", hit) + "。")
+ (missing.isEmpty() ? "" : "建议补充:" + String.join("、", missing) + "。"); + (missing.isEmpty() ? "" : "建议补充:" + String.join("、", missing) + "。");
return new DailyDrillScore(correct, score, feedback); return new DailyDrillScore(correct, score, feedback, "RULE_FALLBACK");
} }
private String stripReferenceAnswer(String feedback) { private String stripReferenceAnswer(String feedback) {
@@ -3581,6 +3597,7 @@ public class AihrPracticeSeedService {
`feedback` varchar(1000) DEFAULT NULL COMMENT '每日三题反馈', `feedback` varchar(1000) DEFAULT NULL COMMENT '每日三题反馈',
`correct` tinyint DEFAULT NULL COMMENT '是否答对', `correct` tinyint DEFAULT NULL COMMENT '是否答对',
`score` int DEFAULT NULL COMMENT '每日三题得分', `score` int DEFAULT NULL COMMENT '每日三题得分',
`score_mode` varchar(30) DEFAULT NULL COMMENT '评分模式 LLM/RULE_FALLBACK',
`finished_time` datetime DEFAULT NULL COMMENT '完成时间', `finished_time` datetime DEFAULT NULL 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 '更新时间',
@@ -3595,7 +3612,8 @@ public class AihrPracticeSeedService {
ensureColumn("aihr_practice_assignment", "feedback", "ALTER TABLE aihr_practice_assignment ADD COLUMN `feedback` varchar(1000) DEFAULT NULL COMMENT '每日三题反馈' AFTER `answer_audio_oss_id`"); ensureColumn("aihr_practice_assignment", "feedback", "ALTER TABLE aihr_practice_assignment ADD COLUMN `feedback` varchar(1000) DEFAULT NULL COMMENT '每日三题反馈' AFTER `answer_audio_oss_id`");
ensureColumn("aihr_practice_assignment", "correct", "ALTER TABLE aihr_practice_assignment ADD COLUMN `correct` tinyint DEFAULT NULL COMMENT '是否答对' AFTER `feedback`"); ensureColumn("aihr_practice_assignment", "correct", "ALTER TABLE aihr_practice_assignment ADD COLUMN `correct` tinyint DEFAULT NULL COMMENT '是否答对' AFTER `feedback`");
ensureColumn("aihr_practice_assignment", "score", "ALTER TABLE aihr_practice_assignment ADD COLUMN `score` int DEFAULT NULL COMMENT '每日三题得分' AFTER `correct`"); ensureColumn("aihr_practice_assignment", "score", "ALTER TABLE aihr_practice_assignment ADD COLUMN `score` int DEFAULT NULL COMMENT '每日三题得分' AFTER `correct`");
ensureColumn("aihr_practice_assignment", "finished_time", "ALTER TABLE aihr_practice_assignment ADD COLUMN `finished_time` datetime DEFAULT NULL COMMENT '完成时间' AFTER `score`"); ensureColumn("aihr_practice_assignment", "score_mode", "ALTER TABLE aihr_practice_assignment ADD COLUMN `score_mode` varchar(30) DEFAULT NULL COMMENT '评分模式 LLM/RULE_FALLBACK' AFTER `score`");
ensureColumn("aihr_practice_assignment", "finished_time", "ALTER TABLE aihr_practice_assignment ADD COLUMN `finished_time` datetime DEFAULT NULL COMMENT '完成时间' AFTER `score_mode`");
assignmentTableReady = true; assignmentTableReady = true;
} }
} }
@@ -4123,7 +4141,7 @@ public class AihrPracticeSeedService {
private record DailyDrillParts(String question, String referenceAnswer) { private record DailyDrillParts(String question, String referenceAnswer) {
} }
private record DailyDrillScore(boolean correct, int score, String feedback) { private record DailyDrillScore(boolean correct, int score, String feedback, String mode) {
} }
private record TurnEvidence(Integer emotion, Integer trust, Boolean redFlag, String coachHint) { private record TurnEvidence(Integer emotion, Integer trust, Boolean redFlag, String coachHint) {
@@ -0,0 +1,46 @@
package org.dromara.aihr.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@Tag("dev")
class AihrPracticeLlmServiceTest {
@Test
void dailyDrillAssessmentParsesStructuredModelResponse() {
AihrModelSeedService modelService = mock(AihrModelSeedService.class);
when(modelService.tryChat(anyString(), anyString(), eq(0.0)))
.thenReturn(Optional.of("{\"score\":86,\"feedback\":\"先承接情绪,再补责任人和反馈时间。\"}"));
AihrPracticeLlmService service = new AihrPracticeLlmService(modelService, new ObjectMapper());
var result = service.scoreDailyDrill(
"业主投诉车库积水,第一句话先说什么?",
"先承接情绪,再确认点位、责任人和首次反馈时间。",
"我先向您道歉并确认具体点位,安排责任人跟进,十分钟内给您首次反馈。"
);
assertTrue(result.isPresent());
assertEquals(86, result.get().score());
assertEquals("先承接情绪,再补责任人和反馈时间。", result.get().feedback());
}
@Test
void dailyDrillAssessmentRejectsUnstructuredResponse() {
AihrModelSeedService modelService = mock(AihrModelSeedService.class);
when(modelService.tryChat(anyString(), anyString(), eq(0.0)))
.thenReturn(Optional.of("模型暂时无法给出结构化结果"));
AihrPracticeLlmService service = new AihrPracticeLlmService(modelService, new ObjectMapper());
assertTrue(service.scoreDailyDrill("题目", "参考答案", "员工回答").isEmpty());
}
}
@@ -230,10 +230,13 @@ public class AihrPracticeSeedServiceTest {
assertTrue(answerStart >= 0 && answerEnd > answerStart); assertTrue(answerStart >= 0 && answerEnd > answerStart);
String answerMethod = source.substring(answerStart, answerEnd); String answerMethod = source.substring(answerStart, answerEnd);
assertTrue(answerMethod.contains("score = ?")); assertTrue(answerMethod.contains("score = ?"));
assertTrue(answerMethod.contains("score_mode = ?"));
assertTrue(answerMethod.contains("score.score()")); assertTrue(answerMethod.contains("score.score()"));
assertTrue(source.contains("feedback, correct, score")); assertTrue(source.contains("feedback, correct, score, score_mode"));
assertTrue(source.contains("rs.getObject(\"score\")")); assertTrue(source.contains("rs.getObject(\"score\")"));
assertTrue(source.contains("score.mode()"));
assertTrue(source.contains("ADD COLUMN `score` int")); assertTrue(source.contains("ADD COLUMN `score` int"));
assertTrue(source.contains("ADD COLUMN `score_mode` varchar(30)"));
} }
@Test @Test
@@ -107,6 +107,7 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_assignment` (
`feedback` varchar(1000) DEFAULT NULL COMMENT '每日三题反馈', `feedback` varchar(1000) DEFAULT NULL COMMENT '每日三题反馈',
`correct` tinyint DEFAULT NULL COMMENT '是否答对', `correct` tinyint DEFAULT NULL COMMENT '是否答对',
`score` int DEFAULT NULL COMMENT '每日三题得分', `score` int DEFAULT NULL COMMENT '每日三题得分',
`score_mode` varchar(30) DEFAULT NULL COMMENT '评分模式 LLM/RULE_FALLBACK',
`finished_time` datetime DEFAULT NULL COMMENT '完成时间', `finished_time` datetime DEFAULT NULL 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 '更新时间',
@@ -0,0 +1,23 @@
-- 每日三题评分模式留痕:幂等补列,不写入业务数据。
SET @db_name = DATABASE();
SET @table_exists = (
SELECT COUNT(*)
FROM information_schema.tables
WHERE table_schema = @db_name
AND table_name = 'aihr_practice_assignment'
);
SET @column_exists = (
SELECT COUNT(*)
FROM information_schema.columns
WHERE table_schema = @db_name
AND table_name = 'aihr_practice_assignment'
AND column_name = 'score_mode'
);
SET @ddl = IF(
@table_exists = 1 AND @column_exists = 0,
'ALTER TABLE aihr_practice_assignment ADD COLUMN score_mode varchar(30) DEFAULT NULL COMMENT ''评分模式 LLM/RULE_FALLBACK'' AFTER score',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
+1
View File
@@ -299,3 +299,4 @@
- 2026-07-14 发布预检业务码误匹配修复:业务码校验进一步收紧为 JSON 顶层首字段 `code=200`,不再接受嵌套对象中的同名 `code=200`;本地成功/嵌套失败样例与线上租户接口均验证通过。三端产物已重建且时间不早于当前提交,完整预检仍因两份用户未提交的 Figma 文档停止,本轮未发布生产。 - 2026-07-14 发布预检业务码误匹配修复:业务码校验进一步收紧为 JSON 顶层首字段 `code=200`,不再接受嵌套对象中的同名 `code=200`;本地成功/嵌套失败样例与线上租户接口均验证通过。三端产物已重建且时间不早于当前提交,完整预检仍因两份用户未提交的 Figma 文档停止,本轮未发布生产。
- 2026-07-14 BRD 最新线上只读复核:生产根站与 `/h5/` 返回 `200`,线上仍加载管理端 `assets/index-CJZ3Ax3Z.js` 与 H5 `assets/index-D4-NrEpb.js`;当前本地提交 `94d483d3` 构建资源分别为 `assets/index-Cv2wI9UI.js`(SHA-256 `cd6e3eab4a684141ccd64617dcde4d99030c9c13f7da618c11e1709c04f244a0`)和 `assets/index-Dq7sfWZP.js`(SHA-256 `f8499e5f261cc06d02de275e285e4a7c946dd6ef54fd29b3a1c0bc7a9a689c3d`)。本轮只读核验,未执行生产静态资源同步、后端重启或业务数据写入;五维修复仍未被证明在线上生效。 - 2026-07-14 BRD 最新线上只读复核:生产根站与 `/h5/` 返回 `200`,线上仍加载管理端 `assets/index-CJZ3Ax3Z.js` 与 H5 `assets/index-D4-NrEpb.js`;当前本地提交 `94d483d3` 构建资源分别为 `assets/index-Cv2wI9UI.js`(SHA-256 `cd6e3eab4a684141ccd64617dcde4d99030c9c13f7da618c11e1709c04f244a0`)和 `assets/index-Dq7sfWZP.js`(SHA-256 `f8499e5f261cc06d02de275e285e4a7c946dd6ef54fd29b3a1c0bc7a9a689c3d`)。本轮只读核验,未执行生产静态资源同步、后端重启或业务数据写入;五维修复仍未被证明在线上生效。
- 2026-07-14 BRD 4.7/M5 SOP 试点评审主体留痕修复:SOP 搜索生成评审记录时,现仅从已认证 APP 用户上下文保存 `requester_ext_party_id`,不接受客户端传入主体;管理端/系统搜索保持空值。正式 SOP 评审统计和严格门禁只统计已评审、有提问员工主体且能命中当前租户在职组织快照的记录,避免管理员烟测、临时 APP 账号或无组织身份数据混入正式可用率;同步补 reset SQL、幂等迁移脚本、运行时补列和回归门禁。该修复只完善证据归属,仍需真实员工提问和人工评审样本,不能把有主体字段等同于 M5 已达标。 - 2026-07-14 BRD 4.7/M5 SOP 试点评审主体留痕修复:SOP 搜索生成评审记录时,现仅从已认证 APP 用户上下文保存 `requester_ext_party_id`,不接受客户端传入主体;管理端/系统搜索保持空值。正式 SOP 评审统计和严格门禁只统计已评审、有提问员工主体且能命中当前租户在职组织快照的记录,避免管理员烟测、临时 APP 账号或无组织身份数据混入正式可用率;同步补 reset SQL、幂等迁移脚本、运行时补列和回归门禁。该修复只完善证据归属,仍需真实员工提问和人工评审样本,不能把有主体字段等同于 M5 已达标。
- 2026-07-14 BRD 4.4 每日三题评分模式补齐:每日题提交现优先调用现有 chat 模型做 temperature=0 的结构化语义评估;模型未配置、成本闸门关闭或 JSON 解析失败时继续使用原关键词/长度规则,并在 `aihr_practice_assignment.score_mode` 留痕为 `LLM` 或 `RULE_FALLBACK`。员工端完成反馈同步显示评分模式,避免把规则分数误称为 AI 评测;新增幂等迁移、AIHR 单测和 demo marker。正式试点仍需确认模型供应商、成本预算和人工抽检,不把 LLM 路径可调用等同于 BRD 4.4 的质量达标。
+5 -2
View File
@@ -370,7 +370,8 @@ const assignmentDesc = (item: PracticeAssignment) => {
if (item.source === 'daily' && item.status !== '已完成') return '提交后显示反馈与参考答案'; if (item.source === 'daily' && item.status !== '已完成') return '提交后显示反馈与参考答案';
if (item.source === 'daily' && item.status === '已完成') { if (item.source === 'daily' && item.status === '已完成') {
const score = typeof item.score === 'number' ? `得分 ${item.score} 分` : ''; const score = typeof item.score === 'number' ? `得分 ${item.score} 分` : '';
return [score, item.feedback || item.reason].filter(Boolean).join(' · '); const mode = item.scoreMode === 'LLM' ? 'AI语义评估' : '规则兜底';
return [score, mode, item.feedback || item.reason].filter(Boolean).join(' · ');
} }
return item.feedback || item.reason; return item.feedback || item.reason;
}; };
@@ -682,12 +683,14 @@ const submitDaily = async (item: PracticeAssignment) => {
feedback: result.feedback, feedback: result.feedback,
referenceAnswer: result.referenceAnswer, referenceAnswer: result.referenceAnswer,
correct: result.correct, correct: result.correct,
score: result.score,
scoreMode: result.scoreMode,
audioUrl: result.audioUrl || audioUrl, audioUrl: result.audioUrl || audioUrl,
audioOssId: result.audioOssId || audioOssId audioOssId: result.audioOssId || audioOssId
} }
: row : row
); );
message.value = `每日三题得分 ${result.score}`; message.value = `每日三题得分 ${result.score}(${result.scoreMode === 'LLM' ? 'AI语义评估' : '规则兜底'})`;
} catch (error) { } catch (error) {
handleError(error, '每日三题提交失败'); handleError(error, '每日三题提交失败');
} finally { } finally {
+2
View File
@@ -310,6 +310,7 @@ export interface PracticeAssignment {
feedback?: string; feedback?: string;
correct?: boolean | null; correct?: boolean | null;
score?: number | null; score?: number | null;
scoreMode?: string;
audioUrl?: string; audioUrl?: string;
audioOssId?: number | string; audioOssId?: number | string;
} }
@@ -330,6 +331,7 @@ export interface DailyDrillAnswerResponse {
score: number; score: number;
feedback: string; feedback: string;
referenceAnswer: string; referenceAnswer: string;
scoreMode?: string;
audioUrl?: string; audioUrl?: string;
audioOssId?: number | string; audioOssId?: number | string;
} }
+4
View File
@@ -565,6 +565,10 @@ contains mobile-uni/src/pages/supervisor/review/index.vue "人工贡献分"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "/practice/reviews/{id}/reviewed" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "/practice/reviews/{id}/reviewed"
contains mobile-uni/src/pages/supervisor/review/index.vue "标记已复盘" contains mobile-uni/src/pages/supervisor/review/index.vue "标记已复盘"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "source = 'retry'" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "source = 'retry'"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "score_mode"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeLlmService.java "scoreDailyDrill"
contains backend/script/sql/update/aihr_20260714_daily_drill_score_mode_mysql8.sql "score_mode"
contains mobile-uni/src/pages/user/practice/index.vue "AI语义评估"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java 'String source = "manual"' contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java 'String source = "manual"'
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "训练场景已停用,无法开始" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "训练场景已停用,无法开始"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "scenario.id(), scenario.name(), source" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "scenario.id(), scenario.name(), source"