feat(practice): server-owned realtime personas with knowledge tool calling
- AihrRealtimePersonaRegistry whitelist: owner-calm (default), owner-impatient, digital-mentor (search_knowledge); persona binds default voice - /realtime/session assembles session.update server-side (instructions/tools); browser forwards payload verbatim; Redis session record 30min with sliding renewal - /realtime/tools/invoke validates login identity + Redis session ownership + persona tool whitelist, then read-only searchAuthorized; per-account rate limits - source=realtime skips sop review and knowledge-gap persistence (Beta no-persist) - frontend: persona cards replace 56-voice picker; serialized tool-call chain with soft-landing fallback
This commit is contained in:
+44
@@ -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<List<ScenarioResponse>> 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<List<RealtimePersonaOption>> 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<RealtimeSessionResponse> 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<RealtimeToolInvokeResponse> 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<TurnResponse> turn(@RequestBody TurnRequest request) {
|
||||
return R.ok(practiceSeedService.turn(request, requirePracticeActorIdentity("继续训练"), currentAppUserId()));
|
||||
|
||||
+30
-1
@@ -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<String, Object> 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<String> allowedTools, Long createdAt) {
|
||||
}
|
||||
|
||||
public record TurnRequest(String sessionId, Integer roundIndex, String traineeText, String traineeAudioUrl, Long traineeAudioOssId, Boolean regenerate, String style) {
|
||||
}
|
||||
|
||||
|
||||
+100
@@ -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<String> 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<String, RealtimePersona> 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<RealtimePersona> listPublic() {
|
||||
return List.copyOf(PERSONAS.values());
|
||||
}
|
||||
}
|
||||
+5
@@ -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()) {
|
||||
|
||||
+130
@@ -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<RealtimePersonaOption> 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<String, Object> 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<String, Object> buildSessionUpdate(RealtimePersona persona, String scenarioName) {
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> event = new LinkedHashMap<>();
|
||||
event.put("type", "session.update");
|
||||
event.put("session", session);
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* qwen realtime 工具声明为嵌套格式:{type:'function', function:{name,description,parameters}}。
|
||||
*/
|
||||
private Map<String, Object> searchKnowledgeToolDeclaration() {
|
||||
Map<String, Object> query = new LinkedHashMap<>();
|
||||
query.put("type", "string");
|
||||
query.put("description", "要检索的物业知识库问题");
|
||||
Map<String, Object> parameters = new LinkedHashMap<>();
|
||||
parameters.put("type", "object");
|
||||
parameters.put("properties", Map.of("query", query));
|
||||
parameters.put("required", List.of("query"));
|
||||
Map<String, Object> 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);
|
||||
}
|
||||
}
|
||||
+43
@@ -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;
|
||||
}
|
||||
}
|
||||
+171
@@ -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<Long> 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<SnippetResponse> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -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) {
|
||||
|
||||
+62
@@ -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}"));
|
||||
}
|
||||
}
|
||||
+182
@@ -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<LoginHelper> 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<RealtimeSessionState> 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<LoginHelper> 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<String, Object> session = sessionOf(response);
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> tools = (List<Map<String, Object>>) session.get("tools");
|
||||
assertEquals(1, tools.size());
|
||||
assertEquals("function", tools.get(0).get("type"));
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> function = (Map<String, Object>) tools.get(0).get("function");
|
||||
assertEquals("search_knowledge", function.get("name"));
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> parameters = (Map<String, Object>) 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<LoginHelper> 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<LoginHelper> 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<LoginHelper> 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<String, Object> sessionUpdate) {
|
||||
assertEquals("session.update", sessionUpdate.get("type"));
|
||||
Map<String, Object> 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<String, Object> transcription = (Map<String, Object>) session.get("input_audio_transcription");
|
||||
assertEquals("qwen3-asr-flash-realtime", transcription.get("model"));
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> turnDetection = (Map<String, Object>) session.get("turn_detection");
|
||||
assertEquals("semantic_vad", turnDetection.get("type"));
|
||||
assertFalse(session.containsKey("enable_search"));
|
||||
}
|
||||
|
||||
private static Map<String, Object> sessionOf(RealtimeSessionResponse response) {
|
||||
return castSession(response.sessionUpdate());
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Map<String, Object> castSession(Map<String, Object> sessionUpdate) {
|
||||
return (Map<String, Object>) sessionUpdate.get("session");
|
||||
}
|
||||
}
|
||||
+201
@@ -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<LoginHelper> login = mockTenant()) {
|
||||
ServiceException error = assertThrows(ServiceException.class,
|
||||
() -> service.invoke(request("not-json"), USERNAME, USER_ID));
|
||||
assertEquals(410, error.getCode());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void foreignSessionReturns403() {
|
||||
try (MockedStatic<LoginHelper> 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<LoginHelper> 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<LoginHelper> 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<LoginHelper> 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<LoginHelper> 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<LoginHelper> 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<LoginHelper> 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<LoginHelper> login = mockTenant()) {
|
||||
ServiceException error = assertThrows(ServiceException.class,
|
||||
() -> service.invoke(request("{\"query\":\"q\"}"), USERNAME, USER_ID));
|
||||
assertEquals(503, error.getCode());
|
||||
}
|
||||
}
|
||||
|
||||
private void stubPrincipalWithSpaces(Set<Long> 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<LoginHelper> mockTenant() {
|
||||
MockedStatic<LoginHelper> login = Mockito.mockStatic(LoginHelper.class);
|
||||
login.when(LoginHelper::getTenantId).thenReturn("000000");
|
||||
return login;
|
||||
}
|
||||
}
|
||||
+8
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user