From acd5fd3a6d703a8428e0ce54b6db2793de6cd5d2 Mon Sep 17 00:00:00 2001 From: let5sne Date: Sat, 25 Jul 2026 15:44:45 +0800 Subject: [PATCH] fix(practice): generate replies from coach hints --- .../dromara/aihr/domain/AihrPracticeDto.java | 14 +++++- .../aihr/service/AihrPracticeLlmService.java | 40 ++++++++++++++- .../aihr/service/AihrPracticeSeedService.java | 32 ++++++++++++ .../service/AihrPracticeLlmServiceTest.java | 27 ++++++++++ .../service/AihrPracticeSeedServiceTest.java | 7 +++ docs/API_INTEGRATION.md | 2 +- mobile-uni/src/pages/user/practice/index.vue | 49 ++++++++++++++----- mobile-uni/src/services/practice.ts | 1 + mobile-uni/src/types/api.ts | 2 + mobile-uni/tests/mobile-user-pages.test.mjs | 13 +++++ 10 files changed, 172 insertions(+), 15 deletions(-) diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java index 83fd19f4..386b60b2 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java @@ -246,7 +246,12 @@ public final class AihrPracticeDto { String tenantId, Set 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) { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeLlmService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeLlmService.java index 5377c2b3..a411e6a5 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeLlmService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeLlmService.java @@ -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 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 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 parseTurn(String content, int defaultEmotion, int defaultTrust, String defaultCoachHint) { try { JsonNode root = objectMapper.readTree(extractJson(content)); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java index 65b98738..136adfd6 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java @@ -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; diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeLlmServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeLlmServiceTest.java index 3396682a..fa9519b8 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeLlmServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeLlmServiceTest.java @@ -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); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java index 4fac2370..7864055f 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java @@ -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()); } diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index 0317b1fc..d5c29f0e 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -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/开发身份 | diff --git a/mobile-uni/src/pages/user/practice/index.vue b/mobile-uni/src/pages/user/practice/index.vue index 57fc9be3..18eefa16 100644 --- a/mobile-uni/src/pages/user/practice/index.vue +++ b/mobile-uni/src/pages/user/practice/index.vue @@ -344,7 +344,13 @@ > {{ speechButtonLabel(`turn-${index}`) }} - + @@ -556,6 +562,7 @@ const helpRefs = ref([]); const lastTraineeText = ref(''); const lastCoachRoundIndex = ref(null); const adjustingStyle = ref(''); +const replySuggestionLoading = ref(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'; diff --git a/mobile-uni/src/services/practice.ts b/mobile-uni/src/services/practice.ts index a598aff6..1ef78487 100644 --- a/mobile-uni/src/services/practice.ts +++ b/mobile-uni/src/services/practice.ts @@ -80,6 +80,7 @@ export const submitPracticeTurn = (params: { traineeAudioOssId?: number | string; regenerate?: boolean; style?: string; + suggestReply?: boolean; }) => apiRequest({ url: '/api/train/practice/turn', diff --git a/mobile-uni/src/types/api.ts b/mobile-uni/src/types/api.ts index cd6c7d85..192a1b3f 100644 --- a/mobile-uni/src/types/api.ts +++ b/mobile-uni/src/types/api.ts @@ -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 { diff --git a/mobile-uni/tests/mobile-user-pages.test.mjs b/mobile-uni/tests/mobile-user-pages.test.mjs index 2d9fdfff..a89f7ead 100644 --- a/mobile-uni/tests/mobile-user-pages.test.mjs +++ b/mobile-uni/tests/mobile-user-pages.test.mjs @@ -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'),