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());
}
+1 -1
View File
@@ -8,7 +8,7 @@
|---|---|---|
| AI面试 `/recruit/interview` | `POST /api/recruit/interview/start`、`/answer`、`/finish`、`GET /records`、`POST /records/{sessionId}/review` | 已接入真实模型优先链路:配置 chat 模型后 `/start` 动态生成面试题,`/finish` 按前端提交的真实回答做结构化评分;未配置模型时使用本地 Rubric 估分,不返回固定候选人分数。AI 分数只作辅助参考,HR/管理员可提交 `reviewedScore/reviewNote` 完成人工复核;记录同时保留 AI 分、人工复核分、最终采用分和复核状态 |
| 候选人入职主体关联 `/recruit/interview` | `GET/POST /api/recruit/interview/candidate-links` | HR/管理员在当前租户范围内把本地候选人 ID 关联到已同步的在职 `ext_party_id`;只保存外部主体 ID,不复制姓名、部门等组织字段,重复关联同一主体幂等,候选人更换主体或同一主体已关联其他候选人会拒绝 |
| 三角色对练 `/train/practice` | `POST /api/train/practice/start`、`/turn`、`/finish` | 已接入编排 API;数据库启用 chat 模型后,`/turn` 客户回复按人设走真 LLM 生成(seed 剧本作剧情锚点),`/finish` 走单次 temperature=0 结构化评分(五维分、导师改写、点评)。APP 员工请求由服务端绑定当前登录手机号并强制 `mode=mobile`,不信任客户端的身份或模式;`turn/finish` 只允许该员工操作本人活动/已完成会话。`superadmin` 或 `hr_operator` 的管理端预览可复用同一路径,但服务端固定为 `operator:{userId}` 与 `mode=preview`,忽略客户端 `extPartyId/mode/assignmentId`;预览记录不进入员工历史、主管复盘、成长、试点或正式校准统计。其他后台身份拒绝。已完成会话的重复 `/finish` 只读取持久化结果,绝不按当前场景目录重算或回写历史快照;模型未配置或调用失败自动回退 seed,契约不变。 |
| 三角色对练 `/train/practice` | `POST /api/train/practice/start`、`/turn`、`/finish` | 已接入编排 API;数据库启用 chat 模型后,`/turn` 客户回复按人设走真 LLM 生成(seed 剧本作剧情锚点),`suggestReply=true` 时服务端按当前会话、回合和老师傅提示生成一条可编辑但不自动发送的员工回复,失败明确提示且不复制指导语;`/finish` 走单次 temperature=0 结构化评分(五维分、导师改写、点评)。APP 员工请求由服务端绑定当前登录手机号并强制 `mode=mobile`,不信任客户端的身份或模式;`turn/finish` 只允许该员工操作本人活动/已完成会话。`superadmin` 或 `hr_operator` 的管理端预览可复用同一路径,但服务端固定为 `operator:{userId}` 与 `mode=preview`,忽略客户端 `extPartyId/mode/assignmentId`;预览记录不进入员工历史、主管复盘、成长、试点或正式校准统计。其他后台身份拒绝。已完成会话的重复 `/finish` 只读取持久化结果,绝不按当前场景目录重算或回写历史快照;正式训练回合模型未配置或调用失败自动回退 seed,契约不变。 |
| 训练场景运营 `/train/scenarios` | 内容读取 `GET /api/train/practice/scenarios`、`GET /scenarios/{id}`;管理写入 `POST /scenarios`、`PATCH /scenarios/{id}/enabled`、`PATCH /scenarios/{id}/review-status`;目录 `GET /curriculum/capabilities`、`GET /curriculum/matrix` | 读取默认只返回当前租户“已发布且启用”的场景,且不返回审核人、稳定用户 ID 或审核时间;仅 `superadmin/hr_operator` 可请求 `includeDisabled=true`、编辑、启停和推进审核。状态只能按 `草稿 → 待业务审核 → 已发布 → 已下线` 流转;发布时服务端重新读取场景并校验住宅范围、固定五岗位、成长能力项、业务来源/SOP、服务红线、目标、成功标准、至少两轮回合和完整五维 Rubric,不信任客户端传入的发布内容。每次编辑已发布内容会退回待业务审核并刷新内容版本/哈希及审核留痕。运营可以选择风险级别,但服务端会对收费、安全、投诉等受控词及全部回合文本自动升级为“高风险”;待评估内容不可发布。高风险场景先记录第一位审核人的稳定用户 ID 和显示名并保持待业务审核,只有第二位不同的已认证运营人员复核同一内容版本后才发布;迁移重跑不得清空等待复核的首审记录。员工可见和可启动路径始终同时要求 `review_status=已发布 AND enabled=1`。 |
| M2 训练预习与求助 | `GET /api/train/practice/scenarios/{id}/prep-card`、`POST /api/train/practice/sessions/{sessionId}/help` | 均要求已登录;预习卡只使用当前租户已启用的 `prep_card/json_prep` 模板,模型返回必须严格为 3 条要点、3 条红线、2 条话术,否则回退场景卡。求助接口只允许 APP 员工访问其本人、同租户的活动训练会话;客户端可传 `roundIndex` 仅为兼容字段,服务端按会话实际进度落库,返回 `{sessionId,scenarioId,roundIndex,recordedAt}`。迁移表为 `aihr_practice_help_event`,不在请求路径执行 DDL。该表已随 2026-07-24 完整包发布并纳入远端 `62/62` 结构预检;完整训练写入与正式试点仍需另验。 |
| 正式试点数据导出 | `GET /api/train/practice/export?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD` | 起止日期必填且包含结束日;只统计窗口内能通过唯一手机号或外部 ID 映射到在职组织快照的正式会话,排除重复手机号和身份碰撞。完训定义为每人至少 10 次,校准必须关联同一窗口内正式会话;CSV 同时给出校准命中数、SOP 可用数、满意度响应数/平均分,以及明细级 AI 分、人工校准分、校准人、校准时间、最终采用分、满意度分和意见,避免用四舍五入后的比率反推门禁状态;汇总和明细均携带正式人员及项目口径,不混入历史 seed/开发身份 |
+38 -11
View File
@@ -344,7 +344,13 @@
>
{{ speechButtonLabel(`turn-${index}`) }}
</button>
<button class="bubble-action" @click="adoptCoachHint(turn.text)">用这句话回复</button>
<button
class="bubble-action"
:disabled="replySuggestionLoading !== null || busy"
@click="adoptCoachHint(turn, index)"
>
{{ replySuggestionLoading === index ? '生成中…' : '用这句话回复' }}
</button>
</view>
</view>
</view>
@@ -556,6 +562,7 @@ const helpRefs = ref<string[]>([]);
const lastTraineeText = ref('');
const lastCoachRoundIndex = ref<number | null>(null);
const adjustingStyle = ref('');
const replySuggestionLoading = ref<number | null>(null);
const emotionScore = ref(0);
const trustScore = ref(0);
const redFlagActive = ref(false);
@@ -808,12 +815,30 @@ const handleComposerSend = (text: string) => {
void submit();
};
/** 旧 H5「建议回复」逻辑的会话版:把老师傅建议填入输入框,员工可改后发送。 */
const adoptCoachHint = (text: string) => {
const value = (text || '').trim();
if (!value) return;
composerRef.value?.setDraft(value);
uni.showToast({ title: '已填入输入框,可修改后发送', icon: 'none' });
const adoptCoachHint = async (turn: PracticeTurn, index: number) => {
if (busy.value || replySuggestionLoading.value !== null || !sessionId.value || !Number.isInteger(turn.roundIndex)) return;
const request = requests.begin('reply-suggestion');
replySuggestionLoading.value = index;
message.value = '';
try {
const data = await submitPracticeTurn({
sessionId: sessionId.value,
roundIndex: turn.roundIndex as number,
traineeText: '',
suggestReply: true
});
if (!requests.isCurrent(request)) return;
const value = (data.suggestedReply || '').trim();
if (!value) throw new Error(data.message || '未生成可用回复');
composerRef.value?.setDraft(value);
uni.showToast({ title: '已生成回复,可修改后发送', icon: 'none' });
} catch (error) {
if (!requests.isCurrent(request)) return;
handleError(error, '回复生成失败');
uni.showToast({ title: message.value || '回复生成失败', icon: 'none' });
} finally {
if (requests.isCurrent(request)) replySuggestionLoading.value = null;
}
};
const submitTranscribed = async (text: string, audioUrl: string, ossId?: string) => {
@@ -953,6 +978,7 @@ const clearPracticeData = () => {
lastTraineeText.value = '';
lastCoachRoundIndex.value = null;
adjustingStyle.value = '';
replySuggestionLoading.value = null;
emotionScore.value = 0;
trustScore.value = 0;
redFlagActive.value = false;
@@ -975,9 +1001,9 @@ const requireLogin = () => {
return false;
};
const appendTurn = (role: PracticeRole, text?: string, emotion = 0, synthesizeCustomerVoice = true, customerTurnIndex?: number) => {
const appendTurn = (role: PracticeRole, text?: string, emotion = 0, synthesizeCustomerVoice = true, customerTurnIndex?: number, coachRoundIndex?: number) => {
if (!text) return;
turns.value.push({ role, text });
turns.value.push({ role, text, roundIndex: coachRoundIndex });
if (role === 'customer' && synthesizeCustomerVoice) {
void prepareTurnVoice(turns.value.length - 1, text, emotion, customerTurnIndex);
}
@@ -1586,7 +1612,7 @@ const replaceLastCoachHint = (text?: string) => {
if (!text) return;
for (let index = turns.value.length - 1; index >= 0; index -= 1) {
if (turns.value[index].role === 'coach') {
turns.value[index] = { role: 'coach', text };
turns.value[index] = { ...turns.value[index], text };
return;
}
}
@@ -1661,7 +1687,7 @@ const submit = async () => {
emotionScore.value = data.emotion ?? emotionScore.value;
trustScore.value = data.trust ?? trustScore.value;
redFlagActive.value = Boolean(data.redFlag);
appendTurn('coach', data.redFlag ? data.coachHint || '先承接诉求,再给处理节点。' : data.coachHint);
appendTurn('coach', data.redFlag ? data.coachHint || '先承接诉求,再给处理节点。' : data.coachHint, 0, true, undefined, submittedRound);
if (data.redFlag) {
message.value = '命中红线,请按老师傅建议重说本轮';
status.value = 'active';
@@ -1757,6 +1783,7 @@ const invalidateRequests = () => {
feedbackSubmittingId.value = null;
helpLoading.value = false;
adjustingStyle.value = '';
replySuggestionLoading.value = null;
satisfactionBusy.value = false;
if (status.value === 'starting') status.value = 'idle';
if (status.value === 'submitting') status.value = sessionId.value ? 'active' : 'idle';
+1
View File
@@ -80,6 +80,7 @@ export const submitPracticeTurn = (params: {
traineeAudioOssId?: number | string;
regenerate?: boolean;
style?: string;
suggestReply?: boolean;
}) =>
apiRequest<PracticeTurnResponse>({
url: '/api/train/practice/turn',
+2
View File
@@ -510,6 +510,7 @@ export type PracticeRole = 'customer' | 'trainee' | 'coach';
export interface PracticeTurn {
role: PracticeRole;
text: string;
roundIndex?: number;
}
export interface PracticeStartResponse {
@@ -548,6 +549,7 @@ export interface PracticeTurnResponse {
roundIndex: number;
finished: boolean;
message?: string;
suggestedReply?: string;
}
export interface PracticeDimensionScore {
@@ -341,6 +341,19 @@ test('训练页保留任务卡、对话区、回复区和结果区并互斥切
assert.match(source, /result\.mentorRewrite/);
});
test('老师傅提示由 LLM 生成可发送回复后再填入输入框', async () => {
const [source, service] = await Promise.all([
pageSource('../src/pages/user/practice/index.vue'),
pageSource('../src/services/practice.ts')
]);
assert.match(source, /suggestReply:\s*true/);
assert.match(source, /data\.suggestedReply/);
assert.match(source, /setDraft\(value\)/);
assert.doesNotMatch(source, /const adoptCoachHint[\s\S]{0,300}setDraft\(\(turn\.text/);
assert.match(service, /suggestReply\?:\s*boolean/);
});
test('底部固定导航页面复用安全操作区', async () => {
const [base, practice, assign, review] = await Promise.all([
pageSource('../src/styles/base.css'),