From 96c70a618f3493a3029fd7619c03b4120c2a3251 Mon Sep 17 00:00:00 2001 From: let5sne Date: Fri, 3 Jul 2026 20:55:38 +0800 Subject: [PATCH] feat(aihr): wire practice turn/finish to real LLM with seed fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /turn 客户回复按人设走 LLM 生成(seed 台词作剧情锚点),/finish 单次 temperature=0 结构化评分(4维+导师改写+点评,档位锚点60/75/90),维度 缺失回落 total、文本截断防落库溢出;模型未配置或调用失败逐级回退 seed,前后端契约不变。会话列表读写同锁,评分只在 finish 跑一次。 --- .../aihr/service/AihrModelSeedService.java | 63 ++++++- .../aihr/service/AihrPracticeLlmService.java | 172 ++++++++++++++++++ .../aihr/service/AihrPracticeSeedService.java | 126 ++++++++++--- 3 files changed, 332 insertions(+), 29 deletions(-) create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeLlmService.java diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java index 467b3e4e..2c4688c5 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java @@ -69,7 +69,9 @@ public class AihrModelSeedService { new ConfigResponse(-1L, "chat", DEFAULT_MODEL, DEFAULT_PROVIDER, "Demo 对话与 RAG 生成模型", null, "Y", null, true, false, false), new ConfigResponse(-2L, "vector", "embedding-3", "zhipu", "后续用于知识片段向量化", 2048, "N", null, false, false, false), new ConfigResponse(-3L, "rerank", "rerank", "zhipu", "后续用于召回片段重排序", null, "N", null, false, false, false), - new ConfigResponse(-4L, "vector", "BAAI/bge-m3", "siliconflow", "硅基流动 bge-m3,用于知识片段向量化", 1024, "N", null, false, false, false) + new ConfigResponse(-4L, "vector", "BAAI/bge-m3", "siliconflow", "硅基流动 bge-m3,用于知识片段向量化", 1024, "N", null, false, false, false), + new ConfigResponse(-5L, "asr", "FunAudioLLM/SenseVoiceSmall", "siliconflow", "语音识别,用于对练语音输入", null, "N", null, false, false, false), + new ConfigResponse(-6L, "tts", "FunAudioLLM/CosyVoice2-0.5B", "siliconflow", "语音合成,用于 AI 客户语音播报(支持粤语/川话)", null, "N", null, false, false, false) ); } @@ -194,7 +196,7 @@ public class AihrModelSeedService { } try { - String content = callOpenAiCompatible(runtime, modelName, prompt, request == null ? null : request.systemPrompt()); + String content = callOpenAiCompatible(runtime, modelName, prompt, request == null ? null : request.systemPrompt(), 0.2); return new ChatResponse(true, runtime.providerCode(), modelName, content, "openai-compatible", null, List.of()); } catch (Exception e) { return new ChatResponse( @@ -209,6 +211,55 @@ public class AihrModelSeedService { } } + /** + * 按类目(asr/tts)查已启用且配置齐全的语音模型运行参数,供 AihrSpeechService 使用。 + */ + public Optional speechModel(String category) { + try { + List rows = jdbcTemplate.query(""" + select c.model_name, c.provider_code, + coalesce(nullif(c.api_host, ''), nullif(p.api_host, '')) as resolved_api_host, + coalesce(nullif(c.api_key, ''), nullif(p.api_key, '')) as resolved_api_key + from aihr_model_config c + left join aihr_model_provider p + on p.tenant_id = c.tenant_id and p.provider_code = c.provider_code + where c.tenant_id = ? and c.category = ? and c.enabled = 1 + and (p.status is null or p.status = '0') + order by case when c.model_show = 'Y' then 0 else 1 end, c.id asc + """, (rs, rowNum) -> new SpeechModel( + rs.getString("provider_code"), + rs.getString("model_name"), + rs.getString("resolved_api_host"), + rs.getString("resolved_api_key") + ), TENANT_ID, category); + return rows.stream() + .filter(model -> configured(model.baseUrl(), model.modelName(), model.apiKey())) + .findFirst(); + } catch (DataAccessException e) { + log.debug("aihr speech model db fallback: {}", e.getMessage()); + return Optional.empty(); + } + } + + public record SpeechModel(String providerCode, String modelName, String baseUrl, String apiKey) { + } + + /** + * 供其他模块(如三角色对练)复用的 chat 调用:模型未配置或调用失败返回 empty,由调用方决定兜底。 + */ + public Optional tryChat(String systemPrompt, String userPrompt, double temperature) { + RuntimeConfig runtime = runtimeConfig(null); + if (!runtime.configured()) { + return Optional.empty(); + } + try { + return Optional.of(callOpenAiCompatible(runtime, runtime.modelName(), userPrompt, systemPrompt, temperature)); + } catch (Exception e) { + log.warn("aihr llm tryChat failed, caller falls back to seed: {}", e.getMessage()); + return Optional.empty(); + } + } + private List dbProviders() { try { return jdbcTemplate.query(""" @@ -286,10 +337,10 @@ public class AihrModelSeedService { } } - private String callOpenAiCompatible(RuntimeConfig runtime, String modelName, String prompt, String systemPrompt) throws Exception { + private String callOpenAiCompatible(RuntimeConfig runtime, String modelName, String prompt, String systemPrompt, double temperature) throws Exception { ObjectNode body = objectMapper.createObjectNode(); body.put("model", modelName); - body.put("temperature", 0.2); + body.put("temperature", temperature); body.put("stream", false); ArrayNode messages = body.putArray("messages"); @@ -417,8 +468,8 @@ public class AihrModelSeedService { private static String category(String value) { String category = required(value, "模型类型不能为空"); - if (!List.of("chat", "vector", "rerank").contains(category)) { - throw new IllegalArgumentException("模型类型只支持 chat/vector/rerank"); + if (!List.of("chat", "vector", "rerank", "asr", "tts").contains(category)) { + throw new IllegalArgumentException("模型类型只支持 chat/vector/rerank/asr/tts"); } return category; } 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 new file mode 100644 index 00000000..1ee0715f --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeLlmService.java @@ -0,0 +1,172 @@ +package org.dromara.aihr.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.aihr.domain.AihrPracticeDto.DialogueResponse; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +/** + * 三角色对练的 LLM 编排:AI 客户回复生成 + /finish 单次结构化评分(TechSpec 第 6 章 P0)。 + * 所有方法失败返回 empty,由 AihrPracticeSeedService 用 seed 数据兜底,前后端契约不变。 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class AihrPracticeLlmService { + + private static final String PROMPT_VERSION = "practice-p0-v1"; + private static final double CUSTOMER_TEMPERATURE = 0.7; + private static final double SCORE_TEMPERATURE = 0.0; + private static final int MAX_CUSTOMER_CHARS = 80; + + private final AihrModelSeedService modelService; + private final ObjectMapper objectMapper; + + public record PracticeScore( + int total, + int compliance, + int communication, + int emotion, + int marketing, + String rewrite, + String summary + ) { + } + + /** + * 生成 AI 客户(业主)的下一句回应。nextBeat 为 seed 剧本中下一轮台词,作为剧情推进锚点。 + */ + public Optional customerReply( + String personaTraits, String project, String goal, + List dialogue, String traineeText, String nextBeat + ) { + String system = """ + 你只扮演一位物业小区的业主本人,正在和物业员工对话。 + 人设特征:%s + 场景:%s + 你的隐性诉求:%s + 规则: + 1. 只输出业主的下一句话,一句以内,不超过%d个字,口语化。 + 2. 禁止提及:系统、人设、评分、SOP、AI、训练。 + 3. 禁止替员工给出解决方案,禁止主动自曝隐性诉求(除非员工直接问到或你已明显被安抚)。 + 4. 剧情推进方向(不要照抄原句,按此方向自然回应):%s + """.formatted(personaTraits, project, goal, MAX_CUSTOMER_CHARS, nextBeat); + String user = "对话记录:\n" + renderDialogue(dialogue) + + "\n员工刚才说:" + traineeText + + "\n请输出业主的下一句话。"; + return modelService.tryChat(system, user, CUSTOMER_TEMPERATURE) + .map(AihrPracticeLlmService::cleanCustomerLine) + .filter(text -> !text.isBlank()); + } + + /** + * /finish 单 LLM 结构化评分:temperature=0 + 固定 prompt 版本 + 档位锚点 60/75/90。 + */ + public Optional score(String scenarioName, String goal, String strategy, List dialogue) { + if (dialogue.stream().noneMatch(turn -> "trainee".equals(turn.role()) && !turn.text().isBlank())) { + return Optional.empty(); + } + String system = """ + 你是物业行业培训考官(prompt版本 %s)。根据对练记录给员工话术评分。 + 评分锚点:60=及格线(有明显缺失);75=合格(覆盖主要要点);90=优秀(完整且超预期)。 + 约束:只根据"员工"实际说过的话评分,未提及的内容不得臆造加分。 + 只输出一个 JSON 对象,不要输出任何其他文字、解释或代码块标记,字段如下: + {"total":0-100,"compliance":0-100,"communication":0-100,"emotion":0-100,"marketing":0-100,"rewrite":"给员工的示范话术改写,80字内","summary":"一句话点评,60字内"} + """.formatted(PROMPT_VERSION); + String user = """ + 训练场景:%s + 训练目标:%s + 教练策略:%s + 对练记录: + %s + """.formatted(scenarioName, goal, strategy, renderDialogue(dialogue)); + return modelService.tryChat(system, user, SCORE_TEMPERATURE).flatMap(this::parseScore); + } + + private Optional parseScore(String content) { + try { + JsonNode root = objectMapper.readTree(extractJson(content)); + JsonNode totalNode = root.path("total"); + if (!totalNode.isNumber() && !totalNode.isTextual()) { + return Optional.empty(); + } + int total = clampScore(totalNode.asInt()); + return Optional.of(new PracticeScore( + total, + dimension(root, "compliance", total), + dimension(root, "communication", total), + dimension(root, "emotion", total), + dimension(root, "marketing", total), + truncateText(root.path("rewrite").asText(""), 400), + truncateText(root.path("summary").asText(""), 300) + )); + } catch (Exception e) { + log.warn("practice score parse failed, falls back to seed: {}", e.getMessage()); + return Optional.empty(); + } + } + + /** 维度字段缺失时回落到 total,避免 asInt() 把缺失当 0 分落库。 */ + private static int dimension(JsonNode root, String field, int fallback) { + JsonNode node = root.path(field); + if (!node.isNumber() && !node.isTextual()) { + return fallback; + } + return clampScore(node.asInt(fallback)); + } + + /** 落库列为 varchar(1000),模型输出截断兜底。 */ + private static String truncateText(String value, int maxChars) { + if (value == null || value.length() <= maxChars) { + return value; + } + return value.substring(0, maxChars); + } + + private static String extractJson(String content) { + String text = content == null ? "" : content.trim(); + int start = text.indexOf('{'); + int end = text.lastIndexOf('}'); + if (start >= 0 && end > start) { + return text.substring(start, end + 1); + } + return text; + } + + private static int clampScore(int value) { + return Math.max(0, Math.min(100, value)); + } + + private static String cleanCustomerLine(String content) { + String text = content == null ? "" : content.trim(); + text = text.replaceAll("^[\"“」』\\s]+", "").replaceAll("[\"”「『\\s]+$", ""); + if (text.startsWith("业主:") || text.startsWith("业主:")) { + text = text.substring(3); + } + if (text.length() > MAX_CUSTOMER_CHARS * 2) { + text = text.substring(0, MAX_CUSTOMER_CHARS * 2); + } + return text.trim(); + } + + private static String renderDialogue(List dialogue) { + StringBuilder builder = new StringBuilder(); + for (DialogueResponse turn : dialogue) { + String speaker = switch (turn.role()) { + case "customer" -> "业主"; + case "trainee" -> "员工"; + default -> null; + }; + if (speaker == null || turn.text() == null || turn.text().isBlank()) { + continue; + } + builder.append(speaker).append(":").append(turn.text().trim()).append('\n'); + } + return builder.toString(); + } +} 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 48e43f19..b8741762 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 @@ -31,7 +31,8 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; /** - * 三角色对练 seed 编排器。P0 先稳定前后端契约,后续再替换为会话表、ASR 和 LLM 评分。 + * 三角色对练编排器。seed 剧本承担剧情锚点与兜底;配置了 chat 模型时, + * 客户回复与 /finish 评分走真 LLM(AihrPracticeLlmService),失败自动回退 seed。 */ @Service public class AihrPracticeSeedService { @@ -43,11 +44,13 @@ public class AihrPracticeSeedService { private final ConcurrentMap activeSessions = new ConcurrentHashMap<>(); private final ObjectMapper objectMapper; private final JdbcTemplate jdbcTemplate; + private final AihrPracticeLlmService practiceLlmService; private volatile boolean practiceTableReady; - public AihrPracticeSeedService(ObjectMapper objectMapper, JdbcTemplate jdbcTemplate) { + public AihrPracticeSeedService(ObjectMapper objectMapper, JdbcTemplate jdbcTemplate, AihrPracticeLlmService practiceLlmService) { this.objectMapper = objectMapper; this.jdbcTemplate = jdbcTemplate; + this.practiceLlmService = practiceLlmService; } public StartResponse start(StartRequest request) { @@ -55,7 +58,9 @@ public class AihrPracticeSeedService { RoundSeed firstRound = scenario.rounds().get(0); String sessionId = "seed-" + scenario.id() + "-" + System.currentTimeMillis(); String trainee = resolveTrainee(request, scenario); - activeSessions.put(sessionId, new ActiveSession(scenario.id(), trainee, resolveExtPartyId(request, trainee), isMobile(request), LocalDateTime.now(), new ArrayList<>())); + List customerLines = new ArrayList<>(); + customerLines.add(firstRound.customer()); + activeSessions.put(sessionId, new ActiveSession(scenario.id(), trainee, resolveExtPartyId(request, trainee), isMobile(request), LocalDateTime.now(), new ArrayList<>(), customerLines)); return new StartResponse( sessionId, scenario.id(), @@ -73,13 +78,15 @@ public class AihrPracticeSeedService { ScenarioSeed scenario = resolveScenario(null, request == null ? null : request.sessionId()); int roundIndex = normalizeRoundIndex(request == null ? null : request.roundIndex(), scenario.rounds().size()); rememberTraineeReply(request, roundIndex); + ActiveSession session = request == null || isBlank(request.sessionId()) ? null : activeSessions.get(request.sessionId()); RoundSeed currentRound = scenario.rounds().get(roundIndex); int nextRoundIndex = roundIndex + 1; boolean finished = nextRoundIndex >= scenario.rounds().size(); RoundSeed nextRound = finished ? currentRound : scenario.rounds().get(nextRoundIndex); int trust = finished ? scenario.trustEnd() : nextRound.trust(); + String customerText = finished ? "" : resolveCustomerReply(scenario, session, request, nextRound, nextRoundIndex); return new TurnResponse( - finished ? "" : nextRound.customer(), + customerText, "", nextRound.emotion(), trust, @@ -89,23 +96,67 @@ public class AihrPracticeSeedService { ); } + /** + * 优先用 LLM 按人设生成客户回应(seed 下一轮台词作为剧情锚点),失败回退 seed 台词。 + */ + private String resolveCustomerReply(ScenarioSeed scenario, ActiveSession session, TurnRequest request, RoundSeed nextRound, int nextRoundIndex) { + String traineeText = request == null || isBlank(request.traineeText()) ? "" : request.traineeText().trim(); + String customerText = nextRound.customer(); + if (session != null && !traineeText.isEmpty()) { + customerText = practiceLlmService.customerReply( + scenario.customer(), + scenario.project(), + scenario.goal(), + dialogueTurns(session, scenario), + traineeText, + nextRound.customer() + ).orElse(customerText); + } + rememberCustomerLine(session, nextRoundIndex, customerText); + return customerText; + } + public FinishResponse finish(FinishRequest request) { String sessionId = request == null ? null : request.sessionId(); ActiveSession activeSession = sessionId == null ? null : activeSessions.remove(sessionId); ScenarioSeed scenario = resolveScenario(activeSession == null ? null : activeSession.scenarioId(), sessionId); String trainee = activeSession == null ? scenario.trainee() : activeSession.trainee(); - RecordResponse record = new RecordResponse(null, sessionId, formatNow(), trainee, scenario.name(), scenario.total(), "待复盘", scenario.summary()); - savePracticeRecord(sessionId, activeSession, scenario, record); + PracticeResult result = evaluate(activeSession, scenario); + RecordResponse record = new RecordResponse(null, sessionId, formatNow(), trainee, scenario.name(), result.total(), "待复盘", result.summary()); + savePracticeRecord(sessionId, activeSession, scenario, record, result); return new FinishResponse( - scenario.total(), - scenario.scores(), - scenario.rewrite(), - scenario.summary(), + result.total(), + result.scores(), + result.rewrite(), + result.summary(), List.of(scenario.trustStart(), scenario.rounds().get(0).trust(), scenario.trustEnd()), List.of(record) ); } + /** + * /finish 单次评分:配置了 chat 模型且学员有真实回复时走 LLM 结构化评分,否则用 seed 分。 + */ + private PracticeResult evaluate(ActiveSession activeSession, ScenarioSeed scenario) { + PracticeResult seedResult = new PracticeResult(scenario.total(), scenario.scores(), scenario.rewrite(), scenario.summary()); + if (activeSession == null) { + return seedResult; + } + return practiceLlmService.score(scenario.name(), scenario.goal(), scenario.strategy(), dialogueTurns(activeSession, scenario)) + .map(score -> new PracticeResult( + score.total(), + List.of( + new DimensionResponse("合规", score.compliance(), "SOP关键点覆盖"), + new DimensionResponse("沟通", score.communication(), "承诺与表达清晰度"), + new DimensionResponse("情绪", score.emotion(), "安抚与承接能力"), + new DimensionResponse("营销", score.marketing(), "增值转化意识") + ), + isBlank(score.rewrite()) ? scenario.rewrite() : score.rewrite(), + isBlank(score.summary()) ? scenario.summary() : score.summary() + )) + .orElse(seedResult); + } + public int mobileCompletedCount() { return countMobileRecords(""); } @@ -232,7 +283,7 @@ public class AihrPracticeSeedService { return LocalDateTime.now().format(TIME_FORMATTER); } - private void savePracticeRecord(String sessionId, ActiveSession activeSession, ScenarioSeed scenario, RecordResponse record) { + private void savePracticeRecord(String sessionId, ActiveSession activeSession, ScenarioSeed scenario, RecordResponse record, PracticeResult result) { ensurePracticeTable(); LocalDateTime now = LocalDateTime.now(); LocalDateTime started = activeSession == null ? now : activeSession.startedAt(); @@ -266,13 +317,13 @@ public class AihrPracticeSeedService { scenario.id(), scenario.name(), mobile ? "mobile" : "text", - scenario.total(), - scoreValue(scenario, "合规"), - scoreValue(scenario, "情绪"), - scoreValue(scenario, "沟通"), - scoreValue(scenario, "营销"), - scenario.rewrite(), - scenario.summary(), + result.total(), + scoreValue(result.scores(), "合规"), + scoreValue(result.scores(), "情绪"), + scoreValue(result.scores(), "沟通"), + scoreValue(result.scores(), "营销"), + result.rewrite(), + result.summary(), record.summary(), dialogueJson(activeSession, scenario), scenario.trustStart() + "," + scenario.rounds().get(0).trust() + "," + scenario.trustEnd(), @@ -388,6 +439,19 @@ public class AihrPracticeSeedService { } } + private void rememberCustomerLine(ActiveSession session, int roundIndex, String customerText) { + if (session == null || isBlank(customerText)) { + return; + } + List lines = session.customerLines(); + synchronized (lines) { + while (lines.size() <= roundIndex) { + lines.add(""); + } + lines.set(roundIndex, customerText.trim()); + } + } + private String dialogueJson(ActiveSession activeSession, ScenarioSeed scenario) { try { return objectMapper.writeValueAsString(dialogueTurns(activeSession, scenario)); @@ -422,10 +486,12 @@ public class AihrPracticeSeedService { private List dialogueTurns(ActiveSession activeSession, ScenarioSeed scenario) { List turns = new ArrayList<>(); - List replies = activeSession == null ? List.of() : activeSession.traineeReplies(); + List replies = snapshot(activeSession == null ? null : activeSession.traineeReplies()); + List customerLines = snapshot(activeSession == null ? null : activeSession.customerLines()); for (int i = 0; i < scenario.rounds().size(); i++) { RoundSeed round = scenario.rounds().get(i); - turns.add(new DialogueResponse("customer", "AI业主", round.customer())); + String customerLine = i < customerLines.size() && !isBlank(customerLines.get(i)) ? customerLines.get(i) : round.customer(); + turns.add(new DialogueResponse("customer", "AI业主", customerLine)); if (i < replies.size() && !isBlank(replies.get(i))) { turns.add(new DialogueResponse("trainee", "员工话术", replies.get(i))); } @@ -434,8 +500,18 @@ public class AihrPracticeSeedService { return turns; } - private Integer scoreValue(ScenarioSeed scenario, String label) { - for (DimensionResponse score : scenario.scores()) { + /** 会话内列表写入均持有各自 monitor,读取同样加锁拷贝,避免并发 /turn、/finish 读到中间态。 */ + private static List snapshot(List source) { + if (source == null) { + return List.of(); + } + synchronized (source) { + return new ArrayList<>(source); + } + } + + private Integer scoreValue(List scores, String label) { + for (DimensionResponse score : scores) { if (label.equals(score.label()) && score.value() instanceof Number) { return ((Number) score.value()).intValue(); } @@ -628,6 +704,10 @@ public class AihrPracticeSeedService { private record CompetencySnapshot(Integer completed, Integer score, Integer pendingReview, Integer compliance, Integer communication, Integer emotion) { } - private record ActiveSession(String scenarioId, String trainee, String extPartyId, boolean mobile, LocalDateTime startedAt, List traineeReplies) { + private record ActiveSession(String scenarioId, String trainee, String extPartyId, boolean mobile, LocalDateTime startedAt, + List traineeReplies, List customerLines) { + } + + private record PracticeResult(Integer total, List scores, String rewrite, String summary) { } }