From 7fd61b6ca32c00f2a0a543f45748b3daa8e7f416 Mon Sep 17 00:00:00 2001 From: let5sne Date: Wed, 8 Jul 2026 14:52:02 +0800 Subject: [PATCH] feat(aihr): add coach-hint style regeneration buttons to practice turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /turn accepts optional regenerate+style (serious/gentle/detailed/professional); regenerate short-circuits before rememberTraineeReply — no round advance, no trainee reply write, no scoring impact; only same-round coachHint evidence is overwritten - Style instruction injected into existing LLM turn prompt; LLM missing/failed returns original hint with explicit message (no fake data) - Mobile practice page renders 再严肃/再温柔/更细致/更专业 pills under coach hints; success replaces text with a style note, failure toasts and keeps original; old clients unaffected (fields appended) --- .../dromara/aihr/domain/AihrPracticeDto.java | 5 +- .../aihr/service/AihrPracticeLlmService.java | 21 +++- .../aihr/service/AihrPracticeSeedService.java | 78 ++++++++++++++- mobile/src/App.vue | 99 ++++++++++++++++++- 4 files changed, 195 insertions(+), 8 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 be59c550..333d1219 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 @@ -81,7 +81,7 @@ public final class AihrPracticeDto { public record PrepCardResponse(List keyPoints, List redLines, List scripts, String promptVersion) { } - public record TurnRequest(String sessionId, Integer roundIndex, String traineeText, String traineeAudioUrl, Long traineeAudioOssId) { + public record TurnRequest(String sessionId, Integer roundIndex, String traineeText, String traineeAudioUrl, Long traineeAudioOssId, Boolean regenerate, String style) { } public record TurnResponse( @@ -92,7 +92,8 @@ public final class AihrPracticeDto { String coachHint, Boolean redFlag, Integer roundIndex, - Boolean finished + Boolean finished, + String message ) { } 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 1280e04e..62e894d5 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 @@ -87,6 +87,24 @@ public class AihrPracticeLlmService { int defaultTrust, String defaultCoachHint ) { + return customerTurn(personaTraits, project, goal, dialogue, traineeText, nextBeat, defaultEmotion, defaultTrust, defaultCoachHint, ""); + } + + public Optional customerTurn( + String personaTraits, + String project, + String goal, + List dialogue, + String traineeText, + String nextBeat, + int defaultEmotion, + int defaultTrust, + String defaultCoachHint, + String styleInstruction + ) { + String styleRule = styleInstruction == null || styleInstruction.isBlank() + ? "" + : "\n 6. 本次只调整 coachHint 风格:" + styleInstruction.trim(); String system = """ 你只扮演物业小区业主,同时作为训练引擎给出结构化回合状态。 人设特征:%s @@ -98,9 +116,10 @@ public class AihrPracticeLlmService { 3. emotion/trust 为 0-100 整数;员工承接越好 trust 越高,推责或对抗则 redFlag=true。 4. coachHint 给员工下一步改进建议,不超过60字。 5. 剧情推进方向(不要照抄原句):%s + %s 只输出 JSON,不要输出其他文字: {"customerText":"","emotion":%d,"trust":%d,"coachHint":"","redFlag":false} - """.formatted(personaTraits, project, goal, MAX_CUSTOMER_CHARS, nextBeat, defaultEmotion, defaultTrust); + """.formatted(personaTraits, project, goal, MAX_CUSTOMER_CHARS, nextBeat, styleRule, defaultEmotion, defaultTrust); String user = "对话记录:\n" + renderDialogue(dialogue) + "\n员工刚才说:" + traineeText + "\n默认教练提示:" + defaultCoachHint 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 4170d806..3bbcb65c 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 @@ -36,6 +36,7 @@ import org.dromara.aihr.domain.AihrPracticeDto.StartResponse; import org.dromara.aihr.domain.AihrPracticeDto.TurnRequest; import org.dromara.aihr.domain.AihrPracticeDto.TurnResponse; import org.dromara.aihr.service.AihrPracticeLlmService.PracticeTurn; +import org.dromara.common.core.exception.ServiceException; import org.springframework.dao.DataAccessException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; @@ -61,6 +62,12 @@ import java.util.concurrent.ConcurrentMap; public class AihrPracticeSeedService { private static final String TENANT_ID = "000000"; + private static final Map REGENERATE_STYLE_INSTRUCTIONS = Map.of( + "serious", "语气更严肃、直接指出问题,明确告诉员工哪里不到位和下一句该怎么补。", + "gentle", "语气更温柔,多用鼓励和陪伴式提醒,让员工愿意继续调整。", + "detailed", "内容更细致,拆成更具体的动作、信息点和反馈节点。", + "professional", "表达更专业,突出SOP依据、责任边界、留痕和闭环口径。" + ); private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("MM-dd HH:mm"); private final Map scenarios = buildScenarios(); @@ -263,9 +270,13 @@ public class AihrPracticeSeedService { } public TurnResponse turn(TurnRequest request) { + String style = normalizeRegenerateStyle(request == null ? null : request.style()); ActiveSession session = request == null || isBlank(request.sessionId()) ? null : activeSessions.get(request.sessionId()); ScenarioSeed scenario = resolveScenario(session == null ? null : session.scenarioId(), request == null ? null : request.sessionId()); int roundIndex = normalizeRoundIndex(request == null ? null : request.roundIndex(), scenario.rounds().size()); + if (request != null && Boolean.TRUE.equals(request.regenerate())) { + return regenerateCoachHint(request, session, scenario, roundIndex, style); + } rememberTraineeReply(request, roundIndex); RoundSeed currentRound = scenario.rounds().get(roundIndex); int nextRoundIndex = roundIndex + 1; @@ -285,10 +296,49 @@ public class AihrPracticeSeedService { coachHint, redFlag, nextRoundIndex, - finished + finished, + "" ); } + private TurnResponse regenerateCoachHint(TurnRequest request, ActiveSession session, ScenarioSeed scenario, int roundIndex, String style) { + if (isBlank(style)) { + throw new ServiceException("请选择有效的调整风格"); + } + RoundSeed currentRound = scenario.rounds().get(roundIndex); + int nextRoundIndex = roundIndex + 1; + boolean finished = nextRoundIndex >= scenario.rounds().size(); + RoundSeed nextRound = finished ? currentRound : scenario.rounds().get(nextRoundIndex); + TurnEvidence evidence = turnEvidence(session, roundIndex); + String originalCoachHint = evidence == null || isBlank(evidence.coachHint()) ? currentRound.coachHint() : evidence.coachHint(); + boolean redFlag = evidence != null && Boolean.TRUE.equals(evidence.redFlag()); + Integer emotion = evidence == null ? currentRound.emotion() : evidence.emotion(); + Integer trust = evidence == null ? currentRound.trust() : evidence.trust(); + String customerText = finished ? "" : customerLine(session, nextRoundIndex, nextRound.customer()); + String traineeText = traineeReply(session, roundIndex, request == null ? null : request.traineeText()); + if (session == null || isBlank(traineeText)) { + return new TurnResponse(customerText, "", emotion, trust, originalCoachHint, redFlag, nextRoundIndex, finished, "未找到本轮员工回复,已保留原建议"); + } + Optional llmTurn = practiceLlmService.customerTurn( + scenario.customer(), + scenario.project(), + scenario.goal(), + dialogueTurns(session, scenario), + traineeText, + firstNonBlank(customerText, nextRound.customer()), + emotion == null ? currentRound.emotion() : emotion, + trust == null ? currentRound.trust() : trust, + originalCoachHint, + REGENERATE_STYLE_INSTRUCTIONS.get(style) + ); + if (llmTurn.isEmpty() || isBlank(llmTurn.get().coachHint())) { + return new TurnResponse(customerText, "", emotion, trust, originalCoachHint, redFlag, nextRoundIndex, finished, "教练建议重生成失败,已保留原建议"); + } + String coachHint = redFlag ? redFlagCoachHint(llmTurn.get().coachHint()) : llmTurn.get().coachHint(); + rememberTurnEvidence(session, roundIndex, emotion, trust, redFlag, coachHint); + return new TurnResponse(customerText, "", emotion, trust, coachHint, redFlag, nextRoundIndex, finished, ""); + } + private boolean hasRedFlag(String traineeText) { if (isBlank(traineeText)) { return false; @@ -1510,6 +1560,32 @@ public class AihrPracticeSeedService { } } + private String normalizeRegenerateStyle(String style) { + if (isBlank(style)) { + return ""; + } + String safeStyle = style.trim(); + if (!REGENERATE_STYLE_INSTRUCTIONS.containsKey(safeStyle)) { + throw new ServiceException("style 仅支持 serious/gentle/detailed/professional"); + } + return safeStyle; + } + + private String traineeReply(ActiveSession session, int roundIndex, String fallback) { + List replies = snapshot(session == null ? null : session.traineeReplies()); + return roundIndex < replies.size() ? firstNonBlank(replies.get(roundIndex), firstNonBlank(fallback, "")) : firstNonBlank(fallback, ""); + } + + private String customerLine(ActiveSession session, int roundIndex, String fallback) { + List lines = snapshot(session == null ? null : session.customerLines()); + return roundIndex < lines.size() ? firstNonBlank(lines.get(roundIndex), fallback) : fallback; + } + + private TurnEvidence turnEvidence(ActiveSession session, int roundIndex) { + List evidence = snapshotEvidence(session == null ? null : session.turnEvidence()); + return roundIndex < evidence.size() ? evidence.get(roundIndex) : null; + } + private void rememberCustomerLine(ActiveSession session, int roundIndex, String customerText) { if (session == null || isBlank(customerText)) { return; diff --git a/mobile/src/App.vue b/mobile/src/App.vue index 7ec27708..aa755951 100644 --- a/mobile/src/App.vue +++ b/mobile/src/App.vue @@ -690,6 +690,18 @@ {{ practiceTurnDuration(turn.role, index) }}

+
+ {{ turn.styleNote }} + +
@@ -1243,11 +1255,14 @@ interface RoleHome { type PracticeStatus = 'idle' | 'starting' | 'active' | 'submitting' | 'finished'; type InterviewStatus = PracticeStatus; type PracticeRole = 'customer' | 'trainee' | 'coach'; +type PracticeStyleKey = 'serious' | 'gentle' | 'detailed' | 'professional'; interface PracticeTurn { role: PracticeRole; label: string; text: string; + roundIndex?: number; + styleNote?: string; } interface PracticeStartResponse { @@ -1267,6 +1282,7 @@ interface PracticePrepCardResponse { interface PracticeTurnResponse { customerText: string; coachHint: string; + message?: string; emotion?: number; trust?: number; redFlag?: boolean; @@ -1693,10 +1709,17 @@ const practiceRoundLabel = computed(() => Math.max(1, Math.min(practiceRoundInde const practiceEmotion = ref(null); const practiceEmotionScore = computed(() => practiceEmotion.value ?? Math.max(45, 76 - practiceRoundIndex.value * 8)); const waveBars = [12, 20, 28, 16, 34, 24, 18, 32, 22, 30, 14, 26, 18, 35, 20, 12]; +const practiceStyleOptions: { key: PracticeStyleKey; label: string }[] = [ + { key: 'serious', label: '再严肃' }, + { key: 'gentle', label: '再温柔' }, + { key: 'detailed', label: '更细致' }, + { key: 'professional', label: '更专业' } +]; const practiceDraft = ref(''); const practiceTurns = ref([]); const practiceResult = ref(null); const practiceMessage = ref(''); +const practiceRegeneratingKey = ref(''); const latestPracticeAudioUrl = ref(''); const latestPracticeAudioOssId = ref(null); const practicePrepCard = ref(null); @@ -2952,13 +2975,13 @@ const submitMaterial = async () => { } }; -const appendPracticeTurn = (role: PracticeRole, text: string) => { +const appendPracticeTurn = (role: PracticeRole, text: string, meta: Partial = {}) => { const labels: Record = { customer: 'AI业主', trainee: '我的回复', coach: 'AI教练' }; - practiceTurns.value.push({ role, label: labels[role], text }); + practiceTurns.value.push({ role, label: labels[role], text, ...meta }); if (role === 'customer') { void playCustomerVoice(text); } @@ -3017,6 +3040,7 @@ const startMobilePractice = async () => { practiceDraft.value = ''; latestPracticeAudioUrl.value = ''; latestPracticeAudioOssId.value = null; + practiceRegeneratingKey.value = ''; practiceTurns.value = []; practiceRoundIndex.value = 0; practiceHelp.value = null; @@ -3062,6 +3086,7 @@ const submitMobilePractice = async () => { const traineeText = practiceDraft.value; const traineeAudioUrl = latestPracticeAudioUrl.value; const traineeAudioOssId = latestPracticeAudioOssId.value; + const submittedRoundIndex = practiceRoundIndex.value; practiceDraft.value = ''; practiceMessage.value = ''; practiceStatus.value = 'submitting'; @@ -3071,7 +3096,7 @@ const submitMobilePractice = async () => { await fetch('/dev-api/api/train/practice/turn', { method: 'POST', headers: apiHeaders(), - body: JSON.stringify({ sessionId: practiceSessionId.value, roundIndex: practiceRoundIndex.value, traineeText, traineeAudioUrl, traineeAudioOssId }) + body: JSON.stringify({ sessionId: practiceSessionId.value, roundIndex: submittedRoundIndex, traineeText, traineeAudioUrl, traineeAudioOssId }) }) ); latestPracticeAudioUrl.value = ''; @@ -3079,7 +3104,7 @@ const submitMobilePractice = async () => { practiceEmotion.value = data.emotion ?? practiceEmotion.value; const coachHint = data.redFlag ? data.coachHint || '红线纠偏:先承接诉求,再给责任人与反馈节点。' : data.coachHint; if (coachHint) { - appendPracticeTurn('coach', coachHint); + appendPracticeTurn('coach', coachHint, { roundIndex: submittedRoundIndex }); } if (data.finished) { await finishMobilePractice(); @@ -3094,6 +3119,41 @@ const submitMobilePractice = async () => { } }; +const regeneratePracticeCoach = async (index: number, option: { key: PracticeStyleKey; label: string }) => { + const turn = practiceTurns.value[index]; + if (!turn || turn.role !== 'coach' || turn.roundIndex === undefined || practiceRegeneratingKey.value) return; + const key = `${index}:${option.key}`; + practiceRegeneratingKey.value = key; + practiceMessage.value = ''; + try { + const data = await readApi( + await fetch('/dev-api/api/train/practice/turn', { + method: 'POST', + headers: apiHeaders(), + body: JSON.stringify({ sessionId: practiceSessionId.value, roundIndex: turn.roundIndex, regenerate: true, style: option.key }) + }) + ); + if (data.message) { + throw new Error(data.message); + } + const coachHint = data.redFlag ? data.coachHint || '红线纠偏:先承接诉求,再给责任人与反馈节点。' : data.coachHint; + if (!coachHint) { + throw new Error('教练建议重生成失败'); + } + practiceTurns.value[index] = { ...turn, text: coachHint, styleNote: `已按“${option.label}”调整` }; + } catch (error) { + toastMessage.value = error instanceof Error ? error.message : '教练建议调整失败'; + if (toastTimer) window.clearTimeout(toastTimer); + toastTimer = window.setTimeout(() => { + toastMessage.value = ''; + }, 1800); + } finally { + if (practiceRegeneratingKey.value === key) { + practiceRegeneratingKey.value = ''; + } + } +}; + const startCandidateInterview = async () => { activeUserPanel.value = 'tasks'; activeTabLabel.value = '面试'; @@ -6629,6 +6689,37 @@ p { background: #f3f8ff; } +.coach-style-actions { + grid-column: 2; + display: flex; + flex-wrap: wrap; + gap: 7px; + align-items: center; +} + +.coach-style-actions button { + min-height: 30px; + padding: 0 12px; + border-radius: 999px; + border: 1px solid rgba(87, 154, 255, 0.18); + background: #ffffff; + color: #3769a9; + font-size: 12px; + font-weight: 850; +} + +.coach-style-actions button:disabled { + opacity: 0.55; +} + +.coach-style-note { + width: 100%; + color: #4e77ad; + font-size: 12px; + line-height: 1.25; + font-weight: 750; +} + .practice-wave { margin-top: 10px; min-height: 34px;