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:
2026-07-25 11:13:04 +08:00
parent 3a19b475a8
commit 35b37b07f7
17 changed files with 1180 additions and 214 deletions
@@ -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()));
@@ -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) {
}
@@ -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());
}
}
@@ -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()) {
@@ -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);
}
}
@@ -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;
}
}
@@ -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;
}
}
}
@@ -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) {
@@ -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}"));
}
}
@@ -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");
}
}
@@ -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;
}
}
@@ -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() {