fix(aihr): add semantic daily drill scoring fallback
This commit is contained in:
+2
@@ -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
|
||||||
) {
|
) {
|
||||||
|
|||||||
+46
@@ -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));
|
||||||
|
|||||||
+30
-12
@@ -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) {
|
||||||
|
|||||||
+46
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-1
@@ -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;
|
||||||
@@ -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 的质量达标。
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user