fix(practice): generate replies from coach hints
This commit is contained in:
+12
-2
@@ -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) {
|
||||
|
||||
+39
-1
@@ -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));
|
||||
|
||||
+32
@@ -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;
|
||||
|
||||
+27
@@ -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);
|
||||
|
||||
+7
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user