diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java index 4e80f527..e6f9e511 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java @@ -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> 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> 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 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 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> 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 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 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 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); + } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java index 6f42fe2e..f991d062 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java @@ -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 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 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 keyPoints, List redLines, List scripts, String promptVersion) { } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeLlmService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeLlmService.java index 85a33d87..01ea0495 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeLlmService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeLlmService.java @@ -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 score(String scenarioName, String goal, String strategy, List 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 score(String scenarioName, String goal, String strategy, List dialogue, + List 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 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(";")); + } + /** * 每日三题的语义评估。模型未配置、成本闸门关闭或结构化结果解析失败时由调用方回退规则评分。 */ diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java index 43a732f1..afa20e3a 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java @@ -27,6 +27,9 @@ import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentBatchResponse; import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest; import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse; import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentTarget; +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.PracticeTeamProfileResponse; import org.dromara.aihr.domain.AihrPracticeDto.PracticeTeamResponse; import org.dromara.aihr.domain.AihrPracticeDto.PromptTemplateRequest; @@ -46,6 +49,7 @@ import org.dromara.aihr.domain.AihrPracticeDto.RubricEnabledRequest; import org.dromara.aihr.domain.AihrPracticeDto.RubricResponse; import org.dromara.aihr.domain.AihrPracticeDto.RubricSaveRequest; import org.dromara.aihr.domain.AihrPracticeDto.ScenarioEnabledRequest; +import org.dromara.aihr.domain.AihrPracticeDto.ScenarioRoundResponse; import org.dromara.aihr.domain.AihrPracticeDto.ScenarioResponse; import org.dromara.aihr.domain.AihrPracticeDto.ScenarioSaveRequest; import org.dromara.aihr.domain.AihrPracticeDto.SatisfactionRequest; @@ -67,6 +71,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DataAccessException; import org.springframework.dao.DuplicateKeyException; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate; @@ -104,6 +109,10 @@ import java.util.function.Supplier; public class AihrPracticeSeedService { private static final Set LEGACY_DAILY_DRILL_PROFILES = Set.of("dev", "local"); + private static final Set PRACTICE_POSITIONS = Set.of("生活顾问", "保安", "保洁", "保修", "客服"); + private static final Set RUBRIC_DIMENSION_CODES = Set.of( + "task_completion", "standardization", "emotion", "response_timeliness", "marketing" + ); private static final String TENANT_ID = "000000"; private static final Map REGENERATE_STYLE_INSTRUCTIONS = Map.of( @@ -272,6 +281,7 @@ public class AihrPracticeSeedService { private volatile boolean practiceTableReady; private volatile boolean assignmentTableReady; private volatile boolean scenarioTableReady; + private volatile boolean learningQuestionTableReady; private volatile boolean calibrationTableReady; private volatile boolean promptTemplateTableReady; private volatile boolean annotationTableReady; @@ -319,20 +329,25 @@ public class AihrPracticeSeedService { } public List scenarios(String position, String scenarioType) { - String safePosition = isBlank(position) ? "" : position.trim(); + return scenarios(position, scenarioType, false); + } + + public List scenarios(String position, String scenarioType, boolean includeDisabled) { + ensureScenarioTable(); + String safePosition = isBlank(position) ? "" : canonicalPracticePosition(position); String safeType = isBlank(scenarioType) ? "" : scenarioType.trim(); try { List rows = jdbcTemplate.query(""" SELECT scenario_code, scenario_name, position, scenario_type, project_type, difficulty, customer_persona, opening_text, explicit_need, hidden_need, irritants, delights, - sop_refs, success_criteria, strategy, goal, enabled + sop_refs, success_criteria, strategy, goal, enabled, rounds_json, content_version, content_hash FROM aihr_practice_scenario WHERE tenant_id = ? - AND enabled = 1 + AND (? = 1 OR enabled = 1) AND (? = '' OR position = ?) AND (? = '' OR scenario_type = ?) ORDER BY sort_order ASC, id ASC - """, this::mapScenarioResponse, tenantId(), safePosition, safePosition, safeType, safeType); + """, this::mapScenarioResponse, tenantId(), includeDisabled ? 1 : 0, safePosition, safePosition, safeType, safeType); return rows; } catch (DataAccessException e) { return fallbackScenarios(); @@ -340,6 +355,7 @@ public class AihrPracticeSeedService { } public ScenarioResponse scenario(String id) { + ensureScenarioTable(); if (isBlank(id)) { return scenarios(null, null).get(0); } @@ -347,7 +363,7 @@ public class AihrPracticeSeedService { List rows = jdbcTemplate.query(""" SELECT scenario_code, scenario_name, position, scenario_type, project_type, difficulty, customer_persona, opening_text, explicit_need, hidden_need, irritants, delights, - sop_refs, success_criteria, strategy, goal, enabled + sop_refs, success_criteria, strategy, goal, enabled, rounds_json, content_version, content_hash FROM aihr_practice_scenario WHERE tenant_id = ? AND scenario_code = ? LIMIT 1 @@ -367,16 +383,24 @@ public class AihrPracticeSeedService { LocalDateTime now = LocalDateTime.now(); String code = normalizeScenarioCode(firstNonBlank(request == null ? null : request.id(), "scenario-" + System.currentTimeMillis())); String name = firstNonBlank(request == null ? null : request.name(), "自定义对练场景"); + String position = canonicalPracticePosition(request == null ? null : request.position()); String scenarioType = firstNonBlank(request == null ? null : request.scenarioType(), "投诉处理"); String projectType = firstNonBlank(request == null ? null : request.projectType(), "住宅"); int difficulty = Math.max(1, Math.min(5, request == null || request.difficulty() == null ? 1 : request.difficulty())); boolean enabled = request == null || request.enabled() == null || request.enabled(); + List rounds = normalizeScenarioRounds(request == null ? null : request.rounds(), + request == null ? null : request.openingText()); + String roundsJson = writeJson(rounds); + String contentVersion = nextScenarioContentVersion(code); + String contentHash = AihrLearningService.contentHash(contentVersion, name + "\\n" + roundsJson, + firstNonBlank(request == null ? null : request.goal(), "")); jdbcTemplate.update(""" INSERT INTO aihr_practice_scenario (tenant_id, scenario_code, scenario_name, position, scenario_type, project_type, difficulty, customer_persona, opening_text, explicit_need, hidden_need, irritants, delights, sop_refs, - success_criteria, strategy, goal, trainee_name, trust_start, trust_end, rounds_json, enabled, sort_order, create_time, update_time) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '张三', 60, 80, NULL, ?, 100, ?, ?) + success_criteria, strategy, goal, trainee_name, trust_start, trust_end, rounds_json, content_version, content_hash, + enabled, sort_order, create_time, update_time) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '张三', 60, 80, ?, ?, ?, ?, 100, ?, ?) ON DUPLICATE KEY UPDATE scenario_name = VALUES(scenario_name), position = VALUES(position), @@ -393,13 +417,16 @@ public class AihrPracticeSeedService { success_criteria = VALUES(success_criteria), strategy = VALUES(strategy), goal = VALUES(goal), + rounds_json = VALUES(rounds_json), + content_version = VALUES(content_version), + content_hash = VALUES(content_hash), enabled = VALUES(enabled), update_time = VALUES(update_time) """, tenantId(), code, name, - firstNonBlank(request == null ? null : request.position(), "生活顾问"), + position, scenarioType, projectType, difficulty, @@ -413,10 +440,14 @@ public class AihrPracticeSeedService { firstNonBlank(request == null ? null : request.successCriteria(), "确认诉求、责任人、处理节点和回访方式"), firstNonBlank(request == null ? null : request.strategy(), "仅在漏关键步骤时提示"), firstNonBlank(request == null ? null : request.goal(), "训练生活顾问按SOP完成闭环沟通。"), + roundsJson, + contentVersion, + contentHash, enabled, Timestamp.valueOf(now), Timestamp.valueOf(now) ); + ensureDefaultRubricForScenario(code, position); return scenario(code); } @@ -438,6 +469,82 @@ public class AihrPracticeSeedService { return updated > 0; } + /** + * Operations owns the question bank through this endpoint. Employee-side + * callers only ever receive the enabled slice selected by the service. + */ + public List questions(String position, String type, boolean includeDisabled) { + ensureLearningQuestionTable(); + String safePosition = isBlank(position) ? "" : canonicalPracticePosition(position); + String safeType = isBlank(type) ? "" : normalizeQuestionType(type); + return jdbcTemplate.query(""" + SELECT q.question_code, q.scenario_code, COALESCE(s.scenario_name, q.scenario_code) AS scenario_name, + q.position_code, q.question_type, q.question_text, q.reference_answer, + q.content_version, q.content_hash, q.enabled, q.sort_order + FROM aihr_learning_question q + LEFT JOIN aihr_practice_scenario s + ON s.tenant_id = q.tenant_id AND s.scenario_code = q.scenario_code + WHERE q.tenant_id = ? + AND (? = 1 OR q.enabled = 1) + AND (? = '' OR q.position_code = ?) + AND (? = '' OR q.question_type = ?) + ORDER BY q.position_code, q.question_type, q.sort_order, q.id + """, this::mapPracticeQuestion, tenantId(), includeDisabled ? 1 : 0, + safePosition, safePosition, safeType, safeType); + } + + @Transactional(rollbackFor = Exception.class) + public PracticeQuestionResponse saveQuestion(PracticeQuestionSaveRequest request) { + ensureLearningQuestionTable(); + String scenarioCode = normalizeScenarioCode(firstNonBlank(request == null ? null : request.scenarioCode(), "")); + ScenarioResponse scenario = scenario(scenarioCode); + if (scenario == null || !scenarioCode.equals(scenario.id())) { + throw new ServiceException("请先选择已存在的训练场景"); + } + String position = canonicalPracticePosition(firstNonBlank(request == null ? null : request.positionCode(), scenario.position())); + if (!position.equals(canonicalPracticePosition(scenario.position()))) { + throw new ServiceException("题目岗位必须与训练场景岗位一致"); + } + String type = normalizeQuestionType(request == null ? null : request.type()); + String question = firstNonBlank(request == null ? null : request.question(), ""); + String reference = firstNonBlank(request == null ? null : request.referenceAnswer(), ""); + if (question.isBlank() || reference.isBlank()) { + throw new ServiceException("题干和参考答案不能为空"); + } + String code = normalizeScenarioCode(firstNonBlank(request == null ? null : request.code(), + scenarioCode + "-" + type + "-" + System.currentTimeMillis())); + String version = nextQuestionContentVersion(code); + String hash = AihrLearningService.contentHash(version, question, reference); + boolean enabled = request == null || request.enabled() == null || request.enabled(); + int sortOrder = Math.max(0, request == null || request.sortOrder() == null ? 100 : request.sortOrder()); + Timestamp now = Timestamp.valueOf(businessNow()); + jdbcTemplate.update(""" + INSERT INTO aihr_learning_question + (tenant_id, question_code, scenario_code, position_code, question_type, question_text, reference_answer, + content_version, content_hash, enabled, sort_order, create_time, update_time) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + scenario_code = VALUES(scenario_code), position_code = VALUES(position_code), question_type = VALUES(question_type), + question_text = VALUES(question_text), reference_answer = VALUES(reference_answer), + content_version = VALUES(content_version), content_hash = VALUES(content_hash), enabled = VALUES(enabled), + sort_order = VALUES(sort_order), update_time = VALUES(update_time) + """, tenantId(), code, scenarioCode, position, type, question, reference, version, hash, enabled, sortOrder, now, now); + return questions(null, null, true).stream().filter(item -> code.equals(item.code())).findFirst() + .orElseThrow(() -> new ServiceException("训练题目保存失败")); + } + + public boolean updateQuestionEnabled(String code, PracticeQuestionEnabledRequest request) { + ensureLearningQuestionTable(); + if (isBlank(code)) { + return false; + } + return jdbcTemplate.update(""" + UPDATE aihr_learning_question SET enabled = ?, update_time = ? + WHERE tenant_id = ? AND question_code = ? + """, request == null || request.enabled() == null || request.enabled(), Timestamp.valueOf(businessNow()), + tenantId(), code.trim()) > 0; + } + public PrepCardResponse prepCard(String scenarioId) { ensureScenarioEnabledForStart(scenarioId); ScenarioResponse scenario = scenario(scenarioId); @@ -528,10 +635,20 @@ public class AihrPracticeSeedService { ensureRubricTables(); String code = normalizeScenarioCode(firstNonBlank(request == null ? null : request.code(), "rubric-" + System.currentTimeMillis())); String scenarioCode = firstNonBlank(request == null ? null : request.scenarioCode(), "complaint-water"); + if (!scenarioExists(scenarioCode)) { + throw new ServiceException("评分规则必须绑定已存在的训练场景"); + } String name = firstNonBlank(request == null ? null : request.name(), "生活顾问评分规则"); String version = firstNonBlank(request == null ? null : request.version(), "v1"); boolean enabled = request == null || request.enabled() == null || request.enabled(); + List dimensions = validateRubricDimensions(request == null ? null : request.dimensions()); Timestamp now = Timestamp.valueOf(LocalDateTime.now()); + if (enabled) { + jdbcTemplate.update(""" + UPDATE aihr_practice_rubric SET enabled = 0, update_time = ? + WHERE tenant_id = ? AND scenario_code = ? AND rubric_code <> ? AND enabled = 1 + """, now, tenantId(), scenarioCode, code); + } jdbcTemplate.update(""" INSERT INTO aihr_practice_rubric (tenant_id, rubric_code, scenario_code, rubric_name, version, enabled, description, create_time, update_time) @@ -543,18 +660,13 @@ public class AihrPracticeSeedService { tenantId(), code, scenarioCode, name, version, enabled, request == null ? null : request.description(), now, now); jdbcTemplate.update("DELETE FROM aihr_practice_rubric_dimension WHERE tenant_id = ? AND rubric_code = ?", tenantId(), code); - List dimensions = request == null || request.dimensions() == null ? List.of() : request.dimensions(); int sort = 10; for (RubricDimensionResponse dimension : dimensions) { - if (dimension == null || isBlank(dimension.code()) || isBlank(dimension.name())) { - continue; - } - double weight = Math.max(0, Math.min(1, dimension.weight() == null ? 0 : dimension.weight())); jdbcTemplate.update(""" INSERT INTO aihr_practice_rubric_dimension (tenant_id, rubric_code, dimension_code, dimension_name, weight, description, sort_order, create_time, update_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - """, tenantId(), code, dimension.code().trim(), dimension.name().trim(), weight, + """, tenantId(), code, dimension.code().trim(), dimension.name().trim(), dimension.weight(), dimension.description(), sort, now, now); sort += 10; } @@ -567,10 +679,24 @@ public class AihrPracticeSeedService { if (isBlank(code)) { return false; } + boolean enabled = request == null || request.enabled() == null || request.enabled(); + List scenarioCodes = jdbcTemplate.query(""" + SELECT scenario_code FROM aihr_practice_rubric WHERE tenant_id = ? AND rubric_code = ? LIMIT 1 + """, (rs, rowNum) -> rs.getString("scenario_code"), tenantId(), code.trim()); + if (scenarioCodes.isEmpty()) { + return false; + } + if (enabled) { + validateRubricDimensions(rubricDimensions(code.trim())); + jdbcTemplate.update(""" + UPDATE aihr_practice_rubric SET enabled = 0, update_time = ? + WHERE tenant_id = ? AND scenario_code = ? AND rubric_code <> ? AND enabled = 1 + """, Timestamp.valueOf(businessNow()), tenantId(), scenarioCodes.get(0), code.trim()); + } return jdbcTemplate.update(""" UPDATE aihr_practice_rubric SET enabled = ?, update_time = ? WHERE tenant_id = ? AND rubric_code = ? - """, request == null || request.enabled() == null || request.enabled(), + """, enabled, Timestamp.valueOf(LocalDateTime.now()), tenantId(), code.trim()) > 0; } @@ -584,11 +710,12 @@ public class AihrPracticeSeedService { List customerLines = new ArrayList<>(); customerLines.add(firstRound.customer()); long promptPresentedAt = System.currentTimeMillis(); + RubricResponse rubric = activeRubricForScenario(scenario.id()); activeSessions.put(sessionId, new ActiveSession( tenantId(), 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 AtomicInteger(0), new ArrayList<>(), new ArrayList<>(), new ArrayList<>(), customerLines, - new ArrayList<>(), new ArrayList<>(), new ArrayList<>(List.of(promptPresentedAt)), new ArrayList<>() + new ArrayList<>(), new ArrayList<>(), new ArrayList<>(List.of(promptPresentedAt)), new ArrayList<>(), rubric )); return new StartResponse( sessionId, @@ -598,6 +725,7 @@ public class AihrPracticeSeedService { firstRound.customer(), firstRound.emotion(), scenario.trustStart(), + scenario.rounds().size(), "", new PersonaResponse(scenario.customer(), scenario.project(), scenario.strategy(), scenario.goal()) ); @@ -851,23 +979,22 @@ public class AihrPracticeSeedService { * /finish 单次评分:配置了 chat 模型且学员有真实回复时走 LLM 结构化评分,否则用 seed 分。 */ private PracticeResult evaluate(ActiveSession activeSession, ScenarioSeed scenario) { - PracticeResult seedResult = seedPracticeResult(activeSession, scenario); + RubricResponse rubric = activeSession == null ? activeRubricForScenario(scenario.id()) : activeSession.rubric(); + PracticeResult seedResult = seedPracticeResult(activeSession, scenario, rubric); if (activeSession == null) { return seedResult; } - return practiceLlmService.score(scenario.name(), scenario.goal(), scenario.strategy(), dialogueTurns(activeSession, scenario)) + return practiceLlmService.score(scenario.name(), scenario.goal(), scenario.strategy(), dialogueTurns(activeSession, scenario), rubric.dimensions()) .map(score -> { Integer measuredResponse = responseTimelinessScore(activeSession); Integer response = measuredResponse != null ? measuredResponse : (score.responseTimeliness() >= 0 ? score.responseTimeliness() : null); int standardization = average(score.compliance(), score.communication()); - int total = response == null - ? score.total() - : weightedPracticeScore(score.taskCompletion(), standardization, score.emotion(), response, score.marketing()); + int total = weightedPracticeScore(score.taskCompletion(), standardization, score.emotion(), response, score.marketing(), rubric); return new PracticeResult( total, - brdDimensions(score.taskCompletion(), standardization, score.emotion(), response, score.marketing()), + brdDimensions(score.taskCompletion(), standardization, score.emotion(), response, score.marketing(), rubric), isBlank(score.rewrite()) ? scenario.rewrite() : score.rewrite(), isBlank(score.summary()) ? scenario.summary() : score.summary(), score.compliance(), score.communication(), score.taskCompletion(), response, @@ -877,38 +1004,56 @@ public class AihrPracticeSeedService { .orElse(seedResult); } - private PracticeResult seedPracticeResult(ActiveSession activeSession, ScenarioSeed scenario) { + private PracticeResult seedPracticeResult(ActiveSession activeSession, ScenarioSeed scenario, RubricResponse rubric) { int taskCompletion = scoreValue(scenario.scores(), "任务完成度", scenario.total()); int standardization = scoreValue(scenario.scores(), "话术规范性", taskCompletion); int emotion = scoreValue(scenario.scores(), "情绪管理能力", scenario.total()); int marketing = scoreValue(scenario.scores(), "增值转化潜力", scenario.total()); Integer response = scoreValue(scenario.scores(), "响应时效"); response = response == null ? responseTimelinessScore(activeSession) : response; - int total = response == null - ? scenario.total() - : weightedPracticeScore(taskCompletion, standardization, emotion, response, marketing); + int total = weightedPracticeScore(taskCompletion, standardization, emotion, response, marketing, rubric); return new PracticeResult( total, - brdDimensions(taskCompletion, standardization, emotion, response, marketing), + brdDimensions(taskCompletion, standardization, emotion, response, marketing, rubric), scenario.rewrite(), scenario.summary(), standardization, standardization, taskCompletion, response, averageResponseLatencyMs(activeSession) ); } private List 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%;增值引导与办理意识") - ); + Integer responseTimeliness, Integer marketing, RubricResponse rubric) { + Map values = new LinkedHashMap<>(); + values.put("task_completion", taskCompletion); + values.put("standardization", standardization); + values.put("emotion", emotion); + values.put("response_timeliness", responseTimeliness == null ? "待采集" : responseTimeliness); + values.put("marketing", marketing); + return rubric.dimensions().stream().map(dimension -> new DimensionResponse( + dimension.name(), + values.getOrDefault(dimension.code(), "待采集"), + "权重" + Math.round(dimension.weight() * 100) + "%;" + firstNonBlank(dimension.description(), "训练评分维度") + )).toList(); } - 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); + private int weightedPracticeScore(int taskCompletion, int standardization, int emotion, Integer responseTimeliness, + int marketing, RubricResponse rubric) { + Map values = new LinkedHashMap<>(); + values.put("task_completion", taskCompletion); + values.put("standardization", standardization); + values.put("emotion", emotion); + values.put("response_timeliness", responseTimeliness); + values.put("marketing", marketing); + double weighted = 0D; + double weight = 0D; + for (RubricDimensionResponse dimension : rubric.dimensions()) { + Integer value = values.get(dimension.code()); + if (value == null) { + continue; + } + weighted += clampScore(value) * dimension.weight(); + weight += dimension.weight(); + } + return weight <= 0D ? 0 : (int) Math.round(weighted / weight); } public int mobileCompletedCount() { @@ -2550,6 +2695,52 @@ public class AihrPracticeSeedService { return visibleAssignments(rows, resultLimit); } + /** HR operation view: includes completed/withdrawn tasks and never creates tasks as a side effect. */ + public List manageAssignments(String position, String status, String source, String keyword, int limit) { + ensureAssignmentTable(); + String safePosition = isBlank(position) ? "" : canonicalPracticePosition(position); + String safeStatus = isBlank(status) ? "" : status.trim(); + String safeSource = isBlank(source) ? "" : source.trim(); + String safeKeyword = isBlank(keyword) ? "" : keyword.trim(); + String nameExpression = !storeDisplayFields ? "'员工'" : "a.trainee_name"; + return jdbcTemplate.query(""" + SELECT a.id, a.ext_party_id, %s AS trainee_name, a.scenario_id, a.scenario_name, a.source, a.reason, a.status, a.create_time, + a.answer_text, a.answer_audio_url, a.answer_audio_oss_id, a.feedback, a.correct, a.score, a.score_mode, a.due_date, + a.content_version, a.question_snapshot, a.reference_answer_snapshot, a.content_hash, + (SELECT f.verdict FROM aihr_practice_question_feedback f + WHERE f.tenant_id = a.tenant_id AND f.assignment_id = a.id AND f.ext_party_id = a.ext_party_id + LIMIT 1) AS feedback_verdict + FROM aihr_practice_assignment a + LEFT JOIN aihr_practice_scenario s + ON s.tenant_id = a.tenant_id AND s.scenario_code = a.scenario_id + WHERE a.tenant_id = ? + AND (? = '' OR s.position = ?) + AND (? = '' OR a.status = ?) + AND (? = '' OR a.source = ?) + AND (? = '' OR a.scenario_name LIKE CONCAT('%%', ?, '%%') OR a.ext_party_id LIKE CONCAT('%%', ?, '%%')) + ORDER BY CASE a.status WHEN '待训练' THEN 0 WHEN '已完成' THEN 1 WHEN '已撤回' THEN 2 ELSE 3 END, + a.due_date ASC, a.create_time DESC, a.id DESC + LIMIT ? + """.formatted(nameExpression), this::mapAssignment, tenantId(), safePosition, safePosition, + safeStatus, safeStatus, safeSource, safeSource, safeKeyword, safeKeyword, safeKeyword, normalizeTeamLimit(limit)); + } + + public boolean cancelAssignment(Long assignmentId, String reason) { + ensureAssignmentTable(); + if (assignmentId == null || assignmentId <= 0) { + return false; + } + String note = firstNonBlank(reason, "运营撤回"); + if (note.length() > 180) { + note = note.substring(0, 180); + } + return jdbcTemplate.update(""" + UPDATE aihr_practice_assignment + SET status = '已撤回', reason = CONCAT(COALESCE(reason, ''), ';撤回原因:', ?), update_time = ? + WHERE tenant_id = ? AND id = ? AND source <> 'daily' AND status = '待训练' + """, note, Timestamp.valueOf(businessNow()), tenantId(), assignmentId) > 0; + } + /** * Read task facts for the current APP identity without creating schema or task rows. * A shared phone or unavailable source must not be silently converted into an empty task list. @@ -2869,6 +3060,7 @@ public class AihrPracticeSeedService { if (isBlank(extPartyId) || !dailyDrillEligible(extPartyId)) { return; } + ensureAssignmentTable(); Set existingQuestions = new LinkedHashSet<>(jdbcTemplate.query(""" SELECT scenario_id, reason FROM aihr_practice_assignment @@ -2877,15 +3069,20 @@ public class AihrPracticeSeedService { """, (rs, rowNum) -> dailyDrillParts(rs.getString("reason"), rs.getString("scenario_id")).question(), tenantId(), extPartyId)); LocalDate today = businessToday(); - List drills = dailyDrills(today, dailyDrillPosition(extPartyId), extPartyId); + List drills = dailyDrillsFromCatalog(today, dailyDrillPosition(extPartyId), extPartyId); + if (drills.size() < 3 && isLegacyDailyDrillFallbackEnabled(allowLegacyDailyDrillFallback, activeProfiles)) { + drills = dailyDrills(today, dailyDrillPosition(extPartyId), extPartyId); + } + if (drills.size() < 3) { + log.warn("daily drill catalog is incomplete for position {}, no fallback is used", dailyDrillPosition(extPartyId)); + return; + } for (int i : missingDailyDrillSlots(drills, existingQuestions)) { DailyDrillSeed drill = drills.get(i); ScenarioSeed scenario = resolveScenario(drill.scenarioId(), null); - String contentVersion = "daily-v1"; - String contentHash = AihrLearningService.contentHash(contentVersion, drill.question(), drill.referenceAnswer()); - if (dailyQuestionRecentlyUsed(extPartyId, contentHash)) { - continue; - } + String contentVersion = firstNonBlank(drill.contentVersion(), "daily-v1"); + String contentHash = firstNonBlank(drill.contentHash(), + AihrLearningService.contentHash(contentVersion, drill.question(), drill.referenceAnswer())); LocalDateTime now = businessNow(); try { jdbcTemplate.update(""" @@ -2917,13 +3114,14 @@ public class AihrPracticeSeedService { } } - private boolean dailyQuestionRecentlyUsed(String extPartyId, String contentHash) { - Integer count = jdbcTemplate.queryForObject(""" - SELECT COUNT(*) FROM aihr_practice_assignment - WHERE tenant_id = ? AND ext_party_id = ? AND source = 'daily' AND content_hash = ? + private Set recentlyUsedDailyContentHashes(String extPartyId) { + return new LinkedHashSet<>(jdbcTemplate.query(""" + SELECT DISTINCT content_hash + FROM aihr_practice_assignment + WHERE tenant_id = ? AND ext_party_id = ? AND source = 'daily' + AND content_hash IS NOT NULL AND content_hash <> '' AND create_time >= DATE_SUB(CURRENT_DATE(), INTERVAL 6 DAY) - """, Integer.class, tenantId(), extPartyId, contentHash); - return count != null && count > 0; + """, (rs, rowNum) -> rs.getString("content_hash"), tenantId(), extPartyId)); } static List missingDailyDrillSlots(List drills, Set existingQuestions) { @@ -2952,12 +3150,79 @@ public class AihrPracticeSeedService { ORDER BY snapshot_date DESC, id DESC LIMIT 1 """, (rs, rowNum) -> rs.getString("position_name"), tenantId(), extPartyId, extPartyId); - return positions.stream().filter(position -> !isBlank(position)).findFirst().orElse("生活顾问"); + return positions.stream().filter(position -> !isBlank(position)).map(AihrPracticeSeedService::canonicalPracticePosition) + .filter(PRACTICE_POSITIONS::contains).findFirst().orElse("生活顾问"); } catch (DataAccessException ignored) { return "生活顾问"; } } + /** + * The daily task selector reads the same versioned question bank that the + * operations console edits. It deliberately does not infer a scenario or + * fall back to the old life-consultant pack in production. + */ + private List dailyDrillsFromCatalog(LocalDate date, String position, String extPartyId) { + ensureLearningQuestionTable(); + String canonicalPosition = canonicalPracticePosition(position); + if (!PRACTICE_POSITIONS.contains(canonicalPosition)) { + return List.of(); + } + List candidates = jdbcTemplate.query(""" + SELECT q.scenario_code, q.question_text, q.reference_answer, q.content_version, q.content_hash + FROM aihr_learning_question q + JOIN aihr_practice_scenario s + ON s.tenant_id = q.tenant_id AND s.scenario_code = q.scenario_code + WHERE q.tenant_id = ? AND q.position_code = ? AND q.question_type = 'daily' AND q.enabled = 1 + AND s.position = ? AND s.enabled = 1 + ORDER BY q.sort_order, q.id + """, (rs, rowNum) -> new DailyDrillSeed( + rs.getString("scenario_code"), rs.getString("question_text"), rs.getString("reference_answer"), + rs.getString("content_version"), rs.getString("content_hash") + ), tenantId(), canonicalPosition, canonicalPosition); + if (candidates.isEmpty()) { + return List.of(); + } + LocalDate safeDate = date == null ? LocalDate.ofEpochDay(0) : date; + String scope = canonicalPosition + "|" + firstNonBlank(extPartyId, "anonymous"); + int start = (int) Math.floorMod(safeDate.toEpochDay() + scope.hashCode(), candidates.size()); + List result = new ArrayList<>(); + Set seenHashes = new LinkedHashSet<>(); + Set recentlyUsedHashes = recentlyUsedDailyContentHashes(extPartyId); + for (int offset = 0; offset < candidates.size() && result.size() < 3; offset++) { + DailyDrillSeed candidate = candidates.get((start + offset) % candidates.size()); + String fingerprint = firstNonBlank(candidate.contentHash(), candidate.question()); + if (!seenHashes.add(fingerprint) || recentlyUsedHashes.contains(fingerprint)) { + continue; + } + result.add(new DailyDrillSeed(candidate.scenarioId(), "每日三题 " + (result.size() + 1) + ":" + candidate.question(), + candidate.referenceAnswer(), candidate.contentVersion(), candidate.contentHash())); + } + return List.copyOf(result); + } + + /** Materializes new-hire daily tasks even before an employee opens the H5 page. */ + @Scheduled(cron = "${aihr.practice.daily-drill-cron:0 5 0 * * *}", zone = "Asia/Shanghai") + public void materializeDailyDrills() { + if (!orgSnapshotTableExists() || !orgSnapshotColumnExists("hire_date")) { + return; + } + try { + List parties = jdbcTemplate.query(""" + SELECT DISTINCT ext_party_id + FROM aihr_org_snapshot + WHERE tenant_id = ? AND employment_status = 'active' + AND ext_party_id IS NOT NULL AND ext_party_id <> '' + AND hire_date IS NOT NULL AND hire_date >= DATE_SUB(CURRENT_DATE(), INTERVAL 3 MONTH) + """, (rs, rowNum) -> rs.getString("ext_party_id"), tenantId()); + for (String party : parties) { + ensureDailyDrills(party); + } + } catch (DataAccessException ex) { + log.warn("daily drill materialization skipped because organization data is unavailable"); + } + } + private String scenarioContentVersion(String scenarioId) { try { List versions = jdbcTemplate.query(""" @@ -3051,6 +3316,7 @@ public class AihrPracticeSeedService { // prepare it once before the per-employee REQUIRES_NEW transactions begin. ensureAssignmentTable(); TeamScope scope = teamScope(supervisorExtPartyId); + AssignmentPositionPolicy positionPolicy = assignmentPositionPolicy(scenarioId); Set scopedExtPartyIds = new LinkedHashSet<>(scope.extPartyIds()); List requestedExtPartyIds = targets.stream() .map(target -> firstNonBlank(target == null ? null : target.extPartyId(), "")) @@ -3082,6 +3348,13 @@ public class AihrPracticeSeedService { failed++; continue; } + try { + requireAssignmentTargetPosition(extPartyId, positionPolicy); + } catch (ServiceException ex) { + results.add(new PracticeAssignmentBatchItemResponse(extPartyId, traineeName, "FAILED", ex.getMessage(), null)); + failed++; + continue; + } if (storageResolution.ambiguousExtPartyIds().contains(extPartyId.trim())) { results.add(new PracticeAssignmentBatchItemResponse(extPartyId, traineeName, "FAILED", "该手机号对应多名在职员工,请先修正组织通讯录后再派发", null)); @@ -3122,6 +3395,73 @@ public class AihrPracticeSeedService { return new PracticeAssignmentBatchResponse(requestId, results.size(), created, duplicate, failed, results); } + private AssignmentPositionPolicy assignmentPositionPolicy(String scenarioId) { + boolean enabled = orgSnapshotTableExists() && orgSnapshotColumnExists("position_name"); + if (!enabled) { + return new AssignmentPositionPolicy(false, false, ""); + } + String scenarioPosition = assignmentScenarioPosition(scenarioId); + if (isBlank(scenarioPosition)) { + throw new ServiceException("训练场景未配置岗位,无法派发"); + } + return new AssignmentPositionPolicy(true, orgSnapshotColumnExists("person_phone"), scenarioPosition); + } + + private void requireAssignmentTargetPosition(String extPartyId, AssignmentPositionPolicy policy) { + if (!policy.enabled()) { + return; + } + String targetPosition = activeAssignmentTargetPosition(extPartyId, policy.hasPhone()); + if (isBlank(targetPosition)) { + throw new ServiceException("员工不在职或未配置岗位"); + } + if (!policy.scenarioPosition().equals(targetPosition)) { + throw new ServiceException("员工岗位(" + targetPosition + ")与训练场景岗位(" + policy.scenarioPosition() + ")不匹配"); + } + } + + private String activeAssignmentTargetPosition(String identity, boolean hasPhone) { + List args = new ArrayList<>(); + args.add(tenantId()); + args.add(identity); + String identityWhere = "ext_party_id = ?"; + if (hasPhone) { + identityWhere = "(ext_party_id = ? OR person_phone = ?)"; + args.add(identity); + } + List rows = jdbcTemplate.query(""" + SELECT position_name + FROM aihr_org_snapshot + WHERE tenant_id = ? AND employment_status = 'active' + AND """ + identityWhere + """ + AND position_name IS NOT NULL AND position_name <> '' + ORDER BY snapshot_date DESC, id DESC + LIMIT 1 + """, (rs, rowNum) -> rs.getString("position_name"), args.toArray()); + return rows.stream() + .filter(position -> !isBlank(position)) + .map(AihrPracticeSeedService::canonicalPracticePosition) + .filter(PRACTICE_POSITIONS::contains) + .findFirst() + .orElse(""); + } + + private String assignmentScenarioPosition(String scenarioId) { + List rows = jdbcTemplate.query(""" + SELECT position + FROM aihr_practice_scenario + WHERE tenant_id = ? AND scenario_code = ? AND enabled = 1 + ORDER BY id DESC + LIMIT 1 + """, (rs, rowNum) -> rs.getString("position"), tenantId(), scenarioId); + return rows.stream() + .filter(position -> !isBlank(position)) + .map(AihrPracticeSeedService::canonicalPracticePosition) + .filter(PRACTICE_POSITIONS::contains) + .findFirst() + .orElse(""); + } + private AssignmentCreation createAssignmentInternal(PracticeAssignmentRequest request) { return createAssignmentInternal(request, null, null); } @@ -3213,6 +3553,8 @@ public class AihrPracticeSeedService { if (!inTeamScope(supervisorExtPartyId, extPartyId)) { throw new ServiceException("只能派发给当前项目范围内员工"); } + requireAssignmentTargetPosition(extPartyId, assignmentPositionPolicy( + firstNonBlank(request == null ? null : request.scenarioId(), "fee-parking"))); return createAssignment(request); } @@ -3584,8 +3926,9 @@ public class AihrPracticeSeedService { private void ensureScenarioEnabledForStart(String scenarioId) { if (isBlank(scenarioId)) { - return; + throw new ServiceException("请选择训练场景"); } + ensureScenarioTable(); try { List enabledRows = jdbcTemplate.query(""" SELECT enabled @@ -3593,6 +3936,9 @@ public class AihrPracticeSeedService { WHERE tenant_id = ? AND scenario_code = ? LIMIT 1 """, (rs, rowNum) -> rs.getBoolean("enabled"), tenantId(), scenarioId.trim()); + if (enabledRows.isEmpty() && !scenarios.containsKey(scenarioId.trim())) { + throw new ServiceException("训练场景不存在或内容未发布"); + } if (!enabledRows.isEmpty() && !Boolean.TRUE.equals(enabledRows.get(0))) { throw new ServiceException("训练场景已停用,无法开始"); } @@ -3644,13 +3990,16 @@ public class AihrPracticeSeedService { String id = isBlank(sessionId) ? "seed-" + scenario.id() + "-" + System.currentTimeMillis() : sessionId; boolean mobile = activeSession != null && activeSession.mobile(); String annotationsJson = annotationsJson(activeSession, scenario); + RubricResponse rubric = activeSession == null ? activeRubricForScenario(scenario.id()) : activeSession.rubric(); + String rubricSnapshot = writeJson(rubric); jdbcTemplate.update(""" INSERT INTO aihr_practice_session (tenant_id, session_id, ext_party_id, trainee_name, scenario_id, scenario_name, mode, total_score, dim_task_completion, dim_response_timeliness, response_latency_ms, dim_compliance, dim_emotion, dim_communication, dim_marketing, + rubric_code, rubric_version, rubric_snapshot_json, 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), @@ -3660,6 +4009,9 @@ public class AihrPracticeSeedService { dim_emotion = VALUES(dim_emotion), dim_communication = VALUES(dim_communication), dim_marketing = VALUES(dim_marketing), + rubric_code = COALESCE(aihr_practice_session.rubric_code, VALUES(rubric_code)), + rubric_version = COALESCE(aihr_practice_session.rubric_version, VALUES(rubric_version)), + rubric_snapshot_json = COALESCE(aihr_practice_session.rubric_snapshot_json, VALUES(rubric_snapshot_json)), mentor_rewrite = VALUES(mentor_rewrite), ai_comment = VALUES(ai_comment), summary = VALUES(summary), @@ -3685,6 +4037,9 @@ public class AihrPracticeSeedService { scoreValue(result.scores(), "情绪管理能力"), result.communication(), scoreValue(result.scores(), "增值转化潜力"), + rubric.code(), + rubric.version(), + rubricSnapshot, result.rewrite(), result.summary(), record.summary(), @@ -4434,7 +4789,26 @@ public class AihrPracticeSeedService { rs.getString("strategy"), rs.getString("goal"), rs.getBoolean("enabled"), - rubricFor(code).orElse(defaultRubric(code)) + rubricFor(code).orElse(defaultRubric(code)), + scenarioRounds(rs.getString("rounds_json"), rs.getString("opening_text")), + firstNonBlank(rs.getString("content_version"), "v1"), + rs.getString("content_hash") + ); + } + + private PracticeQuestionResponse mapPracticeQuestion(ResultSet rs, int rowNum) throws SQLException { + return new PracticeQuestionResponse( + rs.getString("question_code"), + rs.getString("scenario_code"), + rs.getString("scenario_name"), + canonicalPracticePosition(rs.getString("position_code")), + rs.getString("question_type"), + rs.getString("question_text"), + rs.getString("reference_answer"), + firstNonBlank(rs.getString("content_version"), "v1"), + rs.getString("content_hash"), + rs.getBoolean("enabled"), + rs.getInt("sort_order") ); } @@ -4457,7 +4831,7 @@ public class AihrPracticeSeedService { SELECT rubric_code, rubric_name, version FROM aihr_practice_rubric WHERE tenant_id = ? AND scenario_code = ? AND enabled = 1 - ORDER BY id ASC + ORDER BY update_time DESC, id DESC LIMIT 1 """, this::mapRubric, tenantId(), scenarioCode); return rows.stream().findFirst(); @@ -4466,6 +4840,10 @@ public class AihrPracticeSeedService { } } + private RubricResponse activeRubricForScenario(String scenarioCode) { + return rubricFor(scenarioCode).orElseGet(() -> defaultRubric(scenarioCode)); + } + private List rubricDimensions(String rubricCode) { if (isBlank(rubricCode)) { return defaultRubricDimensions(false); @@ -4488,6 +4866,43 @@ public class AihrPracticeSeedService { } } + private boolean scenarioExists(String scenarioCode) { + ensureScenarioTable(); + Integer count = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM aihr_practice_scenario WHERE tenant_id = ? AND scenario_code = ? + """, Integer.class, tenantId(), scenarioCode); + return count != null && count > 0; + } + + private List validateRubricDimensions(List source) { + List dimensions = Optional.ofNullable(source).orElse(List.of()); + if (dimensions.size() != RUBRIC_DIMENSION_CODES.size()) { + throw new ServiceException("评分规则必须包含任务完成、话术规范、情绪、响应时效和增值五个维度"); + } + Set seen = new LinkedHashSet<>(); + double total = 0D; + List normalized = new ArrayList<>(); + for (RubricDimensionResponse item : dimensions) { + if (item == null || isBlank(item.code()) || isBlank(item.name()) || !RUBRIC_DIMENSION_CODES.contains(item.code().trim())) { + throw new ServiceException("评分规则维度不合法"); + } + if (!seen.add(item.code().trim())) { + throw new ServiceException("评分规则不能包含重复维度"); + } + double weight = item.weight() == null ? -1D : item.weight(); + if (weight < 0D || weight > 1D) { + throw new ServiceException("评分规则权重必须在 0 到 1 之间"); + } + total += weight; + normalized.add(new RubricDimensionResponse(item.code().trim(), item.name().trim(), weight, + firstNonBlank(item.description(), "评分维度说明待补充"))); + } + if (!seen.equals(RUBRIC_DIMENSION_CODES) || Math.abs(total - 1D) > 0.001D) { + throw new ServiceException("评分规则五个维度必须齐全且权重合计为 100%"); + } + return List.copyOf(normalized); + } + private List fallbackScenarios() { return scenarios.values().stream().map(this::fallbackScenario).toList(); } @@ -4511,7 +4926,10 @@ public class AihrPracticeSeedService { seed.strategy(), seed.goal(), true, - defaultRubric(seed.id()) + defaultRubric(seed.id()), + scenarioRounds(seed.rounds()), + "seed-v1", + AihrLearningService.contentHash("seed-v1", seed.name(), seed.goal()) ); } @@ -4520,6 +4938,22 @@ public class AihrPracticeSeedService { return new RubricResponse("rubric-" + scenarioCode, "生活顾问试点 Rubric", "seed", defaultRubricDimensions(feeScenario)); } + private void ensureDefaultRubricForScenario(String scenarioCode, String position) { + if (rubricFor(scenarioCode).isPresent()) { + return; + } + String normalizedPosition = canonicalPracticePosition(position); + saveRubric(new RubricSaveRequest( + "rubric-" + normalizeScenarioCode(scenarioCode), + scenarioCode, + normalizedPosition + "基础评分规则", + "v1.0", + true, + "场景内容首次发布时生成;后续版本由训练运营维护。", + defaultRubricDimensions(false) + )); + } + private List defaultRubricDimensions(boolean feeScenario) { return List.of( new RubricDimensionResponse("task_completion", "任务完成度", 0.40, "问题解决有效性、工单闭环率、首解率、SOP关键点执行率"), @@ -5175,6 +5609,9 @@ public class AihrPracticeSeedService { `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 '主管复盘建议', @@ -5199,6 +5636,9 @@ public class AihrPracticeSeedService { 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("rubric_code", "ALTER TABLE aihr_practice_session ADD COLUMN `rubric_code` varchar(100) DEFAULT NULL COMMENT '训练开始时评分规则编码' AFTER `dim_marketing`"); + ensurePracticeColumn("rubric_version", "ALTER TABLE aihr_practice_session ADD COLUMN `rubric_version` varchar(30) DEFAULT NULL COMMENT '训练开始时评分规则版本' AFTER `rubric_code`"); + ensurePracticeColumn("rubric_snapshot_json", "ALTER TABLE aihr_practice_session ADD COLUMN `rubric_snapshot_json` text DEFAULT NULL COMMENT '训练开始时评分规则快照' AFTER `rubric_version`"); 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`"); @@ -5304,6 +5744,8 @@ public class AihrPracticeSeedService { `trust_start` int DEFAULT 60 COMMENT '起始信任', `trust_end` int DEFAULT 80 COMMENT '目标信任', `rounds_json` text DEFAULT NULL COMMENT '回合seed JSON', + `content_version` varchar(30) NOT NULL DEFAULT 'v1.0' COMMENT '内容版本', + `content_hash` char(64) DEFAULT NULL COMMENT '内容快照哈希', `enabled` tinyint DEFAULT 1 COMMENT '是否启用', `sort_order` int DEFAULT 0 COMMENT '排序', `create_time` datetime DEFAULT NULL COMMENT '创建时间', @@ -5313,10 +5755,48 @@ public class AihrPracticeSeedService { KEY `idx_aihr_practice_scenario_filter` (`tenant_id`, `position`, `scenario_type`, `enabled`, `sort_order`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 对练场景'; """); + ensureColumn("aihr_practice_scenario", "content_version", + "ALTER TABLE aihr_practice_scenario ADD COLUMN `content_version` varchar(30) NOT NULL DEFAULT 'v1.0' COMMENT '内容版本' AFTER `rounds_json`"); + ensureColumn("aihr_practice_scenario", "content_hash", + "ALTER TABLE aihr_practice_scenario ADD COLUMN `content_hash` char(64) DEFAULT NULL COMMENT '内容快照哈希' AFTER `content_version`"); scenarioTableReady = true; } } + private void ensureLearningQuestionTable() { + if (learningQuestionTableReady) { + return; + } + synchronized (this) { + if (learningQuestionTableReady) { + return; + } + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS `aihr_learning_question` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) NOT NULL DEFAULT '000000' COMMENT '租户编号', + `question_code` varchar(120) NOT NULL COMMENT '题目编码', + `scenario_code` varchar(80) NOT NULL COMMENT '场景编码', + `position_code` varchar(80) NOT NULL COMMENT '岗位编码', + `question_type` varchar(20) NOT NULL DEFAULT 'daily' COMMENT 'daily/special', + `question_text` varchar(1000) NOT NULL COMMENT '题干', + `reference_answer` varchar(2000) NOT NULL COMMENT '参考答案', + `content_version` varchar(30) NOT NULL DEFAULT 'v1.0' COMMENT '内容版本', + `content_hash` char(64) NOT NULL COMMENT '题目内容哈希', + `enabled` tinyint NOT NULL DEFAULT 1 COMMENT '是否启用', + `sort_order` int NOT NULL DEFAULT 0 COMMENT '排序', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_learning_question` (`tenant_id`, `question_code`), + KEY `idx_aihr_learning_question_hash` (`tenant_id`, `content_hash`), + KEY `idx_aihr_learning_question_pick` (`tenant_id`, `position_code`, `question_type`, `enabled`, `sort_order`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 训练题目库'; + """); + learningQuestionTableReady = true; + } + } + private void ensureRubricTables() { jdbcTemplate.execute(""" CREATE TABLE IF NOT EXISTS `aihr_practice_rubric` ( @@ -5530,6 +6010,102 @@ public class AihrPracticeSeedService { return code.isBlank() ? "scenario-" + System.currentTimeMillis() : code; } + /** One canonical place for the employee, supervisor, content and scheduler paths. */ + static String canonicalPracticePosition(String value) { + String raw = value == null ? "" : value.trim(); + if (raw.isBlank()) { + return "生活顾问"; + } + if (raw.contains("生活顾问") || raw.contains("客服管家") || raw.contains("物业管家") || raw.equals("管家")) { + return "生活顾问"; + } + if (raw.contains("保安") || raw.contains("秩序") || raw.contains("安保")) { + return "保安"; + } + if (raw.contains("保洁") || raw.contains("环境")) { + return "保洁"; + } + if (raw.contains("保修") || raw.contains("维修") || raw.contains("工程")) { + return "保修"; + } + if (raw.contains("客服") || raw.contains("前台")) { + return "客服"; + } + return raw; + } + + private String normalizeQuestionType(String value) { + String type = firstNonBlank(value, "daily").toLowerCase(java.util.Locale.ROOT); + if (!Set.of("daily", "special").contains(type)) { + throw new ServiceException("题目类型仅支持 daily 或 special"); + } + return type; + } + + private List normalizeScenarioRounds(List source, String openingText) { + List rounds = new ArrayList<>(); + for (ScenarioRoundResponse item : Optional.ofNullable(source).orElse(List.of())) { + if (item == null || isBlank(item.customer())) { + continue; + } + rounds.add(new ScenarioRoundResponse( + item.customer().trim(), + firstNonBlank(item.coachHint(), "补上责任人、反馈时间和SOP依据。"), + Math.max(0, Math.min(100, item.emotion() == null ? 70 : item.emotion())), + Math.max(0, Math.min(100, item.trust() == null ? 75 : item.trust())) + )); + if (rounds.size() >= 6) { + break; + } + } + if (rounds.isEmpty()) { + return scenarioRounds(jsonRounds(null, openingText)); + } + if (rounds.size() == 1) { + ScenarioRoundResponse first = rounds.get(0); + rounds.add(new ScenarioRoundResponse("我想知道接下来谁负责、什么时候反馈。", + "补上责任人、处理节点和回访时间。", Math.max(0, first.emotion() - 8), + Math.min(100, first.trust() + 8))); + } + return List.copyOf(rounds); + } + + private List scenarioRounds(String roundsJson, String openingText) { + return scenarioRounds(jsonRounds(roundsJson, openingText)); + } + + private List scenarioRounds(List rounds) { + return Optional.ofNullable(rounds).orElse(List.of()).stream() + .map(round -> new ScenarioRoundResponse(round.customer(), round.coachHint(), round.emotion(), round.trust())) + .toList(); + } + + private String nextScenarioContentVersion(String scenarioCode) { + return nextContentVersion("aihr_practice_scenario", "scenario_code", scenarioCode, "v1.0"); + } + + private String nextQuestionContentVersion(String questionCode) { + return nextContentVersion("aihr_learning_question", "question_code", questionCode, "v1.0"); + } + + private String nextContentVersion(String table, String codeColumn, String code, String fallback) { + try { + List versions = jdbcTemplate.query("SELECT content_version FROM " + table + + " WHERE tenant_id = ? AND " + codeColumn + " = ? LIMIT 1", (rs, rowNum) -> rs.getString(1), tenantId(), code); + if (versions.isEmpty() || isBlank(versions.get(0))) { + return fallback; + } + String current = versions.get(0).trim(); + java.util.regex.Matcher matcher = java.util.regex.Pattern.compile("^(.*?)(\\d+)$").matcher(current); + if (!matcher.matches()) { + return current + ".1"; + } + return matcher.group(1) + (Integer.parseInt(matcher.group(2)) + 1); + } catch (DataAccessException ignored) { + return fallback; + } + } + private int clampScore(Integer value) { if (value == null) { return 0; @@ -5823,7 +6399,11 @@ public class AihrPracticeSeedService { private record ResolvedTurn(String customerText, Integer emotion, Integer trust, String coachHint, boolean redFlag) { } - record DailyDrillSeed(String scenarioId, String question, String referenceAnswer) { + record DailyDrillSeed(String scenarioId, String question, String referenceAnswer, String contentVersion, String contentHash) { + DailyDrillSeed(String scenarioId, String question, String referenceAnswer) { + this(scenarioId, question, referenceAnswer, "daily-v1", + AihrLearningService.contentHash("daily-v1", question, referenceAnswer)); + } } private record DailyDrillParts(String question, String referenceAnswer) { @@ -5835,6 +6415,9 @@ public class AihrPracticeSeedService { private record AssignmentCreation(PracticeAssignmentResponse assignment, boolean duplicate) { } + private record AssignmentPositionPolicy(boolean enabled, boolean hasPhone, String scenarioPosition) { + } + private record TurnEvidence(Integer emotion, Integer trust, Boolean redFlag, String coachHint) { } @@ -5899,7 +6482,7 @@ public class AihrPracticeSeedService { LocalDateTime startedAt, AtomicInteger currentRoundIndex, List traineeReplies, List traineeAudioUrls, List traineeAudioOssIds, List customerLines, List customerAudioOssIds, List turnEvidence, - List promptPresentedAtMillis, List responseLatenciesMs) { + List promptPresentedAtMillis, List responseLatenciesMs, RubricResponse rubric) { } private record PracticeResult(Integer total, List scores, String rewrite, String summary, diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java index a1d0dd83..b0dd05d5 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java @@ -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()); diff --git a/backend/script/sql/aihr_practice_mysql8.sql b/backend/script/sql/aihr_practice_mysql8.sql index 881807e9..4734a651 100644 --- a/backend/script/sql/aihr_practice_mysql8.sql +++ b/backend/script/sql/aihr_practice_mysql8.sql @@ -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 '主管复盘建议', diff --git a/backend/script/sql/update/aihr_20260723_practice_content_operations_mysql8.sql b/backend/script/sql/update/aihr_20260723_practice_content_operations_mysql8.sql new file mode 100644 index 00000000..c29f31fb --- /dev/null +++ b/backend/script/sql/update/aihr_20260723_practice_content_operations_mysql8.sql @@ -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 = '待训练'; diff --git a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md index 5a782c65..3deef1b3 100644 --- a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md +++ b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md @@ -67,6 +67,7 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aih mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260717_practice_assignment_contract_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260717_learning_closure_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260717_practice_five_position_catalog_mysql8.sql +mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260723_practice_content_operations_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260717_web_ai_question_reward_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260718_knowledge_conversation_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260718_release_collation_compat_mysql8.sql @@ -85,7 +86,7 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aih mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/aihr_personal_knowledge_mysql8.sql ``` -顺序原因:场景补充依赖三张场景/Rubric 基础表;五维迁移依赖场景和 Rubric;岗位/SOP/任务/资格迁移只补正式数据契约,不写业务行,也不代表岗位适用范围、任务规则或资格标准已经获得 HR 确认。住宅 SOP 和 Prompt 迁移只补内置内容,不代表内容已完成业务复核。知识会话迁移只新增短期上下文表,不生成业务对话。排序规则兼容迁移先对齐本批发布表,全量排序规则迁移再统一历史 `aihr_*` 表;两者都读取目标库 `aihr_knowledge_info.tenant_id` 的实际排序规则。服务记忆迁移创建候选、统一采集、兼容项目记录和版本留痕四张表;工作上报幂等迁移随后只补原表列与唯一索引;知识分类迁移新增空间内分类表及附件成员的可空 `category_id`,不迁移、删除或扩大任何空间授权与检索范围;银城大喇叭 M0 迁移创建消息、阅读和不可变 v1 快照三表,为已存在消息补一次快照,并补发布请求键/载荷哈希、撤回原因及 `(tenant_id,published_by,publish_request_key)` 唯一约束。紧接着 M1 定向迁移补充必读与目标载荷字段,以及规则快照、命中对象快照两表;它只决定提醒/必读对象,不改变全租户公开频道的可见性。随后,消息追问迁移只给短期会话增加可空 `broadcast_message_id`;它不保存消息正文,并用于约束同一会话不能切换公司消息。历史普通会话保持 `NULL`。个人资料迁移最后创建独立 `aihr_personal_*` 表,并从当前租户既有 MinIO 配置派生私有 `personal-minio` 配置;`ruoyi-personal` bucket 必须由受控运维流程创建和验证,不能在文档或日志中输出访问密钥。它们同样对齐目标排序规则,兼容历史生产库与全新 MySQL 8 数据库。 +顺序原因:场景补充依赖三张场景/Rubric 基础表;五维迁移依赖场景和 Rubric;五岗位题库之后的内容运营迁移为每个场景补齐唯一的初始 Rubric、五个稳定评分维度与训练时评分规则快照列,并只修正仍处于待训练状态的旧每日题场景编号,不重写已完成记录。岗位/SOP/任务/资格迁移只补正式数据契约,不写业务行,也不代表岗位适用范围、任务规则或资格标准已经获得 HR 确认。住宅 SOP 和 Prompt 迁移只补内置内容,不代表内容已完成业务复核。知识会话迁移只新增短期上下文表,不生成业务对话。排序规则兼容迁移先对齐本批发布表,全量排序规则迁移再统一历史 `aihr_*` 表;两者都读取目标库 `aihr_knowledge_info.tenant_id` 的实际排序规则。服务记忆迁移创建候选、统一采集、兼容项目记录和版本留痕四张表;工作上报幂等迁移随后只补原表列与唯一索引;知识分类迁移新增空间内分类表及附件成员的可空 `category_id`,不迁移、删除或扩大任何空间授权与检索范围;银城大喇叭 M0 迁移创建消息、阅读和不可变 v1 快照三表,为已存在消息补一次快照,并补发布请求键/载荷哈希、撤回原因及 `(tenant_id,published_by,publish_request_key)` 唯一约束。紧接着 M1 定向迁移补充必读与目标载荷字段,以及规则快照、命中对象快照两表;它只决定提醒/必读对象,不改变全租户公开频道的可见性。随后,消息追问迁移只给短期会话增加可空 `broadcast_message_id`;它不保存消息正文,并用于约束同一会话不能切换公司消息。历史普通会话保持 `NULL`。个人资料迁移最后创建独立 `aihr_personal_*` 表,并从当前租户既有 MinIO 配置派生私有 `personal-minio` 配置;`ruoyi-personal` bucket 必须由受控运维流程创建和验证,不能在文档或日志中输出访问密钥。它们同样对齐目标排序规则,兼容历史生产库与全新 MySQL 8 数据库。 ## 迁移后只读核验 @@ -128,7 +129,7 @@ FROM information_schema.columns WHERE table_schema = DATABASE() AND ( (table_name = 'aihr_case_record' AND column_name IN ('media_oss_id', 'media_url', 'learning_points', 'supervisor_comment')) - OR (table_name = 'aihr_practice_session' AND column_name IN ('dim_task_completion', 'dim_response_timeliness', 'response_latency_ms', 'annotations_json', 'review_advice', 'incentive_point', 'satisfaction_score', 'satisfaction_comment')) + OR (table_name = 'aihr_practice_session' AND column_name IN ('dim_task_completion', 'dim_response_timeliness', 'response_latency_ms', 'rubric_code', 'rubric_version', 'rubric_snapshot_json', 'annotations_json', 'review_advice', 'incentive_point', 'satisfaction_score', 'satisfaction_comment')) OR (table_name = 'aihr_practice_scenario' AND column_name IN ('content_version', 'content_hash')) OR (table_name = 'aihr_practice_assignment' AND column_name IN ('score', 'score_mode', 'due_date', 'request_key', 'content_version', 'question_snapshot', 'reference_answer_snapshot', 'content_hash')) OR (table_name = 'aihr_sop_answer_review' AND column_name IN ('prompt_version', 'requester_ext_party_id')) @@ -182,6 +183,27 @@ FROM aihr_learning_question WHERE tenant_id = '000000' GROUP BY question_type ORDER BY question_type; + +SELECT s.position, + COUNT(*) AS enabled_scenarios, + SUM(active_rubric.active_count = 1) AS scenarios_with_one_active_rubric +FROM aihr_practice_scenario s +LEFT JOIN ( + SELECT scenario_code, COUNT(*) AS active_count + FROM aihr_practice_rubric + WHERE tenant_id = '000000' AND enabled = 1 + GROUP BY scenario_code +) active_rubric ON active_rubric.scenario_code = s.scenario_code +WHERE s.tenant_id = '000000' AND s.enabled = 1 +GROUP BY s.position +ORDER BY s.position; + +SELECT d.dimension_code, COUNT(*) AS active_dimension_rows +FROM aihr_practice_rubric_dimension d +JOIN aihr_practice_rubric r ON r.tenant_id = d.tenant_id AND r.rubric_code = d.rubric_code +WHERE r.tenant_id = '000000' AND r.enabled = 1 +GROUP BY d.dimension_code +ORDER BY d.dimension_code; ``` 预期是三张场景/Rubric 表、岗位题库、训练反馈表与训练求助事件表、五张考试表、三张全网检索表、问题榜/激励六张表、三张证据表、知识平台治理表和空间内分类表、三张候选人/面试表、四张岗位/SOP/任务/资格契约表、平台组织目录与租户绑定表、四张记忆表、采集状态历史表、每日成果表、原工作上报表、三张大喇叭消息/阅读/v1快照表、两张 M1 定向规则/对象快照表及十张独立个人资料表均存在。`57/57` 只代表当时较窄的发布清单;后续线上只读复核已确认 M1 的目标表和必读字段,但当前线上不存在 `aihr_practice_help_event`,因此不得把包含该表的 M2 完整清单写成已发布。工作上报幂等字段为 `2/2`,大喇叭发布/撤回审计字段为 `3/3`,其发布幂等索引为 `0 | tenant_id,published_by,publish_request_key | 0`,M1 必读/目标载荷字段契约为 `2/2`,两张目标表字段契约为 `16/16`,定向规则与对象快照唯一约束分别为 `4/4`、`3/3`,公司消息追问会话字段为 `1/1`,工作助手新增字段为 `8/8`,会话证据字段为 `8/8`,专项批量/内容快照字段为 `8/8`,候选资料审核字段为 `2/2`,面试复核字段为 `6/6`,字段查询覆盖所有列,内置 Prompt 数量为 `6`。私有资料配置只应返回 `ruoyi-personal`、私有策略和 `configured` 状态,不显示访问密钥。五个岗位各有 `20` 个场景,`daily/special` 各有 `100` 道题;其中 `14` 个未完成正式审核的高风险场景及其 `28` 道题保持禁用。岗位/SOP/任务/资格表及新记忆表为空是允许的;这只证明 schema/seed 迁移完成,不证明已有员工确认过记忆、完整个人知识库或严格试点完成。 @@ -190,7 +212,7 @@ ORDER BY question_type; 1. 重启 `wygj-aihr.service`,确认状态为 `active`。 2. 检查根站、`/h5/`、租户接口和员工首页 API 的 HTTP 与业务码均为 `200`。 -3. 用正式测试账号完成员工端训练、主管复盘和管理端场景/Prompt 只读检查;不要使用开发固定验证码验证生产安全策略。 +3. 用正式测试账号完成员工端训练、主管复盘和管理端五岗位场景、每日/专项题库、Rubric 与任务运营检查;确认停用内容不会被下发,任务创建保留题目/参考答案版本快照,训练完成记录保留开始时 Rubric 快照。不要使用开发固定验证码验证生产安全策略。 4. 用正式测试账号在“工作助手”选择当前项目并说“帮我记一下三栋 3203 需要保洁服务”;确认前 `aihr_assistant_capture` 不得新增。分别验证 `PRIVATE` 仅本人可召回、`COMPANY` 只显示 `PENDING` 且没有虚假送达回执;新开对话按房号查询必须只命中对应项目记录,双击确认不得重复写入。测试表达如不需长期保留,应在确认卡选择“暂不保存”,不要直接删除生产审计记录。 5. 在“今日工作成果”按员工 + 项目 + 日期重复生成两次,内容不变时版本哈希必须一致;员工访问团队结果、主管访问非授权项目必须被拒绝。生产无主管正式测试账号时必须明确记录未验收,不以本地 seed 代替。 6. 用员工测试账号进入“成果投稿”,确认其仍只承载优秀案例、操作视频、完整 SOP 和有用知识,不回退为日常事实表单。重复提交同一 `request_key` 只有一条 `PENDING` 记录;普通员工审核仍为 `403`,审核通过不自动写入案例、知识或积分表。 diff --git a/frontend/src/api/aihr/practice.ts b/frontend/src/api/aihr/practice.ts index 23151951..dc8fe288 100644 --- a/frontend/src/api/aihr/practice.ts +++ b/frontend/src/api/aihr/practice.ts @@ -30,6 +30,9 @@ export type PracticeScenarioResponse = { strategy: string; goal: string; enabled: boolean; + rounds: PracticeScenarioRound[]; + contentVersion: string; + contentHash: string; rubric?: { code: string; name: string; @@ -38,6 +41,13 @@ export type PracticeScenarioResponse = { }; }; +export type PracticeScenarioRound = { + customer: string; + coachHint: string; + emotion: number; + trust: number; +}; + export type PracticeScenarioEnabledRequest = { enabled: boolean; }; @@ -60,6 +70,7 @@ export type PracticeScenarioSaveRequest = { strategy: string; goal: string; enabled: boolean; + rounds: PracticeScenarioRound[]; }; export type PracticeRubricDimension = { @@ -82,6 +93,56 @@ export type PracticeRubricAdmin = { export type PracticeRubricSaveRequest = Omit; +export type PracticeQuestion = { + code: string; + scenarioCode: string; + scenarioName: string; + positionCode: string; + type: 'daily' | 'special'; + question: string; + referenceAnswer: string; + contentVersion: string; + contentHash: string; + enabled: boolean; + sortOrder: number; +}; + +export type PracticeQuestionSaveRequest = Omit; + +export type ManagedPracticeAssignment = { + id: number; + extPartyId: string; + traineeName: string; + scenarioId: string; + scenarioName: string; + source: string; + reason: string; + status: string; + createTime: string; + dueDate?: string; + contentVersion?: string; + contentHash?: string; + question?: string; + referenceAnswer?: string; +}; + +export type ManagedPracticeAssignmentBatchRequest = { + requestId: string; + targets: Array<{ extPartyId: string; traineeName?: string }>; + scenarioId: string; + reason: string; + dueDate: string; +}; + +export type ManagedPracticeAssignmentBatchResponse = { + requestId: string; + total: number; + created: number; + duplicate: number; + failed: number; + results: Array<{ extPartyId: string; traineeName: string; status: string; message: string }>; +}; + export type PracticePrepCardResponse = { keyPoints: string[]; redLines: string[]; @@ -200,7 +261,7 @@ export type PromptTemplateRequest = { description: string; }; -export function listPracticeScenarios(params?: { position?: string; scenarioType?: string }): Promise> { +export function listPracticeScenarios(params?: { position?: string; scenarioType?: string; includeDisabled?: boolean }): Promise> { return request({ url: '/api/train/practice/scenarios', method: 'get', @@ -208,6 +269,18 @@ export function listPracticeScenarios(params?: { position?: string; scenarioType }); } +export function listPracticeQuestions(params?: { position?: string; type?: 'daily' | 'special'; includeDisabled?: boolean }): Promise> { + return request({ url: '/api/train/practice/questions', method: 'get', params }); +} + +export function savePracticeQuestion(data: PracticeQuestionSaveRequest): Promise> { + return request({ url: '/api/train/practice/questions', method: 'post', data }); +} + +export function updatePracticeQuestionEnabled(code: string, enabled: boolean): Promise> { + return request({ url: `/api/train/practice/questions/${encodeURIComponent(code)}/enabled`, method: 'patch', data: { enabled } }); +} + export function updatePracticeScenarioEnabled(id: string, data: PracticeScenarioEnabledRequest): Promise> { return request({ url: `/api/train/practice/scenarios/${id}/enabled`, @@ -240,6 +313,18 @@ export function updatePracticeRubricEnabled(code: string, enabled: boolean): Pro }); } +export function listManagedPracticeAssignments(params?: { position?: string; status?: string; source?: string; keyword?: string; limit?: number }): Promise> { + return request({ url: '/api/train/practice/assignments/manage', method: 'get', params }); +} + +export function createManagedPracticeAssignments(data: ManagedPracticeAssignmentBatchRequest): Promise> { + return request({ url: '/api/train/practice/assignments/manage/batch', method: 'post', data, headers: { repeatSubmit: false } }); +} + +export function cancelManagedPracticeAssignment(id: number, reason?: string): Promise> { + return request({ url: `/api/train/practice/assignments/manage/${id}/cancel`, method: 'patch', data: { reason }, headers: { repeatSubmit: false } }); +} + export function getPracticePrepCard(id: string): Promise> { return request({ url: `/api/train/practice/scenarios/${id}/prep-card`, diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 6312edde..d0c1ad3d 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -120,6 +120,18 @@ export const constantRoutes: RouteRecordRaw[] = [ name: 'TrainScenarios', meta: { title: '场景与评分', icon: 'list' } }, + { + path: 'content', + component: () => import('@/views/train/content.vue'), + name: 'TrainContent', + meta: { title: '训练题库', icon: 'edit' } + }, + { + path: 'tasks', + component: () => import('@/views/train/tasks.vue'), + name: 'TrainTasks', + meta: { title: '任务运营', icon: 'date' } + }, { path: 'rubrics', component: () => import('@/views/train/rubrics.vue'), diff --git a/frontend/src/views/train/content.vue b/frontend/src/views/train/content.vue new file mode 100644 index 00000000..95d669ed --- /dev/null +++ b/frontend/src/views/train/content.vue @@ -0,0 +1,158 @@ + + + + + diff --git a/frontend/src/views/train/scenarios.vue b/frontend/src/views/train/scenarios.vue index e0db950c..eadf1946 100644 --- a/frontend/src/views/train/scenarios.vue +++ b/frontend/src/views/train/scenarios.vue @@ -3,14 +3,14 @@

场景库管理

-

生活顾问岗位 · 场景保存、Rubric 版本与启停维护

+

五岗位训练内容 · 场景回合、版本、Rubric 与启停维护

新增场景
- + @@ -25,6 +25,7 @@
+ @@ -51,7 +52,7 @@