feat(practice): complete five-position training operations

This commit is contained in:
2026-07-24 00:26:27 +08:00
parent 067cc30d80
commit faa2a77e9a
15 changed files with 1357 additions and 77 deletions
@@ -3,10 +3,12 @@ package org.dromara.aihr.controller;
import cn.dev33.satoken.annotation.SaCheckLogin;
import cn.dev33.satoken.annotation.SaCheckRole;
import cn.dev33.satoken.annotation.SaMode;
import cn.dev33.satoken.stp.StpUtil;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.domain.AihrPracticeDto.AnnotationResponse;
import org.dromara.aihr.domain.AihrPracticeDto.AnnotationSaveRequest;
import org.dromara.aihr.domain.AihrPracticeDto.AssignmentCancelRequest;
import org.dromara.aihr.domain.AihrPracticeDto.CalibrationRequest;
import org.dromara.aihr.domain.AihrPracticeDto.CalibrationResponse;
import org.dromara.aihr.domain.AihrPracticeDto.FinishRequest;
@@ -14,12 +16,18 @@ import org.dromara.aihr.domain.AihrPracticeDto.FinishResponse;
import org.dromara.aihr.domain.AihrPracticeDto.HelpEventResponse;
import org.dromara.aihr.domain.AihrPracticeDto.HelpRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PrepCardResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentBatchRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentBatchResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RealtimeSdpRequest;
import org.dromara.aihr.domain.AihrPracticeDto.RealtimeSdpResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RubricResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RubricAdminResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RubricEnabledRequest;
import org.dromara.aihr.domain.AihrPracticeDto.RubricSaveRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeQuestionEnabledRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeQuestionResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeQuestionSaveRequest;
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioEnabledRequest;
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioResponse;
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioSaveRequest;
@@ -66,8 +74,9 @@ public class AihrPracticeController {
@GetMapping("/scenarios")
public R<List<ScenarioResponse>> scenarios(@RequestParam(required = false) String position,
@RequestParam(required = false) String scenarioType) {
return R.ok(practiceSeedService.scenarios(position, scenarioType));
@RequestParam(required = false) String scenarioType,
@RequestParam(required = false, defaultValue = "false") boolean includeDisabled) {
return R.ok(practiceSeedService.scenarios(position, scenarioType, includeDisabled && currentOperatorCanManageContent()));
}
@GetMapping("/scenarios/{id}")
@@ -87,6 +96,49 @@ public class AihrPracticeController {
return practiceSeedService.updateScenarioEnabled(id, request) ? R.ok() : R.fail("训练场景不存在");
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@GetMapping("/questions")
public R<List<PracticeQuestionResponse>> questions(@RequestParam(required = false) String position,
@RequestParam(required = false) String type,
@RequestParam(required = false, defaultValue = "true") boolean includeDisabled) {
return R.ok(practiceSeedService.questions(position, type, includeDisabled));
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@PostMapping("/questions")
public R<PracticeQuestionResponse> saveQuestion(@RequestBody PracticeQuestionSaveRequest request) {
return R.ok(practiceSeedService.saveQuestion(request));
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@PatchMapping("/questions/{code}/enabled")
public R<Void> updateQuestionEnabled(@PathVariable String code, @RequestBody PracticeQuestionEnabledRequest request) {
return practiceSeedService.updateQuestionEnabled(code, request) ? R.ok() : R.fail("训练题目不存在");
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@GetMapping("/assignments/manage")
public R<List<PracticeAssignmentResponse>> manageAssignments(@RequestParam(required = false) String position,
@RequestParam(required = false) String status,
@RequestParam(required = false) String source,
@RequestParam(required = false) String keyword,
@RequestParam(required = false, defaultValue = "100") Integer limit) {
return R.ok(practiceSeedService.manageAssignments(position, status, source, keyword, limit == null ? 100 : limit));
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@PostMapping("/assignments/manage/batch")
public R<PracticeAssignmentBatchResponse> createManagedAssignments(@RequestBody PracticeAssignmentBatchRequest request) {
return R.ok(practiceSeedService.createAssignments(request, ""));
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@PatchMapping("/assignments/manage/{id}/cancel")
public R<Void> cancelAssignment(@PathVariable Long id, @RequestBody(required = false) AssignmentCancelRequest request) {
return practiceSeedService.cancelAssignment(id, request == null ? null : request.reason())
? R.ok() : R.fail("任务不存在、已完成或不能撤回");
}
@GetMapping("/scenarios/{id}/prep-card")
public R<PrepCardResponse> prepCard(@PathVariable String id) {
return R.ok(practiceSeedService.prepCard(id));
@@ -229,4 +281,8 @@ public class AihrPracticeController {
}
return loginUser.getUsername().trim();
}
private static boolean currentOperatorCanManageContent() {
return StpUtil.hasRoleOr(TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE);
}
}
@@ -18,6 +18,7 @@ public final class AihrPracticeDto {
String customerText,
Integer emotion,
Integer trust,
Integer roundCount,
String customerAudioUrl,
PersonaResponse persona
) {
@@ -44,10 +45,21 @@ public final class AihrPracticeDto {
String strategy,
String goal,
Boolean enabled,
RubricResponse rubric
RubricResponse rubric,
List<ScenarioRoundResponse> rounds,
String contentVersion,
String contentHash
) {
}
/**
* A fixed, auditable training beat. The product only supports a small
* dialogue script today, so keeping the four runtime fields explicit is
* safer than accepting arbitrary JSON from the operations console.
*/
public record ScenarioRoundResponse(String customer, String coachHint, Integer emotion, Integer trust) {
}
public record ScenarioSaveRequest(
String id,
String name,
@@ -65,7 +77,8 @@ public final class AihrPracticeDto {
String successCriteria,
String strategy,
String goal,
Boolean enabled
Boolean enabled,
List<ScenarioRoundResponse> rounds
) {
}
@@ -104,6 +117,39 @@ public final class AihrPracticeDto {
public record RubricEnabledRequest(Boolean enabled) {
}
public record PracticeQuestionResponse(
String code,
String scenarioCode,
String scenarioName,
String positionCode,
String type,
String question,
String referenceAnswer,
String contentVersion,
String contentHash,
Boolean enabled,
Integer sortOrder
) {
}
public record PracticeQuestionSaveRequest(
String code,
String scenarioCode,
String positionCode,
String type,
String question,
String referenceAnswer,
Boolean enabled,
Integer sortOrder
) {
}
public record PracticeQuestionEnabledRequest(Boolean enabled) {
}
public record AssignmentCancelRequest(String reason) {
}
public record PrepCardResponse(List<String> keyPoints, List<String> redLines, List<String> scripts, String promptVersion) {
}
@@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.aihr.domain.AihrPracticeDto.DialogueResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RubricDimensionResponse;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
@@ -162,9 +163,21 @@ public class AihrPracticeLlmService {
* /finish 单 LLM 结构化评分:temperature=0 + 固定 prompt 版本 + 档位锚点 60/75/90。
*/
public Optional<PracticeScore> score(String scenarioName, String goal, String strategy, List<DialogueResponse> dialogue) {
return score(scenarioName, goal, strategy, dialogue, List.of());
}
/**
* The model still returns the stable five machine-readable metrics, while
* operations can tune the descriptions and weights of those metrics per
* scenario. This preserves analytics compatibility and makes the rubric
* used for a session auditable by the caller.
*/
public Optional<PracticeScore> score(String scenarioName, String goal, String strategy, List<DialogueResponse> dialogue,
List<RubricDimensionResponse> rubric) {
if (dialogue.stream().noneMatch(turn -> "trainee".equals(turn.role()) && !turn.text().isBlank())) {
return Optional.empty();
}
String rubricText = renderRubric(rubric);
String system = """
你是物业行业培训考官(prompt版本 %s)。根据对练记录给员工话术评分。
评分锚点:60=及格线(有明显缺失);75=合格(覆盖主要要点);90=优秀(完整且超预期)。
@@ -172,7 +185,8 @@ public class AihrPracticeLlmService {
只输出一个 JSON 对象,不要输出任何其他文字、解释或代码块标记,字段如下:
{"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);
本场评分规则:%s
""".formatted(PROMPT_VERSION, rubricText);
String user = """
训练场景:%s
训练目标:%s
@@ -183,6 +197,15 @@ public class AihrPracticeLlmService {
return modelService.tryChat(system, user, SCORE_TEMPERATURE).flatMap(this::parseScore);
}
private String renderRubric(List<RubricDimensionResponse> rubric) {
if (rubric == null || rubric.isEmpty()) {
return "任务完成40%、话术规范25%、情绪20%、响应时效10%、增值转化5%。";
}
return rubric.stream()
.map(item -> item.name() + Math.round((item.weight() == null ? 0D : item.weight()) * 100) + "%(" + item.description() + ")")
.collect(java.util.stream.Collectors.joining(";"));
}
/**
* 每日三题的语义评估。模型未配置、成本闸门关闭或结构化结果解析失败时由调用方回退规则评分。
*/
@@ -158,6 +158,42 @@ public class AihrPracticeSeedServiceTest {
assertEquals("截止日期格式应为YYYY-MM-DD", malformed.getMessage());
}
@Test
public void canonicalPracticePositionUsesOneFivePositionVocabulary() {
assertEquals("生活顾问", AihrPracticeSeedService.canonicalPracticePosition("客服管家"));
assertEquals("保安", AihrPracticeSeedService.canonicalPracticePosition("秩序维护员"));
assertEquals("保洁", AihrPracticeSeedService.canonicalPracticePosition("环境保洁"));
assertEquals("保修", AihrPracticeSeedService.canonicalPracticePosition("工程维修"));
assertEquals("客服", AihrPracticeSeedService.canonicalPracticePosition("前台客服"));
}
@Test
public void dailyDrillSeedCarriesAStableContentSnapshot() {
AihrPracticeSeedService.DailyDrillSeed drill = new AihrPracticeSeedService.DailyDrillSeed(
"repair-leak-followup", "漏水报修跟进要先确认什么?", "确认点位、责任人和反馈时间。"
);
assertEquals("daily-v1", drill.contentVersion());
assertNotNull(drill.contentHash());
assertEquals(64, drill.contentHash().length());
}
@Test
public void contentOperationsUseCatalogSelectionAndPersistRubricSnapshots() throws Exception {
String source = Files.readString(Path.of("src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java"));
String controller = Files.readString(Path.of("src/main/java/org/dromara/aihr/controller/AihrPracticeController.java"));
String migration = Files.readString(Path.of("../../script/sql/update/aihr_20260723_practice_content_operations_mysql8.sql"));
assertTrue(source.contains("dailyDrillsFromCatalog(today, dailyDrillPosition(extPartyId), extPartyId)"));
assertTrue(source.contains("@Scheduled(cron = \"${aihr.practice.daily-drill-cron:0 5 0 * * *}\""));
assertTrue(source.contains("rubric_snapshot_json"));
assertTrue(source.contains("validateRubricDimensions"));
assertTrue(controller.contains("@GetMapping(\"/questions\")"));
assertTrue(controller.contains("@GetMapping(\"/assignments/manage\")"));
assertTrue(migration.contains("rubric_snapshot_json"));
assertTrue(migration.contains("repair-leak-followup"));
}
@Test
public void batchAssignmentUsesPerEmployeeTransactionsAndDatabaseIdempotency() throws Exception {
String serviceSource = Files.readString(Path.of("src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java"));
@@ -167,6 +203,11 @@ public class AihrPracticeSeedServiceTest {
assertTrue(serviceSource.contains("() -> createAssignmentInternal(itemRequest, batchScenario, storedExtPartyId)"));
assertTrue(serviceSource.contains("TeamScope scope = teamScope(supervisorExtPartyId)"));
assertTrue(serviceSource.contains("AssignmentStorageIdentityResolution storageResolution = assignmentStorageIdentities(requestedExtPartyIds)"));
assertTrue(serviceSource.contains("AssignmentPositionPolicy positionPolicy = assignmentPositionPolicy(scenarioId)"));
assertTrue(serviceSource.contains("requireAssignmentTargetPosition(extPartyId, positionPolicy)"));
assertTrue(serviceSource.contains("assignmentScenarioPosition(scenarioId)"));
assertTrue(serviceSource.contains("与训练场景岗位("));
assertTrue(serviceSource.contains("requireAssignmentTargetPosition(extPartyId, assignmentPositionPolicy("));
assertTrue(serviceSource.contains("phone_owner_count"));
assertTrue(serviceSource.contains("ambiguousExtPartyIds().contains"));
assertTrue(serviceSource.contains("uk_aihr_assignment_request"));
@@ -628,6 +669,7 @@ public class AihrPracticeSeedServiceTest {
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, llm, null);
var session = service.start(new StartRequest("employee-a", "complaint-water", "mobile", null));
assertEquals(2, session.roundCount());
ServiceException skipped = assertThrows(ServiceException.class,
() -> service.turn(new TurnRequest(session.sessionId(), 2, "我先确认现场情况", null, null, false, null), "employee-a"));
assertEquals("训练回合已变化,请查看最新对话后再继续", skipped.getMessage());
@@ -41,6 +41,9 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_session` (
`dim_emotion` int DEFAULT NULL COMMENT '情绪分',
`dim_communication` int DEFAULT NULL COMMENT '沟通分',
`dim_marketing` int DEFAULT NULL COMMENT '营销分',
`rubric_code` varchar(100) DEFAULT NULL COMMENT '训练开始时评分规则编码',
`rubric_version` varchar(30) DEFAULT NULL COMMENT '训练开始时评分规则版本',
`rubric_snapshot_json` text DEFAULT NULL COMMENT '训练开始时评分规则快照',
`mentor_rewrite` varchar(1000) DEFAULT NULL COMMENT '导师改写',
`ai_comment` varchar(1000) DEFAULT NULL COMMENT 'AI点评',
`review_advice` varchar(1000) DEFAULT NULL COMMENT '主管复盘建议',
@@ -0,0 +1,88 @@
-- 训练内容运营闭环:会话评分规则快照、五岗位 Rubric 补齐、旧每日任务场景修正。
-- 依赖:aihr_20260717_practice_five_position_catalog_mysql8.sql 已执行。
-- 可重复执行;仅补结构和内置规则,不生成员工业务任务。
SET @aihr_schema = DATABASE();
SET @rubric_code_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_session' AND column_name = 'rubric_code'
);
SET @rubric_code_ddl = IF(@rubric_code_exists = 0,
'ALTER TABLE aihr_practice_session ADD COLUMN rubric_code varchar(100) DEFAULT NULL COMMENT ''训练开始时评分规则编码'' AFTER dim_marketing',
'SELECT 1');
PREPARE aihr_rubric_code_stmt FROM @rubric_code_ddl;
EXECUTE aihr_rubric_code_stmt;
DEALLOCATE PREPARE aihr_rubric_code_stmt;
SET @rubric_version_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_session' AND column_name = 'rubric_version'
);
SET @rubric_version_ddl = IF(@rubric_version_exists = 0,
'ALTER TABLE aihr_practice_session ADD COLUMN rubric_version varchar(30) DEFAULT NULL COMMENT ''训练开始时评分规则版本'' AFTER rubric_code',
'SELECT 1');
PREPARE aihr_rubric_version_stmt FROM @rubric_version_ddl;
EXECUTE aihr_rubric_version_stmt;
DEALLOCATE PREPARE aihr_rubric_version_stmt;
SET @rubric_snapshot_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_session' AND column_name = 'rubric_snapshot_json'
);
SET @rubric_snapshot_ddl = IF(@rubric_snapshot_exists = 0,
'ALTER TABLE aihr_practice_session ADD COLUMN rubric_snapshot_json text DEFAULT NULL COMMENT ''训练开始时评分规则快照'' AFTER rubric_version',
'SELECT 1');
PREPARE aihr_rubric_snapshot_stmt FROM @rubric_snapshot_ddl;
EXECUTE aihr_rubric_snapshot_stmt;
DEALLOCATE PREPARE aihr_rubric_snapshot_stmt;
-- 对五岗位目录中没有 Rubric 的场景创建初始 v1.0 规则;已有版本不改写。
INSERT INTO aihr_practice_rubric
(tenant_id, rubric_code, scenario_code, rubric_name, version, enabled, description, create_time, update_time)
SELECT s.tenant_id, CONCAT('rubric-', s.scenario_code), s.scenario_code,
CONCAT(s.position, '基础评分规则'), 'v1.0', 1,
'五岗位内容目录补齐的初始版本;后续由训练运营维护。', NOW(), NOW()
FROM aihr_practice_scenario s
WHERE NOT EXISTS (
SELECT 1 FROM aihr_practice_rubric r
WHERE r.tenant_id = s.tenant_id AND r.scenario_code = s.scenario_code AND r.enabled = 1
)
ON DUPLICATE KEY UPDATE update_time = VALUES(update_time);
-- 一个场景仅保留最新的已启用版本,避免运行时随机取到历史 Rubric。
UPDATE aihr_practice_rubric r
JOIN (
SELECT tenant_id, scenario_code, MAX(id) AS keep_id
FROM aihr_practice_rubric
WHERE enabled = 1
GROUP BY tenant_id, scenario_code
) current_rule
ON current_rule.tenant_id = r.tenant_id AND current_rule.scenario_code = r.scenario_code
SET r.enabled = CASE WHEN r.id = current_rule.keep_id THEN 1 ELSE 0 END,
r.update_time = NOW()
WHERE r.enabled = 1;
-- 每个生效 Rubric 具备稳定的五个机器可评分维度,前端只能调整名称、描述和权重。
INSERT IGNORE 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_practice_rubric 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
WHERE r.enabled = 1;
-- 旧硬编码每日题把 repair-leak 写成不存在编码;仅在目标场景已存在时修正历史未完成任务。
UPDATE aihr_practice_assignment a
JOIN aihr_practice_scenario s
ON s.tenant_id = a.tenant_id AND s.scenario_code = 'repair-leak-followup'
SET a.scenario_id = s.scenario_code,
a.scenario_name = s.scenario_name,
a.update_time = NOW()
WHERE a.source = 'daily' AND a.scenario_id = 'repair-leak' AND a.status = '待训练';