feat(aihr): harden M0-M2 mobile training flows

This commit is contained in:
2026-07-23 21:18:13 +08:00
parent 06322c994b
commit 927458b9e0
44 changed files with 1674 additions and 119 deletions
@@ -11,6 +11,8 @@ import org.dromara.aihr.domain.AihrPracticeDto.CalibrationRequest;
import org.dromara.aihr.domain.AihrPracticeDto.CalibrationResponse;
import org.dromara.aihr.domain.AihrPracticeDto.FinishRequest;
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.RealtimeSdpRequest;
import org.dromara.aihr.domain.AihrPracticeDto.RealtimeSdpResponse;
@@ -90,6 +92,15 @@ public class AihrPracticeController {
return R.ok(practiceSeedService.prepCard(id));
}
@PostMapping("/sessions/{sessionId}/help")
public R<HelpEventResponse> recordHelp(@PathVariable String sessionId, @RequestBody(required = false) HelpRequest request) {
String username = currentAppUsername();
if (username.isBlank()) {
return R.fail("请使用员工端账号发起求助");
}
return R.ok(practiceSeedService.recordHelp(sessionId, request == null ? null : request.roundIndex(), username));
}
@GetMapping("/rubrics")
public R<List<RubricResponse>> rubrics(@RequestParam(required = false) String scenarioId) {
return R.ok(practiceSeedService.rubrics(scenarioId));
@@ -9,6 +9,7 @@ import org.dromara.aihr.domain.AihrSpeechDto.TtsRequest;
import org.dromara.aihr.domain.AihrSpeechDto.TtsResponse;
import org.dromara.aihr.service.AihrMultipartFiles;
import org.dromara.aihr.service.AihrSpeechService;
import org.dromara.common.core.constant.HttpStatus;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.domain.model.LoginUser;
import org.dromara.common.core.enums.UserType;
@@ -29,6 +30,7 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Base64;
import java.util.List;
import java.util.Optional;
/**
* 对练语音 API:ASR 语音输入 + TTS 客户语音播报。未配置语音模型时返回失败,前端降级为文本。
@@ -115,6 +117,10 @@ public class AihrSpeechController {
@PostMapping("/tts")
public R<TtsResponse> tts(@RequestBody TtsRequest request) {
Optional<String> invalidDialect = AihrSpeechService.invalidDialectMessage(request == null ? null : request.voiceProfile());
if (invalidDialect.isPresent()) {
return R.fail(HttpStatus.BAD_REQUEST, invalidDialect.get());
}
if (!speechService.ttsConfigured()) {
return R.fail(NOT_CONFIGURED_HINT);
}
@@ -107,6 +107,12 @@ public final class AihrPracticeDto {
public record PrepCardResponse(List<String> keyPoints, List<String> redLines, List<String> scripts, String promptVersion) {
}
public record HelpRequest(Integer roundIndex) {
}
public record HelpEventResponse(String sessionId, String scenarioId, Integer roundIndex, String recordedAt) {
}
public record RealtimeSdpRequest(String offerSdp) {
}
@@ -14,7 +14,7 @@ public final class AihrSpeechDto {
public record TtsRequest(String text, String voice, VoiceProfile voiceProfile) {
}
public record VoiceProfile(String role, String voice, Double speed, String emotion) {
public record VoiceProfile(String role, String voice, Double speed, String emotion, String dialect) {
}
public record TtsResponse(String audioUrl, String source, Long ossId, String inlineAudioUrl) {
@@ -7,6 +7,7 @@ import lombok.extern.slf4j.Slf4j;
import org.dromara.aihr.domain.AihrPracticeDto.DialogueResponse;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@@ -23,6 +24,7 @@ public class AihrPracticeLlmService {
private static final double CUSTOMER_TEMPERATURE = 0.7;
private static final double SCORE_TEMPERATURE = 0.0;
private static final double DAILY_DRILL_TEMPERATURE = 0.0;
private static final double PREP_CARD_TEMPERATURE = 0.0;
private static final int MAX_CUSTOMER_CHARS = 80;
private final AihrModelSeedService modelService;
@@ -57,6 +59,24 @@ public class AihrPracticeLlmService {
public record DailyDrillAssessment(int score, String feedback) {
}
public record PrepCard(List<String> keyPoints, List<String> redLines, List<String> scripts) {
}
/**
* 训练前卡严格解析固定三栏 JSON。模板由调用方从租户提示词配置加载,模型不可用时返回 empty,
* 由场景字段回退,不让训练入口因为模型调用失败不可用。
*/
public Optional<PrepCard> prepCard(String systemPrompt, String userPrompt) {
if (systemPrompt == null || systemPrompt.isBlank() || userPrompt == null || userPrompt.isBlank()) {
return Optional.empty();
}
return modelService.tryChat(
AihrSensitiveText.forModel(systemPrompt),
AihrSensitiveText.forModel(userPrompt),
PREP_CARD_TEMPERATURE)
.flatMap(this::parsePrepCard);
}
/**
* 生成 AI 客户(业主)的下一句回应。nextBeat 为 seed 剧本中下一轮台词,作为剧情推进锚点。
*/
@@ -250,6 +270,40 @@ public class AihrPracticeLlmService {
}
}
private Optional<PrepCard> parsePrepCard(String content) {
try {
JsonNode root = objectMapper.readTree(extractJson(content));
List<String> keyPoints = jsonStrings(root.path("keyPoints"), 3, 120);
List<String> redLines = jsonStrings(root.path("redLines"), 3, 120);
List<String> scripts = jsonStrings(root.path("scripts"), 2, 160);
if (keyPoints.size() != 3 || redLines.size() != 3 || scripts.size() != 2) {
return Optional.empty();
}
return Optional.of(new PrepCard(keyPoints, redLines, scripts));
} catch (Exception e) {
log.warn("practice prep card parse failed, falls back to scenario(处理错误已隐藏)");
return Optional.empty();
}
}
private static List<String> jsonStrings(JsonNode node, int expectedItems, int maxChars) {
if (node == null || !node.isArray() || node.size() != expectedItems) {
return List.of();
}
List<String> values = new ArrayList<>(expectedItems);
for (JsonNode item : node) {
if (!item.isTextual()) {
return List.of();
}
String value = truncateText(item.asText(), maxChars).trim();
if (value.isBlank()) {
return List.of();
}
values.add(value);
}
return values;
}
/** 维度字段缺失时回落到 total,避免 asInt() 把缺失当 0 分落库。 */
private static int dimension(JsonNode root, String field, int fallback) {
JsonNode node = root.path(field);
@@ -17,6 +17,7 @@ import org.dromara.aihr.domain.AihrPracticeDto.DimensionResponse;
import org.dromara.aihr.domain.AihrPracticeDto.FinishRequest;
import org.dromara.aihr.domain.AihrPracticeDto.FinishResponse;
import org.dromara.aihr.domain.AihrPracticeDto.GrowthStageResponse;
import org.dromara.aihr.domain.AihrPracticeDto.HelpEventResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PersonaResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PrepCardResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAlertResponse;
@@ -90,6 +91,7 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;
/**
@@ -109,6 +111,20 @@ public class AihrPracticeSeedService {
"detailed", "内容更细致,拆成更具体的动作、信息点和反馈节点。",
"professional", "表达更专业,突出SOP依据、责任边界、留痕和闭环口径。"
);
private static final String PREP_CARD_SYSTEM_PROMPT = """
你是物业培训教练。只返回 JSON,不要 markdown。字段固定为 keyPoints、redLines、scripts。
keyPoints 和 redLines 必须各 3 条,scripts 必须 2 条;只能依据输入场景和 SOP 信息,不得编造时限、政策或承诺。
""";
private static final String PREP_CARD_USER_TEMPLATE = """
场景:{{scenarioName}}。
显性诉求:{{explicitNeed}}。
SOP引用:{{sopRefs}}。
成功条件:{{successCriteria}}。
易激化点:{{irritants}}。
可强化表达:{{delights}}。
请生成训练前预习卡。
""";
private static final String PREP_CARD_PROMPT_VERSION = "builtin-prep_card-v1";
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("MM-dd HH:mm");
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
// 阶段一只统计“员工收到业主话术后到提交下一轮”的陪练响应时长;正式首响/工单处理时效仍需业务系统数据。
@@ -422,7 +438,7 @@ public class AihrPracticeSeedService {
public PrepCardResponse prepCard(String scenarioId) {
ensureScenarioEnabledForStart(scenarioId);
ScenarioResponse scenario = scenario(scenarioId);
return new PrepCardResponse(
PrepCardResponse fallback = new PrepCardResponse(
List.of(
firstNonBlank(scenario.explicitNeed(), "先确认业主当前诉求"),
firstNonBlank(scenario.successCriteria(), "给出责任人、处理节点和反馈方式"),
@@ -433,8 +449,33 @@ public class AihrPracticeSeedService {
"我先确认您的诉求和现场情况,再给您明确的责任人和首次反馈时间。",
firstNonBlank(scenario.delights(), "这个问题我会跟进到闭环,过程节点会同步给您。")
),
"builtin-prep_card-v1"
PREP_CARD_PROMPT_VERSION
);
PrepCardPrompt prompt = prepCardPrompt();
if (practiceLlmService == null) {
return fallback;
}
return practiceLlmService.prepCard(prompt.systemPrompt(), renderPrepCardPrompt(prompt.template(), scenario))
.map(card -> new PrepCardResponse(card.keyPoints(), card.redLines(), card.scripts(), prompt.promptVersion()))
.orElseGet(() -> new PrepCardResponse(
fallback.keyPoints(), fallback.redLines(), fallback.scripts(), prompt.promptVersion() + ":fallback"
));
}
/** 记录员工主动点开的训练求助,和知识检索日志分开保存,便于后续复盘和知识补齐。 */
public HelpEventResponse recordHelp(String sessionId, Integer ignoredRequestedRoundIndex, String ownerIdentity) {
ActiveSession session = isBlank(sessionId) ? null : activeSessions.get(sessionId);
requireSessionOwner(session, ownerIdentity, tenantId());
ScenarioSeed scenario = resolveScenario(session.scenarioId(), sessionId);
int roundIndex = normalizeRoundIndex(session.currentRoundIndex().get(), scenario.rounds().size());
LocalDateTime now = LocalDateTime.now();
jdbcTemplate.update("""
INSERT INTO aihr_practice_help_event
(tenant_id, session_id, scenario_id, ext_party_id, round_index, create_time)
VALUES (?, ?, ?, ?, ?, ?)
""",
session.tenantId(), sessionId, scenario.id(), session.extPartyId(), roundIndex, Timestamp.valueOf(now));
return new HelpEventResponse(sessionId, scenario.id(), roundIndex, now.format(TIME_FORMATTER));
}
public List<RubricResponse> rubrics(String scenarioId) {
@@ -540,7 +581,12 @@ public class AihrPracticeSeedService {
List<String> customerLines = new ArrayList<>();
customerLines.add(firstRound.customer());
long promptPresentedAt = System.currentTimeMillis();
activeSessions.put(sessionId, new ActiveSession(scenario.id(), trainee, resolveExtPartyId(request, trainee), request == null ? null : request.assignmentId(), isMobile(request), LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(promptPresentedAt), java.time.ZoneId.systemDefault()), new ArrayList<>(), new ArrayList<>(), new ArrayList<>(), customerLines, new ArrayList<>(), new ArrayList<>(List.of(promptPresentedAt)), new ArrayList<>()));
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<>(List.of(promptPresentedAt)), new ArrayList<>()
));
return new StartResponse(
sessionId,
scenario.id(),
@@ -566,14 +612,24 @@ public class AihrPracticeSeedService {
String style = normalizeRegenerateStyle(request == null ? null : request.style());
ActiveSession session = request == null || isBlank(request.sessionId()) ? null : activeSessions.get(request.sessionId());
if (!isBlank(ownerIdentity)) {
requireSessionOwner(session, ownerIdentity);
requireSessionOwner(session, ownerIdentity, tenantId());
}
validateAudioOwnership(request == null ? null : request.traineeAudioOssId(), ownerIdentity, ownerUserId);
ScenarioSeed scenario = resolveScenario(session == null ? null : session.scenarioId(), request == null ? null : request.sessionId());
int roundIndex = normalizeRoundIndex(request == null ? null : request.roundIndex(), scenario.rounds().size());
if (session == null) {
return handleTurn(request, null, scenario, style);
}
synchronized (session) {
return handleTurn(request, session, scenario, style);
}
}
private TurnResponse handleTurn(TurnRequest request, ActiveSession session, ScenarioSeed scenario, String style) {
if (request != null && Boolean.TRUE.equals(request.regenerate())) {
int roundIndex = regenerateRoundIndex(session, request.roundIndex(), scenario.rounds().size());
return regenerateCoachHint(request, session, scenario, roundIndex, style);
}
int roundIndex = authoritativeRoundIndex(session, request == null ? null : request.roundIndex(), scenario.rounds().size());
rememberResponseLatency(session, roundIndex, System.currentTimeMillis());
rememberTraineeReply(request, roundIndex);
RoundSeed currentRound = scenario.rounds().get(roundIndex);
@@ -587,6 +643,9 @@ public class AihrPracticeSeedService {
String coachHint = redFlag ? redFlagCoachHint(resolved.coachHint()) : resolved.coachHint();
rememberTurnEvidence(session, roundIndex, resolved.emotion(), resolved.trust(), redFlag, coachHint);
if (!finished) {
if (!redFlag && session != null) {
session.currentRoundIndex().set(nextRoundIndex);
}
rememberPromptPresentedAt(session, nextRoundIndex, System.currentTimeMillis());
}
return new TurnResponse(
@@ -602,6 +661,35 @@ public class AihrPracticeSeedService {
);
}
private int authoritativeRoundIndex(ActiveSession session, Integer requestedRoundIndex, int roundCount) {
if (session == null) {
return normalizeRoundIndex(requestedRoundIndex, roundCount);
}
int activeRoundIndex = normalizeRoundIndex(session.currentRoundIndex().get(), roundCount);
if (requestedRoundIndex != null && (requestedRoundIndex < 0
|| requestedRoundIndex >= roundCount
|| requestedRoundIndex != activeRoundIndex)) {
throw new ServiceException("训练回合已变化,请查看最新对话后再继续");
}
return activeRoundIndex;
}
private int regenerateRoundIndex(ActiveSession session, Integer requestedRoundIndex, int roundCount) {
if (session == null) {
return normalizeRoundIndex(requestedRoundIndex, roundCount);
}
int roundIndex = requestedRoundIndex == null
? normalizeRoundIndex(session.currentRoundIndex().get(), roundCount)
: requestedRoundIndex;
if (roundIndex < 0 || roundIndex >= roundCount) {
throw new ServiceException("训练回合已变化,请查看最新对话后再继续");
}
if (turnEvidence(session, roundIndex) == null) {
throw new ServiceException("请先完成该回合后再调整教练建议");
}
return roundIndex;
}
private TurnResponse regenerateCoachHint(TurnRequest request, ActiveSession session, ScenarioSeed scenario, int roundIndex, String style) {
if (isBlank(style)) {
throw new ServiceException("请选择有效的调整风格");
@@ -717,7 +805,7 @@ public class AihrPracticeSeedService {
activeSession = sessionId == null ? null : activeSessions.remove(sessionId);
} else {
ActiveSession existing = sessionId == null ? null : activeSessions.get(sessionId);
requireSessionOwner(existing, ownerIdentity);
requireSessionOwner(existing, ownerIdentity, tenantId());
activeSession = activeSessions.remove(sessionId);
}
ScenarioSeed scenario = resolveScenario(activeSession == null ? null : activeSession.scenarioId(), sessionId);
@@ -3343,10 +3431,12 @@ public class AihrPracticeSeedService {
return request.extPartyId().trim();
}
private static void requireSessionOwner(ActiveSession session, String ownerIdentity) {
private static void requireSessionOwner(ActiveSession session, String ownerIdentity, String requestTenantId) {
String owner = ownerIdentity == null ? "" : ownerIdentity.trim();
String sessionOwner = session == null || session.extPartyId() == null ? "" : session.extPartyId().trim();
if (session == null || !owner.equals(sessionOwner)) {
String tenant = requestTenantId == null ? "" : requestTenantId.trim();
String sessionTenant = session == null || session.tenantId() == null ? "" : session.tenantId().trim();
if (session == null || !owner.equals(sessionOwner) || !tenant.equals(sessionTenant)) {
throw new ServiceException("训练会话不存在或无权访问");
}
}
@@ -4039,6 +4129,41 @@ public class AihrPracticeSeedService {
""", this::mapPromptTemplate, tenantId(), id);
}
private PrepCardPrompt prepCardPrompt() {
if (jdbcTemplate == null) {
return new PrepCardPrompt(PREP_CARD_SYSTEM_PROMPT, PREP_CARD_USER_TEMPLATE, PREP_CARD_PROMPT_VERSION);
}
try {
List<PrepCardPrompt> rows = jdbcTemplate.query("""
SELECT system_prompt, template, version
FROM aihr_prompt_template
WHERE tenant_id = ? AND code = 'prep_card' AND output_format = 'json_prep' AND enabled = 1
ORDER BY version DESC, id DESC
LIMIT 1
""", (rs, rowNum) -> new PrepCardPrompt(
firstNonBlank(rs.getString("system_prompt"), PREP_CARD_SYSTEM_PROMPT),
firstNonBlank(rs.getString("template"), PREP_CARD_USER_TEMPLATE),
"prep_card:" + firstNonBlank(rs.getString("version"), "v1")
), tenantId());
if (!rows.isEmpty()) {
return rows.get(0);
}
} catch (DataAccessException ignored) {
// Prompt table is optional in the local seed fallback.
}
return new PrepCardPrompt(PREP_CARD_SYSTEM_PROMPT, PREP_CARD_USER_TEMPLATE, PREP_CARD_PROMPT_VERSION);
}
private String renderPrepCardPrompt(String template, ScenarioResponse scenario) {
return firstNonBlank(template, PREP_CARD_USER_TEMPLATE)
.replace("{{scenarioName}}", firstNonBlank(scenario.name(), ""))
.replace("{{explicitNeed}}", firstNonBlank(scenario.explicitNeed(), ""))
.replace("{{sopRefs}}", firstNonBlank(scenario.sopRefs(), ""))
.replace("{{successCriteria}}", firstNonBlank(scenario.successCriteria(), ""))
.replace("{{irritants}}", firstNonBlank(scenario.irritants(), ""))
.replace("{{delights}}", firstNonBlank(scenario.delights(), ""));
}
private CalibrationResponse latestCalibration(String sessionId) {
List<CalibrationResponse> rows = jdbcTemplate.query("""
SELECT id, session_id, original_score, corrected_score, reviewer, reason, status, create_time
@@ -5484,6 +5609,9 @@ public class AihrPracticeSeedService {
private record TurnEvidence(Integer emotion, Integer trust, Boolean redFlag, String coachHint) {
}
private record PrepCardPrompt(String systemPrompt, String template, String promptVersion) {
}
private record OrgScopeRow(String projectCode, String positionLevel) {
}
@@ -5538,8 +5666,9 @@ public class AihrPracticeSeedService {
}
}
private record ActiveSession(String scenarioId, String trainee, String extPartyId, Long assignmentId, boolean mobile, LocalDateTime startedAt,
List<String> traineeReplies, List<String> traineeAudioUrls, List<Long> traineeAudioOssIds,
private record ActiveSession(String tenantId, String scenarioId, String trainee, String extPartyId, Long assignmentId, boolean mobile,
LocalDateTime startedAt, AtomicInteger currentRoundIndex, List<String> traineeReplies,
List<String> traineeAudioUrls, List<Long> traineeAudioOssIds,
List<String> customerLines, List<TurnEvidence> turnEvidence,
List<Long> promptPresentedAtMillis, List<Long> responseLatenciesMs) {
}
@@ -47,6 +47,11 @@ public class AihrSpeechService {
"intense", "请用情绪强烈、急切、有压力且明显不满的语气说",
"calm", "请用平静、自然的语气说"
);
private static final Map<String, String> DIALECT_PROMPTS = Map.of(
"mandarin", "请使用自然普通话表达",
"cantonese", "请使用自然粤语口语表达",
"sichuanese", "请使用自然四川话口语表达"
);
private final AihrModelSeedService modelService;
private final ObjectMapper objectMapper;
@@ -59,6 +64,16 @@ public class AihrSpeechService {
return modelService.speechModel("tts").isPresent();
}
public static Optional<String> invalidDialectMessage(VoiceProfile voiceProfile) {
String rawDialect = voiceProfile == null || voiceProfile.dialect() == null
? ""
: voiceProfile.dialect().trim();
if (rawDialect.isEmpty() || DIALECT_PROMPTS.containsKey(rawDialect.toLowerCase(Locale.ROOT))) {
return Optional.empty();
}
return Optional.of("dialect 仅支持 mandarin、cantonese、sichuanese");
}
/**
* 语音转写:POST {base}/audio/transcriptions(multipart)。
*/
@@ -132,9 +147,11 @@ public class AihrSpeechService {
}
String emotion = normalizedEmotion(voiceProfile == null ? null : voiceProfile.emotion(), role);
String dialect = normalizedDialect(voiceProfile == null ? null : voiceProfile.dialect());
String sanitizedText = AihrSensitiveText.forModel(text.trim());
String input = voiceProfile != null && expressiveCosyVoice && EMOTION_PROMPTS.containsKey(emotion)
? EMOTION_PROMPTS.get(emotion) + "。<|endofprompt|>" + sanitizedText
String expressivePrompt = expressivePrompt(emotion, dialect);
String input = voiceProfile != null && expressiveCosyVoice && !expressivePrompt.isBlank()
? expressivePrompt + "。<|endofprompt|>" + sanitizedText
: sanitizedText;
ObjectNode body = objectMapper.createObjectNode();
@@ -174,6 +191,26 @@ public class AihrSpeechService {
};
}
private static String normalizedDialect(String dialect) {
String value = dialect == null ? "" : dialect.trim().toLowerCase(Locale.ROOT);
if (value.isBlank() || DIALECT_PROMPTS.containsKey(value)) {
return value;
}
throw new IllegalArgumentException("dialect 仅支持 mandarin、cantonese、sichuanese");
}
private static String expressivePrompt(String emotion, String dialect) {
String emotionPrompt = EMOTION_PROMPTS.getOrDefault(emotion, "");
String dialectPrompt = DIALECT_PROMPTS.getOrDefault(dialect, "");
if (emotionPrompt.isBlank()) {
return dialectPrompt;
}
if (dialectPrompt.isBlank()) {
return emotionPrompt;
}
return emotionPrompt + "," + dialectPrompt;
}
private static double resolveSpeed(Double speed, String emotion) {
if (speed != null && Double.isFinite(speed)) {
return Math.max(0.7, Math.min(1.3, speed));
@@ -43,4 +43,52 @@ class AihrPracticeLlmServiceTest {
assertTrue(service.scoreDailyDrill("题目", "参考答案", "员工回答").isEmpty());
}
@Test
void prepCardParsesOnlyTheFixedThreeColumnStructure() {
AihrModelSeedService modelService = mock(AihrModelSeedService.class);
when(modelService.tryChat(anyString(), anyString(), eq(0.0)))
.thenReturn(Optional.of("""
{"keyPoints":["先安抚","确认责任人","给首次反馈时间"],
"redLines":["不推责","不绝对承诺","不与业主争辩"],
"scripts":["我先确认现场情况","十分钟内给您明确节点"],
"extra":"不应透出"}
"""));
AihrPracticeLlmService service = new AihrPracticeLlmService(modelService, new ObjectMapper());
var card = service.prepCard("只返回 JSON", "投诉处理场景");
assertTrue(card.isPresent());
assertEquals(3, card.get().keyPoints().size());
assertEquals(3, card.get().redLines().size());
assertEquals(2, card.get().scripts().size());
}
@Test
void prepCardRejectsPartialColumnsSoTheCallerCanUseScenarioFallback() {
AihrModelSeedService modelService = mock(AihrModelSeedService.class);
when(modelService.tryChat(anyString(), anyString(), eq(0.0)))
.thenReturn(Optional.of("""
{"keyPoints":["先安抚","确认责任人"],
"redLines":["不推责","不绝对承诺","不争辩"],
"scripts":["我先确认现场情况","十分钟内给您明确节点"]}
"""));
AihrPracticeLlmService service = new AihrPracticeLlmService(modelService, new ObjectMapper());
assertTrue(service.prepCard("只返回 JSON", "投诉处理场景").isEmpty());
}
@Test
void prepCardRejectsExtraItemsInsteadOfSilentlyTruncatingThem() {
AihrModelSeedService modelService = mock(AihrModelSeedService.class);
when(modelService.tryChat(anyString(), anyString(), eq(0.0)))
.thenReturn(Optional.of("""
{"keyPoints":["先安抚","确认责任人","给首次反馈时间","多余项"],
"redLines":["不推责","不绝对承诺","不争辩"],
"scripts":["我先确认现场情况","十分钟内给您明确节点"]}
"""));
AihrPracticeLlmService service = new AihrPracticeLlmService(modelService, new ObjectMapper());
assertTrue(service.prepCard("只返回 JSON", "投诉处理场景").isEmpty());
}
}
@@ -16,9 +16,12 @@ import org.dromara.aihr.domain.AihrPracticeDto.TeamMemberResponse;
import org.dromara.aihr.domain.AihrPracticeDto.TurnRequest;
import org.dromara.common.core.constant.HttpStatus;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.tenant.helper.TenantHelper;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentMatchers;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.invocation.InvocationOnMock;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
@@ -43,6 +46,7 @@ import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -52,6 +56,7 @@ import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
@@ -550,6 +555,95 @@ public class AihrPracticeSeedServiceTest {
assertEquals("训练会话不存在或无权访问", missing.getMessage());
}
@Test
public void m2PrepCardUsesTheConfiguredTemplatePathAndHelpIsPersistedForTheSessionOwner() {
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
when(jdbcTemplate.query(contains("SELECT enabled"), any(RowMapper.class), eq("000000"), eq("complaint-water")))
.thenReturn(List.of());
when(jdbcTemplate.query(contains("SELECT scenario_code"), any(RowMapper.class), eq("000000"), eq("complaint-water")))
.thenReturn(List.of());
when(jdbcTemplate.query(contains("FROM aihr_prompt_template"), any(RowMapper.class), eq("000000")))
.thenReturn(List.of());
AihrPracticeLlmService llm = mock(AihrPracticeLlmService.class);
when(llm.prepCard(anyString(), anyString())).thenReturn(Optional.of(new AihrPracticeLlmService.PrepCard(
List.of("先安抚", "确认事实", "给反馈时间"),
List.of("不推责", "不绝对承诺", "不争辩"),
List.of("我先确认情况", "十分钟内回复您")
)));
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, llm, null);
var prep = service.prepCard("complaint-water");
var session = service.start(new StartRequest("employee-a", "complaint-water", "mobile", null));
var help = service.recordHelp(session.sessionId(), 2, "employee-a");
assertEquals(List.of("先安抚", "确认事实", "给反馈时间"), prep.keyPoints());
assertEquals("builtin-prep_card-v1", prep.promptVersion());
assertEquals(session.sessionId(), help.sessionId());
assertEquals("complaint-water", help.scenarioId());
assertEquals(0, help.roundIndex());
verify(jdbcTemplate).update(
contains("INSERT INTO aihr_practice_help_event"),
eq("000000"), eq(session.sessionId()), eq("complaint-water"), eq("employee-a"), eq(0), any(Timestamp.class)
);
assertThrows(ServiceException.class, () -> service.recordHelp(session.sessionId(), 0, "employee-b"));
}
@Test
public void m2HelpRejectsAValidOwnerWhenTheTenantContextChanged() {
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
when(jdbcTemplate.query(contains("SELECT enabled"), any(RowMapper.class), eq("tenant-a"), eq("complaint-water")))
.thenReturn(List.of());
when(jdbcTemplate.query(contains("SELECT scenario_code"), any(RowMapper.class), eq("tenant-a"), eq("complaint-water")))
.thenReturn(List.of());
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null);
try (MockedStatic<TenantHelper> tenantHelper = Mockito.mockStatic(TenantHelper.class, Mockito.CALLS_REAL_METHODS)) {
tenantHelper.when(TenantHelper::getTenantId).thenReturn("tenant-a");
var session = service.start(new StartRequest("employee-a", "complaint-water", "mobile", null));
tenantHelper.when(TenantHelper::getTenantId).thenReturn("tenant-b");
ServiceException error = assertThrows(ServiceException.class,
() -> service.recordHelp(session.sessionId(), 0, "employee-a"));
assertEquals("训练会话不存在或无权访问", error.getMessage());
}
verify(jdbcTemplate, never()).update(contains("INSERT INTO aihr_practice_help_event"), ArgumentMatchers.<Object[]>any());
}
@Test
public void m2TurnRejectsSkippedRoundsAndHelpUsesTheServerTrackedRound() {
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
when(jdbcTemplate.query(contains("SELECT enabled"), any(RowMapper.class), eq("000000"), eq("complaint-water")))
.thenReturn(List.of());
when(jdbcTemplate.query(contains("SELECT scenario_code"), any(RowMapper.class), eq("000000"), eq("complaint-water")))
.thenReturn(List.of());
AihrPracticeLlmService llm = mock(AihrPracticeLlmService.class);
when(llm.customerTurn(
anyString(), anyString(), anyString(), ArgumentMatchers.anyList(), anyString(), anyString(),
ArgumentMatchers.anyInt(), ArgumentMatchers.anyInt(), anyString()
)).thenReturn(Optional.empty());
when(llm.customerTurn(
anyString(), anyString(), anyString(), ArgumentMatchers.anyList(), anyString(), anyString(),
ArgumentMatchers.anyInt(), ArgumentMatchers.anyInt(), anyString(), anyString()
)).thenReturn(Optional.empty());
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, llm, null);
var session = service.start(new StartRequest("employee-a", "complaint-water", "mobile", null));
ServiceException skipped = assertThrows(ServiceException.class,
() -> service.turn(new TurnRequest(session.sessionId(), 2, "我先确认现场情况", null, null, false, null), "employee-a"));
assertEquals("训练回合已变化,请查看最新对话后再继续", skipped.getMessage());
var helpBeforeValidTurn = service.recordHelp(session.sessionId(), 2, "employee-a");
var firstTurn = service.turn(new TurnRequest(session.sessionId(), 0, "我先确认现场情况", null, null, false, null), "employee-a");
var adjustedFirstTurn = service.turn(new TurnRequest(
session.sessionId(), 0, "我先确认现场情况", null, null, true, "serious"
), "employee-a");
var help = service.recordHelp(session.sessionId(), 2, "employee-a");
assertEquals(0, helpBeforeValidTurn.roundIndex());
assertEquals(1, firstTurn.roundIndex());
assertEquals(1, adjustedFirstTurn.roundIndex());
assertEquals(1, help.roundIndex());
}
@Test
public void practiceAudioMustMatchCurrentAppUploaderAndDailyAudioIsReadable() throws Exception {
String service = Files.readString(Path.of("src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java"));
@@ -8,6 +8,7 @@ import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@Tag("dev")
@@ -25,21 +26,38 @@ class AihrSpeechServiceTest {
var mentor = AihrSpeechService.buildRequestBody(
mapper, runtime, "先承接情绪,再说明处理节点。", null,
new VoiceProfile("mentor", null, null, null));
new VoiceProfile("mentor", null, null, null, "mandarin"));
var customer = AihrSpeechService.buildRequestBody(
mapper, runtime, "你们到底什么时候处理?", null,
new VoiceProfile("customer", null, 9.0, "intense"));
new VoiceProfile("customer", null, 9.0, "intense", "sichuanese"));
var legacy = AihrSpeechService.buildRequestBody(
mapper, runtime, "普通播报", "alex", null);
assertEquals("FunAudioLLM/CosyVoice2-0.5B:anna", mentor.path("voice").asText());
assertEquals("speech:shifu-warm-v1:cm3hz4wfz02jy106j6z6muix7:ysvbyzypjgmnceokxedr", mentor.path("voice").asText());
assertEquals(0.88, mentor.path("speed").asDouble());
assertTrue(mentor.path("input").asText().contains("师傅语气"));
assertEquals("FunAudioLLM/CosyVoice2-0.5B:bella", customer.path("voice").asText());
assertEquals(1.3, customer.path("speed").asDouble());
assertTrue(customer.path("input").asText().contains("情绪强烈"));
assertTrue(customer.path("input").asText().contains("四川话"));
assertEquals("FunAudioLLM/CosyVoice2-0.5B:alex", legacy.path("voice").asText());
assertFalse(legacy.has("speed"));
assertFalse(legacy.path("input").asText().contains("<|endofprompt|>"));
}
@Test
void rejectsUnknownDialectInsteadOfSilentlyFallingBackToMandarin() {
ObjectMapper mapper = new ObjectMapper();
SpeechModel runtime = new SpeechModel(
"siliconflow",
"FunAudioLLM/CosyVoice2-0.5B",
"https://api.siliconflow.cn/v1",
"test-key"
);
VoiceProfile invalid = new VoiceProfile("customer", null, null, "calm", "minnan");
assertTrue(AihrSpeechService.invalidDialectMessage(invalid).isPresent());
assertThrows(IllegalArgumentException.class, () -> AihrSpeechService.buildRequestBody(
mapper, runtime, "测试", null, invalid));
}
}