diff --git a/AGENTS.md b/AGENTS.md index b72e440b..d9415b91 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,6 +64,7 @@ - 候选面试复用 `/api/recruit/interview/**`;补充资料走认证的移动端接口,先写 `sys_oss`/MinIO,再写 `aihr_candidate_material`,状态仅为 `待审核/已通过/已驳回`,由管理端 `/recruit/interview` 审核。上传接口不得放入 class-level `@SaIgnore`。 - 后端 API 对接先看 `docs/API_INTEGRATION.md`。三角色对练 `/turn`/`/finish` 使用 `AihrPracticeLlmService`,必须保留“未配置/失败回退 seed”的降级链。 - 对练语音复用 `/api/ai/asr` 与 `/api/ai/tts`,TTS 保持旧 `voice` 兼容并支持角色音色。浏览器录音不可用或权限失败时,用 `audio/*` file input 继续走同一 ASR,不退回纯文字;新增 multipart 外发同样清洗 filename/contentType 的 CRLF。 +- 实时对练 Beta(WebRTC)的人设与工具声明只由服务端 `AihrRealtimePersonaRegistry` 白名单组装,浏览器原样转发 session.update,不得恢复前端自组 instructions 或音色 picker;工具执行按登录态 + Redis 会话归属 + 人设白名单三重校验,仅只读知识检索且 `source=realtime` 不落 SOP 评审/知识缺口表;实时 Beta 不计分、不持久化、仅 H5、需 APP 登录态,接口契约见 `docs/API_INTEGRATION.md`。 - 后端业务代码不要塞进上游 `ruoyi-demo`;自有 API 放在 `backend/ruoyi-modules/ruoyi-aihr`,再接入 `ruoyi-admin`。 - AI 面试、三角色对练、案例、SOP 和三端首页保持真实 API/模型优先与既有降级。知识检索以 MySQL 为事实源,Qdrant/vector/rerank 是可选增强;向量维度不一致必须经状态与重建入口处理,不得静默换成本地向量。 - 批量资料和所有视频只走 `/api/knowledge/doc/upload-async` 异步队列;同步 `/api/knowledge/doc/upload` 仅供 SOP 单文件即时预览,禁止把重加工塞回请求线程。格式、大小、超时、OCR/视频解析和重试契约以 `docs/API_INTEGRATION.md` 为准;图片无可用视觉结果时保持「待处理」,视频关键帧不得退回纯 OCR。 diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java index 0e31049a..33dec38e 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java @@ -23,6 +23,11 @@ import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentBatchResponse; import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse; import org.dromara.aihr.domain.AihrPracticeDto.RealtimeSdpRequest; import org.dromara.aihr.domain.AihrPracticeDto.RealtimeSdpResponse; +import org.dromara.aihr.domain.AihrPracticeDto.RealtimePersonaOption; +import org.dromara.aihr.domain.AihrPracticeDto.RealtimeSessionRequest; +import org.dromara.aihr.domain.AihrPracticeDto.RealtimeSessionResponse; +import org.dromara.aihr.domain.AihrPracticeDto.RealtimeToolInvokeRequest; +import org.dromara.aihr.domain.AihrPracticeDto.RealtimeToolInvokeResponse; import org.dromara.aihr.domain.AihrPracticeDto.RubricResponse; import org.dromara.aihr.domain.AihrPracticeDto.RubricAdminResponse; import org.dromara.aihr.domain.AihrPracticeDto.RubricEnabledRequest; @@ -41,6 +46,8 @@ import org.dromara.aihr.domain.AihrPracticeDto.TurnRequest; import org.dromara.aihr.domain.AihrPracticeDto.TurnResponse; import org.dromara.aihr.service.AihrPracticeSeedService; import org.dromara.aihr.service.AihrRealtimePracticeService; +import org.dromara.aihr.service.AihrRealtimeSessionService; +import org.dromara.aihr.service.AihrRealtimeToolService; import org.dromara.common.core.constant.TenantConstants; import org.dromara.common.core.domain.R; import org.dromara.common.core.domain.model.LoginUser; @@ -76,6 +83,8 @@ public class AihrPracticeController { private final AihrPracticeSeedService practiceSeedService; private final AihrRealtimePracticeService realtimePracticeService; + private final AihrRealtimeSessionService realtimeSessionService; + private final AihrRealtimeToolService realtimeToolService; @GetMapping("/scenarios") public R> scenarios(@RequestParam(required = false) String position, @@ -233,6 +242,41 @@ public class AihrPracticeController { return R.ok(realtimePracticeService.exchangeSdp(request, username)); } + @GetMapping("/realtime/personas") + public R> realtimePersonas() { + String username = currentAppUsername(); + if (username.isBlank()) { + return R.fail("请使用员工端账号开启实时陪练"); + } + return R.ok(realtimeSessionService.listPersonas(username)); + } + + /** + * Creates a realtime session: the server assembles the session.update payload + * (persona instructions, voice, tool declarations) and records ownership for tool invocation. + */ + @PostMapping("/realtime/session") + public R realtimeSession(@RequestBody(required = false) RealtimeSessionRequest request) { + String username = currentAppUsername(); + if (username.isBlank()) { + return R.fail("请使用员工端账号开启实时陪练"); + } + return R.ok(realtimeSessionService.createSession(request, username, currentAppUserId())); + } + + /** + * Server-side execution bridge for model function calling. Identity comes from the login + * token, session ownership and the tool whitelist from the Redis session record. + */ + @PostMapping("/realtime/tools/invoke") + public R realtimeToolInvoke(@RequestBody RealtimeToolInvokeRequest request) { + String username = currentAppUsername(); + if (username.isBlank()) { + return R.fail("请使用员工端账号开启实时陪练"); + } + return R.ok(realtimeToolService.invoke(request, username, currentAppUserId())); + } + @PostMapping("/turn") public R turn(@RequestBody TurnRequest request) { return R.ok(practiceSeedService.turn(request, requirePracticeActorIdentity("继续训练"), currentAppUserId())); 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 2fecd54a..ba78407b 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 @@ -1,6 +1,8 @@ package org.dromara.aihr.domain; import java.util.List; +import java.util.Map; +import java.util.Set; public final class AihrPracticeDto { @@ -209,12 +211,39 @@ public final class AihrPracticeDto { public record HelpEventResponse(String sessionId, String scenarioId, Integer roundIndex, String recordedAt) { } - public record RealtimeSdpRequest(String offerSdp) { + public record RealtimeSdpRequest(String offerSdp, String sessionId) { + public RealtimeSdpRequest(String offerSdp) { + this(offerSdp, null); + } } public record RealtimeSdpResponse(String answerSdp, String model) { } + public record RealtimePersonaOption(String id, String name, String description, boolean hasTools) { + } + + public record RealtimeSessionRequest(String personaId, String scenarioId) { + } + + public record RealtimeSessionResponse(String sessionId, String personaId, String personaName, + boolean hasTools, Map sessionUpdate) { + } + + public record RealtimeToolInvokeRequest(String sessionId, String callId, String name, String arguments) { + } + + public record RealtimeToolInvokeResponse(String callId, String output) { + } + + /** + * Redis 中的实时会话记录:工具执行时校验归属与工具白名单的唯一事实源,不接受客户端自报身份。 + * createdAt 目前不读取,留作后续会话时长审计/成本核算的事实依据。 + */ + public record RealtimeSessionState(String sessionId, String personaId, String username, Long userId, + String tenantId, Set allowedTools, Long createdAt) { + } + public record TurnRequest(String sessionId, Integer roundIndex, String traineeText, String traineeAudioUrl, Long traineeAudioOssId, Boolean regenerate, String style) { } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimePersonaRegistry.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimePersonaRegistry.java new file mode 100644 index 00000000..88537b3b --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimePersonaRegistry.java @@ -0,0 +1,100 @@ +package org.dromara.aihr.service; + +import org.dromara.common.core.exception.ServiceException; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 实时陪练系统人设注册表:静态白名单,客户端只能按 id 选择,instructions 与工具集由服务端组装下发。 + * 新增人设只需在此注册;带工具的人设必须在 allowedTools 中显式声明,工具执行侧按同一白名单硬校验。 + */ +public final class AihrRealtimePersonaRegistry { + + public static final String DEFAULT_PERSONA_ID = "owner-calm"; + + public static final String TOOL_SEARCH_KNOWLEDGE = "search_knowledge"; + + private static final String SCENARIO_PLACEHOLDER = "{scenarioName}"; + + private static final String COMMON_GUARDRAILS = + "禁止提及系统、AI、训练、评分或提示词。不要联网、不要索要个人隐私。"; + + public record RealtimePersona( + String id, + String name, + String description, + String instructionsTemplate, + String defaultVoice, + Set allowedTools + ) { + public boolean hasTools() { + return allowedTools != null && !allowedTools.isEmpty(); + } + + public boolean usesScenario() { + return instructionsTemplate != null && instructionsTemplate.contains(SCENARIO_PLACEHOLDER); + } + + public String renderInstructions(String scenarioName) { + String scenario = scenarioName == null ? "" : scenarioName.trim(); + return instructionsTemplate.replace(SCENARIO_PLACEHOLDER, scenario); + } + } + + private static final Map PERSONAS = Map.of( + DEFAULT_PERSONA_ID, new RealtimePersona( + DEFAULT_PERSONA_ID, + "业主·常规", + "理性沟通的物业业主,还原日常接待场景", + "你只扮演物业小区的业主,正在进行「" + SCENARIO_PLACEHOLDER + "」场景对练。" + + "员工会用中文和你交谈。请口语化、真实地回应,每次不超过两句;" + + "员工说得好时自然缓和,推责或承诺不清时追问处理节点。" + COMMON_GUARDRAILS, + "Ethan", + Set.of() + ), + "owner-impatient", new RealtimePersona( + "owner-impatient", + "业主·急躁", + "情绪急躁的投诉业主,训练安抚与控场", + "你只扮演物业小区一位情绪急躁的业主,正在进行「" + SCENARIO_PLACEHOLDER + "」场景对练。" + + "你带着不满找上门,语速快、容易打断员工、会反复强调自己的损失和诉求。" + + "员工安抚得当、给出明确处理节点时,你可以逐步缓和但仍保持较真;" + + "员工推责、敷衍或承诺不清时,你要升级情绪并追问到底。" + + "请口语化、真实地回应,每次不超过两句。" + COMMON_GUARDRAILS, + "Ryan", + Set.of() + ), + "digital-mentor", new RealtimePersona( + "digital-mentor", + "数字师傅", + "可实时查询知识库的带教师傅,有问必答", + "你是物业一线员工的数字师傅,经验丰富、说话接地气。员工会用中文向你请教工作上的问题。" + + "请口语化回答,每次不超过三句,先给结论再给理由。" + + "当员工询问物业规定、操作流程、SOP 类问题时,先调用 search_knowledge 工具查询," + + "再按查询结果用自己的话转述;查不到时如实说不确定,不要编造。" + COMMON_GUARDRAILS, + "Andre", + Set.of(TOOL_SEARCH_KNOWLEDGE) + ) + ); + + private AihrRealtimePersonaRegistry() { + } + + public static RealtimePersona require(String personaId) { + RealtimePersona persona = PERSONAS.get(personaId == null ? "" : personaId.trim()); + if (persona == null) { + throw new ServiceException("实时陪练人设不存在或未开放"); + } + return persona; + } + + public static RealtimePersona defaultPersona() { + return PERSONAS.get(DEFAULT_PERSONA_ID); + } + + public static List listPublic() { + return List.copyOf(PERSONAS.values()); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimePracticeService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimePracticeService.java index 66f16e78..d5b74b9f 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimePracticeService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimePracticeService.java @@ -59,6 +59,11 @@ public class AihrRealtimePracticeService { throw new ServiceException("请使用员工端账号开启实时陪练"); } enforceRateLimit(account); + String sessionId = request.sessionId() == null ? "" : request.sessionId().trim(); + // sessionId 由客户端回传,仅用于日志关联——限长限字符防日志注入 + if (sessionId.matches("[A-Za-z0-9_-]{1,64}")) { + log.info("realtime sdp exchange bound to session: sessionId={}", sessionId); + } URI signalingUri = signalingUri(endpoint, model); if (apiKey == null || apiKey.isBlank()) { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimeSessionService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimeSessionService.java new file mode 100644 index 00000000..bf488d36 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimeSessionService.java @@ -0,0 +1,130 @@ +package org.dromara.aihr.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.dromara.aihr.domain.AihrPracticeDto.RealtimePersonaOption; +import org.dromara.aihr.domain.AihrPracticeDto.RealtimeSessionRequest; +import org.dromara.aihr.domain.AihrPracticeDto.RealtimeSessionResponse; +import org.dromara.aihr.domain.AihrPracticeDto.RealtimeSessionState; +import org.dromara.aihr.service.AihrRealtimePersonaRegistry.RealtimePersona; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.satoken.utils.LoginHelper; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * 实时陪练会话创建:人设与工具声明的唯一组装点。 + * 浏览器只转发此处下发的 session.update 载荷,instructions/tools 不接受客户端自组。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AihrRealtimeSessionService { + + private static final String FALLBACK_SCENARIO_NAME = "日常接待"; + private static final String TRANSCRIPTION_MODEL = "qwen3-asr-flash-realtime"; + private static final int SESSION_CREATE_RATE_LIMIT = 6; + private static final int SESSION_CREATE_RATE_INTERVAL_SECONDS = 60; + + private final AihrPracticeSeedService practiceSeedService; + private final AihrRealtimeSessionStore sessionStore; + + public List listPersonas(String username) { + String key = "aihr:practice:realtime:personas:" + LoginHelper.getTenantId() + ":" + username; + enforceRateLimit(key, 30, "实时陪练人设查询过于频繁,请稍后重试"); + return AihrRealtimePersonaRegistry.listPublic().stream() + .map(persona -> new RealtimePersonaOption(persona.id(), persona.name(), persona.description(), persona.hasTools())) + .toList(); + } + + public RealtimeSessionResponse createSession(RealtimeSessionRequest request, String username, Long userId) { + String rateKey = "aihr:practice:realtime:session-create:" + LoginHelper.getTenantId() + ":" + username; + enforceRateLimit(rateKey, SESSION_CREATE_RATE_LIMIT, "实时陪练开启过于频繁,请稍后重试"); + String personaId = request == null || StringUtils.isBlank(request.personaId()) + ? AihrRealtimePersonaRegistry.DEFAULT_PERSONA_ID + : request.personaId().trim(); + RealtimePersona persona = AihrRealtimePersonaRegistry.require(personaId); + String scenarioName = resolveScenarioName(persona, request == null ? null : request.scenarioId()); + + String sessionId = "rt_" + UUID.randomUUID().toString().replace("-", ""); + Map sessionUpdate = buildSessionUpdate(persona, scenarioName); + + String tenantId = LoginHelper.getTenantId(); + RealtimeSessionState state = new RealtimeSessionState( + sessionId, persona.id(), username, userId, tenantId, persona.allowedTools(), System.currentTimeMillis()); + sessionStore.save(state); + log.info("realtime session created: persona={}, hasTools={}, usesScenario={}", + persona.id(), persona.hasTools(), persona.usesScenario()); + return new RealtimeSessionResponse(sessionId, persona.id(), persona.name(), persona.hasTools(), sessionUpdate); + } + + private void enforceRateLimit(String key, int limit, String exceededMessage) { + try { + if (sessionStore.rateLimit(key, limit, SESSION_CREATE_RATE_INTERVAL_SECONDS) < 0) { + throw new ServiceException(exceededMessage, 429); + } + } catch (ServiceException known) { + throw known; + } catch (RuntimeException unavailable) { + log.warn("realtime session rate limiter unavailable"); + throw new ServiceException("实时陪练限流暂时不可用,请稍后重试", 503); + } + } + + private String resolveScenarioName(RealtimePersona persona, String scenarioId) { + if (!persona.usesScenario()) { + return ""; + } + if (StringUtils.isBlank(scenarioId)) { + return FALLBACK_SCENARIO_NAME; + } + String name = practiceSeedService.scenario(scenarioId.trim()).name(); + return StringUtils.isBlank(name) ? FALLBACK_SCENARIO_NAME : name.trim(); + } + + private Map buildSessionUpdate(RealtimePersona persona, String scenarioName) { + Map session = new LinkedHashMap<>(); + session.put("modalities", List.of("text", "audio")); + session.put("voice", persona.defaultVoice()); + session.put("input_audio_format", "pcm"); + session.put("output_audio_format", "pcm"); + session.put("input_audio_transcription", Map.of("model", TRANSCRIPTION_MODEL)); + session.put("instructions", persona.renderInstructions(scenarioName)); + Map turnDetection = new LinkedHashMap<>(); + turnDetection.put("type", "semantic_vad"); + turnDetection.put("threshold", 0.5); + turnDetection.put("prefix_padding_ms", 500); + turnDetection.put("silence_duration_ms", 800); + session.put("turn_detection", turnDetection); + if (persona.hasTools()) { + session.put("tools", List.of(searchKnowledgeToolDeclaration())); + } + Map event = new LinkedHashMap<>(); + event.put("type", "session.update"); + event.put("session", session); + return event; + } + + /** + * qwen realtime 工具声明为嵌套格式:{type:'function', function:{name,description,parameters}}。 + */ + private Map searchKnowledgeToolDeclaration() { + Map query = new LinkedHashMap<>(); + query.put("type", "string"); + query.put("description", "要检索的物业知识库问题"); + Map parameters = new LinkedHashMap<>(); + parameters.put("type", "object"); + parameters.put("properties", Map.of("query", query)); + parameters.put("required", List.of("query")); + Map function = new LinkedHashMap<>(); + function.put("name", AihrRealtimePersonaRegistry.TOOL_SEARCH_KNOWLEDGE); + function.put("description", "查询物业知识库中的规定、操作流程与 SOP,员工请教专业问题时使用"); + function.put("parameters", parameters); + return Map.of("type", "function", "function", function); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimeSessionStore.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimeSessionStore.java new file mode 100644 index 00000000..edcfd69a --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimeSessionStore.java @@ -0,0 +1,43 @@ +package org.dromara.aihr.service; + +import org.dromara.aihr.domain.AihrPracticeDto.RealtimeSessionState; +import org.dromara.common.redis.utils.RedisUtils; +import org.redisson.api.RateType; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +/** + * 实时陪练会话的 Redis 存取与限流的唯一出口。 + * 独立成组件是为了让会话/工具服务在单元测试中可注入替身——RedisUtils 的静态初始化依赖 + * Spring 上下文,无法在纯单测中加载。 + */ +@Component +public class AihrRealtimeSessionStore { + + private static final Duration SESSION_TTL = Duration.ofMinutes(30); + private static final String SESSION_KEY_PREFIX = "aihr:practice:realtime:session:"; + + public void save(RealtimeSessionState state) { + RedisUtils.setCacheObject(key(state.tenantId(), state.sessionId()), state, SESSION_TTL); + } + + public RealtimeSessionState find(String tenantId, String sessionId) { + return RedisUtils.getCacheObject(key(tenantId, sessionId)); + } + + /** + * 会话仍活跃时滑动续期,避免长会话中途工具失效;不做无限续期,窗口仍以最近一次活动起算 30 分钟。 + */ + public void touch(String tenantId, String sessionId) { + RedisUtils.expire(key(tenantId, sessionId), SESSION_TTL); + } + + public long rateLimit(String key, int limit, int intervalSeconds) { + return RedisUtils.rateLimiter(key, RateType.OVERALL, limit, intervalSeconds); + } + + static String key(String tenantId, String sessionId) { + return SESSION_KEY_PREFIX + tenantId + ":" + sessionId; + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimeToolService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimeToolService.java new file mode 100644 index 00000000..39eac482 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimeToolService.java @@ -0,0 +1,171 @@ +package org.dromara.aihr.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.dromara.aihr.domain.AihrPracticeDto.RealtimeSessionState; +import org.dromara.aihr.domain.AihrPracticeDto.RealtimeToolInvokeRequest; +import org.dromara.aihr.domain.AihrPracticeDto.RealtimeToolInvokeResponse; +import org.dromara.aihr.domain.AihrSopDto.SearchRequest; +import org.dromara.aihr.domain.AihrSopDto.SearchResponse; +import org.dromara.aihr.domain.AihrSopDto.SnippetResponse; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeAppDto.AuthenticatedApp; +import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; +import org.dromara.aihr.knowledge.service.AihrKnowledgeAccessService; +import org.dromara.aihr.knowledge.service.AihrKnowledgeAppService; +import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.satoken.utils.LoginHelper; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Set; + +/** + * 实时陪练工具执行桥:模型 function calling 的服务端落点。 + * 身份取登录态、会话归属取 Redis 记录、工具白名单取人设注册表,三者不接受客户端自报。 + * 只走浅层检索(searchAuthorized),不经过 queryInternal,避免 LLM 答案生成/审计/记忆副作用—— + * 语音应答由实时模型基于 output 自行组织。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AihrRealtimeToolService { + + private static final int MAX_ARGUMENTS_CHARS = 4096; + private static final int MAX_QUERY_CHARS = 200; + private static final int MAX_OUTPUT_CHARS = 1200; + private static final int SNIPPET_LIMIT = 3; + private static final int TOOL_RATE_LIMIT = 20; + private static final int TOOL_RATE_INTERVAL_SECONDS = 60; + private static final String TOOL_RATE_KEY_PREFIX = "aihr:practice:realtime:tool:"; + + private static final String SOFT_PARAM_ERROR = "工具参数格式有误,请员工换个问法再试。"; + private static final String SOFT_NO_SPACE = "暂无可访问的知识库内容,请如实告知员工你不确定。"; + private static final String SOFT_NO_RESULT = "知识库中未找到相关内容,请如实告知员工你不确定。"; + private static final String SOFT_SEARCH_FAILED = "知识库查询暂时不可用,请如实告知员工稍后再试。"; + + private final AihrSopSeedService sopSeedService; + private final AihrKnowledgePrincipalResolver principalResolver; + private final AihrKnowledgeAccessService accessService; + private final AihrKnowledgeAppService appService; + private final AihrRealtimeSessionStore sessionStore; + private final ObjectMapper objectMapper; + + public RealtimeToolInvokeResponse invoke(RealtimeToolInvokeRequest request, String username, Long userId) { + String sessionId = request == null || request.sessionId() == null ? "" : request.sessionId().trim(); + String callId = request == null || request.callId() == null ? "" : request.callId().trim(); + String name = request == null || request.name() == null ? "" : request.name().trim(); + String arguments = request == null ? null : request.arguments(); + if (sessionId.isEmpty() || callId.isEmpty() || name.isEmpty()) { + throw new ServiceException("实时陪练工具调用请求无效", 400); + } + if (arguments != null && arguments.length() > MAX_ARGUMENTS_CHARS) { + throw new ServiceException("实时陪练工具调用请求无效", 400); + } + + String tenantId = LoginHelper.getTenantId(); + RealtimeSessionState state = sessionStore.find(tenantId, sessionId); + if (state == null) { + throw new ServiceException("实时陪练会话已过期,请重新开始", 410); + } + if (!java.util.Objects.equals(state.username(), username) || !java.util.Objects.equals(state.userId(), userId)) { + throw new ServiceException("无权操作该实时陪练会话", 403); + } + if (!state.allowedTools().contains(name)) { + throw new ServiceException("当前人设不支持该工具", 400); + } + enforceToolRateLimit(state); + + String output = executeSearchKnowledge(arguments); + // 会话仍活跃,滑动续期,避免长会话中途工具失效 + sessionStore.touch(state.tenantId(), state.sessionId()); + log.info("realtime tool invoked: sessionId={}, name={}", sessionId, name); + return new RealtimeToolInvokeResponse(callId, output); + } + + private void enforceToolRateLimit(RealtimeSessionState state) { + // 按账号而非会话限流:会话可反复创建,按 sessionId 限流会被会话扇出绕过 + String key = TOOL_RATE_KEY_PREFIX + state.tenantId() + ":" + state.username(); + try { + if (sessionStore.rateLimit(key, TOOL_RATE_LIMIT, TOOL_RATE_INTERVAL_SECONDS) < 0) { + throw new ServiceException("知识库查询过于频繁,请稍后重试", 429); + } + } catch (ServiceException known) { + throw known; + } catch (RuntimeException unavailable) { + log.warn("realtime tool rate limiter unavailable"); + throw new ServiceException("实时陪练限流暂时不可用,请稍后重试", 503); + } + } + + private String executeSearchKnowledge(String argumentsJson) { + String query = parseQuery(argumentsJson); + if (query == null) { + return SOFT_PARAM_ERROR; + } + AihrKnowledgePrincipal principal = principalResolver.current(); + AuthenticatedApp app = appService.requireSessionApp(principal.tenantId(), principal.clientKey()); + Set spaceIds; + try { + spaceIds = accessService.resolveInternalSpaceIds(principal, app, List.of(), "READ"); + } catch (ServiceException forbidden) { + // 员工无知识授权时 resolveInternalSpaceIds 抛 403 而非返回空集——软着陆让模型如实回答 + return SOFT_NO_SPACE; + } + if (spaceIds == null || spaceIds.isEmpty()) { + return SOFT_NO_SPACE; + } + SearchResponse response; + try { + response = sopSeedService.searchAuthorized( + new SearchRequest(query, null, null, "realtime", SNIPPET_LIMIT), + principal.extPartyId(), spaceIds); + } catch (RuntimeException e) { + log.warn("realtime knowledge search failed(处理错误已隐藏)"); + return SOFT_SEARCH_FAILED; + } + List snippets = response == null || response.snippets() == null + ? List.of() : response.snippets(); + if (snippets.isEmpty()) { + return SOFT_NO_RESULT; + } + StringBuilder output = new StringBuilder(); + for (SnippetResponse snippet : snippets) { + if (snippet == null || StringUtils.isBlank(snippet.text())) { + continue; + } + if (output.length() > 0) { + output.append('\n'); + } + if (StringUtils.isNotBlank(snippet.title())) { + output.append('「').append(snippet.title().trim()).append('」'); + } + output.append(snippet.text().trim()); + } + if (output.length() == 0) { + return SOFT_NO_RESULT; + } + return output.length() > MAX_OUTPUT_CHARS + ? output.substring(0, MAX_OUTPUT_CHARS) + : output.toString(); + } + + private String parseQuery(String argumentsJson) { + if (StringUtils.isBlank(argumentsJson)) { + return null; + } + try { + JsonNode node = objectMapper.readTree(argumentsJson); + String query = node.path("query").asText("").trim(); + if (query.isEmpty()) { + return null; + } + return query.length() > MAX_QUERY_CHARS ? query.substring(0, MAX_QUERY_CHARS) : query; + } catch (Exception e) { + return null; + } + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java index e23d3d4a..56b79b87 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java @@ -2422,7 +2422,8 @@ public class AihrSopSeedService { } private void recordKnowledgeGap(String queryText, String category, String position, String source) { - if (isBlank(queryText)) { + // realtime:实时对练 Beta 不持久化——语音会话衍生的未命中问题不写缺口表 + if (isBlank(queryText) || "realtime".equals(normalizeSearchSource(source))) { return; } try { @@ -2464,7 +2465,8 @@ public class AihrSopSeedService { } static boolean skipSopReview(String source) { - return "verify_demo_questions".equals(source); + // realtime:实时对练 Beta 不持久化——语音会话衍生的检索不进评审队列 + return "verify_demo_questions".equals(source) || "realtime".equals(source); } static boolean shouldCreateSopReview(String queryText, String source) { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrRealtimePersonaRegistryTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrRealtimePersonaRegistryTest.java new file mode 100644 index 00000000..3e26ce0d --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrRealtimePersonaRegistryTest.java @@ -0,0 +1,62 @@ +package org.dromara.aihr.service; + +import org.dromara.aihr.service.AihrRealtimePersonaRegistry.RealtimePersona; +import org.dromara.common.core.exception.ServiceException; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("dev") +class AihrRealtimePersonaRegistryTest { + + @Test + void requireReturnsRegisteredPersona() { + RealtimePersona persona = AihrRealtimePersonaRegistry.require("digital-mentor"); + assertNotNull(persona); + assertEquals("数字师傅", persona.name()); + assertEquals(Set.of(AihrRealtimePersonaRegistry.TOOL_SEARCH_KNOWLEDGE), persona.allowedTools()); + assertTrue(persona.hasTools()); + } + + @Test + void requireRejectsUnknownPersonaId() { + ServiceException error = assertThrows(ServiceException.class, + () -> AihrRealtimePersonaRegistry.require("no-such-persona")); + assertEquals("实时陪练人设不存在或未开放", error.getMessage()); + } + + @Test + void defaultPersonaIsCalmOwnerWithoutTools() { + RealtimePersona persona = AihrRealtimePersonaRegistry.defaultPersona(); + assertEquals(AihrRealtimePersonaRegistry.DEFAULT_PERSONA_ID, persona.id()); + assertFalse(persona.hasTools()); + } + + @Test + void everyPersonaHasVoiceAndOnlyOwnersUseScenario() { + for (RealtimePersona persona : AihrRealtimePersonaRegistry.listPublic()) { + assertNotNull(persona.defaultVoice()); + assertFalse(persona.defaultVoice().isBlank(), persona.id() + " must declare a default voice"); + if (persona.id().startsWith("owner-")) { + assertTrue(persona.usesScenario(), persona.id() + " should render scenario context"); + } else { + assertFalse(persona.usesScenario(), persona.id() + " must not depend on scenarios"); + } + } + } + + @Test + void renderInstructionsFillsScenarioPlaceholder() { + RealtimePersona owner = AihrRealtimePersonaRegistry.require("owner-calm"); + String rendered = owner.renderInstructions("投诉接待"); + assertTrue(rendered.contains("投诉接待")); + assertFalse(rendered.contains("{scenarioName}")); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrRealtimeSessionServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrRealtimeSessionServiceTest.java new file mode 100644 index 00000000..1531d6d8 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrRealtimeSessionServiceTest.java @@ -0,0 +1,182 @@ +package org.dromara.aihr.service; + +import org.dromara.aihr.domain.AihrPracticeDto.RealtimeSessionRequest; +import org.dromara.aihr.domain.AihrPracticeDto.RealtimeSessionResponse; +import org.dromara.aihr.domain.AihrPracticeDto.RealtimeSessionState; +import org.dromara.aihr.domain.AihrPracticeDto.ScenarioResponse; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.satoken.utils.LoginHelper; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.util.List; +import java.util.Map; + +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; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@Tag("dev") +class AihrRealtimeSessionServiceTest { + + @Test + void blankPersonaIdFallsBackToDefaultPersona() { + AihrPracticeSeedService seedService = mock(AihrPracticeSeedService.class); + AihrRealtimeSessionStore store = mock(AihrRealtimeSessionStore.class); + AihrRealtimeSessionService service = new AihrRealtimeSessionService(seedService, store); + + try (MockedStatic login = Mockito.mockStatic(LoginHelper.class)) { + login.when(LoginHelper::getTenantId).thenReturn("000000"); + + RealtimeSessionResponse response = service.createSession( + new RealtimeSessionRequest(null, null), "13900000000", 1L); + + assertEquals(AihrRealtimePersonaRegistry.DEFAULT_PERSONA_ID, response.personaId()); + assertFalse(response.hasTools()); + assertTrue(response.sessionId().startsWith("rt_")); + assertSessionUpdateShape(response.sessionUpdate()); + assertFalse(sessionOf(response).containsKey("tools")); + + ArgumentCaptor saved = ArgumentCaptor.forClass(RealtimeSessionState.class); + verify(store).save(saved.capture()); + assertEquals("000000", saved.getValue().tenantId()); + assertEquals("13900000000", saved.getValue().username()); + assertEquals(1L, saved.getValue().userId()); + assertEquals(response.sessionId(), saved.getValue().sessionId()); + assertTrue(saved.getValue().allowedTools().isEmpty()); + } + } + + @Test + void unknownPersonaIdIsRejected() { + AihrRealtimeSessionService service = new AihrRealtimeSessionService( + mock(AihrPracticeSeedService.class), mock(AihrRealtimeSessionStore.class)); + assertThrows(ServiceException.class, + () -> service.createSession(new RealtimeSessionRequest("ghost", null), "13900000000", 1L)); + } + + @Test + void mentorPersonaDeclaresNestedSearchKnowledgeTool() { + AihrPracticeSeedService seedService = mock(AihrPracticeSeedService.class); + AihrRealtimeSessionService service = new AihrRealtimeSessionService( + seedService, mock(AihrRealtimeSessionStore.class)); + + try (MockedStatic login = Mockito.mockStatic(LoginHelper.class)) { + login.when(LoginHelper::getTenantId).thenReturn("000000"); + + RealtimeSessionResponse response = service.createSession( + new RealtimeSessionRequest("digital-mentor", "complaint-water"), "13900000000", 1L); + + assertTrue(response.hasTools()); + Map session = sessionOf(response); + @SuppressWarnings("unchecked") + List> tools = (List>) session.get("tools"); + assertEquals(1, tools.size()); + assertEquals("function", tools.get(0).get("type")); + @SuppressWarnings("unchecked") + Map function = (Map) tools.get(0).get("function"); + assertEquals("search_knowledge", function.get("name")); + @SuppressWarnings("unchecked") + Map parameters = (Map) function.get("parameters"); + assertEquals(List.of("query"), parameters.get("required")); + String instructions = String.valueOf(session.get("instructions")); + assertFalse(instructions.contains("{scenarioName}")); + // 师傅人设不消费场景,不应触发场景查询 + verify(seedService, never()).scenario(any()); + verify(seedService, never()).scenario(any(), eq(false)); + } + } + + @Test + void ownerPersonaFillsScenarioNameIntoInstructions() { + AihrPracticeSeedService seedService = mock(AihrPracticeSeedService.class); + ScenarioResponse scenario = mock(ScenarioResponse.class); + when(scenario.name()).thenReturn("投诉接待"); + when(seedService.scenario("complaint-water")).thenReturn(scenario); + AihrRealtimeSessionService service = new AihrRealtimeSessionService( + seedService, mock(AihrRealtimeSessionStore.class)); + + try (MockedStatic login = Mockito.mockStatic(LoginHelper.class)) { + login.when(LoginHelper::getTenantId).thenReturn("000000"); + + RealtimeSessionResponse response = service.createSession( + new RealtimeSessionRequest("owner-impatient", "complaint-water"), "13900000000", 1L); + + String instructions = String.valueOf(sessionOf(response).get("instructions")); + assertTrue(instructions.contains("投诉接待")); + assertFalse(instructions.contains("{scenarioName}")); + assertFalse(sessionOf(response).containsKey("tools")); + } + } + + @Test + void ownerPersonaWithoutScenarioIdUsesFallbackName() { + AihrPracticeSeedService seedService = mock(AihrPracticeSeedService.class); + AihrRealtimeSessionService service = new AihrRealtimeSessionService( + seedService, mock(AihrRealtimeSessionStore.class)); + + try (MockedStatic login = Mockito.mockStatic(LoginHelper.class)) { + login.when(LoginHelper::getTenantId).thenReturn("000000"); + + RealtimeSessionResponse response = service.createSession( + new RealtimeSessionRequest("owner-calm", " "), "13900000000", 1L); + + String instructions = String.valueOf(sessionOf(response).get("instructions")); + assertFalse(instructions.contains("{scenarioName}")); + assertFalse(instructions.contains("「」")); + verify(seedService, never()).scenario(any()); + } + } + + @Test + void sessionCreationIsRateLimitedPerAccount() { + AihrRealtimeSessionStore store = mock(AihrRealtimeSessionStore.class); + when(store.rateLimit(any(), anyInt(), anyInt())).thenReturn(-1L); + AihrRealtimeSessionService service = new AihrRealtimeSessionService( + mock(AihrPracticeSeedService.class), store); + + try (MockedStatic login = Mockito.mockStatic(LoginHelper.class)) { + login.when(LoginHelper::getTenantId).thenReturn("000000"); + ServiceException error = assertThrows(ServiceException.class, + () -> service.createSession(new RealtimeSessionRequest(null, null), "13900000000", 1L)); + assertEquals(429, error.getCode()); + // 限流拦截在创建之前,不得写入任何会话记录 + verify(store, never()).save(any()); + } + } + + private static void assertSessionUpdateShape(Map sessionUpdate) { + assertEquals("session.update", sessionUpdate.get("type")); + Map session = castSession(sessionUpdate); + assertEquals(List.of("text", "audio"), session.get("modalities")); + assertEquals("pcm", session.get("input_audio_format")); + assertEquals("pcm", session.get("output_audio_format")); + @SuppressWarnings("unchecked") + Map transcription = (Map) session.get("input_audio_transcription"); + assertEquals("qwen3-asr-flash-realtime", transcription.get("model")); + @SuppressWarnings("unchecked") + Map turnDetection = (Map) session.get("turn_detection"); + assertEquals("semantic_vad", turnDetection.get("type")); + assertFalse(session.containsKey("enable_search")); + } + + private static Map sessionOf(RealtimeSessionResponse response) { + return castSession(response.sessionUpdate()); + } + + @SuppressWarnings("unchecked") + private static Map castSession(Map sessionUpdate) { + return (Map) sessionUpdate.get("session"); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrRealtimeToolServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrRealtimeToolServiceTest.java new file mode 100644 index 00000000..b59fe306 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrRealtimeToolServiceTest.java @@ -0,0 +1,201 @@ +package org.dromara.aihr.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.dromara.aihr.domain.AihrPracticeDto.RealtimeSessionState; +import org.dromara.aihr.domain.AihrPracticeDto.RealtimeToolInvokeRequest; +import org.dromara.aihr.domain.AihrPracticeDto.RealtimeToolInvokeResponse; +import org.dromara.aihr.domain.AihrSopDto.SearchResponse; +import org.dromara.aihr.domain.AihrSopDto.SnippetResponse; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeAppDto.AuthenticatedApp; +import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; +import org.dromara.aihr.knowledge.service.AihrKnowledgeAccessService; +import org.dromara.aihr.knowledge.service.AihrKnowledgeAppService; +import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.satoken.utils.LoginHelper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@Tag("dev") +class AihrRealtimeToolServiceTest { + + private static final String USERNAME = "13900000000"; + private static final Long USER_ID = 1L; + private static final RealtimeSessionState MENTOR_SESSION = new RealtimeSessionState( + "rt_abc", "digital-mentor", USERNAME, USER_ID, "000000", + Set.of(AihrRealtimePersonaRegistry.TOOL_SEARCH_KNOWLEDGE), 0L); + + private AihrSopSeedService sopSeedService; + private AihrKnowledgePrincipalResolver principalResolver; + private AihrKnowledgeAccessService accessService; + private AihrKnowledgeAppService appService; + private AihrRealtimeSessionStore sessionStore; + private AihrRealtimeToolService service; + + @BeforeEach + void setUp() { + sopSeedService = mock(AihrSopSeedService.class); + principalResolver = mock(AihrKnowledgePrincipalResolver.class); + accessService = mock(AihrKnowledgeAccessService.class); + appService = mock(AihrKnowledgeAppService.class); + sessionStore = mock(AihrRealtimeSessionStore.class); + service = new AihrRealtimeToolService( + sopSeedService, principalResolver, accessService, appService, sessionStore, new ObjectMapper()); + lenient().when(sessionStore.find("000000", "rt_abc")).thenReturn(MENTOR_SESSION); + lenient().when(sessionStore.rateLimit(anyString(), anyInt(), anyInt())).thenReturn(1L); + } + + @Test + void expiredSessionReturns410() { + when(sessionStore.find("000000", "rt_abc")).thenReturn(null); + try (MockedStatic login = mockTenant()) { + ServiceException error = assertThrows(ServiceException.class, + () -> service.invoke(request("not-json"), USERNAME, USER_ID)); + assertEquals(410, error.getCode()); + } + } + + @Test + void foreignSessionReturns403() { + try (MockedStatic login = mockTenant()) { + assertThrows(ServiceException.class, + () -> service.invoke(request("{}"), "other-user", USER_ID)); + assertThrows(ServiceException.class, + () -> service.invoke(request("{}"), USERNAME, 99L)); + } + } + + @Test + void undeclaredToolIsRejectedToContainModelHallucination() { + RealtimeSessionState ownerSession = new RealtimeSessionState( + "rt_abc", "owner-calm", USERNAME, USER_ID, "000000", Set.of(), 0L); + when(sessionStore.find("000000", "rt_abc")).thenReturn(ownerSession); + try (MockedStatic login = mockTenant()) { + RealtimeToolInvokeRequest hallucinated = new RealtimeToolInvokeRequest( + "rt_abc", "call_1", "delete_everything", "{}"); + ServiceException error = assertThrows(ServiceException.class, + () -> service.invoke(hallucinated, USERNAME, USER_ID)); + assertEquals(400, error.getCode()); + } + } + + @Test + void invalidArgumentsJsonFallsBackToSoftOutput() { + try (MockedStatic login = mockTenant()) { + RealtimeToolInvokeResponse response = service.invoke(request("not-json{{"), USERNAME, USER_ID); + assertEquals("call_1", response.callId()); + assertTrue(response.output().contains("换个问法")); + Mockito.verifyNoInteractions(sopSeedService); + } + } + + @Test + void forbiddenKnowledgeAccessFallsBackToSoftOutput() { + // 生产真实路径:员工无知识授权时 resolveInternalSpaceIds 抛 403,而不是返回空集 + AihrKnowledgePrincipal principal = new AihrKnowledgePrincipal( + "000000", USER_ID, "app_user", USERNAME, Set.of("employee"), Set.of(), "app"); + when(principalResolver.current()).thenReturn(principal); + when(appService.requireSessionApp("000000", "app")).thenReturn(mock(AuthenticatedApp.class)); + when(accessService.resolveInternalSpaceIds(eq(principal), any(), eq(List.of()), eq("READ"))) + .thenThrow(new ServiceException("当前请求没有可访问的知识空间", 403)); + try (MockedStatic login = mockTenant()) { + RealtimeToolInvokeResponse response = service.invoke( + request("{\"query\":\"装修垃圾怎么清运\"}"), USERNAME, USER_ID); + assertTrue(response.output().contains("暂无可访问")); + verify(sopSeedService, never()).searchAuthorized(any(), any(), any()); + } + } + + @Test + void searchResultsAreJoinedAndTruncatedForSpeech() { + stubPrincipalWithSpaces(Set.of(7L)); + SearchResponse search = mock(SearchResponse.class); + when(search.snippets()).thenReturn(List.of( + new SnippetResponse("装修管理规约", "装修垃圾需袋装化并堆放到指定点位," + "长".repeat(1300), 1L), + new SnippetResponse("清运流程", "联系物业预约清运时间", 2L) + )); + when(sopSeedService.searchAuthorized(any(), eq(USERNAME), eq(Set.of(7L)))).thenReturn(search); + + try (MockedStatic login = mockTenant()) { + RealtimeToolInvokeResponse response = service.invoke( + request("{\"query\":\"装修垃圾\"}"), USERNAME, USER_ID); + assertTrue(response.output().startsWith("「装修管理规约」")); + assertEquals(1200, response.output().length()); + verify(sessionStore).touch("000000", "rt_abc"); + } + } + + @Test + void emptySearchResultAsksModelToAdmitUncertainty() { + stubPrincipalWithSpaces(Set.of(7L)); + SearchResponse search = mock(SearchResponse.class); + when(search.snippets()).thenReturn(List.of()); + when(sopSeedService.searchAuthorized(any(), any(), any())).thenReturn(search); + + try (MockedStatic login = mockTenant()) { + RealtimeToolInvokeResponse response = service.invoke( + request("{\"query\":\"不存在的规定\"}"), USERNAME, USER_ID); + assertTrue(response.output().contains("未找到相关内容")); + } + } + + @Test + void rateLimitExceededReturns429() { + when(sessionStore.rateLimit(anyString(), anyInt(), anyInt())).thenReturn(-1L); + try (MockedStatic login = mockTenant()) { + ServiceException error = assertThrows(ServiceException.class, + () -> service.invoke(request("{\"query\":\"q\"}"), USERNAME, USER_ID)); + assertEquals(429, error.getCode()); + } + } + + @Test + void rateLimiterOutageFailsClosed() { + when(sessionStore.rateLimit(anyString(), anyInt(), anyInt())) + .thenThrow(new RuntimeException("redis down")); + try (MockedStatic login = mockTenant()) { + ServiceException error = assertThrows(ServiceException.class, + () -> service.invoke(request("{\"query\":\"q\"}"), USERNAME, USER_ID)); + assertEquals(503, error.getCode()); + } + } + + private void stubPrincipalWithSpaces(Set spaceIds) { + AihrKnowledgePrincipal principal = new AihrKnowledgePrincipal( + "000000", USER_ID, "app_user", USERNAME, Set.of("employee"), Set.of(), "app"); + when(principalResolver.current()).thenReturn(principal); + when(appService.requireSessionApp("000000", "app")).thenReturn(mock(AuthenticatedApp.class)); + when(accessService.resolveInternalSpaceIds(eq(principal), any(), eq(List.of()), eq("READ"))) + .thenReturn(spaceIds); + } + + private static RealtimeToolInvokeRequest request(String arguments) { + return new RealtimeToolInvokeRequest("rt_abc", "call_1", + AihrRealtimePersonaRegistry.TOOL_SEARCH_KNOWLEDGE, arguments); + } + + private static MockedStatic mockTenant() { + MockedStatic login = Mockito.mockStatic(LoginHelper.class); + login.when(LoginHelper::getTenantId).thenReturn("000000"); + return login; + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java index 93293d19..22292441 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java @@ -53,6 +53,14 @@ public class AihrSopSeedServiceTest { assertFalse(AihrSopSeedService.skipSopReview("knowledge_search")); } + @Test + @Tag("dev") + public void realtimeSourceSkipsPersistenceSideEffects() { + // 实时对练 Beta 不持久化:检索不进评审队列 + assertTrue(AihrSopSeedService.skipSopReview("realtime")); + assertFalse(AihrSopSeedService.shouldCreateSopReview("物业费怎么交", "realtime")); + } + @Test @Tag("dev") public void emptySopQuestionCannotCreateReviewSample() { diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index d0c1cd3c..5339c553 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -13,6 +13,7 @@ | M2 训练预习与求助 | `GET /api/train/practice/scenarios/{id}/prep-card`、`POST /api/train/practice/sessions/{sessionId}/help` | 均要求已登录;预习卡只使用当前租户已启用的 `prep_card/json_prep` 模板,模型返回必须严格为 3 条要点、3 条红线、2 条话术,否则回退场景卡。求助接口只允许 APP 员工访问其本人、同租户的活动训练会话;客户端可传 `roundIndex` 仅为兼容字段,服务端按会话实际进度落库,返回 `{sessionId,scenarioId,roundIndex,recordedAt}`。迁移表为 `aihr_practice_help_event`,不在请求路径执行 DDL。该表已随 2026-07-24 完整包发布并纳入远端 `62/62` 结构预检;完整训练写入与正式试点仍需另验。 | | 正式试点数据导出 | `GET /api/train/practice/export?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD` | 起止日期必填且包含结束日;只统计窗口内能通过唯一手机号或外部 ID 映射到在职组织快照的正式会话,排除重复手机号和身份碰撞。完训定义为每人至少 10 次,校准必须关联同一窗口内正式会话;CSV 同时给出校准命中数、SOP 可用数、满意度响应数/平均分,以及明细级 AI 分、人工校准分、校准人、校准时间、最终采用分、满意度分和意见,避免用四舍五入后的比率反推门禁状态;汇总和明细均携带正式人员及项目口径,不混入历史 seed/开发身份 | | 对练语音 | `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 保持旧客户端可播放;移动端 OSS 下载只允许经本人或主管项目范围验证的 APP 身份,后台账号若需回放必须使用未来独立、受审计的管理端契约。 | +| 实时对练 Beta(WebRTC) | `POST /api/train/practice/realtime/sdp`、`GET /realtime/personas`、`POST /realtime/session`、`POST /realtime/tools/invoke` | 浏览器 WebRTC 直连阿里云(音频不过服务端),后端仅代理 SDP 交换(3 次/分/账号,sessionId 仅作日志关联且限字符)。人设为服务端 `AihrRealtimePersonaRegistry` 白名单:`owner-calm` 业主·常规(默认)、`owner-impatient` 业主·急躁、`digital-mentor` 数字师傅;人设绑定默认音色,客户端只选不编。`session.update`(instructions/音色/turn_detection/tools 声明)由 `/realtime/session` 组装下发,浏览器原样转发;personaId 空白回落默认人设;会话记录写 Redis 30min(成功工具调用滑动续期),创建限流 6 次/分/账号。`digital-mentor` 声明 `search_knowledge` 工具:模型 function calling 命中(`response.function_call_arguments.done`)时浏览器回传 `/realtime/tools/invoke`,服务端按登录态 + Redis 会话归属 + 人设工具白名单三重校验后走 `searchAuthorized` 浅层检索(不生成 LLM 答案、不落 `aihr_agent_run`;`source=realtime` 跳过 SOP 评审与知识缺口写入),output 拼接 ≤3 条片段并截断 1200 字;工具按账号限流 20 次/分,无授权/无结果/超时一律返回软着陆文案让模型如实作答,不中断语音会话。实时 Beta 不计分、不持久化训练数据;仅 H5(App-Plus 不支持),需 APP 员工登录态;不启用模型 `enable_search`。配置 `AIHR_QWEN_REALTIME_ENDPOINT` / `AIHR_QWEN_REALTIME_API_KEY` / `AIHR_QWEN_REALTIME_MODEL`(模型白名单 `qwen3.5-omni-(flash|plus)-realtime`),密钥只放 `.env.local` 或外部环境变量。 | | 案例沉淀 `/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 用户必须先以认证手机号精确校验在职 `person_phone`,再由可信主体解析 `aihr_org_snapshot` 项目范围;案例 SQL 不得把 `person_phone` 与 `ext_party_id` 作为替代查询键。上传、整理、入库、列表和详情均按该项目范围校验,未完成正式组织映射时安全拒绝,不接受前端伪造项目范围;员工列表/详情只返回 `已入库` 案例,管理端系统用户保留全局运营视图;移动端和管理端案例详情通过受保护媒体接口回放原始音频,主管/项目负责人可提交脱敏点评;预渲染视频样片仍待正式媒体资产接入 | | 案例媒体安全 | `GET /api/knowledge/case/records/{caseId}/media` | 案例详情只返回 `mediaOssId`,不返回原始 `sys_oss.url`;媒体下载会重复执行登录、后台角色或 APP 项目范围校验,再由服务端流式读取 OSS。管理端与 `mobile-uni` 通过鉴权 blob/temp 文件播放,关闭详情页时释放本地对象 URL | | 问·数字师傅工作 Agent `/pages/user/sop/index` | `POST /api/aihr/agent/messages`、`POST /api/aihr/agent/messages/media`、`POST /api/aihr/agent/actions/{draftId}/confirm`、`POST /api/aihr/agent/actions/{draftId}/dismiss` | 移动端文字、ASR 转写和图片/视频统一进入 Agent。服务端根据消息规划 `KNOWLEDGE_QA/RESOURCE_DELIVERY/LIVE_MY_WORK/LIVE_TEAM_WORK/PRACTICE_COACHING/CAPTURE_FACT/MEDIA_UNDERSTANDING/WEB_RESEARCH/CLARIFY/SOCIAL`,再由固定策略校验当前登录身份和工具权限;客户端不提交 `toolCode`,模型也不能自由指定身份、SQL、URL 或任意工具。知识、训练概况、当前待办、确认式记忆、媒体分析和全网查询均复用现有领域服务;普通图片问答只做本次视觉分析,不要求先具备知识空间,用户明确问现场制度/流程时才校验授权空间并补充 SOP。全网查询先返回 `NEEDS_INPUT`,只有用户明确同意后才调用外部服务。统一响应使用结构化 `status/sourceSummary/citations/resources/actionDraft/clarification`;按钮不从答案文本推断。写入动作只接受 30 分钟有效的不透明 `draftId`,最终确认继续复用既有版本、幂等与可见性规则。每次运行只审计租户、主体、意图、工具、状态、来源类型、耗时和错误码,不保存原始问题、答案或附件。 | diff --git a/mobile-uni/src/pages/user/practice/index.vue b/mobile-uni/src/pages/user/practice/index.vue index 9fb96030..5567edcc 100644 --- a/mobile-uni/src/pages/user/practice/index.vue +++ b/mobile-uni/src/pages/user/practice/index.vue @@ -110,17 +110,26 @@ > {{ status === 'finished' ? '再练一次' : '我已准备好,开始练习' }} - - - 实时陪练音色 + + + 实时陪练人设 - - - {{ realtimeVoiceLabel }} - › + + + - - 当前模型完整官方音色(56项);下次开练生效 +