fix(practice): generate replies from coach hints

This commit is contained in:
2026-07-25 15:44:45 +08:00
parent 9a34a26707
commit acd5fd3a6d
10 changed files with 172 additions and 15 deletions
@@ -246,7 +246,12 @@ public final class AihrPracticeDto {
String tenantId, Set<String> allowedTools, Long createdAt) {
}
public record TurnRequest(String sessionId, Integer roundIndex, String traineeText, String traineeAudioUrl, Long traineeAudioOssId, Boolean regenerate, String style) {
public record TurnRequest(String sessionId, Integer roundIndex, String traineeText, String traineeAudioUrl,
Long traineeAudioOssId, Boolean regenerate, String style, Boolean suggestReply) {
public TurnRequest(String sessionId, Integer roundIndex, String traineeText, String traineeAudioUrl,
Long traineeAudioOssId, Boolean regenerate, String style) {
this(sessionId, roundIndex, traineeText, traineeAudioUrl, traineeAudioOssId, regenerate, style, false);
}
}
public record TurnResponse(
@@ -258,8 +263,13 @@ public final class AihrPracticeDto {
Boolean redFlag,
Integer roundIndex,
Boolean finished,
String message
String message,
String suggestedReply
) {
public TurnResponse(String customerText, String customerAudioUrl, Integer emotion, Integer trust,
String coachHint, Boolean redFlag, Integer roundIndex, Boolean finished, String message) {
this(customerText, customerAudioUrl, emotion, trust, coachHint, redFlag, roundIndex, finished, message, "");
}
}
public record FinishRequest(String sessionId, Long assignmentId) {
@@ -14,7 +14,7 @@ import java.util.Optional;
/**
* 三角色对练的 LLM 编排:AI 客户回复生成 + /finish 单次结构化评分(TechSpec 第 6 章 P0)。
* 所有方法失败返回 empty,由 AihrPracticeSeedService 用 seed 数据兜底,前后端契约不变。
* 所有方法失败返回 empty;正式训练回合由 seed 兜底,显式请求的 LLM 回复建议则向用户报告生成失败。
*/
@Service
@RequiredArgsConstructor
@@ -26,6 +26,7 @@ public class AihrPracticeLlmService {
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 double REPLY_SUGGESTION_TEMPERATURE = 0.4;
private static final int MAX_CUSTOMER_CHARS = 80;
private final AihrModelSeedService modelService;
@@ -104,6 +105,33 @@ public class AihrPracticeLlmService {
.filter(text -> !text.isBlank());
}
public Optional<String> suggestedReply(
String scenario, String goal, String customerText, String traineeText, String coachHint
) {
String system = """
你是物业员工话术教练。根据老师傅提示,生成员工可直接回复当前业主的一句话。
规则:
1. 只输出 JSON:{"reply":""},不要解释或 markdown。
2. reply 必须是员工第一人称直接对业主说的话,不得出现“建议、应该、员工、老师傅”等指导语。
3. 落实提示中的动作、责任和反馈节点,不照抄提示。
4. 不编造政策、金额、姓名或处理结果;可以承诺员工本人将核实和反馈的动作。
5. 一句话,不超过100字。
""";
String user = """
场景:%s
训练目标:%s
业主当前说:%s
员工上一句:%s
老师傅提示:%s
""".formatted(scenario, goal, customerText, traineeText, coachHint);
return modelService.tryChat(
AihrSensitiveText.forModel(system),
AihrSensitiveText.forModel(user),
REPLY_SUGGESTION_TEMPERATURE)
.flatMap(this::parseSuggestedReply)
.filter(reply -> !reply.equals(coachHint.trim()));
}
/**
* 生成下一句业主回应,并让模型同步给出回合情绪、信任、红线和教练提示;解析失败由调用方回退 seed。
*/
@@ -308,6 +336,16 @@ public class AihrPracticeLlmService {
}
}
private Optional<String> parseSuggestedReply(String content) {
try {
String reply = truncateText(objectMapper.readTree(extractJson(content)).path("reply").asText(""), 160).trim();
return reply.isBlank() ? Optional.empty() : Optional.of(reply);
} catch (Exception e) {
log.warn("practice reply suggestion parse failed(处理错误已隐藏)");
return Optional.empty();
}
}
private Optional<PracticeTurn> parseTurn(String content, int defaultEmotion, int defaultTrust, String defaultCoachHint) {
try {
JsonNode root = objectMapper.readTree(extractJson(content));
@@ -1185,6 +1185,10 @@ public class AihrPracticeSeedService {
}
private TurnResponse handleTurn(TurnRequest request, ActiveSession session, ScenarioSeed scenario, String style) {
if (request != null && Boolean.TRUE.equals(request.suggestReply())) {
int roundIndex = regenerateRoundIndex(session, request.roundIndex(), scenario.rounds().size());
return suggestReply(session, scenario, roundIndex);
}
if (request != null && Boolean.TRUE.equals(request.regenerate())) {
int roundIndex = regenerateRoundIndex(session, request.roundIndex(), scenario.rounds().size());
return regenerateCoachHint(request, session, scenario, roundIndex, style);
@@ -1289,6 +1293,34 @@ public class AihrPracticeSeedService {
return new TurnResponse(customerText, "", emotion, trust, coachHint, redFlag, nextRoundIndex, finished, "");
}
private TurnResponse suggestReply(ActiveSession session, ScenarioSeed scenario, int roundIndex) {
RoundSeed currentRound = scenario.rounds().get(roundIndex);
int nextRoundIndex = roundIndex + 1;
boolean finished = nextRoundIndex >= scenario.rounds().size();
TurnEvidence evidence = turnEvidence(session, roundIndex);
String coachHint = evidence == null || isBlank(evidence.coachHint()) ? currentRound.coachHint() : evidence.coachHint();
String traineeText = traineeReply(session, roundIndex, "");
String customerText = finished ? currentRound.customer() : customerLine(session, nextRoundIndex, scenario.rounds().get(nextRoundIndex).customer());
if (practiceLlmService == null || isBlank(traineeText) || isBlank(coachHint)) {
throw new ServiceException("当前回合信息不完整,暂时无法生成回复");
}
String reply = practiceLlmService.suggestedReply(
scenario.name(), scenario.goal(), customerText, traineeText, coachHint)
.orElseThrow(() -> new ServiceException("回复生成失败,请稍后重试"));
return new TurnResponse(
finished ? "" : customerText,
"",
evidence == null ? currentRound.emotion() : evidence.emotion(),
evidence == null ? currentRound.trust() : evidence.trust(),
coachHint,
evidence != null && Boolean.TRUE.equals(evidence.redFlag()),
nextRoundIndex,
finished,
"",
reply
);
}
private boolean hasRedFlag(String traineeText) {
if (isBlank(traineeText)) {
return false;
@@ -16,6 +16,33 @@ import static org.mockito.Mockito.when;
@Tag("dev")
class AihrPracticeLlmServiceTest {
@Test
void replySuggestionParsesOnlyTheReplyField() {
AihrModelSeedService modelService = mock(AihrModelSeedService.class);
when(modelService.tryChat(anyString(), anyString(), eq(0.4)))
.thenReturn(Optional.of("{\"reply\":\"我现在联系保洁负责人核实,十分钟内给您明确反馈。\"}"));
AihrPracticeLlmService service = new AihrPracticeLlmService(modelService, new ObjectMapper());
var reply = service.suggestedReply(
"日常保洁", "明确责任和反馈节点", "到底谁负责,什么时候回复?", "我再问问", "说明负责人和反馈时间"
);
assertTrue(reply.isPresent());
assertEquals("我现在联系保洁负责人核实,十分钟内给您明确反馈。", reply.get());
}
@Test
void replySuggestionRejectsCopiedCoachHint() {
AihrModelSeedService modelService = mock(AihrModelSeedService.class);
when(modelService.tryChat(anyString(), anyString(), eq(0.4)))
.thenReturn(Optional.of("{\"reply\":\"说明负责人和反馈时间\"}"));
AihrPracticeLlmService service = new AihrPracticeLlmService(modelService, new ObjectMapper());
assertTrue(service.suggestedReply(
"日常保洁", "明确责任和反馈节点", "什么时候回复?", "我再问问", "说明负责人和反馈时间"
).isEmpty());
}
@Test
void dailyDrillAssessmentParsesStructuredModelResponse() {
AihrModelSeedService modelService = mock(AihrModelSeedService.class);
@@ -1150,6 +1150,11 @@ public class AihrPracticeSeedServiceTest {
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");
when(llm.suggestedReply(anyString(), anyString(), anyString(), anyString(), anyString()))
.thenReturn(Optional.of("我现在联系现场负责人核实,确认后第一时间给您反馈。"));
var suggestedReply = service.turn(new TurnRequest(
session.sessionId(), 0, "", null, null, false, null, true
), "employee-a");
var adjustedFirstTurn = service.turn(new TurnRequest(
session.sessionId(), 0, "我先确认现场情况", null, null, true, "serious"
), "employee-a");
@@ -1157,7 +1162,9 @@ public class AihrPracticeSeedServiceTest {
assertEquals(0, helpBeforeValidTurn.roundIndex());
assertEquals(1, firstTurn.roundIndex());
assertEquals("我现在联系现场负责人核实,确认后第一时间给您反馈。", suggestedReply.suggestedReply());
assertEquals(1, adjustedFirstTurn.roundIndex());
assertEquals(firstTurn.coachHint(), adjustedFirstTurn.coachHint());
assertEquals(1, help.roundIndex());
}