feat(aihr): wire practice turn/finish to real LLM with seed fallback

/turn 客户回复按人设走 LLM 生成(seed 台词作剧情锚点),/finish 单次
temperature=0 结构化评分(4维+导师改写+点评,档位锚点60/75/90),维度
缺失回落 total、文本截断防落库溢出;模型未配置或调用失败逐级回退
seed,前后端契约不变。会话列表读写同锁,评分只在 finish 跑一次。
This commit is contained in:
2026-07-03 20:55:38 +08:00
parent 829df0c9aa
commit 96c70a618f
3 changed files with 332 additions and 29 deletions
@@ -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> speechModel(String category) {
try {
List<SpeechModel> 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<String> 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<ProviderResponse> 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;
}
@@ -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<String> customerReply(
String personaTraits, String project, String goal,
List<DialogueResponse> 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<PracticeScore> score(String scenarioName, String goal, String strategy, List<DialogueResponse> 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<PracticeScore> 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<DialogueResponse> 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();
}
}
@@ -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<String, ActiveSession> 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<String> 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<String> 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<DialogueResponse> dialogueTurns(ActiveSession activeSession, ScenarioSeed scenario) {
List<DialogueResponse> turns = new ArrayList<>();
List<String> replies = activeSession == null ? List.of() : activeSession.traineeReplies();
List<String> replies = snapshot(activeSession == null ? null : activeSession.traineeReplies());
List<String> 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<String> snapshot(List<String> source) {
if (source == null) {
return List.of();
}
synchronized (source) {
return new ArrayList<>(source);
}
}
private Integer scoreValue(List<DimensionResponse> 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<String> traineeReplies) {
private record ActiveSession(String scenarioId, String trainee, String extPartyId, boolean mobile, LocalDateTime startedAt,
List<String> traineeReplies, List<String> customerLines) {
}
private record PracticeResult(Integer total, List<DimensionResponse> scores, String rewrite, String summary) {
}
}