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:
+57
-6
@@ -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;
|
||||
}
|
||||
|
||||
+172
@@ -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();
|
||||
}
|
||||
}
|
||||
+103
-23
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user