feat(aihr): harden M0-M2 mobile training flows
This commit is contained in:
+11
@@ -11,6 +11,8 @@ import org.dromara.aihr.domain.AihrPracticeDto.CalibrationRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.CalibrationResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.FinishRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.FinishResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.HelpEventResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.HelpRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.PrepCardResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.RealtimeSdpRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.RealtimeSdpResponse;
|
||||
@@ -90,6 +92,15 @@ public class AihrPracticeController {
|
||||
return R.ok(practiceSeedService.prepCard(id));
|
||||
}
|
||||
|
||||
@PostMapping("/sessions/{sessionId}/help")
|
||||
public R<HelpEventResponse> recordHelp(@PathVariable String sessionId, @RequestBody(required = false) HelpRequest request) {
|
||||
String username = currentAppUsername();
|
||||
if (username.isBlank()) {
|
||||
return R.fail("请使用员工端账号发起求助");
|
||||
}
|
||||
return R.ok(practiceSeedService.recordHelp(sessionId, request == null ? null : request.roundIndex(), username));
|
||||
}
|
||||
|
||||
@GetMapping("/rubrics")
|
||||
public R<List<RubricResponse>> rubrics(@RequestParam(required = false) String scenarioId) {
|
||||
return R.ok(practiceSeedService.rubrics(scenarioId));
|
||||
|
||||
+6
@@ -9,6 +9,7 @@ import org.dromara.aihr.domain.AihrSpeechDto.TtsRequest;
|
||||
import org.dromara.aihr.domain.AihrSpeechDto.TtsResponse;
|
||||
import org.dromara.aihr.service.AihrMultipartFiles;
|
||||
import org.dromara.aihr.service.AihrSpeechService;
|
||||
import org.dromara.common.core.constant.HttpStatus;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.dromara.common.core.domain.model.LoginUser;
|
||||
import org.dromara.common.core.enums.UserType;
|
||||
@@ -29,6 +30,7 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 对练语音 API:ASR 语音输入 + TTS 客户语音播报。未配置语音模型时返回失败,前端降级为文本。
|
||||
@@ -115,6 +117,10 @@ public class AihrSpeechController {
|
||||
|
||||
@PostMapping("/tts")
|
||||
public R<TtsResponse> tts(@RequestBody TtsRequest request) {
|
||||
Optional<String> invalidDialect = AihrSpeechService.invalidDialectMessage(request == null ? null : request.voiceProfile());
|
||||
if (invalidDialect.isPresent()) {
|
||||
return R.fail(HttpStatus.BAD_REQUEST, invalidDialect.get());
|
||||
}
|
||||
if (!speechService.ttsConfigured()) {
|
||||
return R.fail(NOT_CONFIGURED_HINT);
|
||||
}
|
||||
|
||||
+6
@@ -107,6 +107,12 @@ public final class AihrPracticeDto {
|
||||
public record PrepCardResponse(List<String> keyPoints, List<String> redLines, List<String> scripts, String promptVersion) {
|
||||
}
|
||||
|
||||
public record HelpRequest(Integer roundIndex) {
|
||||
}
|
||||
|
||||
public record HelpEventResponse(String sessionId, String scenarioId, Integer roundIndex, String recordedAt) {
|
||||
}
|
||||
|
||||
public record RealtimeSdpRequest(String offerSdp) {
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ public final class AihrSpeechDto {
|
||||
public record TtsRequest(String text, String voice, VoiceProfile voiceProfile) {
|
||||
}
|
||||
|
||||
public record VoiceProfile(String role, String voice, Double speed, String emotion) {
|
||||
public record VoiceProfile(String role, String voice, Double speed, String emotion, String dialect) {
|
||||
}
|
||||
|
||||
public record TtsResponse(String audioUrl, String source, Long ossId, String inlineAudioUrl) {
|
||||
|
||||
+54
@@ -7,6 +7,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.DialogueResponse;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -23,6 +24,7 @@ public class AihrPracticeLlmService {
|
||||
private static final double CUSTOMER_TEMPERATURE = 0.7;
|
||||
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 int MAX_CUSTOMER_CHARS = 80;
|
||||
|
||||
private final AihrModelSeedService modelService;
|
||||
@@ -57,6 +59,24 @@ public class AihrPracticeLlmService {
|
||||
public record DailyDrillAssessment(int score, String feedback) {
|
||||
}
|
||||
|
||||
public record PrepCard(List<String> keyPoints, List<String> redLines, List<String> scripts) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 训练前卡严格解析固定三栏 JSON。模板由调用方从租户提示词配置加载,模型不可用时返回 empty,
|
||||
* 由场景字段回退,不让训练入口因为模型调用失败不可用。
|
||||
*/
|
||||
public Optional<PrepCard> prepCard(String systemPrompt, String userPrompt) {
|
||||
if (systemPrompt == null || systemPrompt.isBlank() || userPrompt == null || userPrompt.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return modelService.tryChat(
|
||||
AihrSensitiveText.forModel(systemPrompt),
|
||||
AihrSensitiveText.forModel(userPrompt),
|
||||
PREP_CARD_TEMPERATURE)
|
||||
.flatMap(this::parsePrepCard);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 AI 客户(业主)的下一句回应。nextBeat 为 seed 剧本中下一轮台词,作为剧情推进锚点。
|
||||
*/
|
||||
@@ -250,6 +270,40 @@ public class AihrPracticeLlmService {
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<PrepCard> parsePrepCard(String content) {
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(extractJson(content));
|
||||
List<String> keyPoints = jsonStrings(root.path("keyPoints"), 3, 120);
|
||||
List<String> redLines = jsonStrings(root.path("redLines"), 3, 120);
|
||||
List<String> scripts = jsonStrings(root.path("scripts"), 2, 160);
|
||||
if (keyPoints.size() != 3 || redLines.size() != 3 || scripts.size() != 2) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(new PrepCard(keyPoints, redLines, scripts));
|
||||
} catch (Exception e) {
|
||||
log.warn("practice prep card parse failed, falls back to scenario(处理错误已隐藏)");
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> jsonStrings(JsonNode node, int expectedItems, int maxChars) {
|
||||
if (node == null || !node.isArray() || node.size() != expectedItems) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> values = new ArrayList<>(expectedItems);
|
||||
for (JsonNode item : node) {
|
||||
if (!item.isTextual()) {
|
||||
return List.of();
|
||||
}
|
||||
String value = truncateText(item.asText(), maxChars).trim();
|
||||
if (value.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
values.add(value);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/** 维度字段缺失时回落到 total,避免 asInt() 把缺失当 0 分落库。 */
|
||||
private static int dimension(JsonNode root, String field, int fallback) {
|
||||
JsonNode node = root.path(field);
|
||||
|
||||
+139
-10
@@ -17,6 +17,7 @@ import org.dromara.aihr.domain.AihrPracticeDto.DimensionResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.FinishRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.FinishResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.GrowthStageResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.HelpEventResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.PersonaResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.PrepCardResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAlertResponse;
|
||||
@@ -90,6 +91,7 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
@@ -109,6 +111,20 @@ public class AihrPracticeSeedService {
|
||||
"detailed", "内容更细致,拆成更具体的动作、信息点和反馈节点。",
|
||||
"professional", "表达更专业,突出SOP依据、责任边界、留痕和闭环口径。"
|
||||
);
|
||||
private static final String PREP_CARD_SYSTEM_PROMPT = """
|
||||
你是物业培训教练。只返回 JSON,不要 markdown。字段固定为 keyPoints、redLines、scripts。
|
||||
keyPoints 和 redLines 必须各 3 条,scripts 必须 2 条;只能依据输入场景和 SOP 信息,不得编造时限、政策或承诺。
|
||||
""";
|
||||
private static final String PREP_CARD_USER_TEMPLATE = """
|
||||
场景:{{scenarioName}}。
|
||||
显性诉求:{{explicitNeed}}。
|
||||
SOP引用:{{sopRefs}}。
|
||||
成功条件:{{successCriteria}}。
|
||||
易激化点:{{irritants}}。
|
||||
可强化表达:{{delights}}。
|
||||
请生成训练前预习卡。
|
||||
""";
|
||||
private static final String PREP_CARD_PROMPT_VERSION = "builtin-prep_card-v1";
|
||||
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("MM-dd HH:mm");
|
||||
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
|
||||
// 阶段一只统计“员工收到业主话术后到提交下一轮”的陪练响应时长;正式首响/工单处理时效仍需业务系统数据。
|
||||
@@ -422,7 +438,7 @@ public class AihrPracticeSeedService {
|
||||
public PrepCardResponse prepCard(String scenarioId) {
|
||||
ensureScenarioEnabledForStart(scenarioId);
|
||||
ScenarioResponse scenario = scenario(scenarioId);
|
||||
return new PrepCardResponse(
|
||||
PrepCardResponse fallback = new PrepCardResponse(
|
||||
List.of(
|
||||
firstNonBlank(scenario.explicitNeed(), "先确认业主当前诉求"),
|
||||
firstNonBlank(scenario.successCriteria(), "给出责任人、处理节点和反馈方式"),
|
||||
@@ -433,8 +449,33 @@ public class AihrPracticeSeedService {
|
||||
"我先确认您的诉求和现场情况,再给您明确的责任人和首次反馈时间。",
|
||||
firstNonBlank(scenario.delights(), "这个问题我会跟进到闭环,过程节点会同步给您。")
|
||||
),
|
||||
"builtin-prep_card-v1"
|
||||
PREP_CARD_PROMPT_VERSION
|
||||
);
|
||||
PrepCardPrompt prompt = prepCardPrompt();
|
||||
if (practiceLlmService == null) {
|
||||
return fallback;
|
||||
}
|
||||
return practiceLlmService.prepCard(prompt.systemPrompt(), renderPrepCardPrompt(prompt.template(), scenario))
|
||||
.map(card -> new PrepCardResponse(card.keyPoints(), card.redLines(), card.scripts(), prompt.promptVersion()))
|
||||
.orElseGet(() -> new PrepCardResponse(
|
||||
fallback.keyPoints(), fallback.redLines(), fallback.scripts(), prompt.promptVersion() + ":fallback"
|
||||
));
|
||||
}
|
||||
|
||||
/** 记录员工主动点开的训练求助,和知识检索日志分开保存,便于后续复盘和知识补齐。 */
|
||||
public HelpEventResponse recordHelp(String sessionId, Integer ignoredRequestedRoundIndex, String ownerIdentity) {
|
||||
ActiveSession session = isBlank(sessionId) ? null : activeSessions.get(sessionId);
|
||||
requireSessionOwner(session, ownerIdentity, tenantId());
|
||||
ScenarioSeed scenario = resolveScenario(session.scenarioId(), sessionId);
|
||||
int roundIndex = normalizeRoundIndex(session.currentRoundIndex().get(), scenario.rounds().size());
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO aihr_practice_help_event
|
||||
(tenant_id, session_id, scenario_id, ext_party_id, round_index, create_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
session.tenantId(), sessionId, scenario.id(), session.extPartyId(), roundIndex, Timestamp.valueOf(now));
|
||||
return new HelpEventResponse(sessionId, scenario.id(), roundIndex, now.format(TIME_FORMATTER));
|
||||
}
|
||||
|
||||
public List<RubricResponse> rubrics(String scenarioId) {
|
||||
@@ -540,7 +581,12 @@ public class AihrPracticeSeedService {
|
||||
List<String> customerLines = new ArrayList<>();
|
||||
customerLines.add(firstRound.customer());
|
||||
long promptPresentedAt = System.currentTimeMillis();
|
||||
activeSessions.put(sessionId, new ActiveSession(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 ArrayList<>(), new ArrayList<>(), new ArrayList<>(), customerLines, new ArrayList<>(), new ArrayList<>(List.of(promptPresentedAt)), new ArrayList<>()));
|
||||
activeSessions.put(sessionId, new ActiveSession(
|
||||
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<>()
|
||||
));
|
||||
return new StartResponse(
|
||||
sessionId,
|
||||
scenario.id(),
|
||||
@@ -566,14 +612,24 @@ public class AihrPracticeSeedService {
|
||||
String style = normalizeRegenerateStyle(request == null ? null : request.style());
|
||||
ActiveSession session = request == null || isBlank(request.sessionId()) ? null : activeSessions.get(request.sessionId());
|
||||
if (!isBlank(ownerIdentity)) {
|
||||
requireSessionOwner(session, ownerIdentity);
|
||||
requireSessionOwner(session, ownerIdentity, tenantId());
|
||||
}
|
||||
validateAudioOwnership(request == null ? null : request.traineeAudioOssId(), ownerIdentity, ownerUserId);
|
||||
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 (session == null) {
|
||||
return handleTurn(request, null, scenario, style);
|
||||
}
|
||||
synchronized (session) {
|
||||
return handleTurn(request, session, scenario, style);
|
||||
}
|
||||
}
|
||||
|
||||
private TurnResponse handleTurn(TurnRequest request, ActiveSession session, ScenarioSeed scenario, String style) {
|
||||
if (request != null && Boolean.TRUE.equals(request.regenerate())) {
|
||||
int roundIndex = regenerateRoundIndex(session, request.roundIndex(), scenario.rounds().size());
|
||||
return regenerateCoachHint(request, session, scenario, roundIndex, style);
|
||||
}
|
||||
int roundIndex = authoritativeRoundIndex(session, request == null ? null : request.roundIndex(), scenario.rounds().size());
|
||||
rememberResponseLatency(session, roundIndex, System.currentTimeMillis());
|
||||
rememberTraineeReply(request, roundIndex);
|
||||
RoundSeed currentRound = scenario.rounds().get(roundIndex);
|
||||
@@ -587,6 +643,9 @@ public class AihrPracticeSeedService {
|
||||
String coachHint = redFlag ? redFlagCoachHint(resolved.coachHint()) : resolved.coachHint();
|
||||
rememberTurnEvidence(session, roundIndex, resolved.emotion(), resolved.trust(), redFlag, coachHint);
|
||||
if (!finished) {
|
||||
if (!redFlag && session != null) {
|
||||
session.currentRoundIndex().set(nextRoundIndex);
|
||||
}
|
||||
rememberPromptPresentedAt(session, nextRoundIndex, System.currentTimeMillis());
|
||||
}
|
||||
return new TurnResponse(
|
||||
@@ -602,6 +661,35 @@ public class AihrPracticeSeedService {
|
||||
);
|
||||
}
|
||||
|
||||
private int authoritativeRoundIndex(ActiveSession session, Integer requestedRoundIndex, int roundCount) {
|
||||
if (session == null) {
|
||||
return normalizeRoundIndex(requestedRoundIndex, roundCount);
|
||||
}
|
||||
int activeRoundIndex = normalizeRoundIndex(session.currentRoundIndex().get(), roundCount);
|
||||
if (requestedRoundIndex != null && (requestedRoundIndex < 0
|
||||
|| requestedRoundIndex >= roundCount
|
||||
|| requestedRoundIndex != activeRoundIndex)) {
|
||||
throw new ServiceException("训练回合已变化,请查看最新对话后再继续");
|
||||
}
|
||||
return activeRoundIndex;
|
||||
}
|
||||
|
||||
private int regenerateRoundIndex(ActiveSession session, Integer requestedRoundIndex, int roundCount) {
|
||||
if (session == null) {
|
||||
return normalizeRoundIndex(requestedRoundIndex, roundCount);
|
||||
}
|
||||
int roundIndex = requestedRoundIndex == null
|
||||
? normalizeRoundIndex(session.currentRoundIndex().get(), roundCount)
|
||||
: requestedRoundIndex;
|
||||
if (roundIndex < 0 || roundIndex >= roundCount) {
|
||||
throw new ServiceException("训练回合已变化,请查看最新对话后再继续");
|
||||
}
|
||||
if (turnEvidence(session, roundIndex) == null) {
|
||||
throw new ServiceException("请先完成该回合后再调整教练建议");
|
||||
}
|
||||
return roundIndex;
|
||||
}
|
||||
|
||||
private TurnResponse regenerateCoachHint(TurnRequest request, ActiveSession session, ScenarioSeed scenario, int roundIndex, String style) {
|
||||
if (isBlank(style)) {
|
||||
throw new ServiceException("请选择有效的调整风格");
|
||||
@@ -717,7 +805,7 @@ public class AihrPracticeSeedService {
|
||||
activeSession = sessionId == null ? null : activeSessions.remove(sessionId);
|
||||
} else {
|
||||
ActiveSession existing = sessionId == null ? null : activeSessions.get(sessionId);
|
||||
requireSessionOwner(existing, ownerIdentity);
|
||||
requireSessionOwner(existing, ownerIdentity, tenantId());
|
||||
activeSession = activeSessions.remove(sessionId);
|
||||
}
|
||||
ScenarioSeed scenario = resolveScenario(activeSession == null ? null : activeSession.scenarioId(), sessionId);
|
||||
@@ -3343,10 +3431,12 @@ public class AihrPracticeSeedService {
|
||||
return request.extPartyId().trim();
|
||||
}
|
||||
|
||||
private static void requireSessionOwner(ActiveSession session, String ownerIdentity) {
|
||||
private static void requireSessionOwner(ActiveSession session, String ownerIdentity, String requestTenantId) {
|
||||
String owner = ownerIdentity == null ? "" : ownerIdentity.trim();
|
||||
String sessionOwner = session == null || session.extPartyId() == null ? "" : session.extPartyId().trim();
|
||||
if (session == null || !owner.equals(sessionOwner)) {
|
||||
String tenant = requestTenantId == null ? "" : requestTenantId.trim();
|
||||
String sessionTenant = session == null || session.tenantId() == null ? "" : session.tenantId().trim();
|
||||
if (session == null || !owner.equals(sessionOwner) || !tenant.equals(sessionTenant)) {
|
||||
throw new ServiceException("训练会话不存在或无权访问");
|
||||
}
|
||||
}
|
||||
@@ -4039,6 +4129,41 @@ public class AihrPracticeSeedService {
|
||||
""", this::mapPromptTemplate, tenantId(), id);
|
||||
}
|
||||
|
||||
private PrepCardPrompt prepCardPrompt() {
|
||||
if (jdbcTemplate == null) {
|
||||
return new PrepCardPrompt(PREP_CARD_SYSTEM_PROMPT, PREP_CARD_USER_TEMPLATE, PREP_CARD_PROMPT_VERSION);
|
||||
}
|
||||
try {
|
||||
List<PrepCardPrompt> rows = jdbcTemplate.query("""
|
||||
SELECT system_prompt, template, version
|
||||
FROM aihr_prompt_template
|
||||
WHERE tenant_id = ? AND code = 'prep_card' AND output_format = 'json_prep' AND enabled = 1
|
||||
ORDER BY version DESC, id DESC
|
||||
LIMIT 1
|
||||
""", (rs, rowNum) -> new PrepCardPrompt(
|
||||
firstNonBlank(rs.getString("system_prompt"), PREP_CARD_SYSTEM_PROMPT),
|
||||
firstNonBlank(rs.getString("template"), PREP_CARD_USER_TEMPLATE),
|
||||
"prep_card:" + firstNonBlank(rs.getString("version"), "v1")
|
||||
), tenantId());
|
||||
if (!rows.isEmpty()) {
|
||||
return rows.get(0);
|
||||
}
|
||||
} catch (DataAccessException ignored) {
|
||||
// Prompt table is optional in the local seed fallback.
|
||||
}
|
||||
return new PrepCardPrompt(PREP_CARD_SYSTEM_PROMPT, PREP_CARD_USER_TEMPLATE, PREP_CARD_PROMPT_VERSION);
|
||||
}
|
||||
|
||||
private String renderPrepCardPrompt(String template, ScenarioResponse scenario) {
|
||||
return firstNonBlank(template, PREP_CARD_USER_TEMPLATE)
|
||||
.replace("{{scenarioName}}", firstNonBlank(scenario.name(), ""))
|
||||
.replace("{{explicitNeed}}", firstNonBlank(scenario.explicitNeed(), ""))
|
||||
.replace("{{sopRefs}}", firstNonBlank(scenario.sopRefs(), ""))
|
||||
.replace("{{successCriteria}}", firstNonBlank(scenario.successCriteria(), ""))
|
||||
.replace("{{irritants}}", firstNonBlank(scenario.irritants(), ""))
|
||||
.replace("{{delights}}", firstNonBlank(scenario.delights(), ""));
|
||||
}
|
||||
|
||||
private CalibrationResponse latestCalibration(String sessionId) {
|
||||
List<CalibrationResponse> rows = jdbcTemplate.query("""
|
||||
SELECT id, session_id, original_score, corrected_score, reviewer, reason, status, create_time
|
||||
@@ -5484,6 +5609,9 @@ public class AihrPracticeSeedService {
|
||||
private record TurnEvidence(Integer emotion, Integer trust, Boolean redFlag, String coachHint) {
|
||||
}
|
||||
|
||||
private record PrepCardPrompt(String systemPrompt, String template, String promptVersion) {
|
||||
}
|
||||
|
||||
private record OrgScopeRow(String projectCode, String positionLevel) {
|
||||
}
|
||||
|
||||
@@ -5538,8 +5666,9 @@ public class AihrPracticeSeedService {
|
||||
}
|
||||
}
|
||||
|
||||
private record ActiveSession(String scenarioId, String trainee, String extPartyId, Long assignmentId, boolean mobile, LocalDateTime startedAt,
|
||||
List<String> traineeReplies, List<String> traineeAudioUrls, List<Long> traineeAudioOssIds,
|
||||
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<Long> promptPresentedAtMillis, List<Long> responseLatenciesMs) {
|
||||
}
|
||||
|
||||
+39
-2
@@ -47,6 +47,11 @@ public class AihrSpeechService {
|
||||
"intense", "请用情绪强烈、急切、有压力且明显不满的语气说",
|
||||
"calm", "请用平静、自然的语气说"
|
||||
);
|
||||
private static final Map<String, String> DIALECT_PROMPTS = Map.of(
|
||||
"mandarin", "请使用自然普通话表达",
|
||||
"cantonese", "请使用自然粤语口语表达",
|
||||
"sichuanese", "请使用自然四川话口语表达"
|
||||
);
|
||||
|
||||
private final AihrModelSeedService modelService;
|
||||
private final ObjectMapper objectMapper;
|
||||
@@ -59,6 +64,16 @@ public class AihrSpeechService {
|
||||
return modelService.speechModel("tts").isPresent();
|
||||
}
|
||||
|
||||
public static Optional<String> invalidDialectMessage(VoiceProfile voiceProfile) {
|
||||
String rawDialect = voiceProfile == null || voiceProfile.dialect() == null
|
||||
? ""
|
||||
: voiceProfile.dialect().trim();
|
||||
if (rawDialect.isEmpty() || DIALECT_PROMPTS.containsKey(rawDialect.toLowerCase(Locale.ROOT))) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of("dialect 仅支持 mandarin、cantonese、sichuanese");
|
||||
}
|
||||
|
||||
/**
|
||||
* 语音转写:POST {base}/audio/transcriptions(multipart)。
|
||||
*/
|
||||
@@ -132,9 +147,11 @@ public class AihrSpeechService {
|
||||
}
|
||||
|
||||
String emotion = normalizedEmotion(voiceProfile == null ? null : voiceProfile.emotion(), role);
|
||||
String dialect = normalizedDialect(voiceProfile == null ? null : voiceProfile.dialect());
|
||||
String sanitizedText = AihrSensitiveText.forModel(text.trim());
|
||||
String input = voiceProfile != null && expressiveCosyVoice && EMOTION_PROMPTS.containsKey(emotion)
|
||||
? EMOTION_PROMPTS.get(emotion) + "。<|endofprompt|>" + sanitizedText
|
||||
String expressivePrompt = expressivePrompt(emotion, dialect);
|
||||
String input = voiceProfile != null && expressiveCosyVoice && !expressivePrompt.isBlank()
|
||||
? expressivePrompt + "。<|endofprompt|>" + sanitizedText
|
||||
: sanitizedText;
|
||||
|
||||
ObjectNode body = objectMapper.createObjectNode();
|
||||
@@ -174,6 +191,26 @@ public class AihrSpeechService {
|
||||
};
|
||||
}
|
||||
|
||||
private static String normalizedDialect(String dialect) {
|
||||
String value = dialect == null ? "" : dialect.trim().toLowerCase(Locale.ROOT);
|
||||
if (value.isBlank() || DIALECT_PROMPTS.containsKey(value)) {
|
||||
return value;
|
||||
}
|
||||
throw new IllegalArgumentException("dialect 仅支持 mandarin、cantonese、sichuanese");
|
||||
}
|
||||
|
||||
private static String expressivePrompt(String emotion, String dialect) {
|
||||
String emotionPrompt = EMOTION_PROMPTS.getOrDefault(emotion, "");
|
||||
String dialectPrompt = DIALECT_PROMPTS.getOrDefault(dialect, "");
|
||||
if (emotionPrompt.isBlank()) {
|
||||
return dialectPrompt;
|
||||
}
|
||||
if (dialectPrompt.isBlank()) {
|
||||
return emotionPrompt;
|
||||
}
|
||||
return emotionPrompt + "," + dialectPrompt;
|
||||
}
|
||||
|
||||
private static double resolveSpeed(Double speed, String emotion) {
|
||||
if (speed != null && Double.isFinite(speed)) {
|
||||
return Math.max(0.7, Math.min(1.3, speed));
|
||||
|
||||
+48
@@ -43,4 +43,52 @@ class AihrPracticeLlmServiceTest {
|
||||
|
||||
assertTrue(service.scoreDailyDrill("题目", "参考答案", "员工回答").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void prepCardParsesOnlyTheFixedThreeColumnStructure() {
|
||||
AihrModelSeedService modelService = mock(AihrModelSeedService.class);
|
||||
when(modelService.tryChat(anyString(), anyString(), eq(0.0)))
|
||||
.thenReturn(Optional.of("""
|
||||
{"keyPoints":["先安抚","确认责任人","给首次反馈时间"],
|
||||
"redLines":["不推责","不绝对承诺","不与业主争辩"],
|
||||
"scripts":["我先确认现场情况","十分钟内给您明确节点"],
|
||||
"extra":"不应透出"}
|
||||
"""));
|
||||
AihrPracticeLlmService service = new AihrPracticeLlmService(modelService, new ObjectMapper());
|
||||
|
||||
var card = service.prepCard("只返回 JSON", "投诉处理场景");
|
||||
|
||||
assertTrue(card.isPresent());
|
||||
assertEquals(3, card.get().keyPoints().size());
|
||||
assertEquals(3, card.get().redLines().size());
|
||||
assertEquals(2, card.get().scripts().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void prepCardRejectsPartialColumnsSoTheCallerCanUseScenarioFallback() {
|
||||
AihrModelSeedService modelService = mock(AihrModelSeedService.class);
|
||||
when(modelService.tryChat(anyString(), anyString(), eq(0.0)))
|
||||
.thenReturn(Optional.of("""
|
||||
{"keyPoints":["先安抚","确认责任人"],
|
||||
"redLines":["不推责","不绝对承诺","不争辩"],
|
||||
"scripts":["我先确认现场情况","十分钟内给您明确节点"]}
|
||||
"""));
|
||||
AihrPracticeLlmService service = new AihrPracticeLlmService(modelService, new ObjectMapper());
|
||||
|
||||
assertTrue(service.prepCard("只返回 JSON", "投诉处理场景").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void prepCardRejectsExtraItemsInsteadOfSilentlyTruncatingThem() {
|
||||
AihrModelSeedService modelService = mock(AihrModelSeedService.class);
|
||||
when(modelService.tryChat(anyString(), anyString(), eq(0.0)))
|
||||
.thenReturn(Optional.of("""
|
||||
{"keyPoints":["先安抚","确认责任人","给首次反馈时间","多余项"],
|
||||
"redLines":["不推责","不绝对承诺","不争辩"],
|
||||
"scripts":["我先确认现场情况","十分钟内给您明确节点"]}
|
||||
"""));
|
||||
AihrPracticeLlmService service = new AihrPracticeLlmService(modelService, new ObjectMapper());
|
||||
|
||||
assertTrue(service.prepCard("只返回 JSON", "投诉处理场景").isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
+94
@@ -16,9 +16,12 @@ import org.dromara.aihr.domain.AihrPracticeDto.TeamMemberResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.TurnRequest;
|
||||
import org.dromara.common.core.constant.HttpStatus;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.dromara.common.tenant.helper.TenantHelper;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentMatchers;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.Mockito;
|
||||
import org.mockito.invocation.InvocationOnMock;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
@@ -43,6 +46,7 @@ import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
@@ -52,6 +56,7 @@ import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.contains;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
@@ -550,6 +555,95 @@ public class AihrPracticeSeedServiceTest {
|
||||
assertEquals("训练会话不存在或无权访问", missing.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void m2PrepCardUsesTheConfiguredTemplatePathAndHelpIsPersistedForTheSessionOwner() {
|
||||
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());
|
||||
when(jdbcTemplate.query(contains("FROM aihr_prompt_template"), any(RowMapper.class), eq("000000")))
|
||||
.thenReturn(List.of());
|
||||
AihrPracticeLlmService llm = mock(AihrPracticeLlmService.class);
|
||||
when(llm.prepCard(anyString(), anyString())).thenReturn(Optional.of(new AihrPracticeLlmService.PrepCard(
|
||||
List.of("先安抚", "确认事实", "给反馈时间"),
|
||||
List.of("不推责", "不绝对承诺", "不争辩"),
|
||||
List.of("我先确认情况", "十分钟内回复您")
|
||||
)));
|
||||
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, llm, null);
|
||||
|
||||
var prep = service.prepCard("complaint-water");
|
||||
var session = service.start(new StartRequest("employee-a", "complaint-water", "mobile", null));
|
||||
var help = service.recordHelp(session.sessionId(), 2, "employee-a");
|
||||
|
||||
assertEquals(List.of("先安抚", "确认事实", "给反馈时间"), prep.keyPoints());
|
||||
assertEquals("builtin-prep_card-v1", prep.promptVersion());
|
||||
assertEquals(session.sessionId(), help.sessionId());
|
||||
assertEquals("complaint-water", help.scenarioId());
|
||||
assertEquals(0, help.roundIndex());
|
||||
verify(jdbcTemplate).update(
|
||||
contains("INSERT INTO aihr_practice_help_event"),
|
||||
eq("000000"), eq(session.sessionId()), eq("complaint-water"), eq("employee-a"), eq(0), any(Timestamp.class)
|
||||
);
|
||||
assertThrows(ServiceException.class, () -> service.recordHelp(session.sessionId(), 0, "employee-b"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void m2HelpRejectsAValidOwnerWhenTheTenantContextChanged() {
|
||||
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
|
||||
when(jdbcTemplate.query(contains("SELECT enabled"), any(RowMapper.class), eq("tenant-a"), eq("complaint-water")))
|
||||
.thenReturn(List.of());
|
||||
when(jdbcTemplate.query(contains("SELECT scenario_code"), any(RowMapper.class), eq("tenant-a"), eq("complaint-water")))
|
||||
.thenReturn(List.of());
|
||||
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null);
|
||||
|
||||
try (MockedStatic<TenantHelper> tenantHelper = Mockito.mockStatic(TenantHelper.class, Mockito.CALLS_REAL_METHODS)) {
|
||||
tenantHelper.when(TenantHelper::getTenantId).thenReturn("tenant-a");
|
||||
var session = service.start(new StartRequest("employee-a", "complaint-water", "mobile", null));
|
||||
tenantHelper.when(TenantHelper::getTenantId).thenReturn("tenant-b");
|
||||
|
||||
ServiceException error = assertThrows(ServiceException.class,
|
||||
() -> service.recordHelp(session.sessionId(), 0, "employee-a"));
|
||||
assertEquals("训练会话不存在或无权访问", error.getMessage());
|
||||
}
|
||||
verify(jdbcTemplate, never()).update(contains("INSERT INTO aihr_practice_help_event"), ArgumentMatchers.<Object[]>any());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void m2TurnRejectsSkippedRoundsAndHelpUsesTheServerTrackedRound() {
|
||||
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());
|
||||
AihrPracticeLlmService llm = mock(AihrPracticeLlmService.class);
|
||||
when(llm.customerTurn(
|
||||
anyString(), anyString(), anyString(), ArgumentMatchers.anyList(), anyString(), anyString(),
|
||||
ArgumentMatchers.anyInt(), ArgumentMatchers.anyInt(), anyString()
|
||||
)).thenReturn(Optional.empty());
|
||||
when(llm.customerTurn(
|
||||
anyString(), anyString(), anyString(), ArgumentMatchers.anyList(), anyString(), anyString(),
|
||||
ArgumentMatchers.anyInt(), ArgumentMatchers.anyInt(), anyString(), anyString()
|
||||
)).thenReturn(Optional.empty());
|
||||
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, llm, null);
|
||||
var session = service.start(new StartRequest("employee-a", "complaint-water", "mobile", null));
|
||||
|
||||
ServiceException skipped = assertThrows(ServiceException.class,
|
||||
() -> service.turn(new TurnRequest(session.sessionId(), 2, "我先确认现场情况", null, null, false, null), "employee-a"));
|
||||
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");
|
||||
var adjustedFirstTurn = service.turn(new TurnRequest(
|
||||
session.sessionId(), 0, "我先确认现场情况", null, null, true, "serious"
|
||||
), "employee-a");
|
||||
var help = service.recordHelp(session.sessionId(), 2, "employee-a");
|
||||
|
||||
assertEquals(0, helpBeforeValidTurn.roundIndex());
|
||||
assertEquals(1, firstTurn.roundIndex());
|
||||
assertEquals(1, adjustedFirstTurn.roundIndex());
|
||||
assertEquals(1, help.roundIndex());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void practiceAudioMustMatchCurrentAppUploaderAndDailyAudioIsReadable() throws Exception {
|
||||
String service = Files.readString(Path.of("src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java"));
|
||||
|
||||
+21
-3
@@ -8,6 +8,7 @@ import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
@Tag("dev")
|
||||
@@ -25,21 +26,38 @@ class AihrSpeechServiceTest {
|
||||
|
||||
var mentor = AihrSpeechService.buildRequestBody(
|
||||
mapper, runtime, "先承接情绪,再说明处理节点。", null,
|
||||
new VoiceProfile("mentor", null, null, null));
|
||||
new VoiceProfile("mentor", null, null, null, "mandarin"));
|
||||
var customer = AihrSpeechService.buildRequestBody(
|
||||
mapper, runtime, "你们到底什么时候处理?", null,
|
||||
new VoiceProfile("customer", null, 9.0, "intense"));
|
||||
new VoiceProfile("customer", null, 9.0, "intense", "sichuanese"));
|
||||
var legacy = AihrSpeechService.buildRequestBody(
|
||||
mapper, runtime, "普通播报", "alex", null);
|
||||
|
||||
assertEquals("FunAudioLLM/CosyVoice2-0.5B:anna", mentor.path("voice").asText());
|
||||
assertEquals("speech:shifu-warm-v1:cm3hz4wfz02jy106j6z6muix7:ysvbyzypjgmnceokxedr", mentor.path("voice").asText());
|
||||
assertEquals(0.88, mentor.path("speed").asDouble());
|
||||
assertTrue(mentor.path("input").asText().contains("师傅语气"));
|
||||
assertEquals("FunAudioLLM/CosyVoice2-0.5B:bella", customer.path("voice").asText());
|
||||
assertEquals(1.3, customer.path("speed").asDouble());
|
||||
assertTrue(customer.path("input").asText().contains("情绪强烈"));
|
||||
assertTrue(customer.path("input").asText().contains("四川话"));
|
||||
assertEquals("FunAudioLLM/CosyVoice2-0.5B:alex", legacy.path("voice").asText());
|
||||
assertFalse(legacy.has("speed"));
|
||||
assertFalse(legacy.path("input").asText().contains("<|endofprompt|>"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnknownDialectInsteadOfSilentlyFallingBackToMandarin() {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
SpeechModel runtime = new SpeechModel(
|
||||
"siliconflow",
|
||||
"FunAudioLLM/CosyVoice2-0.5B",
|
||||
"https://api.siliconflow.cn/v1",
|
||||
"test-key"
|
||||
);
|
||||
VoiceProfile invalid = new VoiceProfile("customer", null, null, "calm", "minnan");
|
||||
|
||||
assertTrue(AihrSpeechService.invalidDialectMessage(invalid).isPresent());
|
||||
assertThrows(IllegalArgumentException.class, () -> AihrSpeechService.buildRequestBody(
|
||||
mapper, runtime, "测试", null, invalid));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user