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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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/开发身份 |
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -80,6 +80,7 @@ export const submitPracticeTurn = (params: {
|
||||
traineeAudioOssId?: number | string;
|
||||
regenerate?: boolean;
|
||||
style?: string;
|
||||
suggestReply?: boolean;
|
||||
}) =>
|
||||
apiRequest<PracticeTurnResponse>({
|
||||
url: '/api/train/practice/turn',
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user