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;
+3 -3
View File
@@ -79,7 +79,7 @@
- `aihr_practice_assignment` 吸收 `daily_drill`(每日一练)与 `training_camp`(专项训练营)的语义,用 `source`(daily/camp/retry/manual)+ `reason` 字段区分来源,不再单独建这两张表。
- `mistake_book`(错题本)语义由“低分维度 → 定向再练派发”承载(见 M4),`assignment.source='retry'` 即错题重练记录。
- 跨期五维画像仍走 TechSpec M6 的 `competency_assessment`,本计划只做单场四维分与四项画像聚合,勿与跨期评估混成一个数(BACKLOG B5 口径)。
- 跨期画像仍走 TechSpec M6 的 `competency_assessment`,本计划单场训练结果按 BRD 4.5.1 五维输出与聚合;任务完成度、响应时效若缺少工单/组织系统证据,只能标代理指标或待采集,勿与跨期正式评估混成一个数(BACKLOG B5 口径)。
### 3.2 复用现有能力
@@ -209,7 +209,7 @@
功能:
- 员工画像扩展为四项:训练时长、贡献度、测评分、AI 等级(BACKLOG B5;贡献度二期用人工计分入口记 `incentive_point`,不做自动案例沉淀)。
- 员工端画像以雷达图 + 趋势呈现单场四维分聚合(BRD 4.5 可视化口径,数据留跨期 `competency_assessment` 的入口)。
- 员工端画像以雷达图 + 趋势呈现单场 BRD 五维分聚合(BRD 4.5 可视化口径,数据留跨期 `competency_assessment` 的入口)。
- 主管端预警:连续低分、超期未训、分数下滑。
- 批量训练任务派发:主管按岗位/短板派发专项。
- 管理者“传帮带”工作台:主管从画像/预警进入员工详情,查看回放与标注,写复盘建议,派发下一次训练,形成“看见问题→带教→复训”的闭环(主管侧帮带承载形态,边界见 §2 暂缓表;定向师带徒已定不做,员工间帮带归阶段二开放问题榜)。
@@ -289,7 +289,7 @@
| 雷点 | 被敷衍、先讲规定、推给业主自己协调 |
| 爽点 | 先共情、给首次反馈时间、明确责任人 |
| SOP 引用 | 对应知识库片段 ID |
| Rubric | 合规、沟通、情绪、营销四维权重 |
| Rubric | 任务完成度40%、话术规范性25%、情绪管理能力20%、响应时效10%、增值转化潜力5% |
| 标杆话术 | 1 条优秀版本、1 条反例 |
## 7. 决策待确认
+2 -1
View File
@@ -175,7 +175,7 @@
- 2026-07-14 G3 案例读取权限复核:发现案例列表和详情对任意已登录后台系统用户返回当前租户数据,存在普通后台账号横向读取案例资产的风险;现收紧为 `superadmin`/`hr_operator`,APP 用户保留登录后的项目范围读取。新增源码契约测试,AIHR 全量测试 `57/57`、脚本语法和空白检查通过,修复提交为 `b0d24970`,尚未发布生产。
- 2026-07-14 G3 驾驶舱权限复核:发现管理驾驶舱只要求登录,APP 用户或普通后台账号理论上可读取当前租户的组织、训练、知识库、案例和候选人汇总;现收紧为 `superadmin`/`hr_operator`,主管团队数据继续走移动端项目范围接口。新增源码契约测试,AIHR 全量测试 `58/58`、脚本语法和空白检查通过,修复提交为 `7522794d`,尚未发布生产。
- 2026-07-14 后续 BRD P1 复核:认证/晋升规则、岗位-SOP 资格 gating、方言样本、正式案例视频样片与组织项目范围仍依赖 HR/运维输入;当前不继续用硬编码、演示 seed 或新增页面伪造完成,下一批优先接正式规则与试点数据后再实现。
- 2026-07-14 BRD 4.5.1 五维能力画像对齐:员工端与管理端画像现在统一返回“任务完成度、话术规范性、情绪管理能力、响应时效、增值转化潜力”五项;话术规范性由现有合规+沟通陪练分数作为训练代理,情绪和增值转化分别由陪练情绪/营销分代理。任务完成度、响应时效因尚未接入工单闭环、首解率、首响时长和处理周期,明确返回“待采集”,不再用训练次数或综合分伪造 BRD 绩效指标;正式权重和绩效口径仍需 HR 确认。
- 2026-07-14 BRD 4.5.1 五维能力画像对齐:员工端与管理端画像现在统一返回“任务完成度、话术规范性、情绪管理能力、响应时效、增值转化潜力”五项;话术规范性由现有合规+沟通陪练分数作为训练代理,情绪和增值转化分别由陪练情绪/营销分代理。任务完成度当前以 SOP 执行/问题推进代理,响应时效在有陪练回合耗时证据时按代理分返回,正式工单闭环率、首解率、首响时长和处理周期仍显示待采集,不把代理指标伪装成 BRD 正式绩效指标。
- 2026-07-14 BRD 五维画像展示语义收紧:员工端、管理端和证据包导出将平均对练分称为“训练综合分”,缺失的五维指标统一显示“待采集”,不再把空值导出为 `null` 或把训练综合分称为正式绩效总分。
- 2026-07-14 BRD 动态难度复核:场景表已有 `difficulty` 字段,但开始训练仍按用户/主管传入的场景直接启动,没有基于连续高分自动升级复合高压场景的规则、候选场景映射、人工关闭开关或升级证据。该项需要 HR 确认连续高分阈值、升级范围和降级条件后再实现,当前不以固定阈值直接改变员工训练难度。
- 2026-07-14 线上资源复核:生产根站、`/h5/` 和租户接口均返回 `200`;生产管理端仍加载 `index-CJZ3Ax3Z.js`(SHA-256 `8b9278a26ccb760abce489b12a20b221748c64d4c4153613d2511777bb9677bc`),当前本地构建为 `index-DF7MSq-V.js`(SHA-256 `96969b2ae71192cb08b3b2f14edb84355dce6b00679f8237d508ab392c97d7d9`),管理端五维画像修复尚未发布;mobile-uni 线上与本地均为 `index-D4-NrEpb.js` 且 hash 一致。发布预检仍因两个既有 Figma 文档未提交而阻断,本轮未执行生产写入或重启。
@@ -292,3 +292,4 @@
- 2026-07-14 BRD 生产迁移资产补齐:`aihr_practice_audio`、`aihr_practice_calibration` 此前只有 reset SQL 和运行时懒建表,生产曾依赖人工建表;现新增可重复执行的 MySQL 8 版本化迁移脚本,仅补齐结构、不写入业务数据。仍需由运维在正式发布窗口执行并核对生产 schema,不能把脚本存在视为迁移已执行。
- 2026-07-14 最新线上只读复核:生产根站、`/h5/` 和 `/prod-api/auth/tenant/list` 均返回 `200`;线上仍加载管理端 `assets/index-CJZ3Ax3Z.js`(SHA-256 `8b9278a26ccb760abce489b12a20b221748c64d4c4153613d2511777bb9677bc`)与 H5 `assets/index-D4-NrEpb.js`(SHA-256 `ab15bfd17cabe58e2f34b0ac61ee198a837128ed85dc6bb0ddf77bc3e22dcd9c`),本地最新提交 `e0da5f0a` 尚未发布;本轮未执行生产同步、后端重启或业务数据写入。
- 2026-07-14 BRD 4.4 入职日期数据链路补齐:组织快照初始化 DDL、外部组织同步和旧库懒迁移现在统一保存 `hire_date`,每日三题在存在该字段但记录缺日期时仍只允许本地 `dev/local` 的显式训练次数兼容回退,生产不会用训练次数推断新员工;新增 MySQL 8 版本迁移与严格预检字段/身份门禁。未写入试点业务数据,正式环境仍需外部组织同步实际提供入职日期并执行迁移。
- 2026-07-14 BRD 4.5.1 五维评分规则数据对齐:对练 `/finish`、复盘、校准和画像代码已补齐任务完成度、话术规范性、情绪管理能力、响应时效、增值转化潜力五项;本轮同步 reset SQL、运行时 seed fallback 和现有生活顾问 Rubric 迁移为 40%/25%/20%/10%/5%,严格预检新增五维 Rubric 数量与权重和检查。任务完成度当前以 SOP 执行/问题推进代理,响应时效当前以陪练回合响应耗时代理;工单闭环率、首解率、正式首响/处理周期仍待业务系统与 HR 确认,未把代理指标宣称为正式绩效口径。迁移只改评分规则结构,不写训练、校准或 SOP 业务样本。
+2
View File
@@ -156,6 +156,8 @@ export type PracticeCalibrationRequest = {
correctedEmotion?: number;
correctedCommunication?: number;
correctedMarketing?: number;
correctedTaskCompletion?: number;
correctedResponseTimeliness?: number;
reason?: string;
};
+17 -2
View File
@@ -81,7 +81,7 @@ check_pilot_samples() {
[[ "$(pilot_sql_scalar "SELECT DATE('$pilot_end') IS NOT NULL")" == "1" ]] || fail "invalid AIHR_PILOT_END_DATE: $pilot_end"
[[ "$pilot_start" > "$pilot_end" ]] && fail "pilot start date must not be after end date"
local scenario_count scenario_service_promotion_count scenario_daily_service_count completed_people pilot_people org_active org_phone_linked org_hire_date_linked org_pilot_people org_pilot_projects org_completed_people audio_table_count prompt_version_schema_count hire_date_schema_count sop_document_count case_stored_count case_unmasked_count
local scenario_count scenario_service_promotion_count scenario_daily_service_count rubric_expected_count rubric_five_dimension_count completed_people pilot_people org_active org_phone_linked org_hire_date_linked org_pilot_people org_pilot_projects org_completed_people audio_table_count prompt_version_schema_count hire_date_schema_count sop_document_count case_stored_count case_unmasked_count
local calibration_stats calibration_count calibration_matched sop_total sop_usable sop_pending satisfaction_count satisfaction_average
local org_identity_cte="
WITH active_org AS (
@@ -127,6 +127,18 @@ check_pilot_samples() {
# ponytail: presence-only core-flow gate; content quality and SOP applicability still require HR review.
scenario_service_promotion_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM aihr_practice_scenario WHERE tenant_id = '$pilot_tenant' AND position = '生活顾问' AND enabled = 1 AND (scenario_type LIKE '%推介%' OR scenario_type LIKE '%增值%' OR scenario_name LIKE '%推介%' OR scenario_name LIKE '%增值%')")"
scenario_daily_service_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM aihr_practice_scenario WHERE tenant_id = '$pilot_tenant' AND position = '生活顾问' AND enabled = 1 AND (scenario_type LIKE '%日常%' OR scenario_name LIKE '%日常%')")"
rubric_expected_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM aihr_practice_rubric r JOIN aihr_practice_scenario s ON s.tenant_id = r.tenant_id AND s.scenario_code = r.scenario_code WHERE r.tenant_id = '$pilot_tenant' AND s.position = '生活顾问' AND s.enabled = 1 AND r.enabled = 1")"
rubric_five_dimension_count="$(pilot_sql_scalar "
SELECT COUNT(*)
FROM (
SELECT 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 aihr_practice_rubric_dimension d ON d.tenant_id = r.tenant_id AND d.rubric_code = r.rubric_code
WHERE r.tenant_id = '$pilot_tenant' AND s.position = '生活顾问' AND s.enabled = 1 AND r.enabled = 1
GROUP BY r.rubric_code
HAVING COUNT(*) = 5 AND ABS(SUM(d.weight) - 1.00) < 0.001
) ready_rubrics")"
audio_table_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'aihr_practice_audio'")"
prompt_version_schema_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'aihr_sop_answer_review' AND column_name = 'prompt_version'")"
hire_date_schema_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'aihr_org_snapshot' AND column_name = 'hire_date'")"
@@ -283,10 +295,11 @@ check_pilot_samples() {
AND s.finished_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY)
)
SELECT COALESCE(ROUND(AVG(satisfaction_score), 1), 0) FROM formal_sessions WHERE satisfaction_score IS NOT NULL")"
echo "OK: pilot gates window=${pilot_start}..${pilot_end} scenarios=${scenario_count}/12 core_flow=service_promotion:${scenario_service_promotion_count} daily_service:${scenario_daily_service_count} sop_docs=${sop_document_count}/5 stored_cases=${case_stored_count}/20 case_unmasked=${case_unmasked_count} ten_sessions=${completed_people}/${pilot_people} org_identity=${org_phone_linked}/${org_active} org_hire_date=${org_hire_date_linked}/${org_active} org_pilot=${org_pilot_people}/20 projects=${org_pilot_projects}/1-2 org_ten_sessions=${org_completed_people}/${org_pilot_people} calibration=${calibration_count}/20 consistency=${calibration_matched}/${calibration_count} sop_usable=${sop_usable}/${sop_total} sop_pending=${sop_pending} satisfaction=${satisfaction_average}/5 responses=${satisfaction_count} audio_schema=${audio_table_count} prompt_version_schema=${prompt_version_schema_count} hire_date_schema=${hire_date_schema_count}"
echo "OK: pilot gates window=${pilot_start}..${pilot_end} scenarios=${scenario_count}/12 rubric_five_dimensions=${rubric_five_dimension_count}/${rubric_expected_count} core_flow=service_promotion:${scenario_service_promotion_count} daily_service:${scenario_daily_service_count} sop_docs=${sop_document_count}/5 stored_cases=${case_stored_count}/20 case_unmasked=${case_unmasked_count} ten_sessions=${completed_people}/${pilot_people} org_identity=${org_phone_linked}/${org_active} org_hire_date=${org_hire_date_linked}/${org_active} org_pilot=${org_pilot_people}/20 projects=${org_pilot_projects}/1-2 org_ten_sessions=${org_completed_people}/${org_pilot_people} calibration=${calibration_count}/20 consistency=${calibration_matched}/${calibration_count} sop_usable=${sop_usable}/${sop_total} sop_pending=${sop_pending} satisfaction=${satisfaction_average}/5 responses=${satisfaction_count} audio_schema=${audio_table_count} prompt_version_schema=${prompt_version_schema_count} hire_date_schema=${hire_date_schema_count}"
[[ "${AIHR_PILOT_STRICT:-false}" != "true" ]] || {
(( scenario_count >= 12 )) || fail "pilot enabled scenarios ${scenario_count}/12"
(( rubric_expected_count >= scenario_count && rubric_five_dimension_count >= rubric_expected_count )) || fail "pilot five-dimension rubrics ${rubric_five_dimension_count}/${rubric_expected_count}"
(( scenario_service_promotion_count > 0 )) || fail "pilot service-promotion scenarios missing"
(( scenario_daily_service_count > 0 )) || fail "pilot daily-service scenarios missing"
(( sop_document_count >= 5 )) || fail "pilot residential SOP documents ${sop_document_count}/5"
@@ -484,6 +497,8 @@ contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/control
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "mobileCompletedCount"
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/script/sql/aihr_practice_mysql8.sql "task_completion"
contains backend/script/sql/update/aihr_20260714_practice_five_dimensions_mysql8.sql "aihr_brd_rubric_codes"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java "MistakeAggregateResponse"
contains mobile-uni/src/pages/supervisor/team/index.vue "团队错题本"
contains mobile-uni/src/types/api.ts "PracticeMistakeAggregate"