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;