fix(aihr): align practice scoring with BRD five dimensions

This commit is contained in:
2026-07-14 15:12:47 +08:00
parent 3f39be7fee
commit 94d483d334
11 changed files with 391 additions and 74 deletions
@@ -161,6 +161,8 @@ public class AihrPracticeController {
request == null ? null : request.correctedEmotion(),
request == null ? null : request.correctedCommunication(),
request == null ? null : request.correctedMarketing(),
request == null ? null : request.correctedTaskCompletion(),
request == null ? null : request.correctedResponseTimeliness(),
request == null ? null : request.reason()
);
}
@@ -196,8 +196,16 @@ public final class AihrPracticeDto {
Integer correctedEmotion,
Integer correctedCommunication,
Integer correctedMarketing,
Integer correctedTaskCompletion,
Integer correctedResponseTimeliness,
String reason
) {
public CalibrationRequest(String reviewer, Integer correctedTotal, Integer correctedCompliance,
Integer correctedEmotion, Integer correctedCommunication, Integer correctedMarketing,
String reason) {
this(reviewer, correctedTotal, correctedCompliance, correctedEmotion, correctedCommunication,
correctedMarketing, null, null, reason);
}
}
public record CalibrationResponse(Long id, String sessionId, Integer originalScore, Integer correctedScore, String reviewer, String reason, String status, String createTime) {
@@ -33,9 +33,15 @@ public class AihrPracticeLlmService {
int communication,
int emotion,
int marketing,
int taskCompletion,
int responseTimeliness,
String rewrite,
String summary
) {
public PracticeScore(int total, int compliance, int communication, int emotion, int marketing,
String rewrite, String summary) {
this(total, compliance, communication, emotion, marketing, compliance, -1, rewrite, summary);
}
}
public record PracticeTurn(
@@ -140,7 +146,8 @@ public class AihrPracticeLlmService {
评分锚点:60=及格线(有明显缺失);75=合格(覆盖主要要点);90=优秀(完整且超预期)。
约束:只根据"员工"实际说过的话评分,未提及的内容不得臆造加分。
只输出一个 JSON 对象,不要输出任何其他文字、解释或代码块标记,字段如下:
{"total":0-100,"compliance":0-100,"communication":0-100,"emotion":0-100,"marketing":0-100,"rewrite":"给员工的示范话术改写,80字内","summary":"一句话点评,60字内"}
{"total":0-100,"taskCompletion":0-100,"compliance":0-100,"communication":0-100,"emotion":0-100,"responseTimeliness":0-100或-1,"marketing":0-100,"rewrite":"给员工的示范话术改写,80字内","summary":"一句话点评,60字内"}
taskCompletion 评估问题是否真正被推进到可执行闭环;responseTimeliness 只有对话中存在明确响应时效证据时填写,否则填 -1。
""".formatted(PROMPT_VERSION);
String user = """
训练场景:%s
@@ -166,6 +173,8 @@ public class AihrPracticeLlmService {
dimension(root, "communication", total),
dimension(root, "emotion", total),
dimension(root, "marketing", total),
dimension(root, "taskCompletion", dimension(root, "compliance", total)),
dimensionOrMissing(root, "responseTimeliness"),
truncateText(root.path("rewrite").asText(""), 400),
truncateText(root.path("summary").asText(""), 300)
));
@@ -204,6 +213,15 @@ public class AihrPracticeLlmService {
return clampScore(node.asInt(fallback));
}
private static int dimensionOrMissing(JsonNode root, String field) {
JsonNode node = root.path(field);
if (!node.isNumber() && !node.isTextual()) {
return -1;
}
int value = node.asInt(-1);
return value < 0 ? -1 : clampScore(value);
}
/** 落库列为 varchar(1000),模型输出截断兜底。 */
private static String truncateText(String value, int maxChars) {
if (value == null || value.length() <= maxChars) {
@@ -90,6 +90,11 @@ public class AihrPracticeSeedService {
"professional", "表达更专业,突出SOP依据、责任边界、留痕和闭环口径。"
);
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("MM-dd HH:mm");
// 阶段一只统计“员工收到业主话术后到提交下一轮”的陪练响应时长;正式首响/工单处理时效仍需业务系统数据。
private static final long RESPONSE_FAST_MS = 5_000L;
private static final long RESPONSE_ACCEPTABLE_MS = 10_000L;
private static final long RESPONSE_SLOW_MS = 20_000L;
private static final long RESPONSE_VERY_SLOW_MS = 30_000L;
private static final String PILOT_SCOPE_CTE = """
WITH pilot_params AS (
SELECT ? AS tenant_id, ? AS start_time, ? AS end_time
@@ -489,7 +494,8 @@ public class AihrPracticeSeedService {
String trainee = resolveTrainee(request, scenario);
List<String> customerLines = new ArrayList<>();
customerLines.add(firstRound.customer());
activeSessions.put(sessionId, new ActiveSession(scenario.id(), trainee, resolveExtPartyId(request, trainee), request == null ? null : request.assignmentId(), isMobile(request), LocalDateTime.now(), new ArrayList<>(), new ArrayList<>(), new ArrayList<>(), customerLines, new ArrayList<>()));
long promptPresentedAt = System.currentTimeMillis();
activeSessions.put(sessionId, new ActiveSession(scenario.id(), trainee, resolveExtPartyId(request, trainee), request == null ? null : request.assignmentId(), isMobile(request), LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(promptPresentedAt), java.time.ZoneId.systemDefault()), new ArrayList<>(), new ArrayList<>(), new ArrayList<>(), customerLines, new ArrayList<>(), new ArrayList<>(List.of(promptPresentedAt)), new ArrayList<>()));
return new StartResponse(
sessionId,
scenario.id(),
@@ -518,6 +524,7 @@ public class AihrPracticeSeedService {
if (request != null && Boolean.TRUE.equals(request.regenerate())) {
return regenerateCoachHint(request, session, scenario, roundIndex, style);
}
rememberResponseLatency(session, roundIndex, System.currentTimeMillis());
rememberTraineeReply(request, roundIndex);
RoundSeed currentRound = scenario.rounds().get(roundIndex);
int nextRoundIndex = roundIndex + 1;
@@ -529,6 +536,9 @@ public class AihrPracticeSeedService {
boolean redFlag = localRedFlag || resolved.redFlag();
String coachHint = redFlag ? redFlagCoachHint(resolved.coachHint()) : resolved.coachHint();
rememberTurnEvidence(session, roundIndex, resolved.emotion(), resolved.trust(), redFlag, coachHint);
if (!finished) {
rememberPromptPresentedAt(session, nextRoundIndex, System.currentTimeMillis());
}
return new TurnResponse(
resolved.customerText(),
"",
@@ -681,25 +691,64 @@ public class AihrPracticeSeedService {
* /finish 单次评分:配置了 chat 模型且学员有真实回复时走 LLM 结构化评分,否则用 seed 分。
*/
private PracticeResult evaluate(ActiveSession activeSession, ScenarioSeed scenario) {
PracticeResult seedResult = new PracticeResult(scenario.total(), scenario.scores(), scenario.rewrite(), scenario.summary());
PracticeResult seedResult = seedPracticeResult(activeSession, scenario);
if (activeSession == null) {
return seedResult;
}
return practiceLlmService.score(scenario.name(), scenario.goal(), scenario.strategy(), dialogueTurns(activeSession, scenario))
.map(score -> new PracticeResult(
score.total(),
List.of(
new DimensionResponse("合规", score.compliance(), "SOP关键点覆盖"),
new DimensionResponse("沟通", score.communication(), "承诺与表达清晰度"),
new DimensionResponse("情绪", score.emotion(), "安抚与承接能力"),
new DimensionResponse("营销", score.marketing(), "增值转化意识")
),
isBlank(score.rewrite()) ? scenario.rewrite() : score.rewrite(),
isBlank(score.summary()) ? scenario.summary() : score.summary()
))
.map(score -> {
Integer measuredResponse = responseTimelinessScore(activeSession);
Integer response = measuredResponse == null && score.responseTimeliness() >= 0
? score.responseTimeliness() : measuredResponse;
int standardization = average(score.compliance(), score.communication());
int total = response == null
? score.total()
: weightedPracticeScore(score.taskCompletion(), standardization, score.emotion(), response, score.marketing());
return new PracticeResult(
total,
brdDimensions(score.taskCompletion(), standardization, score.emotion(), response, score.marketing()),
isBlank(score.rewrite()) ? scenario.rewrite() : score.rewrite(),
isBlank(score.summary()) ? scenario.summary() : score.summary(),
score.compliance(), score.communication(), score.taskCompletion(), response,
averageResponseLatencyMs(activeSession)
);
})
.orElse(seedResult);
}
private PracticeResult seedPracticeResult(ActiveSession activeSession, ScenarioSeed scenario) {
int compliance = scoreValue(scenario.scores(), "合规", scenario.total());
int communication = scoreValue(scenario.scores(), "沟通", scenario.total());
int emotion = scoreValue(scenario.scores(), "情绪", scenario.total());
int marketing = scoreValue(scenario.scores(), "营销", scenario.total());
Integer response = responseTimelinessScore(activeSession);
int total = response == null
? scenario.total()
: weightedPracticeScore(compliance, average(compliance, communication), emotion, response, marketing);
return new PracticeResult(
total,
brdDimensions(compliance, average(compliance, communication), emotion, response, marketing),
scenario.rewrite(), scenario.summary(), compliance, communication, compliance, response,
averageResponseLatencyMs(activeSession)
);
}
private List<DimensionResponse> brdDimensions(Integer taskCompletion, Integer standardization, Integer emotion,
Integer responseTimeliness, Integer marketing) {
return List.of(
new DimensionResponse("任务完成度", taskCompletion, "权重40%;当前以SOP执行和问题推进结果代理"),
new DimensionResponse("话术规范性", standardization, "权重25%;合规与沟通训练维度综合"),
new DimensionResponse("情绪管理能力", emotion, "权重20%;共情承接与信任恢复"),
new DimensionResponse("响应时效", responseTimeliness == null ? "待采集" : responseTimeliness, "权重10%;阶段一按陪练回合响应时长统计"),
new DimensionResponse("增值转化潜力", marketing, "权重5%;增值引导与办理意识")
);
}
private int weightedPracticeScore(int taskCompletion, int standardization, int emotion, int responseTimeliness, int marketing) {
return Math.round(taskCompletion * 0.40f + standardization * 0.25f + emotion * 0.20f
+ responseTimeliness * 0.10f + marketing * 0.05f);
}
public int mobileCompletedCount() {
return countMobileRecords("");
}
@@ -1334,7 +1383,9 @@ public class AihrPracticeSeedService {
}
List<ReviewDetailResponse> rows = jdbcTemplate.query("""
SELECT id, session_id, finished_time, trainee_name, scenario_id, scenario_name, total_score, status,
summary, mentor_rewrite, ai_comment, review_advice, incentive_point, dim_compliance, dim_emotion, dim_communication, dim_marketing, dialogue_json, annotations_json
summary, mentor_rewrite, ai_comment, review_advice, incentive_point,
dim_task_completion, dim_response_timeliness, response_latency_ms,
dim_compliance, dim_emotion, dim_communication, dim_marketing, dialogue_json, annotations_json
FROM aihr_practice_session
WHERE tenant_id = ? AND mode = 'mobile' AND id = ?
""", this::mapReviewDetail, tenantId(), id);
@@ -1497,16 +1548,19 @@ public class AihrPracticeSeedService {
return null;
}
List<ScoreSnapshot> rows = jdbcTemplate.query("""
SELECT total_score, dim_compliance, dim_emotion, dim_communication, dim_marketing
SELECT total_score, dim_task_completion, dim_response_timeliness,
dim_compliance, dim_emotion, dim_communication, dim_marketing
FROM aihr_practice_session
WHERE tenant_id = ? AND session_id = ?
LIMIT 1
""", (rs, rowNum) -> new ScoreSnapshot(
rs.getInt("total_score"),
rs.getInt("dim_compliance"),
rs.getInt("dim_emotion"),
rs.getInt("dim_communication"),
rs.getInt("dim_marketing")
nullableInt(rs, "total_score"),
nullableInt(rs, "dim_task_completion"),
nullableInt(rs, "dim_response_timeliness"),
nullableInt(rs, "dim_compliance"),
nullableInt(rs, "dim_emotion"),
nullableInt(rs, "dim_communication"),
nullableInt(rs, "dim_marketing")
), tenantId(), sessionId.trim());
if (rows.isEmpty()) {
return null;
@@ -1567,9 +1621,11 @@ public class AihrPracticeSeedService {
COUNT(*) completed,
COALESCE(ROUND(AVG(total_score)), 0) score,
SUM(CASE WHEN status = '待复盘' THEN 1 ELSE 0 END) pending_review,
COALESCE(ROUND(AVG(COALESCE(dim_task_completion, dim_compliance))), 0) task_completion,
COALESCE(ROUND(AVG(dim_compliance)), 0) compliance,
COALESCE(ROUND(AVG(dim_communication)), 0) communication,
COALESCE(ROUND(AVG(dim_emotion)), 0) emotion,
ROUND(AVG(dim_response_timeliness)) response_timeliness,
COALESCE(ROUND(AVG(dim_marketing)), 0) marketing,
COALESCE(SUM(incentive_point), 0) incentive_points,
COALESCE(ROUND(SUM(CASE
@@ -1583,14 +1639,16 @@ public class AihrPracticeSeedService {
rs.getInt("completed"),
rs.getInt("score"),
rs.getInt("pending_review"),
nullableInt(rs, "task_completion"),
rs.getInt("compliance"),
rs.getInt("communication"),
rs.getInt("emotion"),
nullableInt(rs, "response_timeliness"),
rs.getInt("marketing"),
rs.getInt("incentive_points"),
rs.getInt("training_minutes")
), tenantId(), party, party);
CompetencySnapshot snapshot = rows.isEmpty() ? new CompetencySnapshot(0, 0, 0, 0, 0, 0, 0, 0, 0) : rows.get(0);
CompetencySnapshot snapshot = rows.isEmpty() ? new CompetencySnapshot(0, 0, 0, null, 0, 0, 0, null, 0, 0, 0) : rows.get(0);
int trainingMinutes = snapshot.trainingMinutes();
int contribution = Math.min(100, Math.max(0, snapshot.incentivePoints()));
int assessmentScore = snapshot.score();
@@ -1604,14 +1662,14 @@ public class AihrPracticeSeedService {
assessmentScore,
aiLevel,
List.of(
new DimensionResponse("任务完成度", null,
"权重40%;需接入工单闭环率、首解率与处理结果,当前待采集"),
new DimensionResponse("任务完成度", snapshot.completed() == 0 ? null : firstNonNull(snapshot.taskCompletion(), snapshot.compliance()),
"权重40%;当前以SOP执行和问题推进结果代理,工单闭环率/首解率待接入"),
new DimensionResponse("话术规范性", snapshot.completed() == 0 ? null : average(snapshot.compliance(), snapshot.communication()),
"权重25%;当前以合规+沟通训练维度代理,正式绩效口径需HR确认"),
new DimensionResponse("情绪管理能力", snapshot.completed() == 0 ? null : snapshot.emotion(),
"权重20%;当前以陪练情绪维度代理"),
new DimensionResponse("响应时效", null,
"权重10%;需接入首响时长、处理周期与跟进频率,当前待采集"),
new DimensionResponse("响应时效", snapshot.completed() == 0 ? null : snapshot.responseTimeliness(),
"权重10%;当前按陪练回合响应时长统计,工单首响/处理周期待接入"),
new DimensionResponse("增值转化潜力", snapshot.completed() == 0 ? null : snapshot.marketing(),
"权重5%;当前以陪练营销维度代理,正式口径需HR确认")
),
@@ -2221,11 +2279,15 @@ public class AihrPracticeSeedService {
jdbcTemplate.update("""
INSERT INTO aihr_practice_session
(tenant_id, session_id, ext_party_id, trainee_name, scenario_id, scenario_name, mode,
total_score, dim_compliance, dim_emotion, dim_communication, dim_marketing,
total_score, dim_task_completion, dim_response_timeliness, response_latency_ms,
dim_compliance, dim_emotion, dim_communication, dim_marketing,
mentor_rewrite, ai_comment, summary, dialogue_json, annotations_json, trust_curve, status, started_time, finished_time, create_time, update_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
total_score = VALUES(total_score),
dim_task_completion = VALUES(dim_task_completion),
dim_response_timeliness = VALUES(dim_response_timeliness),
response_latency_ms = VALUES(response_latency_ms),
dim_compliance = VALUES(dim_compliance),
dim_emotion = VALUES(dim_emotion),
dim_communication = VALUES(dim_communication),
@@ -2248,10 +2310,13 @@ public class AihrPracticeSeedService {
scenario.name(),
mobile ? "mobile" : "text",
result.total(),
scoreValue(result.scores(), "合规"),
scoreValue(result.scores(), "情绪"),
scoreValue(result.scores(), "沟通"),
scoreValue(result.scores(), "营销"),
result.taskCompletion(),
result.responseTimeliness(),
result.responseLatencyMs(),
result.compliance(),
scoreValue(result.scores(), "情绪管理能力"),
result.communication(),
scoreValue(result.scores(), "增值转化潜力"),
result.rewrite(),
result.summary(),
record.summary(),
@@ -2771,17 +2836,27 @@ public class AihrPracticeSeedService {
private List<DimensionResponse> scoreItems(ResultSet rs) throws SQLException {
List<DimensionResponse> items = new ArrayList<>();
addScoreItem(items, "合规", rs.getObject("dim_compliance"), "SOP关键点覆盖");
addScoreItem(items, "沟通", rs.getObject("dim_communication"), "承诺与表达清晰度");
addScoreItem(items, "情绪", rs.getObject("dim_emotion"), "安抚与承接能力");
addScoreItem(items, "营销", rs.getObject("dim_marketing"), "增值转化意识");
Integer compliance = nullableInt(rs, "dim_compliance");
Integer communication = nullableInt(rs, "dim_communication");
addScoreItem(items, "任务完成度", firstNonNull(nullableInt(rs, "dim_task_completion"), compliance), "权重40%;当前以SOP执行和问题推进结果代理");
addScoreItem(items, "话术规范性", average(compliance, communication), "权重25%;合规与沟通训练维度综合");
addScoreItem(items, "情绪管理能力", nullableInt(rs, "dim_emotion"), "权重20%;共情承接与信任恢复");
addScoreItem(items, "响应时效", nullableInt(rs, "dim_response_timeliness"), "权重10%;阶段一按陪练回合响应时长统计");
addScoreItem(items, "增值转化潜力", nullableInt(rs, "dim_marketing"), "权重5%;增值引导与办理意识");
return items;
}
private void addScoreItem(List<DimensionResponse> items, String label, Object value, String note) {
if (value instanceof Number number) {
items.add(new DimensionResponse(label, number.intValue(), note));
}
items.add(new DimensionResponse(label, value == null ? "待采集" : value, note));
}
private Integer nullableInt(ResultSet rs, String column) throws SQLException {
Object value = rs.getObject(column);
return value instanceof Number number ? number.intValue() : null;
}
private Integer firstNonNull(Integer value, Integer fallback) {
return value == null ? fallback : value;
}
private ScenarioResponse mapScenarioResponse(ResultSet rs, int rowNum) throws SQLException {
@@ -2892,10 +2967,11 @@ public class AihrPracticeSeedService {
private List<RubricDimensionResponse> defaultRubricDimensions(boolean feeScenario) {
return List.of(
new RubricDimensionResponse("compliance", "合规", feeScenario ? 0.30 : 0.35, "SOP关键点、权限边界、承诺口径"),
new RubricDimensionResponse("communication", "沟通", 0.25, "诉求确认、信息结构、反馈节点"),
new RubricDimensionResponse("emotion", "情绪", 0.25, "共情承接、降温、信任恢复"),
new RubricDimensionResponse("marketing", "营销", feeScenario ? 0.20 : 0.15, "收费季转化、政策解释、办理引导")
new RubricDimensionResponse("task_completion", "任务完成度", 0.40, "问题解决有效性、工单闭环率、首解率、SOP关键点执行率"),
new RubricDimensionResponse("standardization", "话术规范性", 0.25, "流程完整性、法规引用准确性、沟通合规、用语专业度"),
new RubricDimensionResponse("emotion", "情绪管理能力", 0.20, "共情、语速语气控制、业主情绪引导安抚有效性"),
new RubricDimensionResponse("response_timeliness", "响应时效", 0.10, "首响时长、处理周期、跟进频率"),
new RubricDimensionResponse("marketing", "增值转化潜力", 0.05, "活动引导、产品推介时机与成功率")
);
}
@@ -2969,6 +3045,78 @@ public class AihrPracticeSeedService {
}
}
private void rememberResponseLatency(ActiveSession session, int roundIndex, long submittedAt) {
if (session == null) {
return;
}
List<Long> promptTimes = session.promptPresentedAtMillis();
List<Long> latencies = session.responseLatenciesMs();
synchronized (promptTimes) {
if (roundIndex < 0 || roundIndex >= promptTimes.size() || promptTimes.get(roundIndex) == null) {
return;
}
long latency = Math.max(0L, submittedAt - promptTimes.get(roundIndex));
synchronized (latencies) {
while (latencies.size() <= roundIndex) {
latencies.add(null);
}
latencies.set(roundIndex, latency);
}
}
}
private void rememberPromptPresentedAt(ActiveSession session, int roundIndex, long presentedAt) {
if (session == null || roundIndex < 0) {
return;
}
List<Long> promptTimes = session.promptPresentedAtMillis();
synchronized (promptTimes) {
while (promptTimes.size() <= roundIndex) {
promptTimes.add(null);
}
promptTimes.set(roundIndex, presentedAt);
}
}
private Integer averageResponseLatencyMs(ActiveSession session) {
if (session == null) {
return null;
}
List<Long> latencies = snapshotLong(session.responseLatenciesMs());
long total = 0L;
int count = 0;
for (Long latency : latencies) {
if (latency != null && latency >= 0) {
total += latency;
count++;
}
}
if (count == 0) {
return null;
}
return (int) Math.min(Integer.MAX_VALUE, Math.round(total / (double) count));
}
private Integer responseTimelinessScore(ActiveSession session) {
Integer latency = averageResponseLatencyMs(session);
if (latency == null) {
return null;
}
if (latency <= RESPONSE_FAST_MS) {
return 100;
}
if (latency <= RESPONSE_ACCEPTABLE_MS) {
return 85;
}
if (latency <= RESPONSE_SLOW_MS) {
return 70;
}
if (latency <= RESPONSE_VERY_SLOW_MS) {
return 55;
}
return 40;
}
private void rememberTurnEvidence(ActiveSession session, int roundIndex, Integer emotion, Integer trust, boolean redFlag, String coachHint) {
if (session == null) {
return;
@@ -3163,18 +3311,24 @@ public class AihrPracticeSeedService {
private String calibrationDimensionsJson(ScoreSnapshot score, CalibrationRequest request) {
Map<String, Object> data = new LinkedHashMap<>();
data.put("original", Map.of(
"total", score.total(),
"compliance", score.compliance(),
"emotion", score.emotion(),
"communication", score.communication(),
"marketing", score.marketing()
"total", firstNonNull(score.total(), 0),
"taskCompletion", firstNonNull(firstNonNull(score.taskCompletion(), score.compliance()), 0),
"standardization", firstNonNull(average(score.compliance(), score.communication()), 0),
"emotion", firstNonNull(score.emotion(), 0),
"responseTimeliness", firstNonNull(score.responseTimeliness(), 0),
"conversion", firstNonNull(score.marketing(), 0)
));
Map<String, Object> corrected = new LinkedHashMap<>();
corrected.put("total", request == null || request.correctedTotal() == null ? score.total() : clampScore(request.correctedTotal()));
corrected.put("compliance", request == null || request.correctedCompliance() == null ? score.compliance() : clampScore(request.correctedCompliance()));
corrected.put("emotion", request == null || request.correctedEmotion() == null ? score.emotion() : clampScore(request.correctedEmotion()));
corrected.put("communication", request == null || request.correctedCommunication() == null ? score.communication() : clampScore(request.correctedCommunication()));
corrected.put("marketing", request == null || request.correctedMarketing() == null ? score.marketing() : clampScore(request.correctedMarketing()));
corrected.put("total", request == null || request.correctedTotal() == null ? firstNonNull(score.total(), 0) : clampScore(request.correctedTotal()));
corrected.put("taskCompletion", request == null || request.correctedTaskCompletion() == null
? firstNonNull(firstNonNull(score.taskCompletion(), score.compliance()), 0) : clampScore(request.correctedTaskCompletion()));
corrected.put("standardization", request == null || (request.correctedCompliance() == null && request.correctedCommunication() == null)
? firstNonNull(average(score.compliance(), score.communication()), 0)
: firstNonNull(average(firstNonNull(request.correctedCompliance(), score.compliance()), firstNonNull(request.correctedCommunication(), score.communication())), 0));
corrected.put("emotion", request == null || request.correctedEmotion() == null ? firstNonNull(score.emotion(), 0) : clampScore(request.correctedEmotion()));
corrected.put("responseTimeliness", request == null || request.correctedResponseTimeliness() == null
? firstNonNull(score.responseTimeliness(), 0) : clampScore(request.correctedResponseTimeliness()));
corrected.put("conversion", request == null || request.correctedMarketing() == null ? firstNonNull(score.marketing(), 0) : clampScore(request.correctedMarketing()));
data.put("corrected", corrected);
return writeJson(data);
}
@@ -3321,6 +3475,11 @@ public class AihrPracticeSeedService {
return null;
}
private int scoreValue(List<DimensionResponse> scores, String label, int fallback) {
Integer value = scoreValue(scores, label);
return value == null ? fallback : value;
}
private int normalizeLimit(int limit) {
if (limit <= 0) {
return 5;
@@ -3355,6 +3514,9 @@ public class AihrPracticeSeedService {
`scenario_name` varchar(100) DEFAULT NULL COMMENT '场景名称',
`mode` varchar(30) DEFAULT 'text' COMMENT '训练模式',
`total_score` int DEFAULT NULL COMMENT '总分',
`dim_task_completion` int DEFAULT NULL COMMENT '任务完成度分',
`dim_response_timeliness` int DEFAULT NULL COMMENT '响应时效分',
`response_latency_ms` int DEFAULT NULL COMMENT '平均陪练响应时长毫秒',
`dim_compliance` int DEFAULT NULL COMMENT '合规分',
`dim_emotion` int DEFAULT NULL COMMENT '情绪分',
`dim_communication` int DEFAULT NULL COMMENT '沟通分',
@@ -3380,6 +3542,9 @@ public class AihrPracticeSeedService {
KEY `idx_aihr_practice_review` (`tenant_id`, `mode`, `status`, `finished_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 对练记录';
""");
ensurePracticeColumn("dim_task_completion", "ALTER TABLE aihr_practice_session ADD COLUMN `dim_task_completion` int DEFAULT NULL COMMENT '任务完成度分' AFTER `total_score`");
ensurePracticeColumn("dim_response_timeliness", "ALTER TABLE aihr_practice_session ADD COLUMN `dim_response_timeliness` int DEFAULT NULL COMMENT '响应时效分' AFTER `dim_task_completion`");
ensurePracticeColumn("response_latency_ms", "ALTER TABLE aihr_practice_session ADD COLUMN `response_latency_ms` int DEFAULT NULL COMMENT '平均陪练响应时长毫秒' AFTER `dim_response_timeliness`");
ensurePracticeColumn("dialogue_json", "ALTER TABLE aihr_practice_session ADD COLUMN `dialogue_json` text DEFAULT NULL COMMENT '对练话术JSON' AFTER `summary`");
ensurePracticeColumn("annotations_json", "ALTER TABLE aihr_practice_session ADD COLUMN `annotations_json` text DEFAULT NULL COMMENT '逐句标注JSON' AFTER `dialogue_json`");
ensurePracticeColumn("review_advice", "ALTER TABLE aihr_practice_session ADD COLUMN `review_advice` varchar(1000) DEFAULT NULL COMMENT '主管复盘建议' AFTER `ai_comment`");
@@ -3907,7 +4072,9 @@ public class AihrPracticeSeedService {
return Math.round((left + right) / 2.0f);
}
private record CompetencySnapshot(Integer completed, Integer score, Integer pendingReview, Integer compliance, Integer communication, Integer emotion, Integer marketing, Integer incentivePoints, Integer trainingMinutes) {
private record CompetencySnapshot(Integer completed, Integer score, Integer pendingReview, Integer taskCompletion,
Integer compliance, Integer communication, Integer emotion, Integer responseTimeliness,
Integer marketing, Integer incentivePoints, Integer trainingMinutes) {
}
private record EvidenceScoreSnapshot(Integer aiScore, Integer humanScore, Integer finalScore, Integer calibrationCount) {
@@ -3916,7 +4083,8 @@ public class AihrPracticeSeedService {
private record SessionAnnotationSource(String scenarioId, String dialogueJson, String annotationsJson) {
}
private record ScoreSnapshot(Integer total, Integer compliance, Integer emotion, Integer communication, Integer marketing) {
private record ScoreSnapshot(Integer total, Integer taskCompletion, Integer responseTimeliness,
Integer compliance, Integer emotion, Integer communication, Integer marketing) {
}
private record CalibrationPair(int original, int corrected) {
@@ -3972,9 +4140,12 @@ public class AihrPracticeSeedService {
private record ActiveSession(String scenarioId, String trainee, String extPartyId, Long assignmentId, boolean mobile, LocalDateTime startedAt,
List<String> traineeReplies, List<String> traineeAudioUrls, List<Long> traineeAudioOssIds,
List<String> customerLines, List<TurnEvidence> turnEvidence) {
List<String> customerLines, List<TurnEvidence> turnEvidence,
List<Long> promptPresentedAtMillis, List<Long> responseLatenciesMs) {
}
private record PracticeResult(Integer total, List<DimensionResponse> scores, String rewrite, String summary) {
private record PracticeResult(Integer total, List<DimensionResponse> scores, String rewrite, String summary,
Integer compliance, Integer communication, Integer taskCompletion,
Integer responseTimeliness, Integer responseLatencyMs) {
}
}
@@ -756,7 +756,7 @@ public class AihrPracticeSeedServiceTest {
assertEquals(81, profile.dimensions().get(1).value());
assertEquals(78, profile.dimensions().get(2).value());
assertEquals(74, profile.dimensions().get(4).value());
assertNull(profile.dimensions().get(0).value());
assertEquals(82, profile.dimensions().get(0).value());
assertNull(profile.dimensions().get(3).value());
}
+10 -10
View File
@@ -32,6 +32,9 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_session` (
`scenario_name` varchar(100) DEFAULT NULL COMMENT '场景名称',
`mode` varchar(30) DEFAULT 'text' COMMENT '训练模式',
`total_score` int DEFAULT NULL COMMENT '总分',
`dim_task_completion` int DEFAULT NULL COMMENT '任务完成度分',
`dim_response_timeliness` int DEFAULT NULL COMMENT '响应时效分',
`response_latency_ms` int DEFAULT NULL COMMENT '平均陪练响应时长毫秒',
`dim_compliance` int DEFAULT NULL COMMENT '合规分',
`dim_emotion` int DEFAULT NULL COMMENT '情绪分',
`dim_communication` int DEFAULT NULL COMMENT '沟通分',
@@ -354,7 +357,7 @@ SELECT
CONCAT(`scenario_name`, ' Rubric'),
'v1',
1,
'生活顾问试点四维评分标准',
'生活顾问试点五维评分标准',
NOW(),
NOW()
FROM `aihr_practice_scenario`
@@ -373,11 +376,7 @@ SELECT
r.`rubric_code`,
d.`dimension_code`,
d.`dimension_name`,
CASE
WHEN s.`scenario_type` = '催费/停车费' AND d.`dimension_code` = 'compliance' THEN 0.30
WHEN s.`scenario_type` = '催费/停车费' AND d.`dimension_code` = 'marketing' THEN 0.20
ELSE d.`weight`
END,
d.`weight`,
d.`description`,
d.`sort_order`,
NOW(),
@@ -386,10 +385,11 @@ FROM `aihr_practice_rubric` r
JOIN `aihr_practice_scenario` s
ON s.`tenant_id` = r.`tenant_id` AND s.`scenario_code` = r.`scenario_code`
JOIN (
SELECT 'compliance' AS `dimension_code`, '合规' AS `dimension_name`, 0.35 AS `weight`, 'SOP关键点、权限边界、承诺口径' AS `description`, 10 AS `sort_order`
UNION ALL SELECT 'communication', '沟通', 0.25, '诉求确认、信息结构、反馈节点', 20
UNION ALL SELECT 'emotion', '情绪', 0.25, '共情承接、降温、信任恢复', 30
UNION ALL SELECT 'marketing', '营销', 0.15, '收费季转化、政策解释、办理引导', 40
SELECT 'task_completion' AS `dimension_code`, '任务完成度' AS `dimension_name`, 0.40 AS `weight`, '问题解决有效性、工单闭环率、首解率、SOP关键点执行率' AS `description`, 10 AS `sort_order`
UNION ALL SELECT 'standardization', '话术规范性', 0.25, '流程完整性、法规引用准确性、沟通合规、用语专业度', 20
UNION ALL SELECT 'emotion', '情绪管理能力', 0.20, '共情、语速语气控制、业主情绪引导安抚有效性', 30
UNION ALL SELECT 'response_timeliness', '响应时效', 0.10, '首响时长、处理周期、跟进频率', 40
UNION ALL SELECT 'marketing', '增值转化潜力', 0.05, '活动引导、产品推介时机与成功率', 50
) d
WHERE r.`tenant_id` = '000000'
ON DUPLICATE KEY UPDATE
@@ -0,0 +1,100 @@
-- AIHR 对练五维评分迁移(MySQL 8.x)
-- 补齐会话结构与生活顾问试点 Rubric,不写入训练业务数据;可重复执行。
SET @aihr_practice_table_exists := (
SELECT COUNT(*)
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'aihr_practice_session'
);
SET @aihr_task_completion_exists := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'aihr_practice_session'
AND COLUMN_NAME = 'dim_task_completion'
);
SET @aihr_task_completion_ddl := IF(
@aihr_practice_table_exists = 1 AND @aihr_task_completion_exists = 0,
'ALTER TABLE aihr_practice_session ADD COLUMN `dim_task_completion` int DEFAULT NULL COMMENT ''任务完成度分'' AFTER `total_score`',
'SELECT 1'
);
PREPARE aihr_task_completion_stmt FROM @aihr_task_completion_ddl;
EXECUTE aihr_task_completion_stmt;
DEALLOCATE PREPARE aihr_task_completion_stmt;
SET @aihr_response_timeliness_exists := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'aihr_practice_session'
AND COLUMN_NAME = 'dim_response_timeliness'
);
SET @aihr_response_timeliness_ddl := IF(
@aihr_practice_table_exists = 1 AND @aihr_response_timeliness_exists = 0,
'ALTER TABLE aihr_practice_session ADD COLUMN `dim_response_timeliness` int DEFAULT NULL COMMENT ''响应时效分'' AFTER `dim_task_completion`',
'SELECT 1'
);
PREPARE aihr_response_timeliness_stmt FROM @aihr_response_timeliness_ddl;
EXECUTE aihr_response_timeliness_stmt;
DEALLOCATE PREPARE aihr_response_timeliness_stmt;
SET @aihr_response_latency_exists := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'aihr_practice_session'
AND COLUMN_NAME = 'response_latency_ms'
);
SET @aihr_response_latency_ddl := IF(
@aihr_practice_table_exists = 1 AND @aihr_response_latency_exists = 0,
'ALTER TABLE aihr_practice_session ADD COLUMN `response_latency_ms` int DEFAULT NULL COMMENT ''平均陪练响应时长毫秒'' AFTER `dim_response_timeliness`',
'SELECT 1'
);
PREPARE aihr_response_latency_stmt FROM @aihr_response_latency_ddl;
EXECUTE aihr_response_latency_stmt;
DEALLOCATE PREPARE aihr_response_latency_stmt;
CREATE TEMPORARY TABLE IF NOT EXISTS aihr_brd_rubric_codes (
tenant_id varchar(20) NOT NULL,
rubric_code varchar(100) NOT NULL,
PRIMARY KEY (tenant_id, rubric_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
DELETE FROM aihr_brd_rubric_codes;
INSERT INTO aihr_brd_rubric_codes (tenant_id, rubric_code)
SELECT r.tenant_id, r.rubric_code
FROM aihr_practice_rubric r
JOIN aihr_practice_scenario s
ON s.tenant_id = r.tenant_id AND s.scenario_code = r.scenario_code
JOIN (
SELECT tenant_id, rubric_code
FROM aihr_practice_rubric_dimension
WHERE dimension_code IN ('compliance', 'communication', 'emotion', 'marketing')
GROUP BY tenant_id, rubric_code
HAVING COUNT(*) = 4
) old_dimensions
ON old_dimensions.tenant_id = r.tenant_id AND old_dimensions.rubric_code = r.rubric_code
WHERE r.tenant_id = '000000'
AND s.position = '生活顾问';
DELETE d
FROM aihr_practice_rubric_dimension d
JOIN aihr_brd_rubric_codes r
ON r.tenant_id = d.tenant_id AND r.rubric_code = d.rubric_code;
INSERT INTO aihr_practice_rubric_dimension
(tenant_id, rubric_code, dimension_code, dimension_name, weight, description, sort_order, create_time, update_time)
SELECT r.tenant_id, r.rubric_code, d.dimension_code, d.dimension_name, d.weight, d.description, d.sort_order, NOW(), NOW()
FROM aihr_brd_rubric_codes r
JOIN (
SELECT 'task_completion' AS dimension_code, '任务完成度' AS dimension_name, 0.40 AS weight, '问题解决有效性、工单闭环率、首解率、SOP关键点执行率' AS description, 10 AS sort_order
UNION ALL SELECT 'standardization', '话术规范性', 0.25, '流程完整性、法规引用准确性、沟通合规、用语专业度', 20
UNION ALL SELECT 'emotion', '情绪管理能力', 0.20, '共情、语速语气控制、业主情绪引导安抚有效性', 30
UNION ALL SELECT 'response_timeliness', '响应时效', 0.10, '首响时长、处理周期、跟进频率', 40
UNION ALL SELECT 'marketing', '增值转化潜力', 0.05, '活动引导、产品推介时机与成功率', 50
) d;
DROP TEMPORARY TABLE IF EXISTS aihr_brd_rubric_codes;