feat(aihr): complete M3 protected practice replay

This commit is contained in:
2026-07-23 22:21:43 +08:00
parent 83f775faec
commit 067cc30d80
11 changed files with 413 additions and 47 deletions
@@ -131,26 +131,54 @@ public class AihrSpeechController {
if (text.length() > MAX_TTS_CHARS) {
text = text.substring(0, MAX_TTS_CHARS);
}
String ttsText = text;
return speechService.synthesize(
text,
ttsText,
request == null ? null : request.voice(),
request == null ? null : request.voiceProfile())
.map(this::storeTtsAudio)
.map(audio -> storeTtsAudio(audio, request, ttsText))
.orElseGet(() -> R.fail("语音合成失败,已降级为文本展示"));
}
private R<TtsResponse> storeTtsAudio(byte[] audio) {
private R<TtsResponse> storeTtsAudio(byte[] audio, TtsRequest request, String text) {
Path tempFile = null;
try {
tempFile = Files.createTempFile("aihr-tts-", ".mp3");
Files.write(tempFile, audio);
File file = tempFile.toFile();
SysOssVo oss = ossService.upload(file);
if (hasPracticeContext(request)) {
var context = request.practiceContext();
if (!"customer".equalsIgnoreCase(context.role())) {
deleteOssQuietly(oss.getOssId());
return R.fail(HttpStatus.BAD_REQUEST, "训练语音仅支持关联业主回合");
}
String ownerIdentity = currentAppUsername();
if (ownerIdentity.isBlank()) {
deleteOssQuietly(oss.getOssId());
return R.fail(HttpStatus.FORBIDDEN, "请使用员工端账号关联训练语音");
}
try {
Long replacedOssId = practiceSeedService.attachGeneratedCustomerAudio(
context.sessionId(), context.turnIndex(), text, oss.getOssId(), ownerIdentity
);
if (replacedOssId != null && !replacedOssId.equals(oss.getOssId())) {
deleteOssQuietly(replacedOssId);
}
} catch (RuntimeException ex) {
deleteOssQuietly(oss.getOssId());
log.warn("bind generated practice tts audio failed(处理错误已隐藏)");
return R.fail(HttpStatus.BAD_REQUEST, "训练语音关联失败,请刷新训练后重试");
}
}
// Keep the old client contract playable without returning a public OSS URL.
String inlineAudioUrl = "data:audio/mp3;base64," + Base64.getEncoder().encodeToString(audio);
return R.ok(new TtsResponse(inlineAudioUrl, "openai-compatible", oss.getOssId(), inlineAudioUrl));
} catch (Exception e) {
log.warn("tts audio upload failed(处理错误已隐藏)");
if (hasPracticeContext(request)) {
return R.fail("训练录音保存失败,请稍后重试");
}
String inlineAudioUrl = "data:audio/mp3;base64," + Base64.getEncoder().encodeToString(audio);
return R.ok(new TtsResponse(inlineAudioUrl, "openai-compatible"));
} finally {
@@ -163,4 +191,8 @@ public class AihrSpeechController {
}
}
}
private static boolean hasPracticeContext(TtsRequest request) {
return request != null && request.practiceContext() != null;
}
}
@@ -11,7 +11,17 @@ public final class AihrSpeechDto {
}
}
public record TtsRequest(String text, String voice, VoiceProfile voiceProfile) {
/**
* 可选训练上下文只用于把本次服务端新生成的业主语音与当前训练回合关联。
* 客户端不能借此提交或绑定任意已有 OSS 文件。
*/
public record TtsPracticeContext(String sessionId, Integer turnIndex, String role) {
}
public record TtsRequest(String text, String voice, VoiceProfile voiceProfile, TtsPracticeContext practiceContext) {
public TtsRequest(String text, String voice, VoiceProfile voiceProfile) {
this(text, voice, voiceProfile, null);
}
}
public record VoiceProfile(String role, String voice, Double speed, String emotion, String dialect) {
@@ -85,6 +85,7 @@ import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@@ -276,6 +277,7 @@ public class AihrPracticeSeedService {
private volatile boolean annotationTableReady;
private volatile boolean audioTableReady;
private final TransactionTemplate assignmentTransaction;
private final TransactionTemplate practiceAudioTransaction;
@Autowired
public AihrPracticeSeedService(ObjectMapper objectMapper, JdbcTemplate jdbcTemplate, AihrPracticeLlmService practiceLlmService,
@@ -302,6 +304,7 @@ public class AihrPracticeSeedService {
this.jdbcTemplate = jdbcTemplate;
this.practiceLlmService = practiceLlmService;
this.assignmentTransaction = configureAssignmentTransaction(pilotExportTransaction);
this.practiceAudioTransaction = configurePracticeAudioTransaction(pilotExportTransaction);
this.sopSeedService = sopSeedService;
this.pilotExportTransaction = configurePilotExportTransaction(pilotExportTransaction);
this.ossService = ossService;
@@ -585,7 +588,7 @@ public class AihrPracticeSeedService {
tenantId(), scenario.id(), trainee, resolveExtPartyId(request, trainee), request == null ? null : request.assignmentId(),
isMobile(request), LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(promptPresentedAt), java.time.ZoneId.systemDefault()),
new AtomicInteger(0), new ArrayList<>(), new ArrayList<>(), new ArrayList<>(), customerLines,
new ArrayList<>(), new ArrayList<>(List.of(promptPresentedAt)), new ArrayList<>()
new ArrayList<>(), new ArrayList<>(), new ArrayList<>(List.of(promptPresentedAt)), new ArrayList<>()
));
return new StartResponse(
sessionId,
@@ -620,6 +623,12 @@ public class AihrPracticeSeedService {
return handleTurn(request, null, scenario, style);
}
synchronized (session) {
// A concurrent /finish persists the session and removes it from the active map
// while holding the same monitor. Never let a stale /turn return an answer that
// can no longer be included in the completed training record.
if (activeSessions.get(request.sessionId()) != session) {
throw new ServiceException("训练会话已完成,请查看训练记录");
}
return handleTurn(request, session, scenario, style);
}
}
@@ -800,14 +809,27 @@ public class AihrPracticeSeedService {
public FinishResponse finish(FinishRequest request, String ownerIdentity) {
String sessionId = request == null ? null : request.sessionId();
ActiveSession activeSession;
if (isBlank(ownerIdentity)) {
activeSession = sessionId == null ? null : activeSessions.remove(sessionId);
} else {
ActiveSession existing = sessionId == null ? null : activeSessions.get(sessionId);
requireSessionOwner(existing, ownerIdentity, tenantId());
activeSession = activeSessions.remove(sessionId);
ActiveSession activeSession = sessionId == null ? null : activeSessions.get(sessionId);
if (!isBlank(ownerIdentity)) {
requireSessionOwner(activeSession, ownerIdentity, tenantId());
}
if (activeSession == null) {
return finishActiveSession(sessionId, null);
}
synchronized (activeSession) {
if (activeSessions.get(sessionId) != activeSession) {
throw new ServiceException("训练会话已完成");
}
if (!isBlank(ownerIdentity)) {
requireSessionOwner(activeSession, ownerIdentity, tenantId());
}
FinishResponse response = finishActiveSession(sessionId, activeSession);
activeSessions.remove(sessionId, activeSession);
return response;
}
}
private FinishResponse finishActiveSession(String sessionId, ActiveSession activeSession) {
ScenarioSeed scenario = resolveScenario(activeSession == null ? null : activeSession.scenarioId(), sessionId);
String trainee = activeSession == null ? scenario.trainee() : activeSession.trainee();
PracticeResult result = evaluate(activeSession, scenario);
@@ -1677,6 +1699,15 @@ public class AihrPracticeSeedService {
return result;
}
private static TransactionTemplate configurePracticeAudioTransaction(TransactionTemplate transactionTemplate) {
if (transactionTemplate == null || transactionTemplate.getTransactionManager() == null) {
return null;
}
TransactionTemplate result = new TransactionTemplate(transactionTemplate.getTransactionManager());
result.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);
return result;
}
private int countCompletedPilotPeople() {
ensurePracticeTable();
return count("""
@@ -1822,6 +1853,145 @@ public class AihrPracticeSeedService {
return count != null && count > 0;
}
/**
* Binds only the TTS object produced by the current request to an AI-customer turn.
* The client supplies a session/turn hint, while ownership and the spoken source text
* are always revalidated against the active or already-finished session.
*/
public Long attachGeneratedCustomerAudio(String sessionId, Integer requestedTurnIndex, String customerText,
Long ossId, String ownerIdentity) {
String id = sessionId == null ? "" : sessionId.trim();
String owner = ownerIdentity == null ? "" : ownerIdentity.trim();
if (id.isBlank() || owner.isBlank() || ossId == null || ossId <= 0) {
throw new ServiceException("训练语音关联参数无效");
}
ActiveSession activeSession = activeSessions.get(id);
if (activeSession != null) {
synchronized (activeSession) {
if (activeSessions.get(id) == activeSession) {
requireSessionOwner(activeSession, owner, tenantId());
ScenarioSeed scenario = resolveScenario(activeSession.scenarioId(), id);
int turnIndex = requireCustomerAudioTurnIndex(requestedTurnIndex, scenario);
verifyCustomerAudioText(customerLine(activeSession, turnIndex, scenario.rounds().get(turnIndex).customer()), customerText);
return rememberCustomerAudio(activeSession, turnIndex, ossId);
}
}
}
return attachFinishedCustomerAudio(id, requestedTurnIndex, customerText, ossId, owner);
}
private Long attachFinishedCustomerAudio(String sessionId, Integer requestedTurnIndex, String customerText,
Long ossId, String ownerIdentity) {
ensurePracticeTable();
ensureAudioTable();
ensureAssignmentTable();
return inPracticeAudioTransaction(() -> attachFinishedCustomerAudioLocked(
sessionId, requestedTurnIndex, customerText, ossId, ownerIdentity
));
}
/**
* Keeps the dialogue reference and its protected playback row in the same transaction.
* Locking the session row serializes late TTS retries for the same completed training.
*/
private Long attachFinishedCustomerAudioLocked(String sessionId, Integer requestedTurnIndex, String customerText,
Long ossId, String ownerIdentity) {
List<FinishedSessionAudioSource> rows = jdbcTemplate.query("""
SELECT scenario_id, dialogue_json
FROM aihr_practice_session
WHERE tenant_id = ? AND session_id = ? AND ext_party_id = ?
AND mode = 'mobile' AND finished_time IS NOT NULL
LIMIT 1
FOR UPDATE
""", (rs, rowNum) -> new FinishedSessionAudioSource(
rs.getString("scenario_id"), rs.getString("dialogue_json")
), tenantId(), sessionId, ownerIdentity);
if (rows.isEmpty()) {
throw new ServiceException("训练会话不存在或无权访问");
}
FinishedSessionAudioSource source = rows.get(0);
ScenarioSeed scenario = resolveScenario(source.scenarioId(), sessionId);
int turnIndex = requireCustomerAudioTurnIndex(requestedTurnIndex, scenario);
List<DialogueResponse> dialogue = readDialogue(source.dialogueJson(), scenario);
int dialogueIndex = customerDialogueIndex(dialogue, turnIndex);
if (dialogueIndex < 0) {
throw new ServiceException("训练语音回合无效");
}
DialogueResponse customerTurn = dialogue.get(dialogueIndex);
// Completed dialogue is stored after sensitive-text masking. Compare the same
// canonical form so an otherwise valid late TTS does not fail on phone/room masking.
verifyCustomerAudioText(customerTurn.text(), maskSensitiveText(customerText));
List<Long> storedAudioIds = jdbcTemplate.query("""
SELECT oss_id
FROM aihr_practice_audio
WHERE tenant_id = ? AND session_id = ? AND turn_index = ? AND role = 'customer'
FOR UPDATE
""", (rs, rowNum) -> rs.getLong("oss_id"), tenantId(), sessionId, turnIndex + 1);
Long previousOssId = customerTurn.audioOssId();
if (previousOssId == null && !storedAudioIds.isEmpty()) {
previousOssId = storedAudioIds.get(0);
}
List<DialogueResponse> updatedDialogue = new ArrayList<>(dialogue);
updatedDialogue.set(dialogueIndex, new DialogueResponse(
customerTurn.role(), customerTurn.label(), customerTurn.text(), "", ossId,
customerTurn.emotion(), customerTurn.trust(), customerTurn.redFlag(), customerTurn.coachHint()
));
LocalDateTime now = LocalDateTime.now();
jdbcTemplate.update("""
INSERT INTO aihr_practice_audio
(tenant_id, session_id, turn_index, role, oss_id, audio_url, create_time, update_time)
VALUES (?, ?, ?, 'customer', ?, '', ?, ?)
ON DUPLICATE KEY UPDATE oss_id = VALUES(oss_id), audio_url = VALUES(audio_url), update_time = VALUES(update_time)
""", tenantId(), sessionId, turnIndex + 1, ossId, Timestamp.valueOf(now), Timestamp.valueOf(now));
int updated = jdbcTemplate.update("""
UPDATE aihr_practice_session
SET dialogue_json = ?, update_time = ?
WHERE tenant_id = ? AND session_id = ? AND ext_party_id = ? AND mode = 'mobile'
""", writeJson(maskDialogue(updatedDialogue)), Timestamp.valueOf(now), tenantId(), sessionId, ownerIdentity);
if (updated != 1) {
throw new ServiceException("训练语音关联失败,请刷新训练后重试");
}
if (previousOssId == null || Objects.equals(previousOssId, ossId) || canReadPracticeAudioForTenant(previousOssId)) {
return null;
}
return previousOssId;
}
private static int requireCustomerAudioTurnIndex(Integer requestedTurnIndex, ScenarioSeed scenario) {
int turnIndex = requestedTurnIndex == null ? -1 : requestedTurnIndex;
if (turnIndex < 0 || scenario == null || turnIndex >= scenario.rounds().size()) {
throw new ServiceException("训练语音回合无效");
}
return turnIndex;
}
private static void verifyCustomerAudioText(String expectedText, String requestedText) {
String expected = ttsComparableText(expectedText);
String requested = ttsComparableText(requestedText);
if (expected.isBlank() || !expected.equals(requested)) {
throw new ServiceException("训练语音与当前业主话术不匹配");
}
}
private static String ttsComparableText(String text) {
String value = text == null ? "" : text.trim();
return value.length() > 300 ? value.substring(0, 300) : value;
}
private static int customerDialogueIndex(List<DialogueResponse> dialogue, int turnIndex) {
int customerIndex = 0;
for (int index = 0; index < dialogue.size(); index++) {
if (!"customer".equals(dialogue.get(index).role())) {
continue;
}
if (customerIndex == turnIndex) {
return index;
}
customerIndex += 1;
}
return -1;
}
public void stagePracticeAudio(Long ossId, String audioUrl, String ownerIdentity, Long ownerUserId) {
if (ossId == null || isBlank(ownerIdentity) || ownerUserId == null || ownerUserId <= 0) {
return;
@@ -3081,6 +3251,13 @@ public class AihrPracticeSeedService {
return assignmentTransaction.execute(status -> work.get());
}
private <T> T inPracticeAudioTransaction(Supplier<T> work) {
if (practiceAudioTransaction == null) {
return work.get();
}
return practiceAudioTransaction.execute(status -> work.get());
}
static LocalDate normalizeAssignmentDueDate(String value, LocalDate today) {
LocalDate base = today == null ? businessToday() : today;
if (value == null || value.isBlank()) {
@@ -4423,6 +4600,20 @@ public class AihrPracticeSeedService {
}
}
private Long rememberCustomerAudio(ActiveSession session, int roundIndex, Long ossId) {
if (session == null || ossId == null) {
return null;
}
List<Long> audioOssIds = session.customerAudioOssIds();
synchronized (audioOssIds) {
while (audioOssIds.size() <= roundIndex) {
audioOssIds.add(null);
}
Long previousOssId = audioOssIds.set(roundIndex, ossId);
return Objects.equals(previousOssId, ossId) ? null : previousOssId;
}
}
private void rememberResponseLatency(ActiveSession session, int roundIndex, long submittedAt) {
if (session == null) {
return;
@@ -4656,18 +4847,24 @@ public class AihrPracticeSeedService {
if (isBlank(sessionId) || session == null) {
return;
}
List<String> urls = snapshot(session.traineeAudioUrls());
if (urls.stream().allMatch(this::isBlank)) {
List<String> traineeUrls = snapshot(session.traineeAudioUrls());
List<Long> traineeOssIds = snapshotLong(session.traineeAudioOssIds());
List<Long> customerOssIds = snapshotLong(session.customerAudioOssIds());
boolean hasTraineeAudio = !traineeUrls.stream().allMatch(this::isBlank)
|| traineeOssIds.stream().anyMatch(Objects::nonNull);
boolean hasCustomerAudio = customerOssIds.stream().anyMatch(Objects::nonNull);
if (!hasTraineeAudio && !hasCustomerAudio) {
return;
}
List<Long> ossIds = snapshotLong(session.traineeAudioOssIds());
ensureAudioTable();
String id = sessionId.trim();
LocalDateTime now = LocalDateTime.now();
jdbcTemplate.update("DELETE FROM aihr_practice_audio WHERE tenant_id = ? AND session_id = ?", tenantId(), id);
for (int i = 0; i < urls.size(); i++) {
String url = urls.get(i);
if (isBlank(url)) {
int traineeCount = Math.max(traineeUrls.size(), traineeOssIds.size());
for (int i = 0; i < traineeCount; i++) {
String url = i < traineeUrls.size() ? traineeUrls.get(i) : "";
Long ossId = i < traineeOssIds.size() ? traineeOssIds.get(i) : null;
if (isBlank(url) && ossId == null) {
continue;
}
jdbcTemplate.update("""
@@ -4678,12 +4875,30 @@ public class AihrPracticeSeedService {
tenantId(),
id,
i + 1,
i < ossIds.size() ? ossIds.get(i) : null,
url.trim(),
ossId,
firstNonBlank(url, ""),
Timestamp.valueOf(now),
Timestamp.valueOf(now)
);
bindPracticeAudio(ossId);
}
for (int i = 0; i < customerOssIds.size(); i++) {
Long ossId = customerOssIds.get(i);
if (ossId == null) {
continue;
}
jdbcTemplate.update("""
INSERT INTO aihr_practice_audio
(tenant_id, session_id, turn_index, role, oss_id, audio_url, create_time, update_time)
VALUES (?, ?, ?, 'customer', ?, '', ?, ?)
""",
tenantId(),
id,
i + 1,
ossId,
Timestamp.valueOf(now),
Timestamp.valueOf(now)
);
bindPracticeAudio(i < ossIds.size() ? ossIds.get(i) : null);
}
}
@@ -4762,12 +4977,23 @@ public class AihrPracticeSeedService {
List<String> audioUrls = snapshot(activeSession == null ? null : activeSession.traineeAudioUrls());
List<Long> audioOssIds = snapshotLong(activeSession == null ? null : activeSession.traineeAudioOssIds());
List<String> customerLines = snapshot(activeSession == null ? null : activeSession.customerLines());
List<Long> customerAudioOssIds = snapshotLong(activeSession == null ? null : activeSession.customerAudioOssIds());
List<TurnEvidence> evidenceList = snapshotEvidence(activeSession == null ? null : activeSession.turnEvidence());
for (int i = 0; i < scenario.rounds().size(); i++) {
RoundSeed round = scenario.rounds().get(i);
TurnEvidence evidence = i < evidenceList.size() ? evidenceList.get(i) : null;
String customerLine = i < customerLines.size() && !isBlank(customerLines.get(i)) ? customerLines.get(i) : round.customer();
turns.add(new DialogueResponse("customer", "AI业主", customerLine));
turns.add(new DialogueResponse(
"customer",
"AI业主",
customerLine,
"",
i < customerAudioOssIds.size() ? customerAudioOssIds.get(i) : null,
null,
null,
null,
""
));
if (i < replies.size() && !isBlank(replies.get(i))) {
turns.add(new DialogueResponse(
"trainee",
@@ -5564,6 +5790,9 @@ public class AihrPracticeSeedService {
private record SessionAnnotationSource(String scenarioId, String dialogueJson, String annotationsJson) {
}
private record FinishedSessionAudioSource(String scenarioId, String dialogueJson) {
}
private record ScoreSnapshot(Integer total, Integer taskCompletion, Integer responseTimeliness,
Integer compliance, Integer emotion, Integer communication, Integer marketing) {
}
@@ -5669,7 +5898,7 @@ public class AihrPracticeSeedService {
private record ActiveSession(String tenantId, String scenarioId, String trainee, String extPartyId, Long assignmentId, boolean mobile,
LocalDateTime startedAt, AtomicInteger currentRoundIndex, List<String> traineeReplies,
List<String> traineeAudioUrls, List<Long> traineeAudioOssIds,
List<String> customerLines, List<TurnEvidence> turnEvidence,
List<String> customerLines, List<Long> customerAudioOssIds, List<TurnEvidence> turnEvidence,
List<Long> promptPresentedAtMillis, List<Long> responseLatenciesMs) {
}
@@ -665,6 +665,50 @@ public class AihrPracticeSeedServiceTest {
assertTrue(mobileController.contains("answerDailyDrill(id, request, ownMobileExtPartyId(null), currentAppUserId())"));
}
@Test
@SuppressWarnings("unchecked")
public void m3GeneratedCustomerAudioIsBoundToTheOwnedTurnAndSurvivesIntoReviewStorage() throws Exception {
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
when(jdbcTemplate.query(contains("SELECT enabled"), any(RowMapper.class), eq("000000"), eq("complaint-water")))
.thenReturn(List.of());
when(jdbcTemplate.query(contains("SELECT scenario_code"), any(RowMapper.class), eq("000000"), eq("complaint-water")))
.thenReturn(List.of());
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null);
var session = service.start(new StartRequest("employee-a", "complaint-water", "mobile", null));
assertNull(service.attachGeneratedCustomerAudio(session.sessionId(), 0, session.customerText(), 9001L, "employee-a"));
Field sessionsField = AihrPracticeSeedService.class.getDeclaredField("activeSessions");
sessionsField.setAccessible(true);
Map<String, Object> sessions = (Map<String, Object>) sessionsField.get(service);
Object activeSession = sessions.get(session.sessionId());
Method customerAudioAccessor = activeSession.getClass().getDeclaredMethod("customerAudioOssIds");
customerAudioAccessor.setAccessible(true);
assertEquals(List.of(9001L), customerAudioAccessor.invoke(activeSession));
assertEquals(9001L, service.attachGeneratedCustomerAudio(session.sessionId(), 0, session.customerText(), 9002L, "employee-a"));
assertEquals(List.of(9002L), customerAudioAccessor.invoke(activeSession));
assertThrows(ServiceException.class, () -> service.attachGeneratedCustomerAudio(
session.sessionId(), 0, session.customerText(), 9002L, "employee-b"
));
assertThrows(ServiceException.class, () -> service.attachGeneratedCustomerAudio(
session.sessionId(), 0, "与当前业主话术不一致", 9002L, "employee-a"
));
String serviceSource = Files.readString(Path.of("src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java"));
String speechController = Files.readString(Path.of("src/main/java/org/dromara/aihr/controller/AihrSpeechController.java"));
assertTrue(serviceSource.contains("customerAudioOssIds"));
assertTrue(serviceSource.contains("VALUES (?, ?, ?, 'customer', ?, '', ?, ?)"));
assertTrue(serviceSource.contains("ON DUPLICATE KEY UPDATE oss_id = VALUES(oss_id)"));
assertTrue(serviceSource.contains("activeSessions.get(request.sessionId()) != session"));
assertTrue(serviceSource.contains("inPracticeAudioTransaction"));
assertTrue(serviceSource.contains("LIMIT 1\n FOR UPDATE"));
assertTrue(serviceSource.contains("maskSensitiveText(customerText)"));
assertTrue(speechController.contains("attachGeneratedCustomerAudio"));
assertTrue(speechController.contains("Long replacedOssId"));
assertTrue(speechController.contains("训练语音仅支持关联业主回合"));
}
@Test
public void startChecksScenarioEnabledBeforeSeedFallback() throws Exception {
String source = Files.readString(Path.of("src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java"));
+6 -3
View File
@@ -191,10 +191,11 @@
文件落点:
- `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java`:保存 session 时写 audio/annotation 信息。
- 新增 `AihrPracticeReviewService.java`:生成和查询逐句标注。
- `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java`:保存 session 时写 audio/annotation 信息,并在 TTS 晚到时以会话、员工和原话术重新校验后补绑业主音频。
- `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSpeechController.java`:只接收已认证 APP 员工的 `practiceContext`,将本次生成的业主 TTS 关联到指定回合。
- `AihrMobileController.java`:复盘详情返回 audio 和 annotations。
- `mobile-uni/src/pages/supervisor/index/index.vue`:主管复盘详情增加回放和逐句标注区。
- `mobile-uni/src/pages/user/practice/index.vue`:业主 TTS 随当前会话和回合提交 `practiceContext`。
- `mobile-uni/src/pages/supervisor/review/index.vue`:主管复盘详情增加受保护回放和逐句标注区。
验收:
@@ -202,6 +203,8 @@
- 逐句标注只在 finish 后生成一次,刷新不重复算分。
- 没有录音权限时仍保存文本 transcript。
2026-07-23 本机验收已完成员工训练、业主 TTS 的 `sys_oss` 绑定、同项目主管受控回放、逐句标注和标记复盘;该结果仅证明本地实现,不代表生产部署、真机录音或正式试点验收。
### M4. 画像、校准与主管派发(第 6-7 周)
目标:把训练结果变成员工成长与主管管理动作。
+1 -1
View File
@@ -11,7 +11,7 @@
| 三角色对练 `/train/practice` | `POST /api/train/practice/start`、`/turn`、`/finish` | 已接入编排 API;数据库启用 chat 模型后,`/turn` 客户回复按人设走真 LLM 生成(seed 剧本作剧情锚点),`/finish` 走单次 temperature=0 结构化评分(4 维分+导师改写+点评);移动端 `turn/finish` 校验当前手机号与启动会话归属,未知或他人会话拒绝;模型未配置或调用失败自动回退 seed,契约不变 |
| 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。该契约当前仅完成本地实现与验证,线上尚未有该表。 |
| 正式试点数据导出 | `GET /api/train/practice/export?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD` | 起止日期必填且包含结束日;只统计窗口内能通过唯一手机号或外部 ID 映射到在职组织快照的正式会话,排除重复手机号和身份碰撞。完训定义为每人至少 10 次,校准必须关联同一窗口内正式会话;CSV 同时给出校准命中数、SOP 可用数、满意度响应数/平均分,以及明细级 AI 分、人工校准分、校准人、校准时间、最终采用分、满意度分和意见,避免用四舍五入后的比率反推门禁状态;汇总和明细均携带正式人员及项目口径,不混入历史 seed/开发身份 |
| 对练语音 | `POST /api/ai/asr`(multipart 字段 `file`,≤5MB)、`POST /api/ai/tts`(JSON `{text≤300字, voice?, voiceProfile?:{role,voice?,speed?,emotion?,dialect?}}`,`dialect` 仅接受 `mandarin/cantonese/sichuanese`,非法值返回业务码 `400`;旧 `voice` 兼容;成功返回 `ossId`,客户端播放地址为受控的内联 `data:audio/*`,不返回原始 OSS URL)、`GET /api/aihr/mobile/oss/{ossId}` | 走 OpenAI-compatible audio 接口;生产 SiliconFlow CosyVoice2 按角色映射老师傅/业主/面试官音色,业主对练再按已有情绪分切换平静/严肃/强烈语气和语速;dialect 作为语气提示传给支持表现力提示词的 TTS,不含克隆,也不等同于方言正式验收。设备语音降级按方言选择语言:粤语使用 `zh-HK`,四川话无法由设备语音可靠模拟时明确提示用户转用服务端语音或文字,不伪装成普通话。模型管理需启用 `category=asr/tts` 配置;移动端优先用浏览器录音,`getUserMedia/MediaRecorder` 不可用或麦克风权限失败时,用 `audio/*` file input 选择/录制音频后继续调同一 ASR 接口;ASR/TTS 未配置或失败返回 fail,前端降级设备语音或文本;训练/每日题录音只通过 `audioOssId` 走受保护下载,历史客户端传入的 HTTP `audioUrl` 不再回显;TTS 成功音频仍写入 `sys_oss` 留痕,同时用内联 data URL 保持旧客户端可播放;音频下载按员工本人或主管项目范围授权,系统管理端保持后台访问 |
| 对练语音 | `POST /api/ai/asr`(multipart 字段 `file`,≤5MB)、`POST /api/ai/tts`(JSON `{text≤300字, voice?, voiceProfile?:{role,voice?,speed?,emotion?,dialect?}, practiceContext?:{sessionId,turnIndex,role:'customer'}}`,`dialect` 仅接受 `mandarin/cantonese/sichuanese`,非法值返回业务码 `400`;旧 `voice` 兼容;成功返回 `ossId`,客户端播放地址为受控的内联 `data:audio/*`,不返回原始 OSS URL)、`GET /api/aihr/mobile/oss/{ossId}` | 走 OpenAI-compatible audio 接口;生产 SiliconFlow CosyVoice2 按角色映射老师傅/业主/面试官音色,业主对练再按已有情绪分切换平静/严肃/强烈语气和语速;dialect 作为语气提示传给支持表现力提示词的 TTS,不含克隆,也不等同于方言正式验收。`practiceContext` 仅允许已认证 APP 员工把本次服务端生成的业主 TTS 绑定到本人当前或刚完成会话的指定原话术回合;服务端重新校验租户、员工身份、回合和文本,不接受员工端传 OSS ID 或任意角色。设备语音降级按方言选择语言:粤语使用 `zh-HK`,四川话无法由设备语音可靠模拟时明确提示用户转用服务端语音或文字,不伪装成普通话。模型管理需启用 `category=asr/tts` 配置;移动端优先用浏览器录音,`getUserMedia/MediaRecorder` 不可用或麦克风权限失败时,用 `audio/*` file input 选择/录制音频后继续调同一 ASR 接口;ASR/TTS 未配置或失败返回 fail,前端降级设备语音或文本;训练/每日题录音只通过 `audioOssId` 走受保护下载,历史客户端传入的 HTTP `audioUrl` 不再回显;TTS 成功音频仍写入 `sys_oss` 留痕,同时用内联 data URL 保持旧客户端可播放;音频下载按员工本人或主管项目范围授权,系统管理端保持后台访问 |
| 案例沉淀 `/knowledge/cases` | `GET /api/knowledge/case/capabilities`、`POST /api/knowledge/case/upload`、`/organize`、`/curate`、`GET /records`、`GET /records/{caseId}`、`POST /records/{caseId}/review`、`GET /records/{caseId}/media` | `/capabilities` 返回服务端判定的案例提交/查看能力,移动端不再向普通员工展示无权提交的素材表单;`/upload` 改为 multipart 真实语音上传并走 ASR,服务端只接受 MP3/WAV/M4A/WebM/OGG/AAC/FLAC,成功后原始音频写入 `sys_oss`,案例记录只保存 `mediaOssId` 供受保护媒体接口读取,不向客户端回传原始 `mediaUrl`;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化,并从背景外的真实摘要项提取学习点;APP 用户的项目范围从 `aihr_org_snapshot` 登录身份解析,上传、整理、入库、列表和详情均按项目范围校验,未完成正式组织映射时安全拒绝,不接受前端伪造项目范围;员工列表/详情只返回 `已入库` 案例,管理端系统用户保留全局运营视图;移动端和管理端案例详情通过受保护媒体接口回放原始音频,主管/项目负责人可提交脱敏点评;预渲染视频样片仍待正式媒体资产接入 |
| 案例媒体安全 | `GET /api/knowledge/case/records/{caseId}/media` | 案例详情只返回 `mediaOssId`,不返回原始 `sys_oss.url`;媒体下载会重复执行登录、后台角色或 APP 项目范围校验,再由服务端流式读取 OSS。管理端与 `mobile-uni` 通过鉴权 blob/temp 文件播放,关闭详情页时释放本地对象 URL |
| 问师傅与 SOP 知识库 `/pages/user/sop/index`、`/knowledge/sop` | `POST /api/knowledge/query`、`POST /api/knowledge/query-media`、`GET /api/knowledge/resources/{attachmentId}/content`、`POST /api/knowledge/answer-feedback`、`GET /api/knowledge/position-sop`、`GET /api/aihr/mobile/onboard/tasks`、`POST /api/aihr/mobile/onboard/tasks/{id}/complete`、`GET /api/aihr/mobile/qualification`、`POST /api/knowledge/doc/upload` | 文字/现场附件查询走统一知识空间授权和 MySQL Fulltext + Qdrant 混合召回;内部登录端可选传 `conversationId/contextVersion`,服务端保留 30 分钟不活跃过期、最近 6 轮脱敏截断上下文,用现有 chat 模型或保守规则输出 `QA/FILE/VIDEO/DATA_TOOL` 和 `rewrittenQuery`,旧版本冲突返回 `409`。员工从银城大喇叭详情追问时,内部文字查询还可只传 `broadcastMessageId`:服务端每轮按当前 APP 在职员工、当前租户和 `PUBLISHED` 状态重新取消息,响应仅回显 `{messageId,title,publishedAt}`,消息正文不写入客户端存储、会话 JSON 或查询审计;同一会话的消息来源不可改绑,撤回后续问即拒绝。该上下文只说明公司消息,不可据此捏造个人任务;上下文模式拒绝现场媒体和数据工具,外部无状态 API 不接受该参数。原文件/视频仅从当前命中且仍有授权的附件返回,内容接口每次重新计算租户、应用和主体权限;无资源时明确返回空列表,不生成假链接。旧客户端和外部 API 保持单轮无状态。搜索继续返回 `reviewId/promptVersion`,员工反馈保存评审批次;详细边界见 [专项设计](20260718/问师傅多轮会话与原始资料交付设计.md)。员工学习页按当前 APP 身份读取正式岗前/入职任务,资格证据无正式数据时明确返回 `NOT_CONFIGURED` |
+4
View File
@@ -31,6 +31,10 @@
训练预习/求助、知识缺口留痕、动态情绪与红线纠偏、回复风格重生成、答案反馈屏蔽和方言语气参数均在本地工作区实现并按本地模型配置验证。它们尚未进入本次生产包:线上虽已有既有的录音/标注表,但缺少本批 `aihr_practice_help_event` 表;不得将 M2 的本地测试或模型配置同步写成生产部署或正式试点验收。
### 2026-07-23 M3 本地实现边界
对练的员工录音 OSS 关联、业主 TTS 的受控会话/回合绑定、主管复盘详情的受保护回放及 finish 后一次性逐句标注均已在本地实现并完成浏览器闭环。该验收使用本机项目经理与员工测试身份,只确认同项目权限范围、OSS 引用和页面交互;尚未发布生产,也未在真机麦克风、弱网或正式试点账号下验收。
### 2026-07-20 已部署增量与验收边界
工作助手“记一下”统一采集已提交并部署:生产 `ruoyi-aihr` 包包含 `AihrMemoryService`,H5 包含确认卡资源,生产库存在 `aihr_memory_candidate/aihr_assistant_capture` 及关键确认、范围和流转字段。会话式工作上报也已出现在生产 H5 与后端包中,生产 `aihr_work_report` 已有幂等字段。以上属于生产基础存在性复核;本轮未重新执行正式账号端到端、多项目、真机媒体或真实审核样本验收。
+25 -9
View File
@@ -387,6 +387,7 @@ import type {
PracticePrepCard,
PracticeAssignment,
PracticeRole,
PracticeTtsContext,
SpeechVoiceProfile,
SpeechPlaybackStatus,
PracticeTurn,
@@ -573,9 +574,12 @@ interface TurnVoice {
unread: boolean;
open: boolean;
profile: SpeechVoiceProfile;
practiceContext?: PracticeTtsContext;
requestKey?: string;
}
const turnVoices = ref<Record<number, TurnVoice>>({});
let turnVoiceGeneration = 0;
const turnVoice = (index: number): TurnVoice =>
turnVoices.value[index] || {
@@ -592,18 +596,26 @@ const patchTurnVoice = (index: number, patch: Partial<TurnVoice>) => {
/** Customer lines arrive as WeChat-style voice messages: pre-synthesize so the
* bubble shows a duration and an unread dot; fall back to text when TTS is off. */
const prepareTurnVoice = async (index: number, text: string, emotion = 0) => {
const prepareTurnVoice = async (index: number, text: string, emotion = 0, customerTurnIndex?: number) => {
const spoken = (text || '').trim();
if (!spoken) return;
const generation = turnVoiceGeneration;
const profile = customerVoiceProfile(emotion);
patchTurnVoice(index, { status: 'preparing', duration: null, unread: true, open: false, profile });
const source = await speechPlayback.preload(spoken, profile);
if (!turnVoices.value[index]) return;
const practiceContext: PracticeTtsContext | undefined = sessionId.value && Number.isInteger(customerTurnIndex)
? { sessionId: sessionId.value, turnIndex: Number(customerTurnIndex), role: 'customer' }
: undefined;
const requestKey = `${generation}:${index}:${spoken}:${practiceContext?.sessionId || ''}:${practiceContext?.turnIndex ?? ''}`;
const isCurrentVoiceRequest = () =>
turnVoiceGeneration === generation && turnVoices.value[index]?.requestKey === requestKey;
patchTurnVoice(index, { status: 'preparing', duration: null, unread: true, open: false, profile, practiceContext, requestKey });
const source = await speechPlayback.preload(spoken, profile, practiceContext);
if (!isCurrentVoiceRequest()) return;
if (!source) {
patchTurnVoice(index, { status: 'unavailable', unread: false });
return;
}
const duration = await measureAudioDuration(source);
if (!isCurrentVoiceRequest()) return;
patchTurnVoice(index, { status: 'ready', duration });
};
@@ -623,7 +635,7 @@ const playTurnVoice = (index: number, text: string) => {
return;
}
patchTurnVoice(index, { unread: false });
void speechPlayback.toggle(`turn-${index}`, (text || '').trim(), turnVoice(index).profile);
void speechPlayback.toggle(`turn-${index}`, (text || '').trim(), turnVoice(index).profile, turnVoice(index).practiceContext);
};
const toggleTurnTranscript = (index: number) => {
@@ -762,6 +774,7 @@ const refreshAuthState = () => {
};
const clearPracticeData = () => {
turnVoiceGeneration += 1;
stopRealtime();
speechPlayback.stop();
cancelRecording();
@@ -773,6 +786,7 @@ const clearPracticeData = () => {
prepCard.value = null;
prepLoading.value = false;
turns.value = [];
turnVoices.value = {};
result.value = null;
satisfactionScore.value = 0;
satisfactionComment.value = '';
@@ -812,11 +826,11 @@ const requireLogin = () => {
return false;
};
const appendTurn = (role: PracticeRole, text?: string, emotion = 0, synthesizeCustomerVoice = true) => {
const appendTurn = (role: PracticeRole, text?: string, emotion = 0, synthesizeCustomerVoice = true, customerTurnIndex?: number) => {
if (!text) return;
turns.value.push({ role, text });
if (role === 'customer' && synthesizeCustomerVoice) {
void prepareTurnVoice(turns.value.length - 1, text, emotion);
void prepareTurnVoice(turns.value.length - 1, text, emotion, customerTurnIndex);
}
scrollThreadToBottom();
};
@@ -873,6 +887,7 @@ const startRealtime = async () => {
if (!canStartPractice.value || !requireLogin()) return;
speechPlayback.stop();
cancelRecording();
turnVoiceGeneration += 1;
turns.value = [];
turnVoices.value = {};
result.value = null;
@@ -1254,6 +1269,7 @@ const start = async (scenarioId = defaultScenarioId, assignmentId?: number) => {
satisfactionScore.value = 0;
satisfactionComment.value = '';
satisfactionSubmitted.value = false;
turnVoiceGeneration += 1;
turns.value = [];
turnVoices.value = {};
threadScrollTop.value = 0;
@@ -1276,7 +1292,7 @@ const start = async (scenarioId = defaultScenarioId, assignmentId?: number) => {
emotionScore.value = data.emotion ?? 0;
trustScore.value = data.trust ?? 0;
sessionId.value = data.sessionId;
appendTurn('customer', data.customerText, data.emotion);
appendTurn('customer', data.customerText, data.emotion, true, 0);
status.value = 'active';
} catch (error) {
if (!requests.isCurrent(request)) return;
@@ -1403,7 +1419,7 @@ const submit = async () => {
await finish(true);
return;
}
appendTurn('customer', data.customerText, data.emotion);
appendTurn('customer', data.customerText, data.emotion, true, data.roundIndex);
roundIndex.value = data.roundIndex;
status.value = 'active';
} catch (error) {
+11 -10
View File
@@ -1,4 +1,4 @@
import type { AsrResponse, SpeechPlaybackStatus, SpeechSelectedFile, SpeechVoiceProfile, TtsResponse } from '@/types/api';
import type { AsrResponse, PracticeTtsContext, SpeechPlaybackStatus, SpeechSelectedFile, SpeechVoiceProfile, TtsResponse } from '@/types/api';
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
import type { SpeechCapture } from './speech-capture';
@@ -103,11 +103,11 @@ export const transcribeSpeechCapture = (
? transcribeSpeechBlob(capture.blob, filename, registerAbort)
: transcribeSpeechFile(capture.file, registerAbort);
export const synthesizeSpeech = (text: string, voiceProfile?: SpeechVoiceProfile) =>
export const synthesizeSpeech = (text: string, voiceProfile?: SpeechVoiceProfile, practiceContext?: PracticeTtsContext) =>
apiRequest<TtsResponse>({
url: '/api/ai/tts',
method: 'POST',
data: { text: text.trim().slice(0, 300), voiceProfile },
data: { text: text.trim().slice(0, 300), voiceProfile, practiceContext },
timeout: 60000
});
@@ -133,7 +133,8 @@ export const createSpeechPlaybackController = (
let generation = 0;
const sourceCache = new Map<string, string>();
const maxCachedSources = 4;
const cacheKey = (text: string, profile?: SpeechVoiceProfile) => JSON.stringify([text, profile || null]);
const cacheKey = (text: string, profile?: SpeechVoiceProfile, practiceContext?: PracticeTtsContext) =>
JSON.stringify([text, profile || null, practiceContext || null]);
const publish = (key = '', status: SpeechPlaybackStatus = 'idle') => {
activeKey = key;
@@ -212,7 +213,7 @@ export const createSpeechPlaybackController = (
context.play();
};
const toggle = async (key: string, text: string, voiceProfile?: SpeechVoiceProfile) => {
const toggle = async (key: string, text: string, voiceProfile?: SpeechVoiceProfile, practiceContext?: PracticeTtsContext) => {
if (activeKey === key) {
stop();
return;
@@ -221,7 +222,7 @@ export const createSpeechPlaybackController = (
const value = text.trim();
if (!value) return;
const profile = voiceProfile || defaultVoiceProfile;
const sourceKey = cacheKey(value, profile);
const sourceKey = cacheKey(value, profile, practiceContext);
const currentGeneration = generation;
publish(key, 'loading');
try {
@@ -232,7 +233,7 @@ export const createSpeechPlaybackController = (
playAudioSource(key, cachedSource, sourceKey, currentGeneration);
return;
}
const result = await synthesizeSpeech(value, profile);
const result = await synthesizeSpeech(value, profile, practiceContext);
if (currentGeneration !== generation || activeKey !== key) return;
const source = result.inlineAudioUrl || result.audioUrl;
if (!source) throw new Error('语音合成未返回音频');
@@ -257,15 +258,15 @@ export const createSpeechPlaybackController = (
* WeChat-style voice bubble can show duration before the first tap.
* Returns the audio source URL, or null when remote TTS is unavailable.
*/
const preload = async (text: string, voiceProfile?: SpeechVoiceProfile): Promise<string | null> => {
const preload = async (text: string, voiceProfile?: SpeechVoiceProfile, practiceContext?: PracticeTtsContext): Promise<string | null> => {
const value = text.trim();
if (!value) return null;
const profile = voiceProfile || defaultVoiceProfile;
const sourceKey = cacheKey(value, profile);
const sourceKey = cacheKey(value, profile, practiceContext);
const cached = sourceCache.get(sourceKey);
if (cached) return cached;
try {
const result = await synthesizeSpeech(value, profile);
const result = await synthesizeSpeech(value, profile, practiceContext);
const source = result.inlineAudioUrl || result.audioUrl;
if (!source) return null;
sourceCache.set(sourceKey, source);
+7
View File
@@ -31,6 +31,13 @@ export interface TtsResponse {
ossId?: number | string;
}
/** 可选的服务端训练回合上下文;仅用于关联本次新生成的业主 TTS 录音。 */
export interface PracticeTtsContext {
sessionId: string;
turnIndex: number;
role: 'customer';
}
export type SpeechPlaybackStatus = 'idle' | 'loading' | 'playing';
export interface SpeechVoiceProfile {
+21 -1
View File
@@ -68,6 +68,26 @@ test('语音 profile 透传方言枚举,并保持普通话默认值', async ()
assert.match(speechService, /dialect === 'sichuanese'[\s\S]*?当前设备语音不支持四川话/);
});
test('M3 将业主 TTS 绑定到当前训练会话与准确回合,供主管复盘回放', async () => {
const [practicePage, speechService, apiTypes] = await Promise.all([
pageSource('../src/pages/user/practice/index.vue'),
pageSource('../src/services/speech.ts'),
pageSource('../src/types/api.ts')
]);
assert.match(apiTypes, /export interface PracticeTtsContext/);
assert.match(speechService, /practiceContext\?: PracticeTtsContext/);
assert.match(speechService, /data: \{ text: text\.trim\(\)\.slice\(0, 300\), voiceProfile, practiceContext \}/);
assert.match(practicePage, /\{ sessionId: sessionId\.value, turnIndex: Number\(customerTurnIndex\), role: 'customer' \}/);
assert.match(practicePage, /speechPlayback\.preload\(spoken, profile, practiceContext\)/);
assert.match(practicePage, /turnVoice\(index\)\.profile, turnVoice\(index\)\.practiceContext/);
assert.match(practicePage, /let turnVoiceGeneration = 0/);
assert.match(practicePage, /turnVoiceGeneration === generation && turnVoices\.value\[index\]\?\.requestKey === requestKey/);
assert.match(practicePage, /turnVoiceGeneration \+= 1/);
assert.match(practicePage, /appendTurn\('customer', data\.customerText, data\.emotion, true, 0\)/);
assert.match(practicePage, /appendTurn\('customer', data\.customerText, data\.emotion, true, data\.roundIndex\)/);
});
test('训练结果回到顶部,SOP 答案生成后定位到回答区', async () => {
const [practiceSource, sopSource, navigationSource] = await Promise.all([
pageSource('../src/pages/user/practice/index.vue'),
@@ -560,7 +580,7 @@ test('播报复用已合成音频且总结图按内容高度生成并及时释
assert.match(speechService, /const sourceCache = new Map<string, string>\(\)/);
assert.match(speechService, /const maxCachedSources = 4/);
assert.match(speechService, /const sourceKey = cacheKey\(value, profile\)/);
assert.match(speechService, /const sourceKey = cacheKey\(value, profile, practiceContext\)/);
assert.match(speechService, /const cachedSource = sourceCache\.get\(sourceKey\)/);
assert.match(speechService, /sourceCache\.clear\(\)/);
assert.match(speechService, /registerAbort\?\.\(\(\) => task\.abort\(\)\)/);