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());