diff --git a/backend/ruoyi-admin/src/main/resources/application.yml b/backend/ruoyi-admin/src/main/resources/application.yml index fc108c2d..9a7502e2 100644 --- a/backend/ruoyi-admin/src/main/resources/application.yml +++ b/backend/ruoyi-admin/src/main/resources/application.yml @@ -224,6 +224,14 @@ lock4j: # 分布式锁的超时时间,默认为 30 秒 expire: 30000 +--- # 受约束 Agent 新运行时仅做影子观察;生产与开发默认都关闭 +aihr: + agent: + grounded-mode: ${AIHR_AGENT_GROUNDED_MODE:OFF} + grounded-shadow-threads: ${AIHR_AGENT_GROUNDED_SHADOW_THREADS:2} + grounded-shadow-queue-capacity: ${AIHR_AGENT_GROUNDED_SHADOW_QUEUE_CAPACITY:32} + grounded-shadow-timeout-ms: ${AIHR_AGENT_GROUNDED_SHADOW_TIMEOUT_MS:4000} + --- # Actuator 监控端点的配置项 management: endpoints: diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentOrchestrator.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentOrchestrator.java index a1bd4888..d5d91a44 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentOrchestrator.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentOrchestrator.java @@ -17,6 +17,7 @@ import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver; import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService; import org.dromara.aihr.knowledge.service.AihrFormalPolicyClassifier; import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService.MediaMode; +import org.dromara.aihr.agent.shadow.AihrAgentShadowService; import org.dromara.aihr.webai.AihrWebAiDto; import org.dromara.aihr.webai.AihrWebAiService; import org.dromara.common.core.exception.ServiceException; @@ -42,6 +43,7 @@ public class AihrAgentOrchestrator { private final AihrAgentActionService actionService; private final AihrWebAiService webAiService; private final AihrAgentAuditService auditService; + private final AihrAgentShadowService shadowService; public AgentResponse handle(AgentRequest request) { return execute(request, null, false); @@ -81,12 +83,19 @@ public class AihrAgentOrchestrator { errorCode = "INTERNAL"; throw ex; } finally { + long durationMs = (System.nanoTime() - started) / 1_000_000L; if (auditService != null && plan != null && principal != null) { AgentResponse audited = response == null ? failed(plan, request, errorCode) : response; - auditService.record(principal, plan, audited, - (System.nanoTime() - started) / 1_000_000L, errorCode); + auditService.record(principal, plan, audited, durationMs, errorCode); + } + if (shadowService != null && plan != null && principal != null && response != null) { + try { + shadowService.observe(request, plan, response, principal, durationMs, media); + } catch (RuntimeException ignored) { + // Shadow observation must never alter the already-computed legacy response. + } } } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/answer/AihrClaimReferenceValidator.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/answer/AihrClaimReferenceValidator.java index 7d291a7e..a1eb1898 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/answer/AihrClaimReferenceValidator.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/answer/AihrClaimReferenceValidator.java @@ -86,7 +86,7 @@ public class AihrClaimReferenceValidator { if (!claim.citationIds().isEmpty() && referencedEvidence.stream() .noneMatch(value -> documentEntails(value, claim.text()))) return false; if ("CAPABILITY".equals(claim.claimType()) - && referencedFacts.stream().noneMatch(fact -> "CAPABILITY".equals(fact.factType()))) return false; + && referencedFacts.stream().noneMatch(fact -> capabilityFactEntails(fact, claim.text()))) return false; if ("REALTIME_STATUS".equals(claim.claimType()) && referencedFacts.stream().noneMatch(fact -> currentFact(fact, now))) return false; if (!numbersSupported(claim, evidence, facts)) return false; @@ -105,6 +105,16 @@ public class AihrClaimReferenceValidator { return fact != null && fact.expiresAt() != null && fact.expiresAt().isAfter(now); } + private static boolean capabilityFactEntails(VerifiedFact fact, String claimText) { + if (fact == null || !"CAPABILITY".equals(fact.factType()) || !(fact.value() instanceof Map values)) { + return false; + } + Object supported = values.get("claimText"); + String claim = normalizedStatement(claimText); + String evidence = normalizedStatement(supported == null ? "" : String.valueOf(supported)); + return claim.length() >= 2 && evidence.contains(claim); + } + private static boolean documentEntails(DocumentEvidence evidence, String claimText) { if (evidence == null || evidence.content() == null) return false; String claim = normalizedStatement(claimText); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/answer/AihrGroundedAnswerComposer.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/answer/AihrGroundedAnswerComposer.java index e1125352..8c4c9ff8 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/answer/AihrGroundedAnswerComposer.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/answer/AihrGroundedAnswerComposer.java @@ -11,15 +11,19 @@ import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.SemanticQueryPlan; import org.dromara.aihr.service.AihrModelSeedService; import org.springframework.stereotype.Service; +import java.time.Duration; import java.util.ArrayList; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; @Service public class AihrGroundedAnswerComposer { + static final Duration COMPOSITION_TIMEOUT = Duration.ofSeconds(4); + private static final Set ROOT_FIELDS = Set.of( "status", "summary", "confirmedStatements", "steps", "missingInformation", "clarificationQuestion" ); @@ -77,7 +81,8 @@ public class AihrGroundedAnswerComposer { "conflicts", decision.conflicts(), "decisionStatus", decision.status().name() )); - Optional output = modelService.tryChat(SYSTEM_PROMPT, input, 0.0); + Optional output = modelService.tryStructuredJson( + SYSTEM_PROMPT, input, 1_600, COMPOSITION_TIMEOUT); GroundedAnswer answer = output.map(value -> parse(value, decision.status())).orElse(null); return answer != null && validator.valid(answer, decision) ? answer : compositionFallback(decision); } catch (RuntimeException ex) { @@ -137,12 +142,31 @@ public class AihrGroundedAnswerComposer { } private static GroundedAnswer compositionFallback(DecisionResult decision) { + List deterministicFacts = decision.verifiedFacts().stream() + .map(AihrGroundedAnswerComposer::deterministicFactClaim) + .flatMap(Optional::stream) + .toList(); + if (!deterministicFacts.isEmpty()) { + return new GroundedAnswer(decision.status(), safeSummary(decision.status()), deterministicFacts, + List.of(), decision.missingInformation(), null); + } List missing = new ArrayList<>(decision.missingInformation()); missing.add("ANSWER_COMPOSITION_UNAVAILABLE"); return new GroundedAnswer(DecisionStatus.PARTIAL, "已经取得部分可信结果,但暂时无法完成可靠整理。", List.of(), List.of(), List.copyOf(new java.util.LinkedHashSet<>(missing)), null); } + private static Optional deterministicFactClaim( + org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.VerifiedFact fact) { + if (fact == null || !"CAPABILITY".equals(fact.factType()) || !(fact.value() instanceof Map value)) { + return Optional.empty(); + } + Object rawClaim = value.get("claimText"); + String claimText = rawClaim == null ? "" : String.valueOf(rawClaim).trim(); + if (claimText.isBlank() || claimText.length() > 500) return Optional.empty(); + return Optional.of(new GroundedClaim(claimText, "CAPABILITY", List.of(), List.of(fact.factRef()))); + } + private static String clarification(List missing) { return missing == null || missing.isEmpty() ? "请补充当前问题所需的适用信息。" : "请补充:" + String.join("、", missing); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/context/AihrAgentContextResolver.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/context/AihrAgentContextResolver.java index 0ee48b7d..2716a7ed 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/context/AihrAgentContextResolver.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/context/AihrAgentContextResolver.java @@ -8,6 +8,7 @@ import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.ResolvedContext; import org.dromara.aihr.service.AihrModelSeedService; import org.springframework.stereotype.Service; +import java.time.Duration; import java.util.ArrayList; import java.util.HashSet; import java.util.List; @@ -21,6 +22,8 @@ import java.util.regex.Pattern; @Service public class AihrAgentContextResolver { + static final Duration CONTEXT_RESOLUTION_TIMEOUT = Duration.ofSeconds(3); + private static final Set ROOT_FIELDS = Set.of( "relation", "currentGoal", "referencedOrdinal", "clarificationReasons" ); @@ -53,7 +56,7 @@ public class AihrAgentContextResolver { ConversationState state = rawState == null ? ConversationState.empty(0L) : rawState; Integer deterministicOrdinal = ordinal(query); ContextRelation deterministicRelation = deterministicRelation(query, state, deterministicOrdinal); - ModelResolution model = modelResolution(query, state).orElse(null); + ModelResolution model = hasConversationState(state) ? modelResolution(query, state).orElse(null) : null; ContextRelation relation = deterministicRelation != null ? deterministicRelation : model == null ? ContextRelation.NEW_TOPIC : model.relation(); if (blank(state.currentGoal()) && relation != ContextRelation.NEW_TOPIC) relation = ContextRelation.NEW_TOPIC; @@ -76,6 +79,13 @@ public class AihrAgentContextResolver { state.version()); } + private static boolean hasConversationState(ConversationState state) { + return !blank(state.currentGoal()) || !state.entities().isEmpty() || !state.userAssertions().isEmpty() + || !state.factReferences().isEmpty() || !state.evidenceRefs().isEmpty() + || !state.missingInformation().isEmpty() || !state.conflicts().isEmpty() + || state.lastDecisionStatus() != null; + } + private Optional modelResolution(String query, ConversationState state) { if (query.isBlank() || objectMapper == null || modelService == null) return Optional.empty(); try { @@ -89,7 +99,8 @@ public class AihrAgentContextResolver { "lastDecisionStatus", state.lastDecisionStatus() == null ? "" : state.lastDecisionStatus().name() ) ); - Optional output = modelService.tryChat(SYSTEM_PROMPT, objectMapper.writeValueAsString(input), 0.0); + Optional output = modelService.tryStructuredJson( + SYSTEM_PROMPT, objectMapper.writeValueAsString(input), 600, CONTEXT_RESOLUTION_TIMEOUT); return output.flatMap(this::parse); } catch (RuntimeException ex) { return Optional.empty(); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/context/AihrAgentConversationStateService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/context/AihrAgentConversationStateService.java index 60f97c65..421226b8 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/context/AihrAgentConversationStateService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/context/AihrAgentConversationStateService.java @@ -17,6 +17,7 @@ import org.dromara.common.core.constant.HttpStatus; import org.dromara.common.core.exception.ServiceException; import org.springframework.dao.DataAccessException; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; @@ -47,6 +48,7 @@ public class AihrAgentConversationStateService { private final AihrAgentToolRegistry toolRegistry; private final Clock clock; + @Autowired public AihrAgentConversationStateService(JdbcTemplate jdbcTemplate, ObjectMapper objectMapper, AihrAgentToolRegistry toolRegistry) { this(jdbcTemplate, objectMapper, toolRegistry, Clock.systemUTC()); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/evidence/AihrAgentEvidenceEvaluator.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/evidence/AihrAgentEvidenceEvaluator.java index 12f2e1a8..1359fb1b 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/evidence/AihrAgentEvidenceEvaluator.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/evidence/AihrAgentEvidenceEvaluator.java @@ -84,11 +84,12 @@ public class AihrAgentEvidenceEvaluator { if (!conflicts.isEmpty()) { return decision(DecisionStatus.CONFLICT, evidence.values(), facts.values(), missing, conflicts, reasons); } + boolean hasVerified = !evidence.isEmpty() || !facts.isEmpty(); if (plan.needsClarification()) { missing.addAll(plan.clarificationReasons()); - return decision(DecisionStatus.NEEDS_INPUT, evidence.values(), facts.values(), missing, conflicts, reasons); + return decision(hasVerified ? DecisionStatus.PARTIAL : DecisionStatus.NEEDS_INPUT, + evidence.values(), facts.values(), missing, conflicts, reasons); } - boolean hasVerified = !evidence.isEmpty() || !facts.isEmpty(); DecisionStatus status = !hasVerified ? DecisionStatus.NO_EVIDENCE : missing.isEmpty() ? DecisionStatus.ANSWERED : DecisionStatus.PARTIAL; return decision(status, evidence.values(), facts.values(), missing, conflicts, reasons); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/evidence/AihrAgentEvidenceSupportClassifier.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/evidence/AihrAgentEvidenceSupportClassifier.java index c7fde466..f181e4e3 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/evidence/AihrAgentEvidenceSupportClassifier.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/evidence/AihrAgentEvidenceSupportClassifier.java @@ -6,6 +6,7 @@ import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.DocumentEvidence; import org.dromara.aihr.service.AihrModelSeedService; import org.springframework.stereotype.Service; +import java.time.Duration; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; @@ -28,6 +29,7 @@ public class AihrAgentEvidenceSupportClassifier { "EXCEPTION", "POLICY_DEADLINE", "KNOWLEDGE_FACT" ); private static final Pattern NEED_CODE = Pattern.compile("[A-Z][A-Z0-9_]{1,63}"); + static final Duration CLASSIFICATION_TIMEOUT = Duration.ofMillis(1_800); private static final String SYSTEM_PROMPT = """ 你是企业知识证据的受约束支持范围分类器。输入只包含原问题、本次证据需求以及已经通过 授权和治理门禁的文档片段。只输出 JSON:{"evidence":[...]}; evidence 每项字段必须且只能是 @@ -56,8 +58,9 @@ public class AihrAgentEvidenceSupportClassifier { return candidates; } try { - Optional output = modelService.tryChat(SYSTEM_PROMPT, - objectMapper.writeValueAsString(modelInput(originalQuery, requested, candidates)), 0.0); + Optional output = modelService.tryStructuredJson(SYSTEM_PROMPT, + objectMapper.writeValueAsString(modelInput(originalQuery, requested, candidates)), + 1_200, CLASSIFICATION_TIMEOUT); return output.map(value -> parse(value, requested, candidates)).orElse(candidates); } catch (RuntimeException ex) { return candidates; diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/grounding/AihrAgentGroundingDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/grounding/AihrAgentGroundingDto.java index 93e0502e..7099d976 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/grounding/AihrAgentGroundingDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/grounding/AihrAgentGroundingDto.java @@ -196,7 +196,8 @@ public final class AihrAgentGroundingDto { List missingInformation, List conflicts, List observations, - String auditRef + String auditRef, + Map timings ) { public ToolResult { evidence = copy(evidence); @@ -204,6 +205,14 @@ public final class AihrAgentGroundingDto { missingInformation = copy(missingInformation); conflicts = copy(conflicts); observations = copy(observations); + timings = timings == null ? Map.of() : Map.copyOf(timings); + } + + public ToolResult(String callId, String toolCode, ToolStatus status, List evidence, + List facts, List missingInformation, List conflicts, + List observations, String auditRef) { + this(callId, toolCode, status, evidence, facts, missingInformation, conflicts, observations, + auditRef, Map.of()); } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/planning/AihrAgentSemanticPlanner.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/planning/AihrAgentSemanticPlanner.java index 944ebcd7..29213479 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/planning/AihrAgentSemanticPlanner.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/planning/AihrAgentSemanticPlanner.java @@ -4,15 +4,18 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.EntityCandidate; import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.EvidenceNeed; +import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.ResolvedContext; import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.SemanticQueryPlan; import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.ToolCall; import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.AmountMention; import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryPlan; import org.dromara.aihr.knowledge.service.AihrRagAmountSemanticPlanner; import org.dromara.aihr.service.AihrModelSeedService; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.time.Clock; +import java.time.Duration; import java.time.LocalDate; import java.time.YearMonth; import java.util.ArrayList; @@ -29,6 +32,7 @@ import java.util.regex.Pattern; @Service public class AihrAgentSemanticPlanner { + static final Duration PLANNING_TIMEOUT = Duration.ofSeconds(5); private static final int MAX_ALTERNATES = 3; private static final int MAX_ACTIONS = 3; private static final Set ROOT_FIELDS = Set.of( @@ -37,10 +41,14 @@ public class AihrAgentSemanticPlanner { ); private static final Set ACTION_FIELDS = Set.of("id", "toolCode", "arguments", "evidenceNeeds"); private static final Set NEED_FIELDS = Set.of("code", "required", "description"); - private static final Set TOOLS = Set.of("KNOWLEDGE_SEARCH", "MY_CURRENT_TASKS"); + private static final Set TOOLS = Set.of("KNOWLEDGE_SEARCH", "MY_CURRENT_TASKS", "CAPABILITY_QUERY"); private static final Map> TOOL_ARGUMENTS = Map.of( "KNOWLEDGE_SEARCH", Set.of("query", "domain"), - "MY_CURRENT_TASKS", Set.of("dateRange", "startDate", "endDate", "status") + "MY_CURRENT_TASKS", Set.of("dateRange", "startDate", "endDate", "status"), + "CAPABILITY_QUERY", Set.of("capabilityCode") + ); + private static final Set CAPABILITY_CODES = Set.of( + "CRM", "KNOWLEDGE", "CURRENT_TASKS", "TRAINING", "WORK_ORDERS" ); private static final Set INTENTS = Set.of( "KNOWLEDGE_QA", "PROCESS_GUIDANCE", "ELIGIBILITY_CHECK", "LIVE_MY_WORK", "CAPABILITY_QUERY", @@ -54,6 +62,21 @@ public class AihrAgentSemanticPlanner { "space", "spaceid", "spaceids", "appid", "clientid", "extpartyid", "roles", "sql", "url" ); private static final Pattern SAFE_CODE = Pattern.compile("[A-Z][A-Z0-9_]{1,63}"); + private static final Set FINANCE_TERMS = Set.of( + "报销", "借款", "发票", "采购", "自采", "财务", "报备", "审批" + ); + private static final Set PROCESS_TERMS = Set.of( + "怎么", "如何", "流程", "步骤", "走账", "办理", "手续", "操作" + ); + private static final Set ELIGIBILITY_TERMS = Set.of( + "能否", "是否", "可以", "能不能", "条件", "适用", "额度", "限额", "超额", "以下", "以内" + ); + private static final Set KNOWLEDGE_TERMS = Set.of( + "制度", "规定", "标准", "手册", "话术", "催费", "跟进", "要求", "时限", "多久" + ); + private static final Set MATERIAL_TERMS = Set.of( + "材料", "资料", "文件", "凭证" + ); private static final String SYSTEM_PROMPT = """ 你是企业员工助手的受约束语义规划器。输入仅包含原问题和服务端抽取的候选实体。 只输出 JSON,根字段必须且只能是 normalizedQuery, alternateQueries, intents, domain, @@ -63,12 +86,15 @@ public class AihrAgentSemanticPlanner { WORK_MANAGEMENT, TRAINING, OPERATIONS, GENERAL。 evidenceNeeds 每项字段只能是 code, required, description。 actions 最多3项,每项字段只能是 id, toolCode, arguments, evidenceNeeds;toolCode 只允许 - KNOWLEDGE_SEARCH 或 MY_CURRENT_TASKS。KNOWLEDGE_SEARCH arguments 只允许 query、domain; - MY_CURRENT_TASKS arguments 只允许 dateRange、startDate、endDate、status。 + KNOWLEDGE_SEARCH、MY_CURRENT_TASKS 或 CAPABILITY_QUERY。KNOWLEDGE_SEARCH arguments 只允许 query、domain; + MY_CURRENT_TASKS arguments 只允许 dateRange、startDate、endDate、status;CAPABILITY_QUERY arguments + 只允许 capabilityCode,且只能是 CRM、KNOWLEDGE、CURRENT_TASKS、TRAINING、WORK_ORDERS 之一。 禁止输出 tenant/user/employee/project/space/app/client/extPartyId/role/SQL/URL 等授权或身份参数。 保留问题中的数字、币种、日期、期限、比较符、否定和版本,不得改写候选事实。 - RAG 只代表 KNOWLEDGE_SEARCH;实时待办必须使用 MY_CURRENT_TASKS。需要两类证据时同时规划两个动作。 - 无法确定时输出 CLARIFY,不能猜测系统能力或真实业务状态。不要输出 markdown 或解释文字。 + RAG 只代表 KNOWLEDGE_SEARCH;实时待办必须使用 MY_CURRENT_TASKS;系统是否具备某能力必须使用 + CAPABILITY_QUERY,文档提到系统不等于系统已接入。需要多类事实或证据时同时规划对应动作。 + 只在缺少会改变结论、且不能通过上述工具取得的用户事实时设置 needsClarification=true。 + 只要可以先查询事实或证据,就必须先输出动作,不能用低价值澄清代替工具调用。不要输出 markdown 或解释文字。 """; private final ObjectMapper objectMapper; @@ -76,6 +102,7 @@ public class AihrAgentSemanticPlanner { private final AihrRagAmountSemanticPlanner amountPlanner; private final Clock clock; + @Autowired public AihrAgentSemanticPlanner(ObjectMapper objectMapper, AihrModelSeedService modelService, AihrRagAmountSemanticPlanner amountPlanner) { this(objectMapper, modelService, amountPlanner, Clock.systemDefaultZone()); @@ -90,30 +117,46 @@ public class AihrAgentSemanticPlanner { } public SemanticQueryPlan plan(String rawQuery) { + return plan(rawQuery, null); + } + + public SemanticQueryPlan plan(String rawQuery, ResolvedContext resolvedContext) { String original = clean(rawQuery); List candidates = deterministicCandidates(original); - SemanticQueryPlan fallback = fallback(original, candidates); + PlanningHint hint = deterministicHint(original, resolvedContext, candidates); + SemanticQueryPlan fallback = fallback(original, candidates, hint, "DETERMINISTIC_FALLBACK"); if (original.isBlank() || objectMapper == null || modelService == null) return fallback; try { - Optional output = modelService.tryChat(SYSTEM_PROMPT, modelInput(original, candidates), 0.0); - return output.map(value -> parse(original, candidates, value)).orElse(fallback); + Optional output = modelService.tryStructuredJson( + SYSTEM_PROMPT, modelInput(original, resolvedContext, candidates, hint), 1_200, + PLANNING_TIMEOUT); + return output.map(value -> parse(original, candidates, hint, value)).orElseGet(() -> + fallback(original, candidates, hint, "DETERMINISTIC_FALLBACK_MODEL_UNAVAILABLE")); } catch (RuntimeException ex) { - return fallback; + return fallback(original, candidates, hint, "DETERMINISTIC_FALLBACK_MODEL_FAILURE"); } } - private String modelInput(String original, List candidates) { + private String modelInput(String original, ResolvedContext resolvedContext, List candidates, + PlanningHint hint) { try { Map input = new LinkedHashMap<>(); input.put("originalQuery", original); + input.put("resolvedContext", Map.of( + "relation", resolvedContext == null || resolvedContext.relation() == null + ? "NEW_TOPIC" : resolvedContext.relation().name(), + "currentGoal", resolvedContext == null ? "" : clean(resolvedContext.currentGoal()) + )); input.put("entityCandidates", candidates); + input.put("candidatePlanningHints", hint.asModelInput()); return objectMapper.writeValueAsString(input); } catch (Exception ex) { return ""; } } - private SemanticQueryPlan parse(String original, List candidates, String raw) { + private SemanticQueryPlan parse(String original, List candidates, PlanningHint hint, + String raw) { try { JsonNode root = objectMapper.readTree(raw); if (!root.isObject() || !exactFields(root, ROOT_FIELDS) @@ -121,21 +164,40 @@ public class AihrAgentSemanticPlanner { || !root.path("evidenceNeeds").isArray() || !root.path("actions").isArray() || !root.path("clarificationReasons").isArray() || !root.path("needsClarification").isBoolean()) { - return fallback(original, candidates); + return fallback(original, candidates, hint, "DETERMINISTIC_FALLBACK_INVALID_SCHEMA"); } String normalized = clean(root.path("normalizedQuery").asText()); - if (normalized.isBlank() || normalized.length() > 600 || changesProtectedFacts(original, normalized, candidates)) { - return fallback(original, candidates); + if (normalized.isBlank() || normalized.length() > 600) { + return fallback(original, candidates, hint, "DETERMINISTIC_FALLBACK_INVALID_NORMALIZED_QUERY"); } - Set intents = enumSet(root.path("intents"), INTENTS); + if (changesProtectedFacts(original, normalized, candidates)) { + return fallback(original, candidates, hint, "DETERMINISTIC_FALLBACK_PROTECTED_FACT_REJECTED"); + } + Set modelIntents = enumSet(root.path("intents"), INTENTS); String domain = root.path("domain").asText(); - if (intents.isEmpty() || !DOMAINS.contains(domain)) return fallback(original, candidates); - List needs = evidenceNeeds(root.path("evidenceNeeds")); - List actions = actions(root.path("actions"), candidates); - boolean clarification = root.path("needsClarification").asBoolean(); - if (!clarification && actions.isEmpty() && !intents.equals(Set.of("CLARIFY"))) { - return fallback(original, candidates); + if (modelIntents.isEmpty() || !DOMAINS.contains(domain)) { + return fallback(original, candidates, hint, "DETERMINISTIC_FALLBACK_INVALID_INTENT_DOMAIN"); } + Set intents = reconciledIntents(modelIntents, hint); + domain = reconciledDomain(domain, hint); + List needs = ensureEvidenceNeeds( + evidenceNeeds(root.path("evidenceNeeds")), intents, candidates, hint.materialNeed()); + List actions = actions(root.path("actions"), candidates); + if (root.path("actions").size() > 0 && actions.isEmpty()) { + return fallback(original, candidates, hint, "DETERMINISTIC_FALLBACK_INVALID_ACTION"); + } + actions = ensureExecutableActions(original, domain, intents, needs, actions, candidates); + boolean clarification = root.path("needsClarification").asBoolean() && actions.isEmpty(); + if (!actions.isEmpty() && intents.contains("CLARIFY")) { + Set executableIntents = new LinkedHashSet<>(intents); + executableIntents.remove("CLARIFY"); + intents = Set.copyOf(executableIntents); + } + if (!clarification && actions.isEmpty() && !intents.equals(Set.of("CLARIFY"))) { + return fallback(original, candidates, hint, "DETERMINISTIC_FALLBACK_NON_EXECUTABLE_PLAN"); + } + List clarificationReasons = clarification + ? strings(root.path("clarificationReasons"), 5, 200) : List.of(); return new SemanticQueryPlan( original, normalized, @@ -146,12 +208,12 @@ public class AihrAgentSemanticPlanner { needs, actions, clarification, - strings(root.path("clarificationReasons"), 5, 200), + clarificationReasons, "configured-chat", - "SEMANTIC_MODEL_VALIDATED" + hint.executable() ? "SEMANTIC_MODEL_VALIDATED_WITH_SERVER_HINTS" : "SEMANTIC_MODEL_VALIDATED" ); } catch (Exception ex) { - return fallback(original, candidates); + return fallback(original, candidates, hint, "DETERMINISTIC_FALLBACK_INVALID_JSON"); } } @@ -181,6 +243,11 @@ public class AihrAgentSemanticPlanner { if (TOOL_ARGUMENTS.get(toolCode).contains(key)) arguments.put(key, value); } if ("MY_CURRENT_TASKS".equals(toolCode)) applyDeterministicDate(arguments, candidates); + if ("CAPABILITY_QUERY".equals(toolCode) && arguments.containsKey("capabilityCode")) { + String capabilityCode = arguments.get("capabilityCode").toUpperCase(java.util.Locale.ROOT); + if (!CAPABILITY_CODES.contains(capabilityCode)) return List.of(); + arguments.put("capabilityCode", capabilityCode); + } result.add(new ToolCall(id, toolCode, arguments, strings(action.path("evidenceNeeds"), 10, 64))); } @@ -210,6 +277,103 @@ public class AihrAgentSemanticPlanner { return List.copyOf(result); } + private static List ensureEvidenceNeeds(List modelNeeds, Set intents, + List candidates, boolean materialNeed) { + Map result = new LinkedHashMap<>(); + for (EvidenceNeed need : modelNeeds) result.put(need.code(), need); + if (intents.contains("LIVE_MY_WORK")) { + result.putIfAbsent("CURRENT_TASKS", new EvidenceNeed("CURRENT_TASKS", true, "本人当前待办")); + } + if (intents.contains("CAPABILITY_QUERY")) { + result.putIfAbsent("CAPABILITY_REGISTRY", + new EvidenceNeed("CAPABILITY_REGISTRY", true, "服务端实际注册并授权的能力")); + } + if (materialNeed) { + result.putIfAbsent("REQUIRED_MATERIALS", + new EvidenceNeed("REQUIRED_MATERIALS", true, "办理所需材料或凭证")); + } + if (intents.contains("PROCESS_GUIDANCE") && !result.containsKey("REQUIRED_MATERIALS")) { + result.putIfAbsent("PROCESS_STEPS", new EvidenceNeed("PROCESS_STEPS", true, "正式处理流程")); + } + if (intents.contains("ELIGIBILITY_CHECK")) { + result.putIfAbsent("APPLICABILITY", new EvidenceNeed("APPLICABILITY", true, "适用条件")); + if (candidates.stream().anyMatch(candidate -> "AMOUNT".equals(candidate.type()))) { + result.putIfAbsent("AMOUNT_BASIS", new EvidenceNeed("AMOUNT_BASIS", true, "金额适用口径")); + } + } + if (intents.contains("KNOWLEDGE_QA") && !intents.contains("CAPABILITY_QUERY") + && result.values().stream().noneMatch(need -> documentNeed(need.code()))) { + result.putIfAbsent("KNOWLEDGE_EVIDENCE", + new EvidenceNeed("KNOWLEDGE_EVIDENCE", true, "企业资料依据")); + } + return List.copyOf(result.values()); + } + + private static List ensureExecutableActions(String original, String domain, Set intents, + List needs, List modelActions, + List candidates) { + List result = new ArrayList<>(); + Set tools = new LinkedHashSet<>(); + for (ToolCall call : modelActions) { + if (!tools.add(call.toolCode())) continue; + if ("KNOWLEDGE_SEARCH".equals(call.toolCode())) { + result.add(new ToolCall(call.callId(), call.toolCode(), + Map.of("query", original, "domain", domain), call.evidenceNeeds())); + } else { + result.add(call); + } + } + if (intents.contains("LIVE_MY_WORK") && tools.add("MY_CURRENT_TASKS")) { + Map arguments = new LinkedHashMap<>(); + applyDeterministicDate(arguments, candidates); + result.add(new ToolCall("server_tasks", "MY_CURRENT_TASKS", arguments, + needCodes(needs, AihrAgentSemanticPlanner::taskNeed))); + } + if (intents.contains("CAPABILITY_QUERY") && tools.add("CAPABILITY_QUERY")) { + String capabilityCode = capabilityCode(original); + Map arguments = capabilityCode == null + ? Map.of() : Map.of("capabilityCode", capabilityCode); + result.add(new ToolCall("server_capabilities", "CAPABILITY_QUERY", arguments, + needCodes(needs, AihrAgentSemanticPlanner::capabilityNeed))); + } + boolean knowledgeIntent = intents.stream().anyMatch(Set.of( + "KNOWLEDGE_QA", "PROCESS_GUIDANCE", "ELIGIBILITY_CHECK")::contains); + if (knowledgeIntent && tools.add("KNOWLEDGE_SEARCH")) { + result.add(new ToolCall("server_knowledge", "KNOWLEDGE_SEARCH", + Map.of("query", original, "domain", domain), + needCodes(needs, AihrAgentSemanticPlanner::documentNeed))); + } + List bounded = result.size() > MAX_ACTIONS ? result.subList(0, MAX_ACTIONS) : result; + return bounded.stream().map(call -> bindEvidenceNeeds(call, needs)).toList(); + } + + private static ToolCall bindEvidenceNeeds(ToolCall call, List needs) { + java.util.function.Predicate predicate = switch (call.toolCode()) { + case "MY_CURRENT_TASKS" -> AihrAgentSemanticPlanner::taskNeed; + case "CAPABILITY_QUERY" -> AihrAgentSemanticPlanner::capabilityNeed; + case "KNOWLEDGE_SEARCH" -> AihrAgentSemanticPlanner::documentNeed; + default -> code -> false; + }; + return new ToolCall(call.callId(), call.toolCode(), call.arguments(), needCodes(needs, predicate)); + } + + private static List needCodes(List needs, + java.util.function.Predicate predicate) { + return needs.stream().map(EvidenceNeed::code).filter(predicate).toList(); + } + + private static boolean taskNeed(String code) { + return code != null && (code.contains("CURRENT_TASK") || code.startsWith("MY_")); + } + + private static boolean capabilityNeed(String code) { + return code != null && code.contains("CAPABILITY"); + } + + private static boolean documentNeed(String code) { + return !taskNeed(code) && !capabilityNeed(code); + } + private List deterministicCandidates(String original) { List result = new ArrayList<>(); QueryPlan amountPlan = amountPlanner == null ? null : amountPlanner.plan(original); @@ -262,6 +426,85 @@ public class AihrAgentSemanticPlanner { attributes, "DETERMINISTIC")); } + private static PlanningHint deterministicHint(String original, ResolvedContext resolved, + List candidates) { + String currentGoal = resolved == null ? "" : clean(resolved.currentGoal()); + String signalText = currentGoal.isBlank() || currentGoal.equals(original) + ? original : original + " " + currentGoal; + Set intents = new LinkedHashSet<>(); + String domain = "GENERAL"; + boolean finance = containsAny(signalText, FINANCE_TERMS); + boolean tasks = containsAny(signalText, Set.of("待办", "我的任务", "本人任务")); + boolean explicitProcess = containsAny(signalText, PROCESS_TERMS); + boolean materials = materialQuestion(signalText); + boolean process = explicitProcess || materials; + boolean capability = capabilityQuestion(signalText); + boolean knowledge = finance || explicitProcess || containsAny(signalText, KNOWLEDGE_TERMS); + + if (finance) { + domain = signalText.contains("报销") || signalText.contains("采购") || signalText.contains("自采") + ? "FINANCE_REIMBURSEMENT" : "FINANCE_POLICY"; + } else if (tasks) { + domain = "WORK_MANAGEMENT"; + } else if (knowledge) { + domain = "OPERATIONS"; + } + + if (tasks) intents.add("LIVE_MY_WORK"); + if (capability) intents.add("CAPABILITY_QUERY"); + if (knowledge) intents.add("KNOWLEDGE_QA"); + if (process) intents.add("PROCESS_GUIDANCE"); + if (finance && (!candidates.isEmpty() || containsAny(signalText, ELIGIBILITY_TERMS))) { + intents.add("ELIGIBILITY_CHECK"); + } + return new PlanningHint(Set.copyOf(intents), domain, "HIGH", "DETERMINISTIC_CANDIDATE", materials); + } + + private static Set reconciledIntents(Set modelIntents, PlanningHint hint) { + if (!hint.executable()) return modelIntents; + Set result = new LinkedHashSet<>(modelIntents); + result.remove("CLARIFY"); + result.addAll(hint.intents()); + return Set.copyOf(result); + } + + private static String reconciledDomain(String modelDomain, PlanningHint hint) { + return hint.executable() && !"GENERAL".equals(hint.domain()) ? hint.domain() : modelDomain; + } + + private static boolean capabilityQuestion(String value) { + String upper = value.toUpperCase(java.util.Locale.ROOT); + boolean question = containsAny(value, Set.of( + "能查询哪些", "可以查询哪些", "能查哪些", "可以查哪些", "能查什么", "可以查什么", + "支持哪些", "支持什么", "有什么能力", "能做什么", "是否接入", "有没有接入", "是否支持" + )); + boolean subject = containsAny(value, Set.of( + "账号", "账户", "系统", "模块", "功能", "能力", "查询", "查", "接入" + )) || upper.contains("CRM"); + return question && subject; + } + + private static String capabilityCode(String value) { + String upper = clean(value).toUpperCase(java.util.Locale.ROOT); + if (upper.contains("CRM")) return "CRM"; + if (containsAny(value, Set.of("待办", "任务"))) return "CURRENT_TASKS"; + if (containsAny(value, Set.of("训练", "练习"))) return "TRAINING"; + if (value.contains("工单")) return "WORK_ORDERS"; + if (containsAny(value, Set.of("资料", "知识", "制度", "SOP"))) return "KNOWLEDGE"; + return null; + } + + private static boolean materialQuestion(String value) { + return containsAny(value, MATERIAL_TERMS) && containsAny(value, Set.of( + "需要", "哪些", "什么", "准备", "提交", "提供", "上传", "携带" + )); + } + + private static boolean containsAny(String value, Set terms) { + for (String term : terms) if (value.contains(term)) return true; + return false; + } + private static boolean changesProtectedFacts(String original, String normalized, List candidates) { for (EntityCandidate candidate : candidates) { if (("AMOUNT".equals(candidate.type()) || "DATE_RANGE".equals(candidate.type())) @@ -272,9 +515,32 @@ public class AihrAgentSemanticPlanner { return false; } - private static SemanticQueryPlan fallback(String original, List candidates) { - return new SemanticQueryPlan(original, original, List.of(), Set.of(), "GENERAL", candidates, - List.of(), List.of(), false, List.of(), null, "DETERMINISTIC_FALLBACK"); + private static SemanticQueryPlan fallback(String original, List candidates, PlanningHint hint, + String planSource) { + Set intents = hint.intents(); + List needs = ensureEvidenceNeeds(List.of(), intents, candidates, hint.materialNeed()); + List actions = ensureExecutableActions(original, hint.domain(), intents, needs, List.of(), + candidates); + String source = actions.isEmpty() ? planSource : planSource + "_EXECUTABLE"; + return new SemanticQueryPlan(original, original, List.of(), intents, hint.domain(), candidates, + needs, actions, false, List.of(), null, source); + } + + private record PlanningHint(Set intents, String domain, String confidence, String source, + boolean materialNeed) { + private boolean executable() { + return intents != null && !intents.isEmpty(); + } + + private Map asModelInput() { + return Map.of( + "intents", intents == null ? Set.of() : intents, + "domain", domain, + "confidence", confidence, + "source", source, + "materialNeed", materialNeed + ); + } } private static Set enumSet(JsonNode node, Set allowed) { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/runtime/AihrGroundedAgentPolicy.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/runtime/AihrGroundedAgentPolicy.java index 5a33557c..305905aa 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/runtime/AihrGroundedAgentPolicy.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/runtime/AihrGroundedAgentPolicy.java @@ -25,14 +25,11 @@ public class AihrGroundedAgentPolicy { if (plan == null || call == null || plan.intents().isEmpty()) { throw new ServiceException("Agent 工具计划无效", HttpStatus.FORBIDDEN); } - boolean employee = context.roles().contains("employee"); - boolean supervisor = context.roles().contains("supervisor"); - boolean allowed = switch (call.toolCode()) { - case "KNOWLEDGE_SEARCH" -> employee || supervisor; - case "MY_CURRENT_TASKS" -> employee || supervisor; - default -> false; - }; + boolean allowed = toolAvailable(call.toolCode(), context); if (!allowed) throw new ServiceException("当前身份无权使用该 Agent 工具", HttpStatus.FORBIDDEN); + if ("KNOWLEDGE_SEARCH".equals(call.toolCode()) && context.authorizedKnowledgeSpaceIds().isEmpty()) { + throw new ServiceException("当前主体没有可用于Agent检索的知识空间", HttpStatus.FORBIDDEN); + } if ("MY_CURRENT_TASKS".equals(call.toolCode()) && !plan.intents().contains("LIVE_MY_WORK")) { throw new ServiceException("实时待办工具不允许当前意图", HttpStatus.FORBIDDEN); } @@ -42,4 +39,16 @@ public class AihrGroundedAgentPolicy { throw new ServiceException("知识检索工具不允许当前意图", HttpStatus.FORBIDDEN); } } + + public boolean toolAvailable(String toolCode, ExecutionContext context) { + if (context == null) return false; + boolean employee = context.roles().contains("employee"); + boolean supervisor = context.roles().contains("supervisor"); + if (!employee && !supervisor) return false; + return switch (toolCode) { + case "KNOWLEDGE_SEARCH" -> !context.authorizedKnowledgeSpaceIds().isEmpty(); + case "MY_CURRENT_TASKS", "CAPABILITY_QUERY" -> true; + default -> false; + }; + } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/runtime/AihrGroundedAgentRuntime.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/runtime/AihrGroundedAgentRuntime.java index aefecbb6..025df2b1 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/runtime/AihrGroundedAgentRuntime.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/runtime/AihrGroundedAgentRuntime.java @@ -3,6 +3,7 @@ package org.dromara.aihr.agent.runtime; import org.dromara.aihr.agent.answer.AihrGroundedAnswerComposer; import org.dromara.aihr.agent.evidence.AihrAgentEvidenceEvaluator; import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.DecisionResult; +import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.DecisionStatus; import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.DocumentEvidence; import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.EvidenceNeed; import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.ExecutionContext; @@ -15,6 +16,7 @@ import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.ToolStatus; import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.VerifiedFact; import org.dromara.aihr.agent.tool.AihrAgentToolRegistry; import org.dromara.common.core.exception.ServiceException; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.nio.charset.StandardCharsets; @@ -43,6 +45,7 @@ public class AihrGroundedAgentRuntime { private final RuntimeLimits limits; private final LongSupplier nanoTime; + @Autowired public AihrGroundedAgentRuntime(AihrAgentToolRegistry toolRegistry, AihrGroundedAgentPolicy policy, AihrAgentEvidenceEvaluator evaluator, AihrGroundedAnswerComposer composer) { @@ -64,6 +67,16 @@ public class AihrGroundedAgentRuntime { public RuntimeResult run(String originalQuery, ResolvedContext resolved, SemanticQueryPlan plan, ExecutionContext context) { long started = nanoTime.getAsLong(); + if (clarificationOnly(plan)) { + List missing = List.of("CLARIFICATION_REQUIRED"); + DecisionResult decision = new DecisionResult(DecisionStatus.NEEDS_INPUT, "NEEDS_USER_INPUT", + List.of(), List.of(), missing, List.of(), List.of("ASK_CLARIFICATION")); + long composerStarted = nanoTime.getAsLong(); + GroundedAnswer answer = composer.compose(originalQuery, resolved, plan, decision); + return new RuntimeResult(true, decision, answer, List.of(), + new RuntimeTrace(0, 0, 0, elapsedMillis(started), 0L, elapsedMillis(composerStarted), + List.of(), List.of("ASK_CLARIFICATION"))); + } if (!toolRegistry.supports(plan)) return RuntimeResult.notHandled("PLAN_NOT_EXECUTABLE"); Set callHashes = new LinkedHashSet<>(); Set attemptedNeeds = new LinkedHashSet<>(); @@ -74,6 +87,7 @@ public class AihrGroundedAgentRuntime { int totalCalls = 0; int candidates = 0; int executedRounds = 0; + long evidenceGateMillis = 0L; DecisionResult decision = null; for (int round = 1; round <= limits.maxToolRounds() && !roundCalls.isEmpty(); round++) { @@ -97,7 +111,9 @@ public class AihrGroundedAgentRuntime { actions.add(trace(round, call, bounded)); totalCalls++; } + long gateStarted = nanoTime.getAsLong(); decision = evaluator.evaluate(plan, context, results); + evidenceGateMillis += elapsedMillis(gateStarted); if (elapsedMillis(started) >= limits.maxElapsedMillis()) { stopReasons.add("TIME_BUDGET_EXCEEDED"); break; @@ -113,19 +129,33 @@ public class AihrGroundedAgentRuntime { break; } } - if (decision == null) decision = evaluator.evaluate(plan, context, results); + if (decision == null) { + long gateStarted = nanoTime.getAsLong(); + decision = evaluator.evaluate(plan, context, results); + evidenceGateMillis += elapsedMillis(gateStarted); + } + long composerStarted = nanoTime.getAsLong(); GroundedAnswer answer = composer.compose(originalQuery, resolved, plan, decision); + long composerMillis = elapsedMillis(composerStarted); RuntimeTrace trace = new RuntimeTrace(executedRounds, totalCalls, candidates, elapsedMillis(started), - List.copyOf(actions), List.copyOf(new LinkedHashSet<>(stopReasons))); + evidenceGateMillis, composerMillis, List.copyOf(actions), + List.copyOf(new LinkedHashSet<>(stopReasons))); return new RuntimeResult(true, decision, answer, List.copyOf(results), trace); } private ToolResult execute(SemanticQueryPlan plan, ToolCall call, ExecutionContext context, long started) { + long toolStarted = nanoTime.getAsLong(); try { policy.authorize(plan, call, context); ToolResult result = toolRegistry.executeStored(call, context); - return elapsedMillis(started) >= limits.maxElapsedMillis() - ? failed(call, ToolStatus.TIMEOUT, "TOOL_TIMEOUT") : result; + if (elapsedMillis(started) >= limits.maxElapsedMillis()) { + return timedOut(call, result, toolStarted); + } + Map timings = new LinkedHashMap<>(result.timings()); + timings.put("RUNTIME_TOOL_TOTAL", elapsedMillis(toolStarted)); + return new ToolResult(result.callId(), result.toolCode(), result.status(), result.evidence(), + result.facts(), result.missingInformation(), result.conflicts(), result.observations(), + result.auditRef(), Map.copyOf(timings)); } catch (ServiceException ex) { ToolStatus status = ex.getCode() != null && ex.getCode() == 403 ? ToolStatus.FORBIDDEN : ToolStatus.FAILED; @@ -141,6 +171,14 @@ public class AihrGroundedAgentRuntime { status == ToolStatus.FORBIDDEN ? List.of(reason) : List.of(), List.of(reason), null); } + private ToolResult timedOut(ToolCall call, ToolResult result, long toolStarted) { + Map timings = new LinkedHashMap<>(result == null ? Map.of() : result.timings()); + timings.put("RUNTIME_TOOL_TOTAL", elapsedMillis(toolStarted)); + return new ToolResult(call.callId(), call.toolCode(), ToolStatus.TIMEOUT, List.of(), List.of(), + List.of("TOOL_TIMEOUT"), List.of(), List.of("TOOL_TIMEOUT"), + result == null ? null : result.auditRef(), Map.copyOf(timings)); + } + private static List refinementCalls(SemanticQueryPlan plan, DecisionResult decision, Set attemptedNeeds, Set callHashes, int remainingCalls) { @@ -195,14 +233,15 @@ public class AihrGroundedAgentRuntime { List missing = new ArrayList<>(result.missingInformation()); missing.add("CANDIDATE_BUDGET_EXCEEDED"); return new ToolResult(result.callId(), result.toolCode(), ToolStatus.PARTIAL, evidence, facts, - List.copyOf(new LinkedHashSet<>(missing)), result.conflicts(), result.observations(), result.auditRef()); + List.copyOf(new LinkedHashSet<>(missing)), result.conflicts(), result.observations(), result.auditRef(), + result.timings()); } private static ActionTrace trace(int round, ToolCall call, ToolResult result) { return new ActionTrace(round, call.toolCode(), callHash(call), result.status(), result.evidence().size(), result.facts().size(), result.missingInformation(), result.conflicts(), result.observations(), result.evidence().stream().map(DocumentEvidence::citationId).toList(), - result.facts().stream().map(VerifiedFact::factRef).toList()); + result.facts().stream().map(VerifiedFact::factRef).toList(), result.timings()); } static String callHash(ToolCall call) { @@ -222,6 +261,11 @@ public class AihrGroundedAgentRuntime { return code != null && (code.contains("CURRENT_TASK") || code.startsWith("MY_")); } + private static boolean clarificationOnly(SemanticQueryPlan plan) { + return plan != null && plan.candidateActions().isEmpty() + && (plan.needsClarification() || plan.intents().contains("CLARIFY")); + } + private static void putIfPresent(Map target, String key, String value) { if (value != null && !value.isBlank()) target.put(key, value); } @@ -232,17 +276,20 @@ public class AihrGroundedAgentRuntime { public record ActionTrace(int round, String toolCode, String callHash, ToolStatus status, int evidenceCount, int factCount, List missingInformation, List conflicts, - List observations, List citationIds, List factRefs) { + List observations, List citationIds, List factRefs, + Map timings) { public ActionTrace { missingInformation = copy(missingInformation); conflicts = copy(conflicts); observations = copy(observations); citationIds = copy(citationIds); factRefs = copy(factRefs); + timings = timings == null ? Map.of() : Map.copyOf(timings); } } public record RuntimeTrace(int toolRounds, int totalToolCalls, int candidateCount, long elapsedMillis, + long evidenceGateMillis, long composerMillis, List actions, List stopReasons) { public RuntimeTrace { actions = copy(actions); @@ -258,7 +305,7 @@ public class AihrGroundedAgentRuntime { private static RuntimeResult notHandled(String reason) { return new RuntimeResult(false, null, null, List.of(), - new RuntimeTrace(0, 0, 0, 0, List.of(), List.of(reason))); + new RuntimeTrace(0, 0, 0, 0, 0, 0, List.of(), List.of(reason))); } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/shadow/AihrAgentExecutionContextFactory.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/shadow/AihrAgentExecutionContextFactory.java new file mode 100644 index 00000000..9a2c9c0d --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/shadow/AihrAgentExecutionContextFactory.java @@ -0,0 +1,88 @@ +package org.dromara.aihr.agent.shadow; + +import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.ExecutionContext; +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.constant.HttpStatus; +import org.dromara.common.core.exception.ServiceException; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; + +@Service +public class AihrAgentExecutionContextFactory { + + private final AihrKnowledgeAppService appService; + private final AihrKnowledgeAccessService accessService; + private final AihrKnowledgePrincipalResolver principalResolver; + + public AihrAgentExecutionContextFactory(AihrKnowledgeAppService appService, + AihrKnowledgeAccessService accessService, + AihrKnowledgePrincipalResolver principalResolver) { + this.appService = appService; + this.accessService = accessService; + this.principalResolver = principalResolver; + } + + public ExecutionContext create(AihrKnowledgePrincipal authenticatedPrincipal, String requestedProjectCode) { + AihrKnowledgePrincipal currentPrincipal = principalResolver.revalidate(authenticatedPrincipal); + AihrKnowledgePrincipal principal = selectProject(currentPrincipal, requestedProjectCode); + AuthenticatedApp app = appService.requireSessionApp(principal.tenantId(), principal.clientKey()); + Set spaces; + try { + spaces = accessService.resolveInternalSpaceIds(principal, app, List.of(), "READ"); + } catch (ServiceException ex) { + if (ex.getCode() == null || ex.getCode() != HttpStatus.FORBIDDEN) throw ex; + spaces = Set.of(); + } + String selectedProject = hasText(requestedProjectCode) ? requestedProjectCode.trim() : null; + String snapshot = authorizationHash(principal, app.id(), selectedProject, spaces); + return new ExecutionContext( + principal.tenantId(), principal.userId(), principal.userType(), principal.extPartyId(), app.id(), + selectedProject, principal.projectCodes(), spaces, principal.roles(), snapshot + ); + } + + private static AihrKnowledgePrincipal selectProject(AihrKnowledgePrincipal principal, String projectCode) { + if (principal == null || !hasText(principal.tenantId()) || principal.userId() == null) { + throw new ServiceException("Agent认证主体缺失", HttpStatus.FORBIDDEN); + } + if (!hasText(projectCode)) return principal; + String selected = projectCode.trim(); + if (!principal.projectCodes().contains(selected)) { + throw new ServiceException("无权使用当前项目,请重新选择", HttpStatus.FORBIDDEN); + } + return new AihrKnowledgePrincipal( + principal.tenantId(), principal.userId(), principal.userType(), principal.extPartyId(), + principal.roles(), Set.of(selected), principal.clientKey() + ); + } + + private static String authorizationHash(AihrKnowledgePrincipal principal, Long appId, String projectCode, + Set spaces) { + List values = new ArrayList<>(); + values.add(principal.tenantId()); + values.add(String.valueOf(principal.userId())); + values.add(clean(principal.userType())); + values.add(String.valueOf(appId)); + values.add(clean(projectCode)); + values.add(String.join(",", new TreeSet<>(principal.projectCodes()))); + values.add(new TreeSet<>(spaces).toString()); + values.add(String.join(",", new TreeSet<>(principal.roles()))); + return AihrKnowledgeAppService.sha256(String.join("|", values)); + } + + private static boolean hasText(String value) { + return value != null && !value.isBlank(); + } + + private static String clean(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/shadow/AihrAgentShadowAuditService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/shadow/AihrAgentShadowAuditService.java new file mode 100644 index 00000000..dbeee4c4 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/shadow/AihrAgentShadowAuditService.java @@ -0,0 +1,167 @@ +package org.dromara.aihr.agent.shadow; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +@Service +@Slf4j +public class AihrAgentShadowAuditService { + + private static final Pattern CODE = Pattern.compile("[A-Z][A-Z0-9_:-]{0,63}"); + + private final JdbcTemplate jdbcTemplate; + private final ObjectMapper objectMapper; + + public AihrAgentShadowAuditService(JdbcTemplate jdbcTemplate, ObjectMapper objectMapper) { + this.jdbcTemplate = jdbcTemplate; + this.objectMapper = objectMapper; + } + + public void record(ShadowAuditRecord record) { + if (record == null || record.shadowRunId() == null || record.querySha256() == null) return; + try { + jdbcTemplate.update(""" + insert into aihr_agent_shadow_run + (shadow_run_id, legacy_run_id, query_sha256, tenant_id, user_id, app_id, + project_scope_hash, authorization_snapshot, legacy_intent, legacy_tool_codes_json, + legacy_status, legacy_evidence_count, legacy_fact_count, + legacy_zero_evidence_generated, legacy_latency_ms, shadow_plan_source, + shadow_intents_json, shadow_tool_codes_json, shadow_decision_status, + shadow_evidence_count, shadow_fact_count, missing_codes_json, conflict_codes_json, + citation_qualification_json, trace_codes_json, phase_latency_json, + shadow_latency_ms, shadow_handled, + shadow_zero_evidence_generated, outcome, error_code, complete_time, create_time) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, cast(? as json), ?, ?, ?, ?, ?, ?, cast(? as json), + cast(? as json), ?, ?, ?, cast(? as json), cast(? as json), cast(? as json), + cast(? as json), cast(? as json), ?, ?, ?, ?, ?, now(), now()) + """, + clean(record.shadowRunId(), 64), clean(record.legacyRunId(), 64), record.querySha256(), + clean(record.tenantId(), 20), record.userId(), record.appId(), record.projectScopeHash(), + record.authorizationSnapshot(), code(record.legacyIntent()), jsonCodes(record.legacyToolCodes()), + code(record.legacyStatus()), nonNegative(record.legacyEvidenceCount()), + nonNegative(record.legacyFactCount()), record.legacyZeroEvidenceGenerated(), + nonNegative(record.legacyLatencyMs()), code(record.shadowPlanSource()), + jsonCodes(record.shadowIntents()), jsonCodes(record.shadowToolCodes()), + code(record.shadowDecisionStatus()), nonNegative(record.shadowEvidenceCount()), + nonNegative(record.shadowFactCount()), jsonCodes(record.missingCodes()), + jsonCodes(record.conflictCodes()), jsonMap(record.citationQualification()), + jsonCodes(record.traceCodes()), jsonTimings(record.phaseLatencyMs()), + nonNegative(record.shadowLatencyMs()), record.shadowHandled(), + record.shadowZeroEvidenceGenerated(), code(record.outcome()), code(record.errorCode()) + ); + } catch (RuntimeException ex) { + log.warn("grounded Agent shadow audit write failed (details hidden)"); + } + } + + private String jsonCodes(List values) { + return json(safeCodes(values)); + } + + private String jsonMap(Map values) { + return json(values == null ? Map.of() : values.entrySet().stream() + .filter(entry -> CODE.matcher(entry.getKey()).matches()) + .collect(java.util.stream.Collectors.toMap( + Map.Entry::getKey, entry -> Math.max(0, entry.getValue() == null ? 0 : entry.getValue()), + (left, right) -> left, java.util.LinkedHashMap::new))); + } + + private String jsonTimings(Map values) { + return json(values == null ? Map.of() : values.entrySet().stream() + .filter(entry -> code(entry.getKey()) != null) + .collect(java.util.stream.Collectors.toMap( + entry -> code(entry.getKey()), entry -> Math.max(0L, entry.getValue() == null ? 0L : entry.getValue()), + Long::sum, java.util.LinkedHashMap::new))); + } + + private String json(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException ex) { + return value instanceof Map ? "{}" : "[]"; + } + } + + private static List safeCodes(List values) { + Set result = new LinkedHashSet<>(); + if (values != null) { + values.stream().map(AihrAgentShadowAuditService::code).filter(value -> value != null) + .forEach(result::add); + } + return List.copyOf(result); + } + + private static String code(String value) { + if (value == null || value.isBlank()) return null; + String normalized = value.trim().toUpperCase(java.util.Locale.ROOT); + return CODE.matcher(normalized).matches() ? normalized : null; + } + + private static String clean(String value, int maxLength) { + if (value == null || value.isBlank()) return null; + String normalized = value.trim(); + return normalized.length() <= maxLength ? normalized : normalized.substring(0, maxLength); + } + + private static long nonNegative(long value) { + return Math.max(0L, value); + } + + public record ShadowAuditRecord( + String shadowRunId, + String legacyRunId, + String querySha256, + String tenantId, + Long userId, + Long appId, + String projectScopeHash, + String authorizationSnapshot, + String legacyIntent, + List legacyToolCodes, + String legacyStatus, + int legacyEvidenceCount, + int legacyFactCount, + boolean legacyZeroEvidenceGenerated, + long legacyLatencyMs, + String shadowPlanSource, + List shadowIntents, + List shadowToolCodes, + String shadowDecisionStatus, + int shadowEvidenceCount, + int shadowFactCount, + List missingCodes, + List conflictCodes, + Map citationQualification, + List traceCodes, + Map phaseLatencyMs, + long shadowLatencyMs, + boolean shadowHandled, + boolean shadowZeroEvidenceGenerated, + String outcome, + String errorCode + ) { + public ShadowAuditRecord { + legacyToolCodes = copy(legacyToolCodes); + shadowIntents = copy(shadowIntents); + shadowToolCodes = copy(shadowToolCodes); + missingCodes = copy(missingCodes); + conflictCodes = copy(conflictCodes); + citationQualification = citationQualification == null ? Map.of() : Map.copyOf(citationQualification); + traceCodes = copy(traceCodes); + phaseLatencyMs = phaseLatencyMs == null ? Map.of() : Map.copyOf(phaseLatencyMs); + } + + private static List copy(List values) { + return values == null ? List.of() : List.copyOf(values); + } + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/shadow/AihrAgentShadowDispatcher.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/shadow/AihrAgentShadowDispatcher.java new file mode 100644 index 00000000..2a9bb537 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/shadow/AihrAgentShadowDispatcher.java @@ -0,0 +1,132 @@ +package org.dromara.aihr.agent.shadow; + +import jakarta.annotation.PreDestroy; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; + +@Service +@Slf4j +public class AihrAgentShadowDispatcher { + + public enum CompletionStatus { + COMPLETED, + FAILED, + TIMEOUT, + REJECTED + } + + public record Completion(CompletionStatus status, T value, String errorCode) { + } + + private final ExecutorService workers; + private final ScheduledExecutorService watchdog; + private final ExecutorService completions; + + @Autowired + public AihrAgentShadowDispatcher(AihrAgentShadowProperties properties) { + this(new ThreadPoolExecutor( + properties.getGroundedShadowThreads(), properties.getGroundedShadowThreads(), + 30L, TimeUnit.SECONDS, + new ArrayBlockingQueue<>(properties.getGroundedShadowQueueCapacity()), + daemonFactory("aihr-grounded-shadow"), + new ThreadPoolExecutor.AbortPolicy()), + Executors.newSingleThreadScheduledExecutor(daemonFactory("aihr-grounded-shadow-watchdog")), + new ThreadPoolExecutor( + 1, 1, 30L, TimeUnit.SECONDS, + new ArrayBlockingQueue<>(properties.getGroundedShadowQueueCapacity()), + daemonFactory("aihr-grounded-shadow-completion"), + new ThreadPoolExecutor.AbortPolicy())); + } + + AihrAgentShadowDispatcher(ExecutorService workers, ScheduledExecutorService watchdog, + ExecutorService completions) { + this.workers = workers; + this.watchdog = watchdog; + this.completions = completions; + } + + public void submit(Callable task, long timeoutMs, Consumer> completion) { + AtomicBoolean terminal = new AtomicBoolean(false); + final Future future; + try { + future = workers.submit(() -> { + try { + T value = task.call(); + dispatchCompletion(terminal, completion, + new Completion<>(CompletionStatus.COMPLETED, value, null)); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + dispatchCompletion(terminal, completion, + new Completion<>(CompletionStatus.TIMEOUT, null, "SHADOW_TIMEOUT")); + } catch (Exception ex) { + dispatchCompletion(terminal, completion, + new Completion<>(CompletionStatus.FAILED, null, "SHADOW_EXECUTION_FAILED")); + } + }); + } catch (RejectedExecutionException ex) { + dispatchCompletion(terminal, completion, + new Completion<>(CompletionStatus.REJECTED, null, "SHADOW_QUEUE_FULL")); + return; + } + watchdog.schedule(() -> { + if (dispatchCompletion(terminal, completion, + new Completion<>(CompletionStatus.TIMEOUT, null, "SHADOW_TIMEOUT"))) { + future.cancel(true); + } + }, Math.max(1L, timeoutMs), TimeUnit.MILLISECONDS); + } + + @PreDestroy + public void close() { + workers.shutdownNow(); + watchdog.shutdownNow(); + await(workers); + await(watchdog); + completions.shutdown(); + await(completions); + if (!completions.isTerminated()) completions.shutdownNow(); + } + + private boolean dispatchCompletion(AtomicBoolean terminal, Consumer> completion, + Completion value) { + if (!terminal.compareAndSet(false, true)) return false; + try { + completions.execute(() -> completion.accept(value)); + } catch (RejectedExecutionException ex) { + log.warn("grounded Agent shadow terminal callback dropped because completion queue is full"); + } + return true; + } + + private static ThreadFactory daemonFactory(String prefix) { + AtomicInteger sequence = new AtomicInteger(); + return task -> { + Thread thread = new Thread(task, prefix + "-" + sequence.incrementAndGet()); + thread.setDaemon(true); + return thread; + }; + } + + private static void await(ExecutorService executor) { + try { + executor.awaitTermination(2L, TimeUnit.SECONDS); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/shadow/AihrAgentShadowProperties.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/shadow/AihrAgentShadowProperties.java new file mode 100644 index 00000000..78cab695 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/shadow/AihrAgentShadowProperties.java @@ -0,0 +1,55 @@ +package org.dromara.aihr.agent.shadow; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "aihr.agent") +public class AihrAgentShadowProperties { + + public enum GroundedMode { + OFF, + SHADOW + } + + private GroundedMode groundedMode = GroundedMode.OFF; + private int groundedShadowThreads = 2; + private int groundedShadowQueueCapacity = 32; + private long groundedShadowTimeoutMs = 4_000L; + + public GroundedMode getGroundedMode() { + return groundedMode; + } + + public void setGroundedMode(GroundedMode groundedMode) { + this.groundedMode = groundedMode == null ? GroundedMode.OFF : groundedMode; + } + + public int getGroundedShadowThreads() { + return Math.max(1, Math.min(groundedShadowThreads, 4)); + } + + public void setGroundedShadowThreads(int groundedShadowThreads) { + this.groundedShadowThreads = groundedShadowThreads; + } + + public int getGroundedShadowQueueCapacity() { + return Math.max(1, Math.min(groundedShadowQueueCapacity, 128)); + } + + public void setGroundedShadowQueueCapacity(int groundedShadowQueueCapacity) { + this.groundedShadowQueueCapacity = groundedShadowQueueCapacity; + } + + public long getGroundedShadowTimeoutMs() { + return Math.max(500L, Math.min(groundedShadowTimeoutMs, 60_000L)); + } + + public void setGroundedShadowTimeoutMs(long groundedShadowTimeoutMs) { + this.groundedShadowTimeoutMs = groundedShadowTimeoutMs; + } + + public boolean shadowEnabled() { + return groundedMode == GroundedMode.SHADOW; + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/shadow/AihrAgentShadowService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/shadow/AihrAgentShadowService.java new file mode 100644 index 00000000..37ba32e6 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/shadow/AihrAgentShadowService.java @@ -0,0 +1,347 @@ +package org.dromara.aihr.agent.shadow; + +import lombok.extern.slf4j.Slf4j; +import org.dromara.aihr.agent.AihrAgentDto.AgentPlan; +import org.dromara.aihr.agent.AihrAgentDto.AgentRequest; +import org.dromara.aihr.agent.AihrAgentDto.AgentResponse; +import org.dromara.aihr.agent.context.AihrAgentContextResolver; +import org.dromara.aihr.agent.context.AihrAgentConversationStateService; +import org.dromara.aihr.agent.context.AihrAgentConversationStateService.StoredConversationState; +import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.ConversationState; +import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.DecisionResult; +import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.DecisionStatus; +import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.ExecutionContext; +import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.GroundedAnswer; +import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.GroundedClaim; +import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.ResolvedContext; +import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.SemanticQueryPlan; +import org.dromara.aihr.agent.planning.AihrAgentSemanticPlanner; +import org.dromara.aihr.agent.runtime.AihrGroundedAgentRuntime; +import org.dromara.aihr.agent.runtime.AihrGroundedAgentRuntime.RuntimeResult; +import org.dromara.aihr.agent.shadow.AihrAgentShadowAuditService.ShadowAuditRecord; +import org.dromara.aihr.agent.shadow.AihrAgentShadowDispatcher.Completion; +import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; +import org.dromara.aihr.knowledge.service.AihrKnowledgeAppService; +import org.dromara.common.core.exception.ServiceException; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import java.util.regex.Pattern; + +@Service +@Slf4j +public class AihrAgentShadowService { + + private static final Pattern CONVERSATION_ID = Pattern.compile("[A-Za-z0-9_-]{8,64}"); + + private final AihrAgentShadowProperties properties; + private final AihrAgentShadowDispatcher dispatcher; + private final AihrAgentExecutionContextFactory contextFactory; + private final AihrAgentConversationStateService stateService; + private final AihrAgentContextResolver contextResolver; + private final AihrAgentSemanticPlanner semanticPlanner; + private final AihrGroundedAgentRuntime runtime; + private final AihrAgentShadowAuditService auditService; + + public AihrAgentShadowService(AihrAgentShadowProperties properties, + AihrAgentShadowDispatcher dispatcher, + AihrAgentExecutionContextFactory contextFactory, + AihrAgentConversationStateService stateService, + AihrAgentContextResolver contextResolver, + AihrAgentSemanticPlanner semanticPlanner, + AihrGroundedAgentRuntime runtime, + AihrAgentShadowAuditService auditService) { + this.properties = properties; + this.dispatcher = dispatcher; + this.contextFactory = contextFactory; + this.stateService = stateService; + this.contextResolver = contextResolver; + this.semanticPlanner = semanticPlanner; + this.runtime = runtime; + this.auditService = auditService; + } + + public void observe(AgentRequest request, AgentPlan legacyPlan, AgentResponse legacyResponse, + AihrKnowledgePrincipal authenticatedPrincipal, long legacyLatencyMs, boolean media) { + if (!properties.shadowEnabled() || request == null || legacyPlan == null || legacyResponse == null + || authenticatedPrincipal == null) return; + ShadowSeed seed = seed(request, legacyPlan, legacyResponse, authenticatedPrincipal, legacyLatencyMs, media); + try { + dispatcher.submit(() -> execute(seed), properties.getGroundedShadowTimeoutMs(), + completion -> record(seed, completion)); + } catch (RuntimeException ex) { + log.warn("grounded Agent shadow dispatch failed (details hidden)"); + } + } + + private ShadowExecution execute(ShadowSeed seed) { + long started = System.nanoTime(); + Map timings = new LinkedHashMap<>(); + if (seed.media()) { + seed.progress().advance("MEDIA_SKIPPED"); + return ShadowExecution.skipped(elapsed(started), "MEDIA_SHADOW_SKIPPED", timings); + } + try { + seed.progress().advance("AUTH_REVALIDATION"); + long phaseStarted = System.nanoTime(); + ExecutionContext context = contextFactory.create(seed.principal(), seed.projectCode()); + timings.put("AUTH_REVALIDATION", elapsed(phaseStarted)); + seed.progress().advance("STATE_LOAD"); + phaseStarted = System.nanoTime(); + StoredConversationState stored = loadState(context, seed); + timings.put("STATE_LOAD", elapsed(phaseStarted)); + ConversationState previous = stored == null ? ConversationState.empty(0L) : stored.state(); + seed.progress().advance("CONTEXT_RESOLUTION"); + phaseStarted = System.nanoTime(); + ResolvedContext resolved = contextResolver.resolve(seed.query(), previous); + timings.put("CONTEXT_RESOLUTION", elapsed(phaseStarted)); + seed.progress().advance("SEMANTIC_PLANNING"); + phaseStarted = System.nanoTime(); + SemanticQueryPlan plan = semanticPlanner.plan(seed.query(), resolved); + timings.put("SEMANTIC_PLANNING", elapsed(phaseStarted)); + seed.progress().advance("RUNTIME_EXECUTION"); + phaseStarted = System.nanoTime(); + RuntimeResult result = runtime.run(seed.query(), resolved, plan, context); + timings.put("RUNTIME_EXECUTION", elapsed(phaseStarted)); + seed.progress().advance("STATE_SAVE"); + phaseStarted = System.nanoTime(); + String stateCode = saveState(context, seed, stored, resolved, plan, result); + timings.put("STATE_SAVE", elapsed(phaseStarted)); + seed.progress().advance("COMPLETED"); + timings.put("SHADOW_TOTAL", elapsed(started)); + return new ShadowExecution(context, plan, result, elapsed(started), stateCode, null, Map.copyOf(timings)); + } catch (ServiceException ex) { + timings.put("SHADOW_TOTAL", elapsed(started)); + return ShadowExecution.failed(elapsed(started), ex.getCode() != null && ex.getCode() == 403 + ? "SHADOW_AUTHORIZATION_REJECTED" : "SHADOW_REQUEST_REJECTED", timings); + } catch (RuntimeException ex) { + timings.put("SHADOW_TOTAL", elapsed(started)); + return ShadowExecution.failed(elapsed(started), "SHADOW_EXECUTION_FAILED", timings); + } + } + + private StoredConversationState loadState(ExecutionContext context, ShadowSeed seed) { + if (!validConversation(seed.conversationId()) || seed.contextVersion() == null) return null; + try { + return stateService.load(context, seed.conversationId(), seed.contextVersion()); + } catch (ServiceException ex) { + if (ex.getCode() != null && ex.getCode() == 409) return null; + throw ex; + } + } + + private String saveState(ExecutionContext context, ShadowSeed seed, StoredConversationState stored, + ResolvedContext resolved, SemanticQueryPlan plan, RuntimeResult result) { + if (stored == null || !stored.existingRow() || !result.handled() || result.decision() == null + || !validConversation(seed.conversationId()) || seed.contextVersion() == null) return "STATE_NOT_SAVED"; + try { + ConversationState desired = stateService.capture(context, stored.state(), resolved, plan, + result.toolResults(), result.decision()); + stateService.save(context, seed.conversationId(), seed.contextVersion(), stored, desired); + return "STATE_SAVED"; + } catch (ServiceException ex) { + return ex.getCode() != null && ex.getCode() == 409 ? "STATE_VERSION_CONFLICT" : "STATE_SAVE_FAILED"; + } + } + + private void record(ShadowSeed seed, Completion completion) { + try { + ShadowExecution execution = completion.value(); + String outcome = completion.status().name(); + String errorCode = completion.errorCode(); + if (completion.status() == AihrAgentShadowDispatcher.CompletionStatus.COMPLETED && execution != null) { + errorCode = execution.errorCode(); + outcome = errorCode == null ? "SUCCESS" + : "MEDIA_SHADOW_SKIPPED".equals(errorCode) ? "SKIPPED" : "FAILED"; + } + auditService.record(auditRecord(seed, execution, outcome, errorCode)); + } catch (RuntimeException ex) { + log.warn("grounded Agent shadow completion failed (details hidden)"); + } + } + + private static ShadowAuditRecord auditRecord(ShadowSeed seed, ShadowExecution execution, + String outcome, String errorCode) { + ExecutionContext context = execution == null ? null : execution.context(); + SemanticQueryPlan plan = execution == null ? null : execution.plan(); + RuntimeResult result = execution == null ? null : execution.result(); + DecisionResult decision = result == null ? null : result.decision(); + GroundedAnswer answer = result == null ? null : result.answer(); + List traceCodes = new ArrayList<>(); + traceCodes.add("SHADOW_STAGE_" + seed.progress().stage()); + if (execution != null && execution.stateCode() != null) traceCodes.add(execution.stateCode()); + if (result != null && result.trace() != null) { + traceCodes.addAll(result.trace().stopReasons()); + result.trace().actions().forEach(action -> { + traceCodes.add(action.status().name()); + traceCodes.addAll(action.observations()); + }); + } + if (answer != null) { + answer.missingInformation().stream() + .filter(code -> code != null && code.matches("[A-Z][A-Z0-9_]{1,63}")) + .forEach(traceCodes::add); + if (decision != null && answer.status() != decision.status()) { + traceCodes.add("COMPOSER_STATUS_DOWNGRADED"); + } + } + Map phaseLatency = new LinkedHashMap<>(execution == null + ? Map.of() : execution.phaseLatencyMs()); + if (result != null && result.trace() != null) { + phaseLatency.put("EVIDENCE_GATE", result.trace().evidenceGateMillis()); + phaseLatency.put("COMPOSER", result.trace().composerMillis()); + for (int index = 0; index < result.trace().actions().size(); index++) { + var action = result.trace().actions().get(index); + int sequence = index + 1; + action.timings().forEach((code, millis) -> + phaseLatency.merge("TOOL_" + sequence + "_" + code, millis, Long::sum)); + } + } + int evidenceCount = decision == null ? 0 : decision.verifiedEvidence().size(); + int factCount = decision == null ? 0 : decision.verifiedFacts().size(); + return new ShadowAuditRecord( + seed.shadowRunId(), seed.legacyRunId(), seed.querySha256(), seed.principal().tenantId(), + seed.principal().userId(), context == null ? null : context.appId(), seed.projectScopeHash(), + context == null ? null : context.authorizationSnapshot(), seed.legacyIntent(), seed.legacyToolCodes(), + seed.legacyStatus(), seed.legacyEvidenceCount(), seed.legacyFactCount(), + seed.legacyZeroEvidenceGenerated(), seed.legacyLatencyMs(), plan == null ? null : plan.planSource(), + plan == null ? List.of() : List.copyOf(plan.intents()), + plan == null ? List.of() : plan.candidateActions().stream().map(value -> value.toolCode()).toList(), + decision == null ? null : decision.status().name(), evidenceCount, factCount, + decision == null ? List.of() : decision.missingInformation(), + decision == null ? List.of() : decision.conflicts(), qualification(answer, decision), + List.copyOf(new LinkedHashSet<>(traceCodes)), Map.copyOf(phaseLatency), + execution == null ? 0L : execution.latencyMs(), + result != null && result.handled(), zeroEvidenceGenerated(answer, decision), outcome, errorCode + ); + } + + private static Map qualification(GroundedAnswer answer, DecisionResult decision) { + if (answer == null || decision == null) return Map.of(); + Set citations = decision.verifiedEvidence().stream().map(value -> value.citationId()) + .collect(java.util.stream.Collectors.toSet()); + Set facts = decision.verifiedFacts().stream().map(value -> value.factRef()) + .collect(java.util.stream.Collectors.toSet()); + List claims = new ArrayList<>(answer.confirmedStatements()); + claims.addAll(answer.steps()); + int invalid = 0; + int unreferenced = 0; + int citationClaims = 0; + int factClaims = 0; + for (GroundedClaim claim : claims) { + if (claim.citationIds().isEmpty() && claim.factRefs().isEmpty()) unreferenced++; + if (!claim.citationIds().isEmpty()) citationClaims++; + if (!claim.factRefs().isEmpty()) factClaims++; + invalid += (int) claim.citationIds().stream().filter(value -> !citations.contains(value)).count(); + invalid += (int) claim.factRefs().stream().filter(value -> !facts.contains(value)).count(); + } + Map result = new LinkedHashMap<>(); + result.put("CLAIM_COUNT", claims.size()); + result.put("CITATION_CLAIM_COUNT", citationClaims); + result.put("FACT_CLAIM_COUNT", factClaims); + result.put("UNREFERENCED_CLAIM_COUNT", unreferenced); + result.put("INVALID_REFERENCE_COUNT", invalid); + result.put("COMPOSITION_FALLBACK_COUNT", + answer.missingInformation().contains("ANSWER_COMPOSITION_UNAVAILABLE") ? 1 : 0); + result.put("STATUS_DOWNGRADE_COUNT", answer.status() == decision.status() ? 0 : 1); + result.put("EMPTY_ANSWERED_COUNT", + decision.status() == org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.DecisionStatus.ANSWERED + && claims.isEmpty() ? 1 : 0); + return Map.copyOf(result); + } + + private static boolean zeroEvidenceGenerated(GroundedAnswer answer, DecisionResult decision) { + if (answer == null || decision == null) return false; + boolean zero = decision.verifiedEvidence().isEmpty() && decision.verifiedFacts().isEmpty(); + return zero && (!answer.confirmedStatements().isEmpty() || !answer.steps().isEmpty()); + } + + private static ShadowSeed seed(AgentRequest request, AgentPlan plan, AgentResponse response, + AihrKnowledgePrincipal principal, long legacyLatencyMs, boolean media) { + String query = request.question() == null ? "" : request.question(); + boolean knowledgeCompletedWithoutEvidence = plan.intent() == org.dromara.aihr.agent.AihrAgentDto.Intent.KNOWLEDGE_QA + && response.status() == org.dromara.aihr.agent.AihrAgentDto.AgentStatus.COMPLETED + && response.citations().isEmpty() && response.answer() != null && !response.answer().isBlank(); + int evidenceCount = (int) response.citations().stream() + .filter(value -> value != null && "DOCUMENT".equals(value.sourceType())).count(); + int factCount = response.data() == null ? 0 : 1; + String project = request.projectCode() == null ? "" : request.projectCode().trim(); + return new ShadowSeed( + "shadow_run_" + UUID.randomUUID().toString().replace("-", ""), response.runId(), query, + AihrKnowledgeAppService.sha256(query), principal, project.isBlank() ? null : project, + project.isBlank() ? null : AihrKnowledgeAppService.sha256(project), response.conversationId(), + response.contextVersion(), plan.intent().name(), List.of(plan.tool().name()), response.status().name(), + evidenceCount, factCount, knowledgeCompletedWithoutEvidence, Math.max(0L, legacyLatencyMs), media, + new ShadowProgress() + ); + } + + private static boolean validConversation(String conversationId) { + return conversationId != null && CONVERSATION_ID.matcher(conversationId).matches(); + } + + private static long elapsed(long started) { + return Math.max(0L, (System.nanoTime() - started) / 1_000_000L); + } + + private record ShadowSeed( + String shadowRunId, + String legacyRunId, + String query, + String querySha256, + AihrKnowledgePrincipal principal, + String projectCode, + String projectScopeHash, + String conversationId, + Long contextVersion, + String legacyIntent, + List legacyToolCodes, + String legacyStatus, + int legacyEvidenceCount, + int legacyFactCount, + boolean legacyZeroEvidenceGenerated, + long legacyLatencyMs, + boolean media, + ShadowProgress progress + ) { + } + + private static final class ShadowProgress { + private final AtomicReference stage = new AtomicReference<>("QUEUED"); + + private void advance(String value) { + stage.set(value); + } + + private String stage() { + return stage.get(); + } + } + + private record ShadowExecution( + ExecutionContext context, + SemanticQueryPlan plan, + RuntimeResult result, + long latencyMs, + String stateCode, + String errorCode, + Map phaseLatencyMs + ) { + private static ShadowExecution skipped(long latencyMs, String errorCode, Map timings) { + return new ShadowExecution(null, null, null, latencyMs, "STATE_NOT_SAVED", errorCode, + Map.copyOf(timings)); + } + + private static ShadowExecution failed(long latencyMs, String errorCode, Map timings) { + return new ShadowExecution(null, null, null, latencyMs, "STATE_NOT_SAVED", errorCode, + Map.copyOf(timings)); + } + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/tool/AihrAgentToolRegistry.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/tool/AihrAgentToolRegistry.java index 6322e274..fb6539a0 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/tool/AihrAgentToolRegistry.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/tool/AihrAgentToolRegistry.java @@ -51,6 +51,11 @@ public class AihrAgentToolRegistry { } } + public List definitions() { + return tools.values().stream().map(AihrAgentTool::definition) + .sorted(java.util.Comparator.comparing(AihrAgentTool.ToolDefinition::code)).toList(); + } + public List execute(SemanticQueryPlan plan, ExecutionContext context) { validate(plan); validateContext(context); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/tool/AihrCapabilityQueryAgentTool.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/tool/AihrCapabilityQueryAgentTool.java new file mode 100644 index 00000000..733fa6a8 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/tool/AihrCapabilityQueryAgentTool.java @@ -0,0 +1,99 @@ +package org.dromara.aihr.agent.tool; + +import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.ExecutionContext; +import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.ToolCall; +import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.ToolResult; +import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.ToolStatus; +import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.VerifiedFact; +import org.dromara.aihr.agent.runtime.AihrGroundedAgentPolicy; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.stereotype.Service; + +import java.time.Clock; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +/** Returns the currently registered and authorized tool boundary as a verified server fact. */ +@Service +public class AihrCapabilityQueryAgentTool implements AihrAgentTool { + + private static final long TTL_SECONDS = 60L; + private static final ToolDefinition DEFINITION = new ToolDefinition( + "CAPABILITY_QUERY", Set.of("CAPABILITY_QUERY"), Set.of("capabilityCode"), true, TTL_SECONDS); + private static final Map BUSINESS_CAPABILITIES = Map.of( + "KNOWLEDGE_SEARCH", "企业知识资料", + "MY_CURRENT_TASKS", "本人的当前待办" + ); + private static final Map CAPABILITY_TO_TOOL = Map.of( + "KNOWLEDGE", "KNOWLEDGE_SEARCH", + "CURRENT_TASKS", "MY_CURRENT_TASKS" + ); + + private final ObjectProvider registryProvider; + private final AihrGroundedAgentPolicy policy; + private final Clock clock; + + @Autowired + public AihrCapabilityQueryAgentTool(ObjectProvider registryProvider, + AihrGroundedAgentPolicy policy) { + this(registryProvider, policy, Clock.systemUTC()); + } + + AihrCapabilityQueryAgentTool(ObjectProvider registryProvider, + AihrGroundedAgentPolicy policy, Clock clock) { + this.registryProvider = registryProvider; + this.policy = policy; + this.clock = clock; + } + + @Override + public ToolDefinition definition() { + return DEFINITION; + } + + @Override + public ToolResult execute(ToolCall call, ExecutionContext context) { + long started = System.nanoTime(); + Set registered = registryProvider.getObject().definitions().stream() + .map(ToolDefinition::code) + .filter(BUSINESS_CAPABILITIES::containsKey) + .filter(code -> policy.toolAvailable(code, context)) + .collect(java.util.stream.Collectors.toCollection(java.util.LinkedHashSet::new)); + List labels = registered.stream().map(BUSINESS_CAPABILITIES::get).toList(); + String requested = call.arguments().get("capabilityCode"); + String requestedTool = requested == null ? null : CAPABILITY_TO_TOOL.get(requested); + boolean supported = requestedTool != null && registered.contains(requestedTool); + String claimText = requested == null || requested.isBlank() + ? "当前注册并授权的业务查询能力:" + (labels.isEmpty() ? "无" : String.join("、", labels)) + "。" + : supported + ? "当前已注册并授权" + BUSINESS_CAPABILITIES.get(requestedTool) + "查询能力。" + : "当前没有注册并授权 " + requested + " 实时查询能力。"; + + Map value = new LinkedHashMap<>(); + value.put("itemKey", "CAPABILITY_SET"); + value.put("toolCodes", List.copyOf(registered)); + value.put("capabilityLabels", labels); + value.put("requestedCapability", requested == null ? "ALL" : requested); + value.put("requestedSupported", supported); + value.put("claimText", claimText); + Instant asOf = clock.instant(); + String auditRef = "toolrun_" + UUID.randomUUID().toString().replace("-", ""); + VerifiedFact fact = new VerifiedFact( + "fact_" + UUID.randomUUID().toString().replace("-", ""), "CAPABILITY", Map.copyOf(value), + "AIHR_AGENT_TOOL_REGISTRY", asOf, asOf.plusSeconds(TTL_SECONDS), TTL_SECONDS, + Map.of("subject", "SELF", "project", context.projectCode() == null + ? "AUTHORIZED_PROJECTS" : context.projectCode()), context.authorizationSnapshot(), auditRef); + return new ToolResult(call.callId(), DEFINITION.code(), ToolStatus.SUCCESS, List.of(), List.of(fact), + List.of(), List.of(), List.of("AUTHORIZED_CAPABILITY_REGISTRY", "READ_ONLY"), auditRef, + Map.of("CAPABILITY_REGISTRY", elapsedMillis(started))); + } + + private static long elapsedMillis(long started) { + return Math.max(0L, (System.nanoTime() - started) / 1_000_000L); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/tool/AihrCurrentTasksAgentTool.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/tool/AihrCurrentTasksAgentTool.java index d4320dfd..bd90914e 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/tool/AihrCurrentTasksAgentTool.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/tool/AihrCurrentTasksAgentTool.java @@ -9,6 +9,7 @@ import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; import org.dromara.aihr.knowledge.service.AihrKnowledgeDataToolService; import org.dromara.aihr.knowledge.service.AihrKnowledgeDataToolService.CurrentTask; import org.dromara.aihr.knowledge.service.AihrKnowledgeDataToolService.CurrentTaskSummary; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.time.Clock; @@ -36,6 +37,7 @@ public class AihrCurrentTasksAgentTool implements AihrAgentTool { private final AihrKnowledgeDataToolService dataToolService; private final Clock clock; + @Autowired public AihrCurrentTasksAgentTool(AihrKnowledgeDataToolService dataToolService) { this(dataToolService, Clock.systemUTC()); } @@ -52,6 +54,7 @@ public class AihrCurrentTasksAgentTool implements AihrAgentTool { @Override public ToolResult execute(ToolCall call, ExecutionContext context) { + long started = System.nanoTime(); String auditRef = "toolrun_" + UUID.randomUUID().toString().replace("-", ""); AihrKnowledgePrincipal principal = new AihrKnowledgePrincipal( context.tenantId(), context.userId(), context.userType(), context.extPartyId(), context.roles(), @@ -60,7 +63,8 @@ public class AihrCurrentTasksAgentTool implements AihrAgentTool { principal, AihrKnowledgeDataToolService.MY_CURRENT_TASKS); if (!(legacy.data() instanceof CurrentTaskSummary summary) || "UNAVAILABLE".equals(summary.state())) { return new ToolResult(call.callId(), DEFINITION.code(), ToolStatus.UNAVAILABLE, List.of(), List.of(), - List.of("CURRENT_TASKS_UNAVAILABLE"), List.of(), List.of("SOURCE_UNAVAILABLE"), auditRef); + List.of("CURRENT_TASKS_UNAVAILABLE"), List.of(), List.of("SOURCE_UNAVAILABLE"), auditRef, + Map.of("BUSINESS_FACT_QUERY", elapsedMillis(started))); } Instant asOf = clock.instant(); @@ -88,7 +92,8 @@ public class AihrCurrentTasksAgentTool implements AihrAgentTool { ToolStatus status = missing.isEmpty() ? ToolStatus.SUCCESS : ToolStatus.PARTIAL; return new ToolResult(call.callId(), DEFINITION.code(), status, List.of(), facts, List.copyOf(new java.util.LinkedHashSet<>(missing)), List.of(), - List.of("AUTHORIZED_SELF_SCOPE", "READ_ONLY"), auditRef); + List.of("AUTHORIZED_SELF_SCOPE", "READ_ONLY"), auditRef, + Map.of("BUSINESS_FACT_QUERY", elapsedMillis(started))); } private static VerifiedFact fact(CurrentTask task, ExecutionContext context, ToolCall call, @@ -149,6 +154,10 @@ public class AihrCurrentTasksAgentTool implements AihrAgentTool { return value == null ? "" : value; } + private static long elapsedMillis(long started) { + return Math.max(0L, (System.nanoTime() - started) / 1_000_000L); + } + private record DateFilter(LocalDate start, LocalDate end) { private boolean active() { return start != null && end != null; diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/tool/AihrKnowledgeSearchAgentTool.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/tool/AihrKnowledgeSearchAgentTool.java index 58ef5591..96b5bb5e 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/tool/AihrKnowledgeSearchAgentTool.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/tool/AihrKnowledgeSearchAgentTool.java @@ -9,6 +9,7 @@ import org.dromara.aihr.agent.evidence.AihrAgentEvidenceSupportClassifier; import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.Citation; import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.KnowledgeEvidenceResult; import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest; +import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; import org.dromara.aihr.knowledge.service.AihrFormalPolicyClassifier; import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService; import org.springframework.stereotype.Service; @@ -18,6 +19,8 @@ import java.time.LocalDate; import java.time.ZoneOffset; import java.time.format.DateTimeParseException; import java.util.List; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.Set; @Service @@ -47,26 +50,36 @@ public class AihrKnowledgeSearchAgentTool implements AihrAgentTool { @Override public ToolResult execute(ToolCall call, ExecutionContext context) { + long started = System.nanoTime(); String query = clean(call.arguments().get("query")); if (query.isBlank()) { return new ToolResult(call.callId(), DEFINITION.code(), ToolStatus.FAILED, List.of(), List.of(), - List.of("KNOWLEDGE_QUERY_MISSING"), List.of(), List.of(), null); + List.of("KNOWLEDGE_QUERY_MISSING"), List.of(), List.of(), null, + Map.of("KNOWLEDGE_TOOL_TOTAL", elapsedMillis(started))); } + AihrKnowledgePrincipal principal = new AihrKnowledgePrincipal( + context.tenantId(), context.userId(), context.userType(), context.extPartyId(), context.roles(), + effectiveProjects(context), null + ); KnowledgeEvidenceResult result = queryService.queryEvidenceInternal(new QueryRequest( query, List.of(), "sop", null, AihrFormalPolicyClassifier.MOBILE_AGENT_SOURCE, 5, null, null, null, context.projectCode(), null - )); + ), principal, context.appId(), context.authorizedKnowledgeSpaceIds()); List candidates = result.citations().stream() .filter(citation -> "DOCUMENT".equals(citation.sourceType())) .map(AihrKnowledgeSearchAgentTool::evidence) .toList(); + long classificationStarted = System.nanoTime(); List evidence = supportClassifier == null ? candidates : supportClassifier.classify(query, call.evidenceNeeds(), candidates); + Map timings = new LinkedHashMap<>(result.timings()); + timings.put("SUPPORT_CLASSIFICATION", elapsedMillis(classificationStarted)); + timings.put("KNOWLEDGE_TOOL_TOTAL", elapsedMillis(started)); ToolStatus status = evidence.isEmpty() ? ToolStatus.PARTIAL : ToolStatus.SUCCESS; return new ToolResult(call.callId(), DEFINITION.code(), status, evidence, List.of(), evidence.isEmpty() ? List.of("NO_AUTHORIZED_DOCUMENT_EVIDENCE") : List.of(), List.of(), List.of("AUTHORIZED_KNOWLEDGE_SCOPE", "GOVERNANCE_GATE_APPLIED", "ANSWER_GENERATION_SKIPPED"), - "knowledge_query:" + result.requestId()); + "knowledge_query:" + result.requestId(), Map.copyOf(timings)); } private static DocumentEvidence evidence(Citation citation) { @@ -89,4 +102,12 @@ public class AihrKnowledgeSearchAgentTool implements AihrAgentTool { private static String clean(String value) { return value == null ? "" : value.trim(); } + + private static long elapsedMillis(long started) { + return Math.max(0L, (System.nanoTime() - started) / 1_000_000L); + } + + private static Set effectiveProjects(ExecutionContext context) { + return context.projectCode() == null ? context.authorizedProjectCodes() : Set.of(context.projectCode()); + } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgeQueryDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgeQueryDto.java index c35024e9..ff29de15 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgeQueryDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgeQueryDto.java @@ -229,12 +229,21 @@ public final class AihrKnowledgeQueryDto { List citations, List retrievalCandidates, List usedSpaceCodes, - boolean noEvidence + boolean noEvidence, + java.util.Map timings ) { public KnowledgeEvidenceResult { citations = citations == null ? List.of() : List.copyOf(citations); retrievalCandidates = retrievalCandidates == null ? List.of() : List.copyOf(retrievalCandidates); usedSpaceCodes = usedSpaceCodes == null ? List.of() : List.copyOf(usedSpaceCodes); + timings = timings == null ? java.util.Map.of() : java.util.Map.copyOf(timings); + } + + public KnowledgeEvidenceResult(String requestId, QueryPlan queryPlan, List citations, + List retrievalCandidates, List usedSpaceCodes, + boolean noEvidence) { + this(requestId, queryPlan, citations, retrievalCandidates, usedSpaceCodes, noEvidence, + java.util.Map.of()); } } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeIndexOutboxService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeIndexOutboxService.java index 8efe77d1..1e481c8d 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeIndexOutboxService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeIndexOutboxService.java @@ -64,7 +64,7 @@ public class AihrKnowledgeIndexOutboxService { select o.id, o.tenant_id, o.asset_id, o.version_id, o.operation, o.target_collection, o.index_generation, o.attempt_count, a.knowledge_id, a.doc_id, a.lifecycle_status, - coalesce(a.published_version_id, a.current_version_id), + coalesce(a.published_version_id, a.current_version_id) current_version_id, coalesce(scope.active_generation, 1) active_generation from aihr_index_outbox o join aihr_data_asset a on a.tenant_id = o.tenant_id and a.id = o.asset_id diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeAppService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeAppService.java index 18104a8f..1f6da037 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeAppService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeAppService.java @@ -52,6 +52,29 @@ public class AihrKnowledgeAppService { return rows.get(0).authenticated(); } + /** Revalidates a server-resolved session application without relying on request ThreadLocals. */ + public AuthenticatedApp requireSessionApp(String tenantId, Long appId) { + if (!hasText(tenantId) || appId == null) { + throw forbidden("当前登录端未配置知识问答应用"); + } + List rows = jdbcTemplate.query(""" + select id, tenant_id, app_code, app_name, auth_type, token_hash, status, + rate_limit_per_minute, expires_time + from aihr_knowledge_app + where tenant_id = ? and id = ? and auth_type = 'SESSION' + limit 1 + """, (rs, rowNum) -> new AppRow( + rs.getLong("id"), rs.getString("tenant_id"), rs.getString("app_code"), + rs.getString("app_name"), rs.getString("auth_type"), rs.getString("token_hash"), + rs.getString("status"), rs.getInt("rate_limit_per_minute"), + rs.getTimestamp("expires_time") == null ? null : rs.getTimestamp("expires_time").toLocalDateTime() + ), tenantId, appId); + if (rows.isEmpty() || !active(rows.get(0))) { + throw forbidden("当前登录端未启用知识问答应用"); + } + return rows.get(0).authenticated(); + } + public AuthenticatedApp authenticateBearer(String authorization) { String token = bearerToken(authorization); String code; diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgePrincipalResolver.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgePrincipalResolver.java index d3e28235..1059e69d 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgePrincipalResolver.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgePrincipalResolver.java @@ -8,6 +8,7 @@ import org.dromara.aihr.service.AihrMobileSeedService; import org.dromara.aihr.service.AihrOrgSyncService; import org.dromara.common.core.domain.model.LoginUser; import org.dromara.common.core.enums.UserType; +import org.dromara.common.core.constant.HttpStatus; import org.dromara.common.core.exception.ServiceException; import org.dromara.common.satoken.utils.LoginHelper; import org.dromara.common.tenant.helper.TenantHelper; @@ -33,6 +34,48 @@ public class AihrKnowledgePrincipalResolver { return from(loginUser); } + /** + * Rebuilds mutable APP role and project scope from current organization data without using request ThreadLocals. + * Stable authentication identifiers remain frozen; any identity-mapping drift fails closed. + */ + public AihrKnowledgePrincipal revalidate(AihrKnowledgePrincipal authenticatedPrincipal) { + if (authenticatedPrincipal == null || !hasText(authenticatedPrincipal.tenantId()) + || authenticatedPrincipal.userId() == null || !hasText(authenticatedPrincipal.clientKey())) { + throw new ServiceException("Agent认证主体缺失", HttpStatus.FORBIDDEN); + } + if (!UserType.APP_USER.getUserType().equals(authenticatedPrincipal.userType())) { + return authenticatedPrincipal; + } + if (!hasText(authenticatedPrincipal.extPartyId())) { + throw new ServiceException("APP组织身份缺失", HttpStatus.FORBIDDEN); + } + String mobilePhone = mobileSeedService.requireMobilePhoneForOrganizationIdentity( + authenticatedPrincipal.extPartyId()); + String currentExtPartyId = mobileSeedService.requireMobileOrganizationIdentity(mobilePhone); + if (!authenticatedPrincipal.extPartyId().equals(currentExtPartyId)) { + throw new ServiceException("APP组织身份已变更,请重新登录", HttpStatus.FORBIDDEN); + } + List rows; + try { + rows = orgSyncService.activeByMobilePhone(mobilePhone); + } catch (RuntimeException ex) { + throw new ServiceException("当前组织授权暂时无法复核", HttpStatus.FORBIDDEN); + } + if (rows.isEmpty() || rows.stream().anyMatch(row -> !currentExtPartyId.equals(row.extPartyId()))) { + throw new ServiceException("当前组织身份已失效,请重新登录", HttpStatus.FORBIDDEN); + } + Set roles = new LinkedHashSet<>(); + Set projectCodes = new LinkedHashSet<>(); + roles.add("employee"); + if (rows.stream().anyMatch(AihrKnowledgePrincipalResolver::isSupervisor)) roles.add("supervisor"); + rows.stream().map(OrgPersonRow::projectCode).filter(AihrKnowledgePrincipalResolver::hasText) + .forEach(projectCodes::add); + return new AihrKnowledgePrincipal( + authenticatedPrincipal.tenantId(), authenticatedPrincipal.userId(), authenticatedPrincipal.userType(), + currentExtPartyId, roles, projectCodes, authenticatedPrincipal.clientKey() + ); + } + AihrKnowledgePrincipal from(LoginUser loginUser) { Set roles = new LinkedHashSet<>(); Set projectCodes = new LinkedHashSet<>(); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryAuditService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryAuditService.java index 8afe3976..ea51e0d7 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryAuditService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryAuditService.java @@ -16,6 +16,7 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import java.util.List; +import java.util.ArrayList; import java.util.LinkedHashSet; @Service @@ -192,33 +193,70 @@ public class AihrKnowledgeQueryAuditService { public void recordCandidates(String requestId, String tenantId, List candidates) { if (candidates == null || candidates.isEmpty()) return; + List rows = new ArrayList<>(); for (RetrievalCandidate candidate : candidates) { if (candidate == null || candidate.fragmentId() == null || candidate.fragmentId() <= 0) continue; List contributions = candidate.contributions() == null ? List.of() : candidate.contributions(); for (ChannelContribution contribution : contributions) { - try { - jdbcTemplate.update(""" - insert into aihr_query_candidate_trace - (tenant_id, request_id, fragment_id, candidate_rank, channel, channel_rank, - raw_score, fusion_score, rerank_score, rerank_model, rerank_applied, - source_authority, source_kind, query_variant, selected_for_evidence, - decision_reason, create_time) - values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now()) - """, tenantId, requestId, candidate.fragmentId(), candidate.candidateRank(), - normalizedChannel(contribution.channel()), contribution.channelRank(), - contribution.rawScore(), candidate.fusionScore(), candidate.rerankScore(), - bounded(candidate.rerankModel(), 100), candidate.rerankApplied(), - bounded(candidate.sourceAuthority(), 30), bounded(candidate.sourceKind(), 30), - bounded(contribution.queryVariant(), 30), candidate.selectedForEvidence(), - bounded(candidate.decisionReason(), 64)); - } catch (DataAccessException ex) { - log.warn("knowledge query candidate trace write failed for request {} fragment {}", - requestId, candidate.fragmentId()); - break; - } + rows.add(new CandidateTraceRow(tenantId, requestId, candidate.fragmentId(), candidate.candidateRank(), + normalizedChannel(contribution.channel()), contribution.channelRank(), contribution.rawScore(), + candidate.fusionScore(), candidate.rerankScore(), bounded(candidate.rerankModel(), 100), + candidate.rerankApplied(), bounded(candidate.sourceAuthority(), 30), + bounded(candidate.sourceKind(), 30), bounded(contribution.queryVariant(), 30), + candidate.selectedForEvidence(), bounded(candidate.decisionReason(), 64))); } } + if (rows.isEmpty()) return; + try { + jdbcTemplate.batchUpdate(""" + insert into aihr_query_candidate_trace + (tenant_id, request_id, fragment_id, candidate_rank, channel, channel_rank, + raw_score, fusion_score, rerank_score, rerank_model, rerank_applied, + source_authority, source_kind, query_variant, selected_for_evidence, + decision_reason, create_time) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now()) + """, rows, 200, (statement, row) -> { + statement.setString(1, row.tenantId()); + statement.setString(2, row.requestId()); + statement.setObject(3, row.fragmentId()); + statement.setObject(4, row.candidateRank()); + statement.setString(5, row.channel()); + statement.setObject(6, row.channelRank()); + statement.setObject(7, row.rawScore()); + statement.setObject(8, row.fusionScore()); + statement.setObject(9, row.rerankScore()); + statement.setString(10, row.rerankModel()); + statement.setBoolean(11, row.rerankApplied()); + statement.setString(12, row.sourceAuthority()); + statement.setString(13, row.sourceKind()); + statement.setString(14, row.queryVariant()); + statement.setBoolean(15, row.selectedForEvidence()); + statement.setString(16, row.decisionReason()); + }); + } catch (DataAccessException ex) { + log.warn("knowledge query candidate trace batch write failed for request {}", requestId); + } + } + + private record CandidateTraceRow( + String tenantId, + String requestId, + Long fragmentId, + Integer candidateRank, + String channel, + Integer channelRank, + Double rawScore, + Double fusionScore, + Double rerankScore, + String rerankModel, + boolean rerankApplied, + String sourceAuthority, + String sourceKind, + String queryVariant, + boolean selectedForEvidence, + String decisionReason + ) { } private static String normalizedChannel(String value) { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java index b8746cdf..5ac22dfd 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java @@ -232,13 +232,41 @@ public class AihrKnowledgeQueryService { */ public KnowledgeEvidenceResult queryEvidenceInternal(QueryRequest rawRequest) { QueryRequest request = validate(rawRequest); + AihrKnowledgePrincipal principal = projectPrincipal(principalResolver.current(), request.projectCode()); + AuthenticatedApp app = appService.requireSessionApp(principal.tenantId(), principal.clientKey()); + Set spaceIds = accessService.resolveInternalSpaceIds(principal, app, request.spaceCodes(), "READ"); + return queryEvidence(request, principal, app, spaceIds); + } + + /** + * Async-safe Agent entry point. The caller supplies a server-created authorization snapshot; this method + * revalidates the app, project and grants, then intersects current grants with the frozen space scope. + */ + public KnowledgeEvidenceResult queryEvidenceInternal(QueryRequest rawRequest, + AihrKnowledgePrincipal trustedPrincipal, + Long trustedAppId, + Set frozenSpaceIds) { + QueryRequest request = validate(rawRequest); + AihrKnowledgePrincipal principal = projectPrincipal(requireTrustedPrincipal(trustedPrincipal), + request.projectCode()); + AuthenticatedApp app = appService.requireSessionApp(principal.tenantId(), trustedAppId); + Set currentSpaceIds = accessService.resolveInternalSpaceIds( + principal, app, request.spaceCodes(), "READ"); + Set allowed = frozenSpaceIds == null ? Set.of() : Set.copyOf(frozenSpaceIds); + Set spaceIds = currentSpaceIds.stream().filter(allowed::contains) + .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)); + if (spaceIds.isEmpty()) { + throw new ServiceException("当前授权快照与实时知识空间授权没有交集", HttpStatus.FORBIDDEN); + } + return queryEvidence(request, principal, app, Set.copyOf(spaceIds)); + } + + private KnowledgeEvidenceResult queryEvidence(QueryRequest request, AihrKnowledgePrincipal principal, + AuthenticatedApp app, Set spaceIds) { if (hasText(request.toolCode()) || hasText(request.conversationId()) || request.contextVersion() != null || request.broadcastMessageId() != null) { throw new ServiceException("证据检索不接受工具、会话或消息上下文参数", HttpStatus.BAD_REQUEST); } - AihrKnowledgePrincipal principal = projectPrincipal(principalResolver.current(), request.projectCode()); - AuthenticatedApp app = appService.requireSessionApp(principal.tenantId(), principal.clientKey()); - Set spaceIds = accessService.resolveInternalSpaceIds(principal, app, request.spaceCodes(), "READ"); String requestId = UUID.randomUUID().toString().replace("-", ""); long started = System.nanoTime(); List scopeCodes = spaceCodes(app.tenantId(), spaceIds); @@ -252,22 +280,28 @@ public class AihrKnowledgeQueryService { principal.roles()); SearchRequest searchRequest = new SearchRequest( request.queryText(), request.category(), request.position(), retrievalSource, request.limit()); - SearchExecution execution = sopService.searchAuthorizedEvidenceDetailed( + SearchExecution execution = sopService.searchAuthorizedGovernedEvidenceDetailed( searchRequest, principal.extPartyId(), spaceIds, retrievalQuery); SearchResponse evidenceResponse = execution == null ? null : execution.response(); List snippets = evidenceResponse == null ? List.of() : evidenceResponse.snippets(); + long citationGateStarted = System.nanoTime(); List citations = withCitationIds(requestId, citations( app.tenantId(), spaceIds, snippets, formalPolicyOnly, request.queryText())); + Map timings = new LinkedHashMap<>(execution == null ? Map.of() : execution.timings()); + timings.put("CITATION_GATE", elapsedMillis(citationGateStarted)); List candidates = execution == null ? List.of() : execution.candidates(); String status = citations.isEmpty() ? "NO_EVIDENCE" : "EVIDENCE_READY"; + long auditStarted = System.nanoTime(); auditService.record(requestId, principal, app, request.queryText(), scopeCodes, citations.isEmpty() ? List.of() : List.of("DOCUMENT"), status, elapsedMillis(started), "evidence-only-v1"); auditService.recordEvidence(requestId, app.tenantId(), snippets, citations.stream() .map(Citation::fragmentId).filter(Objects::nonNull).toList()); auditService.recordCandidates(requestId, app.tenantId(), candidates); + timings.put("QUERY_AUDIT", elapsedMillis(auditStarted)); + timings.put("KNOWLEDGE_TOOL_TOTAL", elapsedMillis(started)); return new KnowledgeEvidenceResult(requestId, execution == null ? null : execution.queryPlan(), - citations, candidates, scopeCodes, citations.isEmpty()); + citations, candidates, scopeCodes, citations.isEmpty(), Map.copyOf(timings)); } catch (RuntimeException ex) { boolean rejected = ex instanceof ServiceException serviceEx && serviceEx.getCode() != null && serviceEx.getCode() < 500; @@ -279,6 +313,13 @@ public class AihrKnowledgeQueryService { } } + private static AihrKnowledgePrincipal requireTrustedPrincipal(AihrKnowledgePrincipal principal) { + if (principal == null || !hasText(principal.tenantId()) || principal.userId() == null) { + throw new ServiceException("Agent认证主体缺失", HttpStatus.FORBIDDEN); + } + return principal; + } + private static List withCitationIds(String requestId, List citations) { List result = new ArrayList<>(); for (int index = 0; index < citations.size(); index++) { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrRagAmountSemanticPlanner.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrRagAmountSemanticPlanner.java index e7100eb7..4c33c5c4 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrRagAmountSemanticPlanner.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrRagAmountSemanticPlanner.java @@ -11,6 +11,7 @@ import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryPlan; import org.dromara.aihr.service.AihrModelSeedService; import org.springframework.stereotype.Service; +import java.time.Duration; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; @@ -23,6 +24,8 @@ import java.util.Set; @Service public class AihrRagAmountSemanticPlanner { + static final Duration SEMANTIC_TIMEOUT = Duration.ofSeconds(4); + private static final Set ROOT_FIELDS = Set.of("primaryCandidateId", "mentions"); private static final Set MENTION_FIELDS = Set.of( "id", "rawText", "start", "end", "value", "currency", "comparator", "semanticRole", @@ -72,8 +75,8 @@ public class AihrRagAmountSemanticPlanner { return deterministic; } try { - Optional response = modelService.tryChat( - SYSTEM_PROMPT, modelInput(deterministic), 0.0); + Optional response = modelService.tryStructuredJson( + SYSTEM_PROMPT, modelInput(deterministic), 1_000, SEMANTIC_TIMEOUT); if (response.isEmpty()) return deterministic; QueryEntity verified = verify(deterministic.originalQuery(), deterministic.entities(), response.get()); return verified == null ? deterministic : AihrRagQueryPlanner.plan(deterministic.originalQuery(), verified); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java index 14c8d9ad..b90b8516 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java @@ -21,8 +21,6 @@ import org.springframework.dao.DataAccessException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; -import java.io.IOException; -import java.io.InputStream; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; @@ -45,6 +43,8 @@ public class AihrModelSeedService { private static final String DEFAULT_MODEL = "gpt-4o-mini"; private static final String TENANT_ID = "000000"; private static final int MAX_CHAT_RESPONSE_BYTES = 2 * 1024 * 1024; + private static final Duration DEFAULT_CHAT_TIMEOUT = Duration.ofSeconds(60); + private static final Duration MAX_STRUCTURED_CHAT_TIMEOUT = Duration.ofSeconds(20); private final ObjectMapper objectMapper; private final JdbcTemplate jdbcTemplate; @@ -232,7 +232,8 @@ public class AihrModelSeedService { } try { - ModelCallResult call = callOpenAiCompatible(runtime, modelName, prompt, request == null ? null : request.systemPrompt(), 0.2); + ModelCallResult call = callOpenAiCompatible(runtime, modelName, prompt, + request == null ? null : request.systemPrompt(), 0.2, ChatCallOptions.standard()); return new ChatResponse(true, runtime.providerCode(), modelName, call.content(), "openai-compatible", null, List.of(), call.usage()); } catch (Exception e) { return new ChatResponse( @@ -315,7 +316,8 @@ public class AihrModelSeedService { return Optional.empty(); } try { - ModelCallResult call = callOpenAiCompatible(runtime, runtime.modelName(), userPrompt, systemPrompt, temperature); + ModelCallResult call = callOpenAiCompatible(runtime, runtime.modelName(), userPrompt, systemPrompt, + temperature, ChatCallOptions.standard()); ModelUsage usage = call.usage(); return Optional.of(new ChatCallResult(call.content(), runtime.modelName(), usage == null || usage.promptTokens() == null ? 0 : usage.promptTokens(), @@ -326,6 +328,26 @@ public class AihrModelSeedService { } } + /** + * Bounded JSON call for internal classifiers and planners. Reasoning is disabled only for providers with an + * explicit compatible control, so a structured shadow step cannot consume the entire request budget before + * returning its JSON payload. + */ + public Optional tryStructuredJson(String systemPrompt, String userPrompt, int maxTokens, + Duration timeout) { + if (!chatAllowed()) return Optional.empty(); + RuntimeConfig runtime = runtimeConfig(null); + if (!runtime.configured()) return Optional.empty(); + try { + ChatCallOptions options = ChatCallOptions.structured(maxTokens, timeout); + return Optional.of(callOpenAiCompatible(runtime, runtime.modelName(), userPrompt, systemPrompt, 0.0, + options).content()); + } catch (Exception ex) { + log.warn("aihr structured llm call failed, caller falls back deterministically(处理错误已隐藏)"); + return Optional.empty(); + } + } + private boolean chatAllowed() { return aiEnabled && chatEnabled; } @@ -420,12 +442,15 @@ public class AihrModelSeedService { } } - private ModelCallResult callOpenAiCompatible(RuntimeConfig runtime, String modelName, String prompt, String systemPrompt, double temperature) throws Exception { + private ModelCallResult callOpenAiCompatible(RuntimeConfig runtime, String modelName, String prompt, + String systemPrompt, double temperature, + ChatCallOptions options) throws Exception { long startedAt = System.nanoTime(); ObjectNode body = objectMapper.createObjectNode(); body.put("model", modelName); body.put("temperature", temperature); body.put("stream", false); + applyCallOptions(body, runtime.providerCode(), options); ArrayNode messages = body.putArray("messages"); ObjectNode system = messages.addObject(); @@ -440,7 +465,7 @@ public class AihrModelSeedService { HttpRequest.Builder builder = HttpRequest.newBuilder() .uri(URI.create(normalizeBaseUrl(runtime.baseUrl()) + "/chat/completions")) - .timeout(Duration.ofSeconds(60)) + .timeout(options.timeout()) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body))); @@ -448,18 +473,15 @@ public class AihrModelSeedService { builder.header("Authorization", "Bearer " + runtime.apiKey()); } - HttpResponse response = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(15)) + HttpResponse response = HttpClient.newBuilder() + .connectTimeout(connectTimeout(options.timeout())) .build() - .send(builder.build(), HttpResponse.BodyHandlers.ofInputStream()); + .send(builder.build(), HttpResponse.BodyHandlers.ofByteArray()); - JsonNode root; - try (InputStream bodyStream = response.body()) { - if (response.statusCode() < 200 || response.statusCode() >= 300) { - throw new IllegalStateException("LLM HTTP " + response.statusCode() + "(外部响应体已隐藏)"); - } - root = objectMapper.readTree(readLimitedResponse(bodyStream, MAX_CHAT_RESPONSE_BYTES)); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new IllegalStateException("LLM HTTP " + response.statusCode() + "(外部响应体已隐藏)"); } + JsonNode root = objectMapper.readTree(limitedResponse(response.body(), MAX_CHAT_RESPONSE_BYTES)); JsonNode choices = root.path("choices"); if (!choices.isArray() || choices.size() == 0) { throw new IllegalStateException("LLM response missing choices"); @@ -479,6 +501,21 @@ public class AihrModelSeedService { return new ModelCallResult(content, modelUsage); } + static Duration connectTimeout(Duration requestTimeout) { + long millis = requestTimeout == null ? 1_000L : requestTimeout.toMillis(); + return Duration.ofMillis(Math.max(100L, Math.min(1_000L, millis))); + } + + static void applyCallOptions(ObjectNode body, String providerCode, ChatCallOptions options) { + ChatCallOptions value = options == null ? ChatCallOptions.standard() : options; + if (!value.structuredJson()) return; + body.put("max_tokens", value.maxTokens()); + body.putObject("response_format").put("type", "json_object"); + if ("deepseek".equalsIgnoreCase(providerCode)) { + body.putObject("thinking").put("type", "disabled"); + } + } + private static Integer integerUsage(JsonNode usage, String field) { if (usage == null || usage.isMissingNode() || !usage.has(field) || !usage.get(field).canConvertToInt()) { return null; @@ -486,8 +523,8 @@ public class AihrModelSeedService { return usage.get(field).intValue(); } - static byte[] readLimitedResponse(InputStream input, int maxBytes) throws IOException { - byte[] bytes = input.readNBytes(maxBytes + 1); + static byte[] limitedResponse(byte[] bytes, int maxBytes) { + if (bytes == null) return new byte[0]; if (bytes.length > maxBytes) { throw new IllegalStateException("LLM response too large(外部响应体已隐藏)"); } @@ -665,6 +702,22 @@ public class AihrModelSeedService { private record ModelCallResult(String content, ModelUsage usage) { } + record ChatCallOptions(boolean structuredJson, int maxTokens, Duration timeout) { + private static ChatCallOptions standard() { + return new ChatCallOptions(false, 0, DEFAULT_CHAT_TIMEOUT); + } + + static ChatCallOptions structured(int maxTokens, Duration timeout) { + int boundedTokens = Math.max(128, Math.min(maxTokens, 2_000)); + Duration requested = timeout == null ? Duration.ofSeconds(12) : timeout; + Duration boundedTimeout = requested.compareTo(Duration.ofSeconds(1)) < 0 + ? Duration.ofSeconds(1) + : requested.compareTo(MAX_STRUCTURED_CHAT_TIMEOUT) > 0 + ? MAX_STRUCTURED_CHAT_TIMEOUT : requested; + return new ChatCallOptions(true, boundedTokens, boundedTimeout); + } + } + public record ChatCallResult(String content, String modelName, int inputTokens, int outputTokens) { } 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 c44c5eaa..eeadaf54 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 @@ -135,6 +135,10 @@ public class AihrSopSeedService { private static final int RETRIEVAL_RERANK_K = 50; private static final int RETRIEVAL_EVIDENCE_K = 3; private static final int RETRIEVAL_DISPLAY_K = 3; + private static final SearchBudget LEGACY_SEARCH_BUDGET = + new SearchBudget(Duration.ofSeconds(60), Duration.ofSeconds(3), Duration.ofSeconds(30), true); + private static final SearchBudget AGENT_EVIDENCE_SEARCH_BUDGET = + new SearchBudget(Duration.ofMillis(1_800), Duration.ofMillis(800), Duration.ofMillis(900), false); private static final KnowledgeDocumentParser KNOWLEDGE_DOCUMENT_PARSER = new TikaKnowledgeDocumentParser(); private static final String NO_CLEAR_SOP_EVIDENCE = "未在已发布 SOP 中找到明确依据"; private static final String LEGACY_NO_CLEAR_SOP_EVIDENCE = "未在已入库 SOP 中找到明确依据"; @@ -262,19 +266,48 @@ public class AihrSopSeedService { public SearchExecution searchAuthorizedDetailed(SearchRequest request, String requesterExtPartyId, Set allowedKnowledgeIds, String retrievalQueryText) { - return searchAuthorizedDetailed(request, requesterExtPartyId, allowedKnowledgeIds, retrievalQueryText, true); + return searchAuthorizedDetailed(request, requesterExtPartyId, allowedKnowledgeIds, retrievalQueryText, true, + LEGACY_SEARCH_BUDGET); } /** Retrieval-only entry point for Agent tools. It never invokes the answer-generation model. */ public SearchExecution searchAuthorizedEvidenceDetailed(SearchRequest request, String requesterExtPartyId, Set allowedKnowledgeIds, String retrievalQueryText) { - return searchAuthorizedDetailed(request, requesterExtPartyId, allowedKnowledgeIds, retrievalQueryText, false); + return searchAuthorizedDetailed(request, requesterExtPartyId, allowedKnowledgeIds, retrievalQueryText, false, + AGENT_EVIDENCE_SEARCH_BUDGET); + } + + /** + * Agent-only evidence retrieval from the READY governed generation. SHADOW generations are read without + * changing the serving rollout, and every vector match is revalidated against current MySQL governance. + */ + public SearchExecution searchAuthorizedGovernedEvidenceDetailed(SearchRequest request, + String requesterExtPartyId, + Set allowedKnowledgeIds, + String retrievalQueryText) { + if (allowedKnowledgeIds == null || allowedKnowledgeIds.isEmpty()) { + throw new ServiceException("当前请求没有可访问的知识空间", 403); + } + String queryText = request == null ? "" : Optional.ofNullable(request.queryText()).orElse("").trim(); + String retrievalQuery = firstNonBlank(retrievalQueryText, queryText); + String source = normalizeSearchSource(request == null ? null : request.source()); + QueryPlan plan = ragAmountSemanticPlanner.plan(queryText); + if (isBlank(queryText)) { + return new SearchExecution(noEvidenceResponse("", "sop"), plan, List.of()); + } + boolean formalPolicyOnly = AihrFormalPolicyClassifier.FORMAL_POLICY_SOURCE.equals(source); + SearchExecution execution = governedDbSearchDetailed( + "", queryText, retrievalQuery, request == null ? null : request.limit(), allowedKnowledgeIds, + formalPolicyOnly, plan, AGENT_EVIDENCE_SEARCH_BUDGET); + SearchResponse response = execution.response() == null ? noEvidenceResponse(queryText, "sop") + : execution.response(); + return new SearchExecution(response, plan, execution.candidates(), execution.timings()); } private SearchExecution searchAuthorizedDetailed(SearchRequest request, String requesterExtPartyId, Set allowedKnowledgeIds, String retrievalQueryText, - boolean composeAnswer) { + boolean composeAnswer, SearchBudget searchBudget) { if (allowedKnowledgeIds == null || allowedKnowledgeIds.isEmpty()) { throw new ServiceException("当前请求没有可访问的知识空间", 403); } @@ -290,14 +323,14 @@ public class AihrSopSeedService { boolean formalPolicyOnly = AihrFormalPolicyClassifier.FORMAL_POLICY_SOURCE.equals(source); SearchExecution execution = dbSearchDetailed( "", queryText, retrievalQuery, request == null ? null : request.limit(), allowedKnowledgeIds, - formalPolicyOnly, plan, composeAnswer); + formalPolicyOnly, plan, composeAnswer, searchBudget); SearchResponse response = execution.response(); if (response == null || isNoEvidenceAnswer(response.answer())) { if (composeAnswer) recordKnowledgeGap(queryText, "sop", position, source); if (response == null) response = noEvidenceResponse(queryText, "sop"); } SearchResponse completed = composeAnswer ? withReviewId(response, source, requester) : response; - return new SearchExecution(completed, plan, execution.candidates()); + return new SearchExecution(completed, plan, execution.candidates(), execution.timings()); } /** @@ -2849,6 +2882,11 @@ public class AihrSopSeedService { } private List callEmbeddings(EmbeddingRuntime runtime, List fragments) throws Exception { + return callEmbeddings(runtime, fragments, Duration.ofSeconds(60)); + } + + private List callEmbeddings(EmbeddingRuntime runtime, List fragments, + Duration requestTimeout) throws Exception { if (LOCAL_EMBEDDING_BASE_URL.equals(runtime.baseUrl())) { return localEmbeddings(fragments); } @@ -2860,7 +2898,7 @@ public class AihrSopSeedService { HttpRequest.Builder builder = HttpRequest.newBuilder() .uri(URI.create(normalizeBaseUrl(runtime.baseUrl()) + "/embeddings")) - .timeout(Duration.ofSeconds(60)) + .timeout(requestTimeout) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body))); if (!isBlank(runtime.apiKey())) { @@ -2868,7 +2906,7 @@ public class AihrSopSeedService { } HttpResponse response = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(15)) + .connectTimeout(connectTimeout(requestTimeout)) .build() .send(builder.build(), HttpResponse.BodyHandlers.ofString()); if (response.statusCode() < 200 || response.statusCode() >= 300) { @@ -3528,12 +3566,19 @@ public class AihrSopSeedService { private SearchExecution dbSearchDetailed(String category, String queryText, String retrievalQueryText, Integer displayLimit, Set allowedKnowledgeIds, - boolean formalPolicyOnly, QueryPlan plan, boolean composeAnswer) { + boolean formalPolicyOnly, QueryPlan plan, boolean composeAnswer, + SearchBudget searchBudget) { + long searchStarted = System.nanoTime(); + Map timings = new LinkedHashMap<>(); try { boolean requiresFormal = formalPolicyOnly || !plan.requiredAuthorities().isEmpty(); List candidates = retrieveCandidates( - category, queryText, retrievalQueryText, allowedKnowledgeIds, plan, requiresFormal); - if (candidates.isEmpty()) return new SearchExecution(null, plan, List.of()); + category, queryText, retrievalQueryText, allowedKnowledgeIds, plan, requiresFormal, + searchBudget, timings); + if (candidates.isEmpty()) { + return new SearchExecution(null, plan, List.of(), searchTimings(timings, searchStarted)); + } + long gateStarted = System.nanoTime(); enrichCandidateGovernance(candidates); Set rejectedFeedback = downFeedbackFragmentIds(queryText); for (CandidateAccumulator candidate : candidates) { @@ -3549,17 +3594,20 @@ public class AihrSopSeedService { .filter(candidate -> candidate.decisionReason == null) .sorted(Comparator.comparingDouble(CandidateAccumulator::fusionScore).reversed()) .toList(); + timings.put("RETRIEVAL_GATE", elapsedSearchMillis(gateStarted)); List rerankPool = eligible.stream().limit(RETRIEVAL_RERANK_K).toList(); eligible.stream().skip(RETRIEVAL_RERANK_K) .forEach(candidate -> candidate.reject("OUTSIDE_RERANK_K")); - List ranked = rerankCandidates(queryText, rerankPool); + long rerankStarted = System.nanoTime(); + List ranked = rerankCandidates(queryText, rerankPool, searchBudget, timings); + timings.put("RERANK", elapsedSearchMillis(rerankStarted)); for (int index = 0; index < ranked.size(); index++) ranked.get(index).candidateRank = index + 1; List evidence = ranked.stream().limit(RETRIEVAL_EVIDENCE_K).toList(); if (requiresFormal && evidence.isEmpty()) { return new SearchExecution(noEvidenceResponse(queryText, category, List.of("没有找到当前有效且经过人工审核的正式制度、操作手册或已批准 SOP。")), - plan, candidateTraces(candidates)); + plan, candidateTraces(candidates), searchTimings(timings, searchStarted)); } if (plan.needsClarification()) { markSupportingEvidence(evidence); @@ -3567,7 +3615,7 @@ public class AihrSopSeedService { + amountText(plan) + "”是单笔金额还是月累计额度。当前资料不能据此推导零星采购必然可以报销。"; return new SearchExecution(clarificationResponse(queryText, category, message, evidence, composeAnswer), plan, - candidateTraces(candidates)); + candidateTraces(candidates), searchTimings(timings, searchStarted)); } if (requiresAmountEvidence(plan) && !supportsAmountRule(plan, evidence)) { markSupportingEvidence(evidence); @@ -3575,13 +3623,14 @@ public class AihrSopSeedService { + "”对应零星采购的适用额度和口径。请由财务负责人确认单笔/月累计规则后再办理。"; return new SearchExecution(clarificationResponse(queryText, category, message, evidence, composeAnswer), - withClarification(plan, "正式资料缺少金额额度或口径依据"), candidateTraces(candidates)); + withClarification(plan, "正式资料缺少金额额度或口径依据"), candidateTraces(candidates), + searchTimings(timings, searchStarted)); } if (!hasRequiredFinanceEvidence(plan, evidence)) { evidence.forEach(candidate -> candidate.reject("INSUFFICIENT_REQUIRED_EVIDENCE")); return new SearchExecution(noEvidenceResponse(queryText, category, List.of("正式资料尚未同时覆盖报销入口、必要材料和审批/复审要求。")), - plan, candidateTraces(candidates)); + plan, candidateTraces(candidates), searchTimings(timings, searchStarted)); } evidence.forEach(candidate -> { candidate.selectedForEvidence = true; @@ -3595,36 +3644,356 @@ public class AihrSopSeedService { ? responseFromHits(category, queryText, evidenceHits, List.of(), displayLimitValue(displayLimit)) : evidenceResponseFromHits(category, queryText, evidenceHits, List.of(), displayLimitValue(displayLimit)); - return new SearchExecution(response, plan, candidateTraces(candidates)); + return new SearchExecution(response, plan, candidateTraces(candidates), + searchTimings(timings, searchStarted)); } catch (DataAccessException ex) { - return new SearchExecution(null, plan, List.of()); + return new SearchExecution(null, plan, List.of(), searchTimings(timings, searchStarted)); } } + private SearchExecution governedDbSearchDetailed(String category, String queryText, String retrievalQueryText, + Integer displayLimit, Set allowedKnowledgeIds, + boolean formalPolicyOnly, QueryPlan plan, + SearchBudget searchBudget) { + long searchStarted = System.nanoTime(); + Map timings = new LinkedHashMap<>(); + try { + boolean requiresFormal = formalPolicyOnly || !plan.requiredAuthorities().isEmpty(); + List candidates = retrieveGovernedCandidates( + category, queryText, retrievalQueryText, allowedKnowledgeIds, plan, searchBudget, timings); + if (candidates.isEmpty()) { + return new SearchExecution(null, plan, List.of(), searchTimings(timings, searchStarted)); + } + long gateStarted = System.nanoTime(); + Set rejectedFeedback = downFeedbackFragmentIds(queryText); + for (CandidateAccumulator candidate : candidates) { + if (candidate.hit.fragmentId() != null && rejectedFeedback.contains(candidate.hit.fragmentId())) { + candidate.reject("REJECTED_DOWNSTREAM_FEEDBACK"); + } else if (requiresFormal && !formalSourceKind(candidate.sourceKind)) { + candidate.reject("REJECTED_SOURCE_AUTHORITY"); + } else if (!AihrRagQueryPlanner.domainMatches(plan, candidate.hit.title(), candidate.hit.content())) { + candidate.reject("REJECTED_DOMAIN_MISMATCH"); + } + } + List eligible = candidates.stream() + .filter(candidate -> candidate.decisionReason == null) + .sorted(Comparator.comparingDouble(CandidateAccumulator::fusionScore).reversed()) + .toList(); + timings.put("RETRIEVAL_GATE", elapsedSearchMillis(gateStarted)); + List rerankPool = eligible.stream().limit(RETRIEVAL_RERANK_K).toList(); + eligible.stream().skip(RETRIEVAL_RERANK_K) + .forEach(candidate -> candidate.reject("OUTSIDE_RERANK_K")); + long rerankStarted = System.nanoTime(); + List ranked = rerankCandidates(queryText, rerankPool, searchBudget, timings); + timings.put("RERANK", elapsedSearchMillis(rerankStarted)); + for (int index = 0; index < ranked.size(); index++) ranked.get(index).candidateRank = index + 1; + + List evidence = ranked.stream().limit(RETRIEVAL_EVIDENCE_K).toList(); + if (requiresFormal && evidence.isEmpty()) { + return new SearchExecution(noEvidenceResponse(queryText, category, + List.of("没有找到当前有效且经过人工审核的正式制度、操作手册或已批准 SOP。")), + plan, candidateTraces(candidates), searchTimings(timings, searchStarted)); + } + if (plan.needsClarification()) { + markSupportingEvidence(evidence); + String message = "已找到正式的费用报销资料,但当前问题中的金额口径不明确:请确认“" + + amountText(plan) + "”是单笔金额还是月累计额度。当前资料不能据此推导零星采购必然可以报销。"; + return new SearchExecution(clarificationResponse(queryText, category, message, evidence, false), + plan, candidateTraces(candidates), searchTimings(timings, searchStarted)); + } + if (requiresAmountEvidence(plan) && !supportsAmountRule(plan, evidence)) { + markSupportingEvidence(evidence); + String message = "已找到正式的费用报销操作资料,但当前正式资料没有证明“" + amountText(plan) + + "”对应零星采购的适用额度和口径。请由财务负责人确认单笔/月累计规则后再办理。"; + return new SearchExecution(clarificationResponse(queryText, category, message, evidence, false), + withClarification(plan, "正式资料缺少金额额度或口径依据"), candidateTraces(candidates), + searchTimings(timings, searchStarted)); + } + if (!hasRequiredFinanceEvidence(plan, evidence)) { + evidence.forEach(candidate -> candidate.reject("INSUFFICIENT_REQUIRED_EVIDENCE")); + return new SearchExecution(noEvidenceResponse(queryText, category, + List.of("正式资料尚未同时覆盖报销入口、必要材料和审批/复审要求。")), + plan, candidateTraces(candidates), searchTimings(timings, searchStarted)); + } + evidence.forEach(candidate -> { + candidate.selectedForEvidence = true; + candidate.decisionReason = "SELECTED_EVIDENCE"; + }); + ranked.stream().skip(evidence.size()).forEach(candidate -> { + if (candidate.decisionReason == null) candidate.decisionReason = "OUTSIDE_EVIDENCE_K"; + }); + List evidenceHits = evidence.stream().map(candidate -> candidate.hit).toList(); + SearchResponse response = evidenceResponseFromHits(category, queryText, evidenceHits, List.of(), + displayLimitValue(displayLimit)); + return new SearchExecution(response, plan, candidateTraces(candidates), + searchTimings(timings, searchStarted)); + } catch (ServiceException ex) { + throw ex; + } catch (HttpTimeoutException ex) { + throw new ServiceException("Governed evidence retrieval timed out", 503) + .setDetailMessage(ex.getMessage()); + } catch (Exception ex) { + throw new ServiceException("Governed evidence retrieval failed", 503) + .setDetailMessage(ex.getMessage()); + } + } + + private List retrieveGovernedCandidates(String category, String queryText, + String retrievalQueryText, + Set allowedKnowledgeIds, + QueryPlan plan, SearchBudget searchBudget, + Map timings) throws Exception { + List scopes = governedVectorScopes(allowedKnowledgeIds); + if (scopes.isEmpty()) return List.of(); + List variants = queryVariants(queryText, retrievalQueryText, plan); + Map> grouped = new LinkedHashMap<>(); + for (GovernedVectorScope scope : scopes) { + GovernedVectorGroup group = new GovernedVectorGroup(scope.collection(), scope.generation(), + scope.embeddingModel(), scope.embeddingDimension()); + grouped.computeIfAbsent(group, ignored -> new ArrayList<>()).add(scope); + } + Map candidates = new LinkedHashMap<>(); + for (Map.Entry> entry : grouped.entrySet()) { + GovernedVectorGroup group = entry.getKey(); + EmbeddingRuntime runtime = embeddingRuntimes().stream() + .filter(candidate -> group.embeddingModel().equals(candidate.modelName()) + && group.embeddingDimension() == candidate.dimension() + && !LOCAL_EMBEDDING_BASE_URL.equals(candidate.baseUrl())) + .findFirst() + .orElseThrow(() -> new ServiceException( + "Governed embedding manifest runtime is unavailable", 503)); + long embeddingStarted = System.nanoTime(); + List embeddings; + try { + embeddings = callEmbeddings(runtime, variants.stream().map(QueryVariant::text).toList(), + searchBudget.embeddingTimeout()); + } finally { + mergeTiming(timings, "VECTOR_EMBEDDING", elapsedSearchMillis(embeddingStarted)); + } + if (embeddings.size() != variants.size() + || requireEmbeddingDimension(embeddings) != group.embeddingDimension()) { + throw new ServiceException("Governed embedding dimension does not match the generation", 409); + } + Set knowledgeIds = entry.getValue().stream().map(GovernedVectorScope::knowledgeId) + .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)); + Map> matchesByVariant = new LinkedHashMap<>(); + LinkedHashMap uniqueMatches = new LinkedHashMap<>(); + for (int index = 0; index < variants.size(); index++) { + long qdrantStarted = System.nanoTime(); + List matches; + try { + matches = queryGovernedQdrantCollection(group, category, embeddings.get(index), + RETRIEVAL_CANDIDATE_K, knowledgeIds, searchBudget.qdrantTimeout()); + } finally { + mergeTiming(timings, "QDRANT_SEARCH", elapsedSearchMillis(qdrantStarted)); + } + matchesByVariant.put(variants.get(index).code(), matches); + matches.forEach(match -> uniqueMatches.putIfAbsent(match.fragmentId(), match)); + } + long hydrationStarted = System.nanoTime(); + Map rows; + try { + rows = hydrateGovernedFragments(group, knowledgeIds, uniqueMatches.values()); + } finally { + mergeTiming(timings, "GOVERNED_HYDRATION", elapsedSearchMillis(hydrationStarted)); + } + for (QueryVariant variant : variants) { + List matches = matchesByVariant.getOrDefault(variant.code(), List.of()); + for (int index = 0; index < matches.size(); index++) { + GovernedVectorMatch match = matches.get(index); + GovernedFragmentRow row = rows.get(match.fragmentId()); + if (row == null || !row.matches(match)) { + throw new ServiceException("Governed vector lineage no longer matches MySQL", 409); + } + String key = String.valueOf(match.fragmentId()); + CandidateAccumulator candidate = candidates.computeIfAbsent(key, + ignored -> new CandidateAccumulator(row.hit(match.score()))); + candidate.sourceAuthority = row.sourceAuthority(); + candidate.sourceKind = row.sourceKind(); + candidate.add("GOVERNED_VECTOR", index + 1, match.score(), variant.code(), false); + } + } + } + long fusionStarted = System.nanoTime(); + applyFamilyFusion(candidates.values(), false); + List fused = candidates.values().stream() + .peek(CandidateAccumulator::finishFusion) + .sorted(Comparator.comparingDouble(CandidateAccumulator::fusionScore).reversed()) + .toList(); + timings.put("FUSION", elapsedSearchMillis(fusionStarted)); + return fused; + } + + private List governedVectorScopes(Set allowedKnowledgeIds) { + List args = new ArrayList<>(); + args.add(tenantId()); + args.addAll(allowedKnowledgeIds); + return jdbcTemplate.query(""" + select s.knowledge_id, g.collection_name, g.generation, + g.embedding_model, g.embedding_dimension + from aihr_knowledge_rollout_scope s + join aihr_knowledge_info k + on k.tenant_id = s.tenant_id and k.id = s.knowledge_id and k.status = 'ACTIVE' + join aihr_knowledge_generation g + on g.tenant_id = s.tenant_id and g.knowledge_id = s.knowledge_id + and g.generation = case when s.mode = 'SHADOW' then s.candidate_generation + when s.mode = 'ENFORCED' then s.active_generation end + and g.collection_name = s.governed_collection and g.status = 'READY' + where s.tenant_id = ? and s.knowledge_id in (%s) + and s.mode in ('SHADOW', 'ENFORCED') + and g.embedding_model is not null and g.embedding_model <> '' + and g.embedding_dimension is not null and g.embedding_dimension > 0 + order by s.knowledge_id + """.formatted(sqlPlaceholders(allowedKnowledgeIds.size())), (rs, rowNum) -> new GovernedVectorScope( + rs.getLong("knowledge_id"), rs.getString("collection_name"), rs.getLong("generation"), + rs.getString("embedding_model"), rs.getInt("embedding_dimension")), args.toArray()); + } + + private List queryGovernedQdrantCollection(GovernedVectorGroup group, String category, + String embeddingJson, int limit, + Set knowledgeIds, + Duration requestTimeout) throws Exception { + ObjectNode body = objectMapper.createObjectNode(); + body.set("query", objectMapper.readTree(embeddingJson)); + body.set("filter", qdrantProductionFilterForSpaces(knowledgeIds, null, category, group.generation())); + body.put("limit", limit); + body.put("with_payload", true); + body.put("with_vector", false); + HttpResponse response = qdrantRequest( + "POST", "/collections/" + group.collection() + "/points/query", body, requestTimeout); + if (!ok(response.statusCode())) { + throw new ServiceException("Governed Qdrant query failed", 503); + } + JsonNode points = objectMapper.readTree(response.body()).path("result").path("points"); + if (!points.isArray()) { + throw new ServiceException("Governed Qdrant response is malformed", 503); + } + List matches = new ArrayList<>(); + for (JsonNode point : points) { + JsonNode payload = point.path("payload"); + GovernedVectorMatch match = new GovernedVectorMatch( + payload.path("fragment_id").asLong(0L), payload.path("knowledge_id").asLong(0L), + payload.path("doc_id").asText(""), payload.path("idx").asInt(0), + payload.path("asset_id").asLong(0L), payload.path("version_id").asLong(0L), + payload.path("chunk_revision_id").asLong(0L), payload.path("content_sha256").asText(""), + point.path("score").asDouble()); + if (!match.complete() || !knowledgeIds.contains(match.knowledgeId())) { + throw new ServiceException("Governed Qdrant point is missing authorized lineage", 409); + } + matches.add(match); + } + return List.copyOf(matches); + } + + private Map hydrateGovernedFragments(GovernedVectorGroup group, + Set knowledgeIds, + java.util.Collection matches) { + if (matches.isEmpty()) return Map.of(); + List fragmentIds = matches.stream().map(GovernedVectorMatch::fragmentId).distinct().toList(); + List args = new ArrayList<>(); + args.add(group.generation()); + args.add(group.generation()); + args.add(tenantId()); + args.addAll(knowledgeIds); + args.addAll(fragmentIds); + List rows = jdbcTemplate.query(""" + select f.id fragment_id, f.knowledge_id, f.doc_id, f.idx, f.content, + coalesce(v.redacted_source_name, a.source_name, k.name) title, + k.name category, coalesce(k.description, '') description, + a.id asset_id, c.version_id, c.id chunk_revision_id, c.content_sha256, + a.source_authority, a.source_type, a.source_name + from aihr_knowledge_fragment f + join aihr_knowledge_info k + on k.tenant_id = f.tenant_id and k.id = f.knowledge_id and k.status = 'ACTIVE' + join aihr_chunk_revision c + on c.tenant_id = f.tenant_id and c.published_fragment_id = f.id + and c.index_generation = ? + join aihr_data_asset a + on a.tenant_id = c.tenant_id and a.id = c.asset_id + and a.knowledge_id = f.knowledge_id and a.published_version_id = c.version_id + join aihr_data_version v + on v.tenant_id = c.tenant_id and v.id = c.version_id and v.version_status = 'PUBLISHED' + join aihr_dataset_membership d + on d.tenant_id = c.tenant_id and d.version_id = c.version_id + and d.dataset_code = 'production' and d.status = 'ACTIVE' + join aihr_generation_version g + on g.tenant_id = c.tenant_id and g.knowledge_id = a.knowledge_id + and g.generation = ? and g.asset_id = a.id and g.version_id = c.version_id + and g.status = 'INCLUDED' + where f.tenant_id = ? and f.knowledge_id in (%s) and f.id in (%s) + and a.lifecycle_status = 'PUBLISHED' and a.trust_level = 'HUMAN_VERIFIED' + and (a.effective_from is null or a.effective_from <= current_date()) + and (a.effective_to is null or a.effective_to >= current_date()) + """.formatted(sqlPlaceholders(knowledgeIds.size()), sqlPlaceholders(fragmentIds.size())), + (rs, rowNum) -> { + String authority = rs.getString("source_authority"); + return new GovernedFragmentRow( + rs.getLong("fragment_id"), rs.getLong("knowledge_id"), rs.getString("doc_id"), + rs.getInt("idx"), rs.getString("title"), rs.getString("category"), + rs.getString("description"), rs.getString("content"), rs.getLong("asset_id"), + rs.getLong("version_id"), rs.getLong("chunk_revision_id"), + rs.getString("content_sha256"), authority, + AihrRagQueryPlanner.sourceKind(authority, rs.getString("source_type"), + rs.getString("source_name"))); + }, args.toArray()); + Map byId = new LinkedHashMap<>(); + rows.forEach(row -> byId.put(row.fragmentId(), row)); + if (byId.size() != fragmentIds.size()) { + throw new ServiceException("Governed vector lineage is no longer published", 409); + } + return Map.copyOf(byId); + } + + private static Map searchTimings(Map values, long started) { + Map result = new LinkedHashMap<>(values); + result.put("KNOWLEDGE_SEARCH", elapsedSearchMillis(started)); + return Map.copyOf(result); + } + + private static long elapsedSearchMillis(long started) { + return Math.max(0L, (System.nanoTime() - started) / 1_000_000L); + } + + private static void mergeTiming(Map timings, String code, long elapsedMillis) { + timings.merge(code, Math.max(0L, elapsedMillis), Long::sum); + } + + private static Duration connectTimeout(Duration requestTimeout) { + long millis = requestTimeout == null ? 1_000L : requestTimeout.toMillis(); + return Duration.ofMillis(Math.max(100L, Math.min(1_000L, millis))); + } + private List retrieveCandidates(String category, String queryText, String retrievalQueryText, Set allowedKnowledgeIds, QueryPlan plan, - boolean includePublishedCandidates) { + boolean includePublishedCandidates, + SearchBudget searchBudget, Map timings) { List variants = queryVariants(queryText, retrievalQueryText, plan); Map candidates = new LinkedHashMap<>(); + long sparseStarted = System.nanoTime(); for (QueryVariant variant : variants) { addCandidateHits(candidates, likeHits(category, variant.text(), RETRIEVAL_CANDIDATE_K, allowedKnowledgeIds, false, includePublishedCandidates), "KEYWORD", variant.code(), true); addCandidateHits(candidates, fulltextHits(category, variant.text(), RETRIEVAL_CANDIDATE_K, allowedKnowledgeIds, false, includePublishedCandidates), "FULLTEXT", variant.code(), true); } + timings.put("SPARSE_RETRIEVAL", elapsedSearchMillis(sparseStarted)); Map> vectorHits = vectorHits( - category, variants, RETRIEVAL_CANDIDATE_K, allowedKnowledgeIds, false, includePublishedCandidates); + category, variants, RETRIEVAL_CANDIDATE_K, allowedKnowledgeIds, false, includePublishedCandidates, + searchBudget, timings); for (QueryVariant variant : variants) { addCandidateHits(candidates, vectorHits.getOrDefault(variant.code(), List.of()), "VECTOR", variant.code(), false); } + long fusionStarted = System.nanoTime(); applyFamilyFusion(candidates.values(), true); applyFamilyFusion(candidates.values(), false); - return candidates.values().stream() + List fused = candidates.values().stream() .peek(CandidateAccumulator::finishFusion) .sorted(Comparator.comparingDouble(CandidateAccumulator::fusionScore).reversed()) .toList(); + timings.put("FUSION", elapsedSearchMillis(fusionStarted)); + return fused; } private static List queryVariants(String queryText, String retrievalQueryText, QueryPlan plan) { @@ -3713,7 +4082,9 @@ public class AihrSopSeedService { } } - private List rerankCandidates(String queryText, List candidates) { + private List rerankCandidates(String queryText, List candidates, + SearchBudget searchBudget, + Map timings) { if (candidates.size() < 2) return candidates; Optional runtime = rerankRuntime(); if (runtime.isEmpty()) return candidates; @@ -3728,12 +4099,13 @@ public class AihrSopSeedService { AihrSensitiveText.forModel(truncate(candidate.hit.content(), 2000)))); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(normalizeBaseUrl(runtime.get().baseUrl()) + "/rerank")) - .timeout(Duration.ofSeconds(30)) + .timeout(searchBudget.rerankTimeout()) .header("Content-Type", "application/json") .header("Authorization", "Bearer " + runtime.get().apiKey()) .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body))) .build(); - HttpResponse response = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(15)) + HttpResponse response = HttpClient.newBuilder() + .connectTimeout(connectTimeout(searchBudget.rerankTimeout())) .build().send(request, HttpResponse.BodyHandlers.ofString()); if (!ok(response.statusCode())) throw new IllegalStateException("rerank HTTP " + response.statusCode()); JsonNode results = objectMapper.readTree(response.body()).path("results"); @@ -3752,6 +4124,10 @@ public class AihrSopSeedService { } for (CandidateAccumulator candidate : candidates) if (!reranked.contains(candidate)) reranked.add(candidate); return reranked; + } catch (HttpTimeoutException ex) { + timings.put("RERANK_TIMEOUT", searchBudget.rerankTimeout().toMillis()); + log.warn("candidate rerank timed out, keep family RRF order"); + return candidates; } catch (Exception ex) { log.warn("candidate rerank failed, keep family RRF order(处理错误已隐藏)"); return candidates; @@ -4096,23 +4472,56 @@ public class AihrSopSeedService { Set allowedKnowledgeIds, boolean formalPolicyOnly, boolean includePublishedCandidates) { + return vectorHits(category, variants, limit, allowedKnowledgeIds, formalPolicyOnly, + includePublishedCandidates, LEGACY_SEARCH_BUDGET, new LinkedHashMap<>()); + } + + private Map> vectorHits(String category, List variants, int limit, + Set allowedKnowledgeIds, + boolean formalPolicyOnly, + boolean includePublishedCandidates, + SearchBudget searchBudget, + Map timings) { if (variants == null || variants.isEmpty()) return Map.of(); List queries = variants.stream().map(QueryVariant::text).toList(); + int remoteAttempts = 0; for (EmbeddingRuntime runtime : embeddingRuntimes()) { + if (!searchBudget.allowLocalEmbeddingFallback() + && LOCAL_EMBEDDING_BASE_URL.equals(runtime.baseUrl())) { + continue; + } + if (!searchBudget.allowLocalEmbeddingFallback() && remoteAttempts++ > 0) break; try { - List embeddings = callEmbeddings(runtime, queries); + long embeddingStarted = System.nanoTime(); + List embeddings; + try { + embeddings = callEmbeddings(runtime, queries, searchBudget.embeddingTimeout()); + } finally { + mergeTiming(timings, "VECTOR_EMBEDDING", elapsedSearchMillis(embeddingStarted)); + } if (embeddings.size() != variants.size()) continue; Map> results = new LinkedHashMap<>(); for (int index = 0; index < variants.size(); index++) { List hits = new ArrayList<>(); - for (VectorMatch match : queryQdrant( - category, embeddings.get(index), limit, allowedKnowledgeIds)) { + long qdrantStarted = System.nanoTime(); + List matches; + try { + matches = queryQdrant(category, embeddings.get(index), limit, allowedKnowledgeIds, + searchBudget.qdrantTimeout()); + } finally { + mergeTiming(timings, "QDRANT_SEARCH", elapsedSearchMillis(qdrantStarted)); + } + for (VectorMatch match : matches) { hydrateQdrantHit(match, allowedKnowledgeIds, formalPolicyOnly, includePublishedCandidates) .ifPresent(hits::add); } results.put(variants.get(index).code(), List.copyOf(hits)); } return results; + } catch (HttpTimeoutException ex) { + timings.putIfAbsent("VECTOR_RETRIEVAL_TIMEOUT", Math.max( + searchBudget.embeddingTimeout().toMillis(), searchBudget.qdrantTimeout().toMillis())); + // Vector recall is additive. Agent evidence retrieval continues with governed sparse recall. } catch (Exception ignored) { // Vector recall is additive; try the next configured runtime before sparse-only fallback. } @@ -4975,6 +5384,11 @@ public class AihrSopSeedService { } private List queryQdrant(String category, String embeddingJson, int limit, Set allowedKnowledgeIds) throws Exception { + return queryQdrant(category, embeddingJson, limit, allowedKnowledgeIds, Duration.ofSeconds(3)); + } + + private List queryQdrant(String category, String embeddingJson, int limit, + Set allowedKnowledgeIds, Duration requestTimeout) throws Exception { List scopes = rolloutVectorScopes(allowedKnowledgeIds); List matches = new ArrayList<>(); Set legacySpaces = new LinkedHashSet<>(); @@ -4989,14 +5403,14 @@ public class AihrSopSeedService { } if (!legacySpaces.isEmpty()) { matches.addAll(queryQdrantCollection(legacyQdrantCollection(), category, embeddingJson, - limit, legacySpaces, null)); + limit, legacySpaces, null, requestTimeout)); } for (Map.Entry> entry : governedSpaces.entrySet()) { int split = entry.getKey().lastIndexOf('\n'); String collection = entry.getKey().substring(0, split); long generation = Long.parseLong(entry.getKey().substring(split + 1)); matches.addAll(queryQdrantCollection(collection, category, embeddingJson, - limit, entry.getValue(), generation)); + limit, entry.getValue(), generation, requestTimeout)); } return matches.stream().sorted(Comparator.comparingDouble(VectorMatch::score).reversed()) .limit(limit).toList(); @@ -5005,6 +5419,13 @@ public class AihrSopSeedService { private List queryQdrantCollection(String collection, String category, String embeddingJson, int limit, Set knowledgeIds, Long generation) throws Exception { + return queryQdrantCollection(collection, category, embeddingJson, limit, knowledgeIds, generation, + Duration.ofSeconds(3)); + } + + private List queryQdrantCollection(String collection, String category, String embeddingJson, + int limit, Set knowledgeIds, + Long generation, Duration requestTimeout) throws Exception { ObjectNode body = objectMapper.createObjectNode(); body.set("query", objectMapper.readTree(embeddingJson)); body.set("filter", generation == null @@ -5014,7 +5435,8 @@ public class AihrSopSeedService { body.put("with_payload", true); body.put("with_vector", false); - HttpResponse response = qdrantRequest("POST", "/collections/" + collection + "/points/query", body); + HttpResponse response = qdrantRequest("POST", "/collections/" + collection + "/points/query", body, + requestTimeout); if (!ok(response.statusCode())) { return List.of(); } @@ -5410,9 +5832,14 @@ public class AihrSopSeedService { } private HttpResponse qdrantRequest(String method, String path, JsonNode body) throws Exception { + return qdrantRequest(method, path, body, Duration.ofSeconds(3)); + } + + private HttpResponse qdrantRequest(String method, String path, JsonNode body, + Duration requestTimeout) throws Exception { HttpRequest.Builder builder = HttpRequest.newBuilder() .uri(URI.create(qdrantBaseUrl() + path)) - .timeout(Duration.ofSeconds(3)) + .timeout(requestTimeout) .header("Content-Type", "application/json"); if (!isBlank(qdrantApiKey())) { builder.header("api-key", qdrantApiKey()); @@ -5423,7 +5850,7 @@ public class AihrSopSeedService { builder.method(method, HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body))); } return HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(1)) + .connectTimeout(connectTimeout(requestTimeout)) .build() .send(builder.build(), HttpResponse.BodyHandlers.ofString()); } @@ -5963,14 +6390,60 @@ public class AihrSopSeedService { } public record SearchExecution(SearchResponse response, QueryPlan queryPlan, - List candidates) { + List candidates, Map timings) { public SearchExecution { candidates = candidates == null ? List.of() : List.copyOf(candidates); + timings = timings == null ? Map.of() : Map.copyOf(timings); + } + + public SearchExecution(SearchResponse response, QueryPlan queryPlan, List candidates) { + this(response, queryPlan, candidates, Map.of()); + } + } + + private record SearchBudget(Duration embeddingTimeout, Duration qdrantTimeout, Duration rerankTimeout, + boolean allowLocalEmbeddingFallback) { + private SearchBudget { + Objects.requireNonNull(embeddingTimeout, "embeddingTimeout"); + Objects.requireNonNull(qdrantTimeout, "qdrantTimeout"); + Objects.requireNonNull(rerankTimeout, "rerankTimeout"); } } private record QueryVariant(String code, String text) {} + private record GovernedVectorScope(long knowledgeId, String collection, long generation, + String embeddingModel, int embeddingDimension) {} + + private record GovernedVectorGroup(String collection, long generation, + String embeddingModel, int embeddingDimension) {} + + private record GovernedVectorMatch(long fragmentId, long knowledgeId, String docId, int idx, + long assetId, long versionId, long chunkRevisionId, + String contentSha256, double score) { + private boolean complete() { + return fragmentId > 0 && knowledgeId > 0 && !isBlank(docId) && idx > 0 && assetId > 0 + && versionId > 0 && chunkRevisionId > 0 && !isBlank(contentSha256); + } + } + + private record GovernedFragmentRow(long fragmentId, long knowledgeId, String docId, int idx, + String title, String category, String description, String content, + long assetId, long versionId, long chunkRevisionId, + String contentSha256, String sourceAuthority, String sourceKind) { + private boolean matches(GovernedVectorMatch match) { + return match != null && fragmentId == match.fragmentId() && knowledgeId == match.knowledgeId() + && Objects.equals(docId, match.docId()) && idx == match.idx() && assetId == match.assetId() + && versionId == match.versionId() && chunkRevisionId == match.chunkRevisionId() + && Objects.equals(contentSha256, match.contentSha256()); + } + + private KnowledgeHit hit(double score) { + return new KnowledgeHit(fragmentId, title, category, description, docId, content, idx, score, + "GOVERNED_VECTOR"); + } + } + private static final class CandidateAccumulator { private final KnowledgeHit hit; private final List contributions = new ArrayList<>(); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentMediaTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentMediaTest.java index e83ffde9..e487b9d7 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentMediaTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentMediaTest.java @@ -56,6 +56,7 @@ class AihrAgentMediaTest { query, null, null, + null, null ); } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentOrchestratorTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentOrchestratorTest.java index ae081d40..382b5462 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentOrchestratorTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentOrchestratorTest.java @@ -17,6 +17,7 @@ import org.dromara.aihr.knowledge.service.AihrFormalPolicyClassifier; import org.dromara.aihr.knowledge.service.AihrKnowledgeDataToolService.CurrentTaskSummary; import org.dromara.aihr.memory.AihrMemoryDto.MemoryCandidateResponse; import org.dromara.aihr.memory.AihrMemoryDto.MemoryDraft; +import org.dromara.aihr.agent.shadow.AihrAgentShadowService; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; @@ -30,6 +31,13 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; @Tag("dev") class AihrAgentOrchestratorTest { @@ -234,12 +242,45 @@ class AihrAgentOrchestratorTest { assertEquals(0, query.callCount); } + @Test + void shadowObservesTheExactLegacyResponseWithoutChangingIt() { + var query = new CapturingQueryService(response("按访客进场制度登记。", null, false)); + var shadow = mock(AihrAgentShadowService.class); + + var result = orchestrator(query, null, shadow).handle(request("装修人员怎么进场?")); + + var responseCaptor = org.mockito.ArgumentCaptor.forClass(AihrAgentDto.AgentResponse.class); + verify(shadow).observe(any(), any(), responseCaptor.capture(), any(), anyLong(), anyBoolean()); + assertSame(result, responseCaptor.getValue()); + assertEquals("按访客进场制度登记。", result.answer()); + assertEquals(AgentStatus.COMPLETED, result.status()); + } + + @Test + void shadowFailureCannotEscapeOrChangeLegacyResponse() { + var query = new CapturingQueryService(response("按访客进场制度登记。", null, false)); + var shadow = mock(AihrAgentShadowService.class); + doThrow(new IllegalStateException("shadow failed")).when(shadow) + .observe(any(), any(), any(), any(), anyLong(), anyBoolean()); + + var result = orchestrator(query, null, shadow).handle(request("装修人员怎么进场?")); + + assertEquals("按访客进场制度登记。", result.answer()); + assertEquals(AgentStatus.COMPLETED, result.status()); + } + private static AihrAgentOrchestrator orchestrator(CapturingQueryService query) { return orchestrator(query, null); } private static AihrAgentOrchestrator orchestrator(CapturingQueryService query, AihrAgentActionService actionService) { + return orchestrator(query, actionService, null); + } + + private static AihrAgentOrchestrator orchestrator(CapturingQueryService query, + AihrAgentActionService actionService, + AihrAgentShadowService shadowService) { return new AihrAgentOrchestrator( new AihrAgentPlanner(null, new ObjectMapper()), new AihrAgentPolicy(), @@ -253,7 +294,8 @@ class AihrAgentOrchestratorTest { query, actionService, null, - null + null, + shadowService ); } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentWebResearchTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentWebResearchTest.java index 236d644f..5fe60ce5 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentWebResearchTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentWebResearchTest.java @@ -73,6 +73,7 @@ class AihrAgentWebResearchTest { }, null, web, + null, null ); } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/answer/AihrGroundedAnswerComposerTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/answer/AihrGroundedAnswerComposerTest.java index 0b021a18..a9a24b50 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/answer/AihrGroundedAnswerComposerTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/answer/AihrGroundedAnswerComposerTest.java @@ -16,13 +16,16 @@ import org.junit.jupiter.api.Test; import java.time.Clock; import java.time.Instant; import java.time.ZoneOffset; +import java.time.Duration; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -50,7 +53,7 @@ class AihrGroundedAnswerComposerTest { assertThat(answer.confirmedStatements()).isEmpty(); assertThat(answer.steps()).isEmpty(); assertThat(answer.missingInformation()).containsExactly("PROCESS_STEPS"); - verify(model, never()).tryChat(anyString(), anyString(), anyDouble()); + verify(model, never()).tryStructuredJson(anyString(), anyString(), anyInt(), any()); } @Test @@ -122,6 +125,71 @@ class AihrGroundedAnswerComposerTest { assertThat(answer.missingInformation()).contains("ANSWER_COMPOSITION_UNAVAILABLE"); } + @Test + void capabilityFactCannotBeUsedToInvertTheRegisteredBoundary() { + VerifiedFact capability = new VerifiedFact("fact-capability", "CAPABILITY", Map.of( + "itemKey", "CAPABILITY_SET", + "requestedCapability", "CRM", + "requestedSupported", false, + "claimText", "当前没有注册并授权 CRM 实时查询能力。" + ), "AIHR_AGENT_TOOL_REGISTRY", NOW, NOW.plusSeconds(60), 60, + Map.of("subject", "SELF", "project", "P1"), "auth-1", "audit-capability"); + DecisionResult decision = decision(DecisionStatus.ANSWERED, List.of(), List.of(capability), List.of()); + GroundedAnswer supported = new GroundedAnswer(DecisionStatus.ANSWERED, "已核验能力。", List.of( + new GroundedClaim("当前没有注册并授权 CRM 实时查询能力。", "CAPABILITY", List.of(), + List.of("fact-capability"))), List.of(), List.of(), null); + GroundedAnswer hallucinated = new GroundedAnswer(DecisionStatus.ANSWERED, "已核验能力。", List.of( + new GroundedClaim("CRM已接入。", "CAPABILITY", List.of(), List.of("fact-capability"))), + List.of(), List.of(), null); + + assertThat(validator.valid(supported, decision)).isTrue(); + assertThat(validator.valid(hallucinated, decision)).isFalse(); + } + + @Test + void unavailableComposerCopiesOnlyTheVerifiedCapabilityClaimAndKeepsItsFactRef() { + AihrModelSeedService model = mock(AihrModelSeedService.class); + when(model.tryStructuredJson(anyString(), anyString(), anyInt(), any())).thenReturn(Optional.empty()); + var composer = new AihrGroundedAnswerComposer(objectMapper, model, validator); + VerifiedFact capability = new VerifiedFact("fact-capability", "CAPABILITY", Map.of( + "itemKey", "CAPABILITY_SET", + "claimText", "当前注册并授权的业务查询能力:企业知识资料、本人的当前待办。" + ), "AIHR_AGENT_TOOL_REGISTRY", NOW, NOW.plusSeconds(60), 60, + Map.of("subject", "SELF", "project", "P1"), "auth-1", "audit-capability"); + DecisionResult decision = decision(DecisionStatus.ANSWERED, List.of(), List.of(capability), List.of()); + + GroundedAnswer answer = composer.compose("当前账号能查询哪些内容?", context(), plan(), decision); + + assertThat(answer.status()).isEqualTo(DecisionStatus.ANSWERED); + assertThat(answer.confirmedStatements()).singleElement().satisfies(claim -> { + assertThat(claim.text()).isEqualTo("当前注册并授权的业务查询能力:企业知识资料、本人的当前待办。"); + assertThat(claim.claimType()).isEqualTo("CAPABILITY"); + assertThat(claim.factRefs()).containsExactly("fact-capability"); + assertThat(claim.citationIds()).isEmpty(); + }); + assertThat(answer.missingInformation()).doesNotContain("ANSWER_COMPOSITION_UNAVAILABLE"); + assertThat(validator.valid(answer, decision)).isTrue(); + } + + @Test + void compositionUsesTheBoundedShadowPhaseTimeout() { + AtomicReference timeout = new AtomicReference<>(); + AihrModelSeedService model = new AihrModelSeedService(objectMapper, null) { + @Override + public Optional tryStructuredJson(String systemPrompt, String userPrompt, int maxTokens, + Duration requestedTimeout) { + timeout.set(requestedTimeout); + return Optional.empty(); + } + }; + var composer = new AihrGroundedAnswerComposer(objectMapper, model, validator); + + composer.compose("当前账号能查询哪些内容?", context(), plan(), decision(DecisionStatus.ANSWERED, + List.of(), List.of(currentTask(NOW.plusSeconds(300))), List.of())); + + assertThat(timeout.get()).isEqualTo(AihrGroundedAnswerComposer.COMPOSITION_TIMEOUT); + } + @Test void currentTaskStatusRequiresAnUnexpiredFactAndProcessStepRequiresFormalEvidence() { VerifiedFact fact = currentTask(NOW.plusSeconds(300)); @@ -155,7 +223,7 @@ class AihrGroundedAnswerComposerTest { private static AihrModelSeedService modelReturning(String value) { AihrModelSeedService model = mock(AihrModelSeedService.class); - when(model.tryChat(anyString(), anyString(), anyDouble())).thenReturn(Optional.of(value)); + when(model.tryStructuredJson(anyString(), anyString(), anyInt(), any())).thenReturn(Optional.of(value)); return model; } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/context/AihrAgentContextResolverTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/context/AihrAgentContextResolverTest.java index 35d724d4..3a2de277 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/context/AihrAgentContextResolverTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/context/AihrAgentContextResolverTest.java @@ -8,6 +8,7 @@ import org.dromara.aihr.service.AihrModelSeedService; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; +import java.time.Duration; import java.time.Instant; import java.util.List; import java.util.Map; @@ -77,7 +78,8 @@ class AihrAgentContextResolverTest { private AihrAgentContextResolver resolver(String output) { AihrModelSeedService model = new AihrModelSeedService(objectMapper, null) { @Override - public Optional tryChat(String systemPrompt, String userPrompt, double temperature) { + public Optional tryStructuredJson(String systemPrompt, String userPrompt, int maxTokens, + Duration timeout) { return Optional.of(output); } }; diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/evidence/AihrAgentEvidenceEvaluatorTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/evidence/AihrAgentEvidenceEvaluatorTest.java index dc827c5e..10963a0b 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/evidence/AihrAgentEvidenceEvaluatorTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/evidence/AihrAgentEvidenceEvaluatorTest.java @@ -66,6 +66,17 @@ class AihrAgentEvidenceEvaluatorTest { assertThat(needsInput.missingInformation()).contains("PROJECT_REQUIRED"); } + @Test + void clarificationAfterRetrievalPreservesVerifiedResultsAsPartial() { + var decision = evaluator.evaluate(plan(true), context("P1", "auth-1"), List.of( + result(List.of(formalEvidence()), List.of()) + )); + + assertThat(decision.status()).isEqualTo(DecisionStatus.PARTIAL); + assertThat(decision.verifiedEvidence()).hasSize(1); + assertThat(decision.missingInformation()).contains("PROJECT_REQUIRED", "CURRENT_TASKS"); + } + @Test void formalProcessRejectsExperienceMaterialEvenWhenCitationIdBelongsToThisRun() { DocumentEvidence experience = new DocumentEvidence( diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/evidence/AihrAgentEvidenceSupportClassifierTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/evidence/AihrAgentEvidenceSupportClassifierTest.java index ad256b3f..27422757 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/evidence/AihrAgentEvidenceSupportClassifierTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/evidence/AihrAgentEvidenceSupportClassifierTest.java @@ -6,10 +6,12 @@ import org.dromara.aihr.service.AihrModelSeedService; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; +import java.time.Duration; import java.time.Instant; import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; import static org.assertj.core.api.Assertions.assertThat; @@ -72,10 +74,31 @@ class AihrAgentEvidenceSupportClassifierTest { assertThat(result.claimTypes()).isEmpty(); } + @Test + void classificationUsesItsOwnStrictExternalCallBudget() { + AtomicReference timeout = new AtomicReference<>(); + AihrModelSeedService model = new AihrModelSeedService(objectMapper, null) { + @Override + public Optional tryStructuredJson(String systemPrompt, String userPrompt, int maxTokens, + Duration value) { + timeout.set(value); + return Optional.empty(); + } + }; + AihrAgentEvidenceSupportClassifier classifier = new AihrAgentEvidenceSupportClassifier(objectMapper, model); + + classifier.classify("怎么报销?", List.of("PROCESS_STEPS"), + List.of(evidence("citation-1", "提交报销单。"))); + + assertThat(timeout.get()).isEqualTo(AihrAgentEvidenceSupportClassifier.CLASSIFICATION_TIMEOUT) + .isLessThan(Duration.ofSeconds(2)); + } + private AihrModelSeedService modelReturning(String output) { return new AihrModelSeedService(objectMapper, null) { @Override - public Optional tryChat(String systemPrompt, String userPrompt, double temperature) { + public Optional tryStructuredJson(String systemPrompt, String userPrompt, int maxTokens, + Duration timeout) { return Optional.of(output); } }; diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/planning/AihrAgentSemanticPlannerTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/planning/AihrAgentSemanticPlannerTest.java index 6e262079..9ef1c283 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/planning/AihrAgentSemanticPlannerTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/planning/AihrAgentSemanticPlannerTest.java @@ -7,8 +7,10 @@ import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import java.time.Clock; +import java.time.Duration; import java.time.Instant; import java.time.ZoneId; +import java.util.Map; import java.util.Optional; import java.util.concurrent.atomic.AtomicReference; @@ -44,14 +46,15 @@ class AihrAgentSemanticPlannerTest { var plan = planner.plan("我这个月还有哪些待办,相关流程怎么处理?"); - assertThat(input.get()).contains("originalQuery", "entityCandidates", "THIS_MONTH"); - assertThat(plan.intents()).containsExactlyInAnyOrder("LIVE_MY_WORK", "PROCESS_GUIDANCE"); + assertThat(input.get()).contains("originalQuery", "resolvedContext", "entityCandidates", + "candidatePlanningHints", "THIS_MONTH"); + assertThat(plan.intents()).contains("LIVE_MY_WORK", "PROCESS_GUIDANCE", "KNOWLEDGE_QA"); assertThat(plan.candidateActions()).extracting(action -> action.toolCode()) .containsExactly("MY_CURRENT_TASKS", "KNOWLEDGE_SEARCH"); assertThat(plan.candidateActions().get(0).arguments()) .containsEntry("startDate", "2026-08-01") .containsEntry("endDate", "2026-08-31"); - assertThat(plan.planSource()).isEqualTo("SEMANTIC_MODEL_VALIDATED"); + assertThat(plan.planSource()).isEqualTo("SEMANTIC_MODEL_VALIDATED_WITH_SERVER_HINTS"); } @Test @@ -84,10 +87,10 @@ class AihrAgentSemanticPlannerTest { .containsEntry("startDate", "2026-08-01") .containsEntry("endDate", "2026-08-31"), knowledge -> assertThat(knowledge.arguments()) - .containsOnlyKeys("query") + .containsOnlyKeys("query", "domain") .doesNotContainKeys("dateRange", "startDate", "endDate") ); - assertThat(plan.planSource()).isEqualTo("SEMANTIC_MODEL_VALIDATED"); + assertThat(plan.planSource()).isEqualTo("SEMANTIC_MODEL_VALIDATED_WITH_SERVER_HINTS"); } @Test @@ -107,8 +110,123 @@ class AihrAgentSemanticPlannerTest { var plan = planner.plan("查询待办"); - assertThat(plan.candidateActions()).isEmpty(); - assertThat(plan.planSource()).isEqualTo("DETERMINISTIC_FALLBACK"); + assertThat(plan.candidateActions()).singleElement().satisfies(action -> { + assertThat(action.toolCode()).isEqualTo("MY_CURRENT_TASKS"); + assertThat(action.arguments()).doesNotContainKeys("userId", "tenantId", "projectCode"); + }); + assertThat(plan.planSource()).isEqualTo("DETERMINISTIC_FALLBACK_INVALID_ACTION_EXECUTABLE"); + } + + @Test + void executableKnowledgeIntentCannotBeStoppedByLowValueClarification() { + var planner = planner(modelReturning(""" + { + "normalizedQuery": "500元以下的零星采购怎么报销?", + "alternateQueries": ["零星采购报销制度"], + "intents": ["PROCESS_GUIDANCE", "ELIGIBILITY_CHECK", "CLARIFY"], + "domain": "FINANCE_REIMBURSEMENT", + "evidenceNeeds": [{"code":"AMOUNT_BASIS","required":true,"description":"金额口径"}], + "actions": [], + "needsClarification": true, + "clarificationReasons": ["单笔或月累计口径待确认"] + } + """, new AtomicReference<>())); + + var plan = planner.plan("500元以下的零星采购怎么报销?"); + + assertThat(plan.candidateActions()).singleElement().satisfies(action -> { + assertThat(action.toolCode()).isEqualTo("KNOWLEDGE_SEARCH"); + assertThat(action.arguments()).containsEntry("query", plan.originalQuery()); + assertThat(action.evidenceNeeds()).contains("AMOUNT_BASIS", "PROCESS_STEPS", "APPLICABILITY"); + }); + assertThat(plan.needsClarification()).isFalse(); + } + + @Test + void duplicateKnowledgeActionsAreCollapsedAndReboundToTheOriginalQuery() { + String query = "500\u5143\u4ee5\u4e0b\u7684\u96f6\u661f\u91c7\u8d2d\u600e\u4e48\u62a5\u9500\uff1f"; + String modelJson = """ + { + "normalizedQuery": "%s", + "alternateQueries": ["controlled alternate"], + "intents": ["PROCESS_GUIDANCE", "ELIGIBILITY_CHECK"], + "domain": "FINANCE_REIMBURSEMENT", + "evidenceNeeds": [ + {"code":"REIMBURSEMENT_POLICY","required":true,"description":"policy"}, + {"code":"ELIGIBILITY_CRITERIA","required":true,"description":"eligibility"} + ], + "actions": [ + {"id":"knowledge-original","toolCode":"KNOWLEDGE_SEARCH","arguments":{"query":"%s"},"evidenceNeeds":["REIMBURSEMENT_POLICY"]}, + {"id":"knowledge-alternate","toolCode":"KNOWLEDGE_SEARCH","arguments":{"query":"controlled alternate"},"evidenceNeeds":["ELIGIBILITY_CRITERIA"]} + ], + "needsClarification": false, + "clarificationReasons": [] + } + """.formatted(query, query); + var planner = planner(modelReturning(modelJson, new AtomicReference<>())); + + var plan = planner.plan(query); + + assertThat(plan.candidateActions()).singleElement().satisfies(action -> { + assertThat(action.toolCode()).isEqualTo("KNOWLEDGE_SEARCH"); + assertThat(action.arguments()).containsExactlyInAnyOrderEntriesOf(Map.of( + "query", plan.originalQuery(), + "domain", "FINANCE_REIMBURSEMENT" + )); + assertThat(action.evidenceNeeds()).contains( + "REIMBURSEMENT_POLICY", "ELIGIBILITY_CRITERIA", "PROCESS_STEPS", "APPLICABILITY", + "AMOUNT_BASIS"); + }); + } + + @Test + void capabilityIntentUsesTheRegistryToolInsteadOfTreatingDocumentsAsSystemFacts() { + var planner = planner(modelReturning(""" + { + "normalizedQuery": "资料里提到CRM,系统是不是已经接入了?", + "alternateQueries": [], + "intents": ["CAPABILITY_QUERY", "KNOWLEDGE_QA"], + "domain": "GENERAL", + "evidenceNeeds": [{"code":"CAPABILITY_REGISTRY","required":true,"description":"实际工具能力"}], + "actions": [{"id":"capability","toolCode":"CAPABILITY_QUERY","arguments":{"capabilityCode":"CRM"},"evidenceNeeds":["CAPABILITY_REGISTRY"]}], + "needsClarification": false, + "clarificationReasons": [] + } + """, new AtomicReference<>())); + + var plan = planner.plan("资料里提到CRM,系统是不是已经接入了?"); + + assertThat(plan.candidateActions()).extracting(action -> action.toolCode()) + .containsExactly("CAPABILITY_QUERY", "KNOWLEDGE_SEARCH"); + assertThat(plan.candidateActions().get(0).arguments()).containsEntry("capabilityCode", "CRM"); + assertThat(plan.evidenceNeeds()).extracting(need -> need.code()) + .containsExactly("CAPABILITY_REGISTRY") + .doesNotContain("KNOWLEDGE_EVIDENCE"); + assertThat(plan.candidateActions().get(1).evidenceNeeds()).isEmpty(); + } + + @Test + void tasksAndProcessIntentsRecoverBothActionsBeforeAnyClarification() { + var planner = planner(modelReturning(""" + { + "normalizedQuery": "我这个月还有哪些待办,相关流程怎么处理?", + "alternateQueries": [], + "intents": ["LIVE_MY_WORK", "PROCESS_GUIDANCE"], + "domain": "WORK_MANAGEMENT", + "evidenceNeeds": [], + "actions": [], + "needsClarification": true, + "clarificationReasons": ["待办类型不明确"] + } + """, new AtomicReference<>())); + + var plan = planner.plan("我这个月还有哪些待办,相关流程怎么处理?"); + + assertThat(plan.candidateActions()).extracting(action -> action.toolCode()) + .containsExactly("MY_CURRENT_TASKS", "KNOWLEDGE_SEARCH"); + assertThat(plan.candidateActions().get(0).arguments()).containsEntry("dateRange", "THIS_MONTH"); + assertThat(plan.candidateActions().get(1).arguments()).doesNotContainKeys( + "dateRange", "startDate", "endDate"); } @Test @@ -133,15 +251,73 @@ class AihrAgentSemanticPlannerTest { assertThat(candidate.rawText()).isEqualTo("500元"); assertThat(candidate.attributes()).containsEntry("value", "500.0"); }); - assertThat(plan.needsClarification()).isTrue(); + assertThat(plan.candidateActions()).singleElement().satisfies(action -> + assertThat(action.evidenceNeeds()).contains( + "AMOUNT_BASIS", "PROCESS_STEPS", "APPLICABILITY")); + assertThat(plan.needsClarification()).isFalse(); } @Test - void modelFailureProducesNoExecutableShadowActions() { + void modelFailureFallsBackToAnExecutableKnowledgeCandidate() { var plan = planner(null).plan("催费后多久需要再次跟进?"); - assertThat(plan.candidateActions()).isEmpty(); - assertThat(plan.planSource()).isEqualTo("DETERMINISTIC_FALLBACK"); + assertThat(plan.intents()).contains("KNOWLEDGE_QA"); + assertThat(plan.candidateActions()).extracting(action -> action.toolCode()) + .containsExactly("KNOWLEDGE_SEARCH"); + assertThat(plan.planSource()).isEqualTo("DETERMINISTIC_FALLBACK_EXECUTABLE"); + } + + @Test + void materialsQuestionCreatesASpecificEvidenceNeedWithoutInventingProcessSteps() { + var plan = planner(null).plan("第二项需要什么材料?"); + + assertThat(plan.intents()).containsExactly("PROCESS_GUIDANCE"); + assertThat(plan.evidenceNeeds()).extracting(need -> need.code()) + .containsExactly("REQUIRED_MATERIALS") + .doesNotContain("PROCESS_STEPS"); + assertThat(plan.candidateActions()).singleElement().satisfies(action -> { + assertThat(action.toolCode()).isEqualTo("KNOWLEDGE_SEARCH"); + assertThat(action.evidenceNeeds()).containsExactly("REQUIRED_MATERIALS"); + }); + } + + @Test + void reportsInvalidModelJsonWithoutPersistingTheModelOutput() { + var plan = planner(modelReturning("not-json", new AtomicReference<>())) + .plan("催费后多久需要再次跟进?"); + + assertThat(plan.candidateActions()).extracting(action -> action.toolCode()) + .containsExactly("KNOWLEDGE_SEARCH"); + assertThat(plan.planSource()).isEqualTo("DETERMINISTIC_FALLBACK_INVALID_JSON_EXECUTABLE"); + assertThat(plan.planSource()).doesNotContain("not-json"); + } + + @Test + void modelClarificationCannotBlockARegisteredCapabilityQuery() { + var planner = planner(modelReturning(""" + { + "normalizedQuery": "当前账号能查询哪些内容?", + "alternateQueries": [], + "intents": ["CLARIFY"], + "domain": "GENERAL", + "evidenceNeeds": [], + "actions": [], + "needsClarification": true, + "clarificationReasons": ["查询内容范围不明确"] + } + """, new AtomicReference<>())); + + var plan = planner.plan("当前账号能查询哪些内容?"); + + assertThat(plan.intents()).containsExactly("CAPABILITY_QUERY"); + assertThat(plan.candidateActions()).singleElement().satisfies(action -> { + assertThat(action.toolCode()).isEqualTo("CAPABILITY_QUERY"); + assertThat(action.arguments()).isEmpty(); + assertThat(action.evidenceNeeds()).containsExactly("CAPABILITY_REGISTRY"); + }); + assertThat(plan.needsClarification()).isFalse(); + assertThat(plan.clarificationReasons()).isEmpty(); + assertThat(plan.planSource()).isEqualTo("SEMANTIC_MODEL_VALIDATED_WITH_SERVER_HINTS"); } private AihrAgentSemanticPlanner planner(AihrModelSeedService model) { @@ -153,7 +329,8 @@ class AihrAgentSemanticPlannerTest { if (response == null) return null; return new AihrModelSeedService(objectMapper, null) { @Override - public Optional tryChat(String systemPrompt, String userPrompt, double temperature) { + public Optional tryStructuredJson(String systemPrompt, String userPrompt, int maxTokens, + Duration timeout) { input.set(userPrompt); return Optional.of(response); } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/runtime/AihrGroundedAgentGoldenTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/runtime/AihrGroundedAgentGoldenTest.java index b360c5aa..11a0e7ad 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/runtime/AihrGroundedAgentGoldenTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/runtime/AihrGroundedAgentGoldenTest.java @@ -29,6 +29,7 @@ import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.TestFactory; import java.io.InputStream; +import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -80,7 +81,7 @@ class AihrGroundedAgentGoldenTest { ? modelEmpty() : modelReturning(plannerResponse(fixture)); AihrAgentSemanticPlanner planner = new AihrAgentSemanticPlanner(objectMapper, planningModel, new AihrRagAmountSemanticPlanner(objectMapper, null)); - SemanticQueryPlan plan = planner.plan(fixture.query()); + SemanticQueryPlan plan = planner.plan(fixture.query(), resolved); assertThat(plan.originalQuery()).isEqualTo(fixture.query()); assertThat(plan.normalizedQuery()).isEqualTo(fixture.normalizedQuery()); @@ -163,15 +164,31 @@ class AihrGroundedAgentGoldenTest { || "TWO_ROUNDS_PARTIAL".equals(fixture.profile()); boolean needsKnowledge = list(fixture.toolCodes()).contains("KNOWLEDGE_SEARCH") || "TWO_ROUNDS_PARTIAL".equals(fixture.profile()); + boolean needsCapabilities = list(fixture.toolCodes()).contains("CAPABILITY_QUERY"); if (needsTasks) result.add(new ScenarioTool("MY_CURRENT_TASKS", Set.of("LIVE_MY_WORK"), Set.of("dateRange", "startDate", "endDate", "status"), call -> taskResult(fixture, call, calls))); if (needsKnowledge) result.add(new ScenarioTool("KNOWLEDGE_SEARCH", Set.of("KNOWLEDGE_QA", "PROCESS_GUIDANCE", "ELIGIBILITY_CHECK"), Set.of("query", "domain"), call -> knowledgeResult(fixture, call, calls, nanos))); + if (needsCapabilities) result.add(new ScenarioTool("CAPABILITY_QUERY", Set.of("CAPABILITY_QUERY"), + Set.of("capabilityCode"), call -> capabilityResult(call, calls))); return List.copyOf(result); } + private ToolResult capabilityResult(ToolCall call, AtomicInteger calls) { + calls.incrementAndGet(); + String requested = call.arguments().get("capabilityCode"); + String claimText = "CRM".equals(requested) + ? "当前没有注册并授权 CRM 实时查询能力。" + : "当前注册并授权的业务查询能力:企业知识资料、本人的当前待办。"; + VerifiedFact fact = new VerifiedFact("fact_cccccccccccccccccccccccccccccccc", "CAPABILITY", + Map.of("itemKey", "CAPABILITY_SET", "claimText", claimText), "AIHR_AGENT_TOOL_REGISTRY", + NOW, VALID_UNTIL, 60, Map.of("subject", "SELF", "project", "P1"), "auth-1", "cap-audit"); + return new ToolResult(call.callId(), "CAPABILITY_QUERY", ToolStatus.SUCCESS, List.of(), List.of(fact), + List.of(), List.of(), List.of("AUTHORIZED_CAPABILITY_REGISTRY"), "cap-audit"); + } + private ToolResult taskResult(Fixture fixture, ToolCall call, AtomicInteger calls) { calls.incrementAndGet(); if (Set.of("CROSS_PROJECT").contains(fixture.profile())) { @@ -284,6 +301,9 @@ class AihrGroundedAgentGoldenTest { if (fixture.query().contains("这个月")) arguments.put("dateRange", "THIS_MONTH"); if ("MONTH_CORRECTION".equals(fixture.profile())) arguments.put("dateRange", "THIS_MONTH"); actionNeeds = needs.stream().filter(value -> value.contains("CURRENT_TASK")).toList(); + } else if ("CAPABILITY_QUERY".equals(toolCode)) { + if (fixture.query().contains("CRM")) arguments.put("capabilityCode", "CRM"); + actionNeeds = needs.stream().filter(value -> value.contains("CAPABILITY")).toList(); } else { arguments.put("query", fixture.query()); arguments.put("domain", fixture.domain()); @@ -303,7 +323,9 @@ class AihrGroundedAgentGoldenTest { String relation = valueOr(fixture.contextRelation(), "NEW_TOPIC"); Map response = new LinkedHashMap<>(); response.put("relation", relation); - response.put("currentGoal", "NEW_TOPIC".equals(relation) ? fixture.query() : "查询待办及对应流程"); + response.put("currentGoal", "NEW_TOPIC".equals(relation) + || "REFERENCE_REQUEST".equals(relation) || "CORRECTION".equals(relation) + ? fixture.query() : "查询待办及对应流程"); response.put("referencedOrdinal", "REFERENCE_REQUEST".equals(relation) ? 2 : null); response.put("clarificationReasons", List.of()); return objectMapper.writeValueAsString(response); @@ -392,7 +414,8 @@ class AihrGroundedAgentGoldenTest { private AihrModelSeedService modelReturning(String output) { return new AihrModelSeedService(objectMapper, null) { @Override - public Optional tryChat(String systemPrompt, String userPrompt, double temperature) { + public Optional tryStructuredJson(String systemPrompt, String userPrompt, int maxTokens, + Duration timeout) { return Optional.of(output); } }; @@ -401,7 +424,8 @@ class AihrGroundedAgentGoldenTest { private AihrModelSeedService modelEmpty() { return new AihrModelSeedService(objectMapper, null) { @Override - public Optional tryChat(String systemPrompt, String userPrompt, double temperature) { + public Optional tryStructuredJson(String systemPrompt, String userPrompt, int maxTokens, + Duration timeout) { return Optional.empty(); } }; @@ -441,16 +465,22 @@ class AihrGroundedAgentGoldenTest { } @Override - public Optional tryChat(String systemPrompt, String userPrompt, double temperature) { + public Optional tryStructuredJson(String systemPrompt, String userPrompt, int maxTokens, + Duration timeout) { calls++; try { JsonNode input = objectMapper.readTree(userPrompt); List> confirmed = new ArrayList<>(); List> steps = new ArrayList<>(); for (JsonNode fact : input.path("verifiedFacts")) { - String title = fact.path("value").path("title").asText("当前事项"); - confirmed.add(claim("当前待办是" + title + "。", "REALTIME_STATUS", List.of(), - List.of(fact.path("factRef").asText()))); + if ("CAPABILITY".equals(fact.path("factType").asText())) { + confirmed.add(claim(fact.path("value").path("claimText").asText(), "CAPABILITY", List.of(), + List.of(fact.path("factRef").asText()))); + } else { + String title = fact.path("value").path("title").asText("当前事项"); + confirmed.add(claim("当前待办是" + title + "。", "REALTIME_STATUS", List.of(), + List.of(fact.path("factRef").asText()))); + } } for (JsonNode evidence : input.path("verifiedEvidence")) { Set claimTypes = new LinkedHashSet<>(); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/runtime/AihrGroundedAgentRuntimeTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/runtime/AihrGroundedAgentRuntimeTest.java index 625c1b47..55b2ef6e 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/runtime/AihrGroundedAgentRuntimeTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/runtime/AihrGroundedAgentRuntimeTest.java @@ -31,7 +31,8 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -64,6 +65,9 @@ class AihrGroundedAgentRuntimeTest { assertThat(result.handled()).isTrue(); assertThat(result.trace().totalToolCalls()).isEqualTo(1); assertThat(result.trace().actions()).hasSize(1); + assertThat(result.trace().actions().get(0).timings()).containsKey("RUNTIME_TOOL_TOTAL"); + assertThat(result.trace().evidenceGateMillis()).isGreaterThanOrEqualTo(0L); + assertThat(result.trace().composerMillis()).isGreaterThanOrEqualTo(0L); assertThat(calls).hasValue(1); } @@ -82,7 +86,7 @@ class AihrGroundedAgentRuntimeTest { assertThat(result.trace().totalToolCalls()).isEqualTo(1); assertThat(result.trace().stopReasons()).contains("NO_NEW_EVIDENCE_TARGET"); assertThat(result.decision().status()).isEqualTo(DecisionStatus.NO_EVIDENCE); - verify(model, never()).tryChat(anyString(), anyString(), anyDouble()); + verify(model, never()).tryStructuredJson(anyString(), anyString(), anyInt(), any()); } @Test @@ -135,7 +139,7 @@ class AihrGroundedAgentRuntimeTest { assertThat(result.trace().actions().get(0).observations()).doesNotContain("sensitive downstream failure"); assertThat(result.decision().status()).isEqualTo(DecisionStatus.NO_EVIDENCE); assertThat(calls).hasValue(1); - verify(model, never()).tryChat(anyString(), anyString(), anyDouble()); + verify(model, never()).tryStructuredJson(anyString(), anyString(), anyInt(), any()); } @Test @@ -148,7 +152,8 @@ class AihrGroundedAgentRuntimeTest { return new ToolResult(call.callId(), "KNOWLEDGE_SEARCH", ToolStatus.SUCCESS, List.of(new DocumentEvidence("late", 9L, "COMPANY_POLICY", "OPERATING_MANUAL", "v1", NOW, "P1", "迟到结果", "提交报销单。", Set.of("PROCESS_STEPS"), - Set.of("PROCESS_STEP"))), List.of(), List.of(), List.of(), List.of(), "late-audit"); + Set.of("PROCESS_STEP"))), List.of(), List.of(), List.of(), List.of(), "late-audit", + Map.of("VECTOR_EMBEDDING", 4L)); }); SemanticQueryPlan plan = plan(List.of(new ToolCall("knowledge", "KNOWLEDGE_SEARCH", Map.of("query", "怎么报销", "domain", "FINANCE_REIMBURSEMENT"), List.of("PROCESS_STEPS"))), @@ -164,10 +169,13 @@ class AihrGroundedAgentRuntimeTest { assertThat(value.status()).isEqualTo(ToolStatus.TIMEOUT); assertThat(value.evidence()).isEmpty(); assertThat(value.observations()).containsExactly("TOOL_TIMEOUT"); + assertThat(value.auditRef()).isEqualTo("late-audit"); + assertThat(value.timings()).containsEntry("VECTOR_EMBEDDING", 4L) + .containsKey("RUNTIME_TOOL_TOTAL"); }); assertThat(result.decision().status()).isEqualTo(DecisionStatus.NO_EVIDENCE); assertThat(result.trace().stopReasons()).contains("TIME_BUDGET_EXCEEDED"); - verify(model, never()).tryChat(anyString(), anyString(), anyDouble()); + verify(model, never()).tryStructuredJson(anyString(), anyString(), anyInt(), any()); } @Test @@ -182,6 +190,28 @@ class AihrGroundedAgentRuntimeTest { assertThat(result.trace().stopReasons()).containsExactly("PLAN_NOT_EXECUTABLE"); } + @Test + void clarificationPlanProducesNeedsInputWithoutCallingToolsOrComposerModel() { + AihrModelSeedService model = mock(AihrModelSeedService.class); + SemanticQueryPlan clarification = new SemanticQueryPlan( + "CRM里这个客户现在是什么状态", "CRM里这个客户现在是什么状态", List.of(), Set.of("CLARIFY"), + "GENERAL", List.of(), List.of(), List.of(), true, List.of("缺少已注册的实时业务事实工具"), + "test-model", "SEMANTIC_MODEL_VALIDATED"); + + var result = runtime(List.of(), new AihrGroundedAgentPolicy(), model).run( + clarification.originalQuery(), resolved(), clarification, context()); + + assertThat(result.handled()).isTrue(); + assertThat(result.decision().status()).isEqualTo(DecisionStatus.NEEDS_INPUT); + assertThat(result.decision().missingInformation()).containsExactly("CLARIFICATION_REQUIRED"); + assertThat(result.decision().missingInformation()) + .doesNotContain(clarification.clarificationReasons().get(0)); + assertThat(result.answer().status()).isEqualTo(DecisionStatus.NEEDS_INPUT); + assertThat(result.trace().totalToolCalls()).isZero(); + assertThat(result.trace().stopReasons()).containsExactly("ASK_CLARIFICATION"); + verify(model, never()).tryStructuredJson(anyString(), anyString(), anyInt(), any()); + } + private static AihrGroundedAgentRuntime runtime(List tools, AihrGroundedAgentPolicy policy, AihrModelSeedService model) { return new AihrGroundedAgentRuntime(new AihrAgentToolRegistry(tools), policy, @@ -191,7 +221,7 @@ class AihrGroundedAgentRuntimeTest { private static AihrModelSeedService passiveModel() { AihrModelSeedService model = mock(AihrModelSeedService.class); - when(model.tryChat(anyString(), anyString(), anyDouble())).thenReturn(Optional.empty()); + when(model.tryStructuredJson(anyString(), anyString(), anyInt(), any())).thenReturn(Optional.empty()); return model; } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/shadow/AihrAgentExecutionContextFactoryTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/shadow/AihrAgentExecutionContextFactoryTest.java new file mode 100644 index 00000000..84225cce --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/shadow/AihrAgentExecutionContextFactoryTest.java @@ -0,0 +1,85 @@ +package org.dromara.aihr.agent.shadow; + +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.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@Tag("dev") +class AihrAgentExecutionContextFactoryTest { + + @Test + void freezesOnlyServerResolvedIdentityAppProjectAndSpaces() { + AihrKnowledgeAppService apps = mock(AihrKnowledgeAppService.class); + AihrKnowledgeAccessService access = mock(AihrKnowledgeAccessService.class); + AihrKnowledgePrincipalResolver principals = mock(AihrKnowledgePrincipalResolver.class); + AuthenticatedApp app = new AuthenticatedApp(7L, "000000", "mobile", "移动端", "SESSION", 60, null); + AihrKnowledgePrincipal principal = principal(); + when(principals.revalidate(principal)).thenReturn(principal); + when(apps.requireSessionApp("000000", "client-mobile")).thenReturn(app); + when(access.resolveInternalSpaceIds(principal, app, List.of(), "READ")).thenReturn(Set.of(11L, 12L)); + + var context = new AihrAgentExecutionContextFactory(apps, access, principals).create(principal, null); + + assertThat(context.tenantId()).isEqualTo("000000"); + assertThat(context.userId()).isEqualTo(1L); + assertThat(context.appId()).isEqualTo(7L); + assertThat(context.authorizedKnowledgeSpaceIds()).containsExactlyInAnyOrder(11L, 12L); + assertThat(context.authorizationSnapshot()).matches("[a-f0-9]{64}"); + assertThat(context.authorizationSnapshot()).doesNotContain("client-mobile", "P1"); + verify(apps).requireSessionApp("000000", "client-mobile"); + verify(access).resolveInternalSpaceIds(principal, app, List.of(), "READ"); + verify(principals).revalidate(principal); + } + + @Test + void rejectsCrossProjectBeforeAnyAppOrSpaceLookup() { + AihrKnowledgeAppService apps = mock(AihrKnowledgeAppService.class); + AihrKnowledgeAccessService access = mock(AihrKnowledgeAccessService.class); + AihrKnowledgePrincipalResolver principals = mock(AihrKnowledgePrincipalResolver.class); + AihrKnowledgePrincipal current = principal(); + when(principals.revalidate(current)).thenReturn(current); + + assertThatThrownBy(() -> new AihrAgentExecutionContextFactory(apps, access, principals) + .create(current, "P2")) + .isInstanceOf(ServiceException.class) + .extracting("code").isEqualTo(403); + verifyNoInteractions(apps, access); + } + + @Test + void rejectsProjectRemovedAfterRequestAuthentication() { + AihrKnowledgeAppService apps = mock(AihrKnowledgeAppService.class); + AihrKnowledgeAccessService access = mock(AihrKnowledgeAccessService.class); + AihrKnowledgePrincipalResolver principals = mock(AihrKnowledgePrincipalResolver.class); + AihrKnowledgePrincipal authenticated = principal(); + AihrKnowledgePrincipal refreshed = new AihrKnowledgePrincipal( + "000000", 1L, "app_user", "employee-1", Set.of("employee"), Set.of("P3"), "client-mobile"); + when(principals.revalidate(authenticated)).thenReturn(refreshed); + + assertThatThrownBy(() -> new AihrAgentExecutionContextFactory(apps, access, principals) + .create(authenticated, "P1")) + .isInstanceOf(ServiceException.class) + .extracting("code").isEqualTo(403); + verifyNoInteractions(apps, access); + } + + private static AihrKnowledgePrincipal principal() { + return new AihrKnowledgePrincipal("000000", 1L, "app_user", "employee-1", + Set.of("employee"), Set.of("P1"), "client-mobile"); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/shadow/AihrAgentShadowAuditServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/shadow/AihrAgentShadowAuditServiceTest.java new file mode 100644 index 00000000..04260a6f --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/shadow/AihrAgentShadowAuditServiceTest.java @@ -0,0 +1,60 @@ +package org.dromara.aihr.agent.shadow; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.dromara.aihr.agent.shadow.AihrAgentShadowAuditService.ShadowAuditRecord; +import org.dromara.aihr.knowledge.service.AihrKnowledgeAppService; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@Tag("dev") +class AihrAgentShadowAuditServiceTest { + + @Test + void storesOnlyHashesCodesAndCountsWithoutQuestionAnswerOrToolBodies() { + CapturingJdbcTemplate jdbc = new CapturingJdbcTemplate(); + AihrAgentShadowAuditService service = new AihrAgentShadowAuditService(jdbc, new ObjectMapper()); + String sensitiveQuestion = "500元采购怎么报销"; + String sensitiveAnswer = "请把发票交给财务张三"; + String sensitivePhone = "13900000000"; + String sensitiveToken = "Bearer local-secret-token"; + String sensitiveAttachment = "报销单-张三.pdf"; + String sensitiveToolBody = "{\"taskTitle\":\"客户投诉\"}"; + + service.record(new ShadowAuditRecord( + "shadow_run_1", "agent_run_1", AihrKnowledgeAppService.sha256(sensitiveQuestion), "000000", 1L, + 7L, AihrKnowledgeAppService.sha256("P1"), AihrKnowledgeAppService.sha256("scope"), + "KNOWLEDGE_QA", List.of("KNOWLEDGE_SEARCH"), "COMPLETED", 1, 0, false, 12L, + "SEMANTIC_MODEL_VALIDATED", List.of("PROCESS_GUIDANCE"), List.of("KNOWLEDGE_SEARCH"), + "PARTIAL", 1, 0, List.of("MATERIALS", sensitiveAnswer, sensitivePhone, sensitiveToken), + List.of(sensitiveAttachment), Map.of("CLAIM_COUNT", 1, "INVALID_REFERENCE_COUNT", 0), + List.of("NO_NEW_EVIDENCE_TARGET", sensitiveToolBody), + Map.of("SEMANTIC_PLANNING", 12L, sensitiveToolBody, 99L), + 28L, true, false, "SUCCESS", null + )); + + String stored = jdbc.sql + Arrays.toString(jdbc.args); + assertThat(stored).contains("shadow_run_1", "PROCESS_GUIDANCE", "MATERIALS"); + assertThat(stored).contains("SEMANTIC_PLANNING", "12"); + assertThat(stored).doesNotContain(sensitiveQuestion, sensitiveAnswer, sensitivePhone, sensitiveToken, + sensitiveAttachment, sensitiveToolBody, "question", "answer", "attachment"); + } + + private static final class CapturingJdbcTemplate extends JdbcTemplate { + private String sql; + private Object[] args; + + @Override + public int update(String sql, Object... args) { + this.sql = sql; + this.args = args; + return 1; + } + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/shadow/AihrAgentShadowDispatcherTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/shadow/AihrAgentShadowDispatcherTest.java new file mode 100644 index 00000000..fc32541d --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/shadow/AihrAgentShadowDispatcherTest.java @@ -0,0 +1,121 @@ +package org.dromara.aihr.agent.shadow; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.assertj.core.api.Assertions.assertThat; + +@Tag("dev") +class AihrAgentShadowDispatcherTest { + + @Test + void timeoutWinsOnceAndLateCompletionIsDiscarded() throws Exception { + ThreadPoolExecutor workers = executor(1, 1); + ScheduledExecutorService watchdog = Executors.newSingleThreadScheduledExecutor(); + ExecutorService completionExecutor = executor(1, 4); + AihrAgentShadowDispatcher dispatcher = new AihrAgentShadowDispatcher(workers, watchdog, completionExecutor); + List> completions = new CopyOnWriteArrayList<>(); + CountDownLatch terminal = new CountDownLatch(1); + + dispatcher.submit(() -> { + try { + Thread.sleep(150L); + } catch (InterruptedException ignored) { + Thread.sleep(60L); + } + return "late"; + }, 20L, completion -> { + completions.add(completion); + terminal.countDown(); + }); + + assertThat(terminal.await(1L, TimeUnit.SECONDS)).isTrue(); + Thread.sleep(250L); + assertThat(completions).hasSize(1); + assertThat(completions.get(0).status()).isEqualTo(AihrAgentShadowDispatcher.CompletionStatus.TIMEOUT); + dispatcher.close(); + } + + @Test + void fullQueueRejectsImmediatelyWithoutBlockingCaller() throws Exception { + ThreadPoolExecutor workers = executor(1, 1); + ScheduledExecutorService watchdog = Executors.newSingleThreadScheduledExecutor(); + ExecutorService completionExecutor = executor(1, 4); + AihrAgentShadowDispatcher dispatcher = new AihrAgentShadowDispatcher(workers, watchdog, completionExecutor); + CountDownLatch blocker = new CountDownLatch(1); + CountDownLatch rejected = new CountDownLatch(1); + AtomicBoolean firstStarted = new AtomicBoolean(); + + dispatcher.submit(() -> { + firstStarted.set(true); + blocker.await(); + return "first"; + }, 5_000L, ignored -> { }); + while (!firstStarted.get()) Thread.onSpinWait(); + dispatcher.submit(() -> "queued", 5_000L, ignored -> { }); + long started = System.nanoTime(); + dispatcher.submit(() -> "rejected", 5_000L, completion -> { + assertThat(completion.status()).isEqualTo(AihrAgentShadowDispatcher.CompletionStatus.REJECTED); + rejected.countDown(); + }); + + assertThat((System.nanoTime() - started) / 1_000_000L).isLessThan(100L); + assertThat(rejected.await(1L, TimeUnit.SECONDS)).isTrue(); + blocker.countDown(); + dispatcher.close(); + } + + @Test + void slowTerminalCallbackDoesNotDelayLaterWatchdogTimeout() throws Exception { + ThreadPoolExecutor workers = executor(2, 1); + ScheduledExecutorService watchdog = Executors.newSingleThreadScheduledExecutor(); + ExecutorService completionExecutor = executor(1, 4); + AihrAgentShadowDispatcher dispatcher = new AihrAgentShadowDispatcher(workers, watchdog, completionExecutor); + CountDownLatch firstCallbackStarted = new CountDownLatch(1); + CountDownLatch releaseFirstCallback = new CountDownLatch(1); + CountDownLatch secondInterrupted = new CountDownLatch(1); + + dispatcher.submit(() -> { + Thread.sleep(5_000L); + return "first"; + }, 10L, ignored -> { + firstCallbackStarted.countDown(); + try { + releaseFirstCallback.await(); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + }); + assertThat(firstCallbackStarted.await(1L, TimeUnit.SECONDS)).isTrue(); + + dispatcher.submit(() -> { + try { + Thread.sleep(5_000L); + } catch (InterruptedException ex) { + secondInterrupted.countDown(); + throw ex; + } + return "second"; + }, 20L, ignored -> { }); + + assertThat(secondInterrupted.await(500L, TimeUnit.MILLISECONDS)).isTrue(); + releaseFirstCallback.countDown(); + dispatcher.close(); + } + + private static ThreadPoolExecutor executor(int threads, int queueCapacity) { + return new ThreadPoolExecutor(threads, threads, 30L, TimeUnit.SECONDS, + new ArrayBlockingQueue<>(queueCapacity), new ThreadPoolExecutor.AbortPolicy()); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/shadow/AihrAgentShadowPropertiesTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/shadow/AihrAgentShadowPropertiesTest.java new file mode 100644 index 00000000..41bfeccf --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/shadow/AihrAgentShadowPropertiesTest.java @@ -0,0 +1,37 @@ +package org.dromara.aihr.agent.shadow; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +@Tag("dev") +class AihrAgentShadowPropertiesTest { + + @Test + void groundedRuntimeDefaultsToOff() { + AihrAgentShadowProperties properties = new AihrAgentShadowProperties(); + + assertThat(properties.getGroundedMode()).isEqualTo(AihrAgentShadowProperties.GroundedMode.OFF); + assertThat(properties.shadowEnabled()).isFalse(); + } + + @Test + void shadowRequiresAnExplicitModeChange() { + AihrAgentShadowProperties properties = new AihrAgentShadowProperties(); + properties.setGroundedMode(AihrAgentShadowProperties.GroundedMode.SHADOW); + + assertThat(properties.shadowEnabled()).isTrue(); + } + + @Test + void localDiagnosticTimeoutIsBoundedWithoutChangingTheDefault() { + AihrAgentShadowProperties properties = new AihrAgentShadowProperties(); + + properties.setGroundedShadowTimeoutMs(120_000L); + + assertThat(properties.getGroundedShadowTimeoutMs()).isEqualTo(60_000L); + properties.setGroundedShadowTimeoutMs(45_000L); + assertThat(properties.getGroundedShadowTimeoutMs()).isEqualTo(45_000L); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/shadow/AihrAgentShadowSchemaTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/shadow/AihrAgentShadowSchemaTest.java new file mode 100644 index 00000000..f6a5378d --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/shadow/AihrAgentShadowSchemaTest.java @@ -0,0 +1,33 @@ +package org.dromara.aihr.agent.shadow; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +@Tag("dev") +class AihrAgentShadowSchemaTest { + + @Test + void migrationIsAdditiveIdempotentAndHasNoSensitiveBodyColumns() throws Exception { + String sql = Files.readString(repoRoot().resolve( + "backend/script/sql/update/aihr_20260804_agent_shadow_comparison_mysql8.sql")); + String normalized = sql.toLowerCase(java.util.Locale.ROOT); + + assertThat(normalized).contains("create table if not exists `aihr_agent_shadow_run`"); + assertThat(normalized).contains("query_sha256", "legacy_run_id", "shadow_decision_status", + "citation_qualification_json", "phase_latency_json", "shadow_zero_evidence_generated"); + assertThat(normalized).doesNotContain("`question`", "`answer`", "`attachment`", "`evidence_body`", + "`tool_body`"); + } + + private static Path repoRoot() { + Path current = Path.of("").toAbsolutePath(); + while (current != null && !Files.exists(current.resolve("backend/script/sql"))) current = current.getParent(); + if (current == null) throw new IllegalStateException("repository root not found"); + return current; + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/shadow/AihrAgentShadowServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/shadow/AihrAgentShadowServiceTest.java new file mode 100644 index 00000000..c8b31f05 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/shadow/AihrAgentShadowServiceTest.java @@ -0,0 +1,134 @@ +package org.dromara.aihr.agent.shadow; + +import org.dromara.aihr.agent.AihrAgentDto.AgentPlan; +import org.dromara.aihr.agent.AihrAgentDto.AgentRequest; +import org.dromara.aihr.agent.AihrAgentDto.AgentResponse; +import org.dromara.aihr.agent.AihrAgentDto.AgentStatus; +import org.dromara.aihr.agent.AihrAgentDto.Intent; +import org.dromara.aihr.agent.AihrAgentDto.ResponseStyle; +import org.dromara.aihr.agent.AihrAgentDto.Tool; +import org.dromara.aihr.agent.context.AihrAgentContextResolver; +import org.dromara.aihr.agent.context.AihrAgentConversationStateService; +import org.dromara.aihr.agent.planning.AihrAgentSemanticPlanner; +import org.dromara.aihr.agent.runtime.AihrGroundedAgentRuntime; +import org.dromara.aihr.agent.shadow.AihrAgentShadowAuditService.ShadowAuditRecord; +import org.dromara.aihr.agent.shadow.AihrAgentShadowDispatcher.Completion; +import org.dromara.aihr.agent.shadow.AihrAgentShadowDispatcher.CompletionStatus; +import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; +import org.dromara.aihr.knowledge.service.AihrKnowledgeAppService; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; +import java.util.concurrent.Callable; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +@Tag("dev") +class AihrAgentShadowServiceTest { + + @Test + void offModeDoesNotDispatchPlanRunOrAudit() { + Fixture fixture = fixture(false); + + fixture.service.observe(request(), plan(), response(), principal(), 12L, false); + + verifyNoInteractions(fixture.dispatcher, fixture.contextFactory, fixture.semanticPlanner, + fixture.runtime, fixture.auditService); + } + + @Test + @SuppressWarnings("unchecked") + void mediaRequestIsSkippedWithoutCopyingAttachmentOrCallingPlanner() throws Exception { + Fixture fixture = fixture(true); + doAnswer(invocation -> { + Callable task = invocation.getArgument(0); + Consumer> completion = invocation.getArgument(2); + completion.accept(new Completion<>(CompletionStatus.COMPLETED, task.call(), null)); + return null; + }).when(fixture.dispatcher).submit(any(), anyLong(), any()); + + fixture.service.observe(request(), plan(), response(), principal(), 12L, true); + + var captor = org.mockito.ArgumentCaptor.forClass(ShadowAuditRecord.class); + verify(fixture.auditService).record(captor.capture()); + assertThat(captor.getValue().outcome()).isEqualTo("SKIPPED"); + assertThat(captor.getValue().errorCode()).isEqualTo("MEDIA_SHADOW_SKIPPED"); + assertThat(captor.getValue().querySha256()).isEqualTo( + AihrKnowledgeAppService.sha256(request().question())); + verifyNoInteractions(fixture.contextFactory, fixture.semanticPlanner, fixture.runtime); + } + + @Test + @SuppressWarnings("unchecked") + void dispatcherTimeoutBecomesStructuredAuditWithoutRunningPlanner() { + Fixture fixture = fixture(true); + doAnswer(invocation -> { + Consumer> completion = invocation.getArgument(2); + completion.accept(new Completion<>(CompletionStatus.TIMEOUT, null, "SHADOW_TIMEOUT")); + return null; + }).when(fixture.dispatcher).submit(any(), anyLong(), any()); + + fixture.service.observe(request(), plan(), response(), principal(), 12L, false); + + var captor = org.mockito.ArgumentCaptor.forClass(ShadowAuditRecord.class); + verify(fixture.auditService).record(captor.capture()); + assertThat(captor.getValue().outcome()).isEqualTo("TIMEOUT"); + assertThat(captor.getValue().errorCode()).isEqualTo("SHADOW_TIMEOUT"); + assertThat(captor.getValue().traceCodes()).containsExactly("SHADOW_STAGE_QUEUED"); + verifyNoInteractions(fixture.contextFactory, fixture.semanticPlanner, fixture.runtime); + } + + private static Fixture fixture(boolean enabled) { + AihrAgentShadowProperties properties = new AihrAgentShadowProperties(); + if (enabled) properties.setGroundedMode(AihrAgentShadowProperties.GroundedMode.SHADOW); + AihrAgentShadowDispatcher dispatcher = mock(AihrAgentShadowDispatcher.class); + AihrAgentExecutionContextFactory contextFactory = mock(AihrAgentExecutionContextFactory.class); + AihrAgentConversationStateService stateService = mock(AihrAgentConversationStateService.class); + AihrAgentContextResolver contextResolver = mock(AihrAgentContextResolver.class); + AihrAgentSemanticPlanner semanticPlanner = mock(AihrAgentSemanticPlanner.class); + AihrGroundedAgentRuntime runtime = mock(AihrGroundedAgentRuntime.class); + AihrAgentShadowAuditService auditService = mock(AihrAgentShadowAuditService.class); + return new Fixture(new AihrAgentShadowService(properties, dispatcher, contextFactory, stateService, + contextResolver, semanticPlanner, runtime, auditService), dispatcher, contextFactory, + semanticPlanner, runtime, auditService); + } + + private static AgentRequest request() { + return new AgentRequest("500元采购怎么报销", "conversation_1", 1L, "P1", null, false); + } + + private static AgentPlan plan() { + return new AgentPlan(Intent.KNOWLEDGE_QA, request().question(), Tool.KNOWLEDGE_SEARCH, + false, false, ResponseStyle.GROUNDED); + } + + private static AgentResponse response() { + return new AgentResponse("agent_run_1", "conversation_1", 1L, Intent.KNOWLEDGE_QA, + AgentStatus.COMPLETED, "旧回答", List.of(), List.of(), List.of(), null, null, + null, null, List.of(), null); + } + + private static AihrKnowledgePrincipal principal() { + return new AihrKnowledgePrincipal("000000", 1L, "app_user", "employee-1", + Set.of("employee"), Set.of("P1"), "client-mobile"); + } + + private record Fixture( + AihrAgentShadowService service, + AihrAgentShadowDispatcher dispatcher, + AihrAgentExecutionContextFactory contextFactory, + AihrAgentSemanticPlanner semanticPlanner, + AihrGroundedAgentRuntime runtime, + AihrAgentShadowAuditService auditService + ) { + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/shadow/AihrAgentSpringWiringContractTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/shadow/AihrAgentSpringWiringContractTest.java new file mode 100644 index 00000000..9f3dfca3 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/shadow/AihrAgentSpringWiringContractTest.java @@ -0,0 +1,36 @@ +package org.dromara.aihr.agent.shadow; + +import org.dromara.aihr.agent.context.AihrAgentConversationStateService; +import org.dromara.aihr.agent.planning.AihrAgentSemanticPlanner; +import org.dromara.aihr.agent.runtime.AihrGroundedAgentRuntime; +import org.dromara.aihr.agent.tool.AihrCurrentTasksAgentTool; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.lang.reflect.Constructor; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@Tag("dev") +class AihrAgentSpringWiringContractTest { + + @Test + void beansWithTestConstructorsDeclareOneProductionInjectionConstructor() { + for (Class type : List.of( + AihrAgentShadowDispatcher.class, + AihrCurrentTasksAgentTool.class, + AihrAgentConversationStateService.class, + AihrGroundedAgentRuntime.class, + AihrAgentSemanticPlanner.class + )) { + assertThat(List.of(type.getDeclaredConstructors()).stream() + .filter(constructor -> constructor.isAnnotationPresent(Autowired.class)) + .map(Constructor::toString) + .toList()) + .as(type.getSimpleName()) + .hasSize(1); + } + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/tool/AihrCapabilityQueryAgentToolTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/tool/AihrCapabilityQueryAgentToolTest.java new file mode 100644 index 00000000..d20e227b --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/tool/AihrCapabilityQueryAgentToolTest.java @@ -0,0 +1,106 @@ +package org.dromara.aihr.agent.tool; + +import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.ExecutionContext; +import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.ToolCall; +import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.ToolResult; +import org.dromara.aihr.agent.runtime.AihrGroundedAgentPolicy; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@Tag("dev") +class AihrCapabilityQueryAgentToolTest { + + @Test + void springContextResolvesCapabilityToolAndRegistryCycle() { + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { + context.register(AihrGroundedAgentPolicy.class, AihrCapabilityQueryAgentTool.class, + AihrAgentToolRegistry.class); + context.refresh(); + + assertThat(context.getBean(AihrCapabilityQueryAgentTool.class)).isNotNull(); + assertThat(context.getBean(AihrAgentToolRegistry.class).definitions()) + .extracting(AihrAgentTool.ToolDefinition::code) + .containsExactly("CAPABILITY_QUERY"); + } + } + + @Test + void returnsOnlyActuallyRegisteredAndAuthorizedBusinessTools() { + Fixture fixture = fixture(); + + ToolResult result = fixture.tool().execute( + new ToolCall("capabilities", "CAPABILITY_QUERY", Map.of(), List.of("CAPABILITY_REGISTRY")), + context(Set.of(11L))); + + assertThat(result.facts()).singleElement().satisfies(fact -> { + assertThat(fact.factType()).isEqualTo("CAPABILITY"); + assertThat(fact.sourceSystem()).isEqualTo("AIHR_AGENT_TOOL_REGISTRY"); + assertThat(String.valueOf(fact.value())).contains("KNOWLEDGE_SEARCH", "MY_CURRENT_TASKS") + .doesNotContain("CRM"); + }); + assertThat(result.timings()).containsKey("CAPABILITY_REGISTRY"); + } + + @Test + void unsupportedCrmIsARegistryFactRatherThanADocumentInference() { + Fixture fixture = fixture(); + + ToolResult result = fixture.tool().execute(new ToolCall("crm", "CAPABILITY_QUERY", + Map.of("capabilityCode", "CRM"), List.of("CAPABILITY_REGISTRY")), context(Set.of(11L))); + + assertThat(result.evidence()).isEmpty(); + assertThat(result.facts()).singleElement().satisfies(fact -> + assertThat(String.valueOf(fact.value())).contains("requestedSupported=false", "没有注册并授权 CRM")); + } + + @SuppressWarnings("unchecked") + private static Fixture fixture() { + ObjectProvider provider = mock(ObjectProvider.class); + AtomicReference registry = new AtomicReference<>(); + AihrCapabilityQueryAgentTool capability = new AihrCapabilityQueryAgentTool(provider, + new AihrGroundedAgentPolicy(), Clock.fixed(Instant.parse("2026-08-04T02:00:00Z"), ZoneOffset.UTC)); + registry.set(new AihrAgentToolRegistry(List.of( + capability, + stub("KNOWLEDGE_SEARCH", Set.of("KNOWLEDGE_QA")), + stub("MY_CURRENT_TASKS", Set.of("LIVE_MY_WORK")) + ))); + when(provider.getObject()).thenAnswer(ignored -> registry.get()); + return new Fixture(capability); + } + + private static AihrAgentTool stub(String code, Set intents) { + return new AihrAgentTool() { + @Override + public ToolDefinition definition() { + return new ToolDefinition(code, intents, Set.of(), true, 0L); + } + + @Override + public ToolResult execute(ToolCall call, ExecutionContext context) { + throw new UnsupportedOperationException(); + } + }; + } + + private static ExecutionContext context(Set spaces) { + return new ExecutionContext("000000", 1L, "app_user", "employee-1", 7L, "P1", + Set.of("P1"), spaces, Set.of("employee"), "auth-1"); + } + + private record Fixture(AihrCapabilityQueryAgentTool tool) { + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/tool/AihrKnowledgeSearchAgentToolTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/tool/AihrKnowledgeSearchAgentToolTest.java index 2a7347a8..d094d476 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/tool/AihrKnowledgeSearchAgentToolTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/tool/AihrKnowledgeSearchAgentToolTest.java @@ -7,6 +7,7 @@ import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.Citation; import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.KnowledgeEvidenceResult; import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest; import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryResponse; +import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; @@ -35,6 +36,11 @@ class AihrKnowledgeSearchAgentToolTest { Map.of("query", "零星采购怎么报销"), List.of("PROCESS_STEPS")), context()); assertThat(query.evidenceCalls).isEqualTo(1); + assertThat(query.threadLocalEvidenceCalls).isZero(); + assertThat(query.trustedPrincipal.userId()).isEqualTo(1L); + assertThat(query.trustedPrincipal.clientKey()).isNull(); + assertThat(query.trustedAppId).isEqualTo(7L); + assertThat(query.frozenSpaceIds).containsExactly(11L); assertThat(query.answerCalls).isZero(); assertThat(result.status()).isEqualTo(ToolStatus.SUCCESS); assertThat(result.facts()).isEmpty(); @@ -69,7 +75,11 @@ class AihrKnowledgeSearchAgentToolTest { private static final class CapturingQueryService extends AihrKnowledgeQueryService { private final KnowledgeEvidenceResult result; private int evidenceCalls; + private int threadLocalEvidenceCalls; private int answerCalls; + private AihrKnowledgePrincipal trustedPrincipal; + private Long trustedAppId; + private Set frozenSpaceIds; private CapturingQueryService(KnowledgeEvidenceResult result) { super(null, null, null, null, null, null, null, null, null); @@ -78,7 +88,19 @@ class AihrKnowledgeSearchAgentToolTest { @Override public KnowledgeEvidenceResult queryEvidenceInternal(QueryRequest request) { + threadLocalEvidenceCalls++; + throw new AssertionError("async Agent tool must not read request ThreadLocal identity"); + } + + @Override + public KnowledgeEvidenceResult queryEvidenceInternal(QueryRequest request, + AihrKnowledgePrincipal trustedPrincipal, + Long trustedAppId, + Set frozenSpaceIds) { evidenceCalls++; + this.trustedPrincipal = trustedPrincipal; + this.trustedAppId = trustedAppId; + this.frozenSpaceIds = frozenSpaceIds; return result; } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryAuditServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryAuditServiceTest.java index fd58a0a6..0e652454 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryAuditServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryAuditServiceTest.java @@ -3,6 +3,8 @@ package org.dromara.aihr.knowledge; import com.fasterxml.jackson.databind.ObjectMapper; import org.dromara.aihr.domain.AihrSopDto.SnippetResponse; import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryAuditService; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.ChannelContribution; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.RetrievalCandidate; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -13,6 +15,7 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -39,4 +42,23 @@ class AihrKnowledgeQueryAuditServiceTest { assertThat(args.getAllValues().get(1)).containsSequence( "000000", "request-1", 2, 12L, "FULLTEXT", 0.31, false); } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void candidateContributionsUseOneBoundedBatchInsteadOfOneDatabaseRoundTripPerChannel() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + AihrKnowledgeQueryAuditService service = new AihrKnowledgeQueryAuditService(jdbc, new ObjectMapper()); + RetrievalCandidate candidate = new RetrievalCandidate( + 11L, "制度", "COMPANY_POLICY", "OPERATING_MANUAL", 1, 0.03, 0.9, "rerank", true, + true, "SELECTED_EVIDENCE", List.of( + new ChannelContribution("KEYWORD", 1, 0.8, 0.02, "ORIGINAL"), + new ChannelContribution("VECTOR", 2, 0.7, 0.01, "NORMALIZED") + )); + + service.recordCandidates("request-1", "000000", List.of(candidate)); + + ArgumentCaptor rows = ArgumentCaptor.forClass(List.class); + verify(jdbc).batchUpdate(anyString(), rows.capture(), eq(200), any()); + assertThat(rows.getValue()).hasSize(2); + } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryServiceTest.java index a3e79306..4d537e1a 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryServiceTest.java @@ -22,6 +22,7 @@ import org.dromara.aihr.memory.AihrMemoryDto.MemoryDraft; import org.dromara.aihr.memory.AihrMemoryDto.ServiceMemoryRecall; import org.dromara.aihr.memory.AihrMemoryDto.ServiceMemoryRecallItem; import org.dromara.aihr.service.AihrSopSeedService; +import org.dromara.aihr.service.AihrSopSeedService.SearchExecution; import org.dromara.aihr.service.AihrModelSeedService; import org.dromara.common.core.exception.ServiceException; import org.junit.jupiter.api.Tag; @@ -86,6 +87,60 @@ class AihrKnowledgeQueryServiceTest { verify(sop, never()).searchAuthorized(any(), any(), any()); } + @Test + void explicitAgentContextRevalidatesAppAndIntersectsCurrentGrantsWithFrozenScope() { + var resolver = mock(AihrKnowledgePrincipalResolver.class); + var appService = mock(AihrKnowledgeAppService.class); + var access = mock(AihrKnowledgeAccessService.class); + var sop = mock(AihrSopSeedService.class); + var jdbc = mock(JdbcTemplate.class); + var audit = mock(AihrKnowledgeQueryAuditService.class); + var principal = new AihrKnowledgePrincipal("000000", 7L, "app_user", "employee-7", + Set.of("employee"), Set.of("P1"), null); + var app = new AuthenticatedApp(7L, "000000", "yc_mobile", "员工端", "SESSION", 60, null); + when(appService.requireSessionApp("000000", 7L)).thenReturn(app); + when(access.resolveInternalSpaceIds(principal, app, List.of(), "READ")).thenReturn(Set.of(9L, 10L)); + when(jdbc.queryForList(anyString(), eq(String.class), any(Object[].class))).thenReturn(List.of("finance")); + when(sop.searchAuthorizedGovernedEvidenceDetailed(any(), anyString(), eq(Set.of(9L)), anyString())) + .thenReturn(new SearchExecution(new SearchResponse("报销", "sop", "", "", List.of(), + List.of(), List.of(), List.of(), List.of(), List.of(), "test", null), null, List.of())); + var service = new AihrKnowledgeQueryService(resolver, appService, access, sop, audit, jdbc, + mock(AihrKnowledgeDataToolService.class), mock(AihrKnowledgeConversationService.class), + mock(AihrMemoryService.class)); + + service.queryEvidenceInternal(new QueryRequest( + "500元采购怎么报销", List.of(), "sop", null, "mobile", 5, null, + null, null, "P1", null), principal, 7L, Set.of(9L)); + + verify(appService).requireSessionApp("000000", 7L); + verify(access).resolveInternalSpaceIds(principal, app, List.of(), "READ"); + verify(sop).searchAuthorizedGovernedEvidenceDetailed(any(), anyString(), eq(Set.of(9L)), anyString()); + verify(sop, never()).searchAuthorizedEvidenceDetailed(any(), anyString(), any(), anyString()); + verify(resolver, never()).current(); + } + + @Test + void explicitAgentContextRejectsRevokedFrozenSpacesBeforeRetrieval() { + var resolver = mock(AihrKnowledgePrincipalResolver.class); + var appService = mock(AihrKnowledgeAppService.class); + var access = mock(AihrKnowledgeAccessService.class); + var sop = mock(AihrSopSeedService.class); + var principal = new AihrKnowledgePrincipal("000000", 7L, "app_user", "employee-7", + Set.of("employee"), Set.of("P1"), null); + var app = new AuthenticatedApp(7L, "000000", "yc_mobile", "员工端", "SESSION", 60, null); + when(appService.requireSessionApp("000000", 7L)).thenReturn(app); + when(access.resolveInternalSpaceIds(principal, app, List.of(), "READ")).thenReturn(Set.of(10L)); + var service = service(resolver, appService, access, sop); + + ServiceException error = assertThrows(ServiceException.class, () -> service.queryEvidenceInternal( + new QueryRequest("500元采购怎么报销", List.of(), "sop", null, "mobile", 5, null, + null, null, "P1", null), principal, 7L, Set.of(9L))); + + assertEquals(403, error.getCode()); + verify(sop, never()).searchAuthorizedGovernedEvidenceDetailed(any(), anyString(), any(), anyString()); + verify(resolver, never()).current(); + } + @Test void emptyEffectiveScopeStopsBeforeRag() { var resolver = mock(AihrKnowledgePrincipalResolver.class); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeIndexOutboxServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeIndexOutboxServiceTest.java index 190e0848..2af0ad45 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeIndexOutboxServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/quality/AihrKnowledgeIndexOutboxServiceTest.java @@ -1,12 +1,41 @@ package org.dromara.aihr.knowledge.quality; +import org.dromara.aihr.service.AihrSopSeedService; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Tag; +import org.mockito.ArgumentCaptor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; class AihrKnowledgeIndexOutboxServiceTest { + @SuppressWarnings({"rawtypes", "unchecked"}) + @Test + @Tag("dev") + void outboxSelectionAliasesTheResolvedCurrentVersion() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.update(anyString())).thenReturn(0); + when(jdbc.query(anyString(), any(RowMapper.class))).thenReturn(List.of()); + + new AihrKnowledgeIndexOutboxService(jdbc, mock(AihrSopSeedService.class), + mock(TransactionTemplate.class)).drain(); + + ArgumentCaptor sql = ArgumentCaptor.forClass(String.class); + verify(jdbc).query(sql.capture(), any(RowMapper.class)); + assertThat(sql.getValue()).contains( + "coalesce(a.published_version_id, a.current_version_id) current_version_id"); + } + @Test @Tag("dev") void movesToDeadLetterAtConfiguredAttemptLimit() { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/service/AihrKnowledgePrincipalResolverTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/service/AihrKnowledgePrincipalResolverTest.java index 9dfa2fd1..fd4eefc1 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/service/AihrKnowledgePrincipalResolverTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/service/AihrKnowledgePrincipalResolverTest.java @@ -1,6 +1,7 @@ package org.dromara.aihr.knowledge.service; import org.dromara.aihr.domain.AihrOrgSyncDto.OrgPersonRow; +import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; import org.dromara.aihr.service.AihrMobileSeedService; import org.dromara.aihr.service.AihrOrgSyncService; import org.dromara.common.core.domain.model.LoginUser; @@ -16,6 +17,7 @@ import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -62,6 +64,44 @@ class AihrKnowledgePrincipalResolverTest { } } + @Test + void asyncRevalidationRebuildsCurrentProjectsAndRoles() { + AihrOrgSyncService orgService = mock(AihrOrgSyncService.class); + AihrMobileSeedService mobileSeedService = mock(AihrMobileSeedService.class); + var authenticated = new AihrKnowledgePrincipal("000000", 1L, UserType.APP_USER.getUserType(), + "party-1", Set.of("employee", "supervisor"), Set.of("OLD"), "client-mobile"); + when(mobileSeedService.requireMobilePhoneForOrganizationIdentity("party-1")) + .thenReturn("13900001111"); + when(mobileSeedService.requireMobileOrganizationIdentity("13900001111")).thenReturn("party-1"); + when(orgService.activeByMobilePhone("13900001111")).thenReturn(List.of( + new OrgPersonRow(1L, "NEW", "", "", "party-1", "", "员工", "员工", "active", "2026-08-04") + )); + + var refreshed = new AihrKnowledgePrincipalResolver(orgService, mobileSeedService) + .revalidate(authenticated); + + assertEquals(Set.of("employee"), refreshed.roles()); + assertEquals(Set.of("NEW"), refreshed.projectCodes()); + assertEquals(authenticated.userId(), refreshed.userId()); + assertEquals(authenticated.clientKey(), refreshed.clientKey()); + } + + @Test + void asyncRevalidationRejectsOrganizationIdentityDrift() { + AihrOrgSyncService orgService = mock(AihrOrgSyncService.class); + AihrMobileSeedService mobileSeedService = mock(AihrMobileSeedService.class); + var authenticated = new AihrKnowledgePrincipal("000000", 1L, UserType.APP_USER.getUserType(), + "party-1", Set.of("employee"), Set.of("P1"), "client-mobile"); + when(mobileSeedService.requireMobilePhoneForOrganizationIdentity("party-1")) + .thenReturn("13900001111"); + when(mobileSeedService.requireMobileOrganizationIdentity("13900001111")).thenReturn("party-2"); + + assertThatThrownBy(() -> new AihrKnowledgePrincipalResolver(orgService, mobileSeedService) + .revalidate(authenticated)) + .isInstanceOf(org.dromara.common.core.exception.ServiceException.class) + .extracting("code").isEqualTo(403); + } + private static LoginUser login(String userType, String username, Set roles) { LoginUser login = new LoginUser(); login.setTenantId("000000"); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/service/AihrRagAmountSemanticPlannerTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/service/AihrRagAmountSemanticPlannerTest.java index ddf559ba..dc9293c5 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/service/AihrRagAmountSemanticPlannerTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/service/AihrRagAmountSemanticPlannerTest.java @@ -5,6 +5,7 @@ import org.dromara.aihr.service.AihrModelSeedService; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; +import java.time.Duration; import java.util.Optional; import java.util.concurrent.atomic.AtomicReference; @@ -130,8 +131,11 @@ class AihrRagAmountSemanticPlannerTest { private AihrModelSeedService modelReturning(String response, AtomicReference capturedPrompt) { return new AihrModelSeedService(objectMapper, null) { @Override - public Optional tryChat(String systemPrompt, String userPrompt, double temperature) { + public Optional tryStructuredJson(String systemPrompt, String userPrompt, int maxTokens, + Duration timeout) { capturedPrompt.set(userPrompt); + assertThat(maxTokens).isEqualTo(1_000); + assertThat(timeout).isEqualTo(AihrRagAmountSemanticPlanner.SEMANTIC_TIMEOUT); return Optional.of(response); } }; diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrModelSeedServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrModelSeedServiceTest.java index 6d78cc70..2fd8f970 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrModelSeedServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrModelSeedServiceTest.java @@ -1,12 +1,17 @@ package org.dromara.aihr.service; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; +import java.time.Duration; import java.nio.file.Files; import java.nio.file.Path; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @Tag("dev") @@ -24,4 +29,43 @@ class AihrModelSeedServiceTest { assertTrue(code.contains("fallback(TenantHelper.getTenantId(), TENANT_ID)")); assertFalse(code.contains("fallback(LoginHelper.getTenantId(), TENANT_ID)")); } + + @Test + void structuredDeepseekCallsAreJsonBoundedAndDisableReasoning() { + ObjectNode body = new ObjectMapper().createObjectNode(); + var options = AihrModelSeedService.ChatCallOptions.structured(800, Duration.ofSeconds(12)); + + AihrModelSeedService.applyCallOptions(body, "deepseek", options); + + assertEquals(800, body.path("max_tokens").asInt()); + assertEquals("json_object", body.path("response_format").path("type").asText()); + assertEquals("disabled", body.path("thinking").path("type").asText()); + assertEquals(Duration.ofSeconds(12), options.timeout()); + } + + @Test + void structuredCallsDoNotSendProviderSpecificThinkingControlElsewhere() { + ObjectNode body = new ObjectMapper().createObjectNode(); + var options = AihrModelSeedService.ChatCallOptions.structured(9_999, Duration.ofMinutes(1)); + + AihrModelSeedService.applyCallOptions(body, "custom_api", options); + + assertEquals(2_000, body.path("max_tokens").asInt()); + assertEquals("json_object", body.path("response_format").path("type").asText()); + assertFalse(body.has("thinking")); + assertEquals(Duration.ofSeconds(20), options.timeout()); + } + + @Test + void structuredConnectionCannotOutliveThePhaseBudget() { + assertEquals(Duration.ofSeconds(1), AihrModelSeedService.connectTimeout(Duration.ofSeconds(5))); + assertEquals(Duration.ofMillis(250), AihrModelSeedService.connectTimeout(Duration.ofMillis(250))); + } + + @Test + void rejectsCompletedModelBodiesAboveTheConfiguredLimit() { + assertEquals(3, AihrModelSeedService.limitedResponse(new byte[]{1, 2, 3}, 3).length); + assertThrows(IllegalStateException.class, + () -> AihrModelSeedService.limitedResponse(new byte[]{1, 2, 3, 4}, 3)); + } } 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 cf67c087..0a19c16e 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 @@ -705,6 +705,29 @@ public class AihrSopSeedServiceTest { assertTrue(code.contains("governedQdrantCollection()")); } + @Test + @Tag("dev") + public void agentEvidenceUsesReadyGovernedGenerationWithStrictMysqlLineage() throws Exception { + Path source = Path.of("src/main/java/org/dromara/aihr/service/AihrSopSeedService.java"); + if (!Files.exists(source)) { + source = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java"); + } + String code = Files.readString(source); + + assertTrue(code.contains("searchAuthorizedGovernedEvidenceDetailed")); + assertTrue(code.contains("when s.mode = 'SHADOW' then s.candidate_generation")); + assertTrue(code.contains("when s.mode = 'ENFORCED' then s.active_generation")); + assertTrue(code.contains("g.collection_name = s.governed_collection and g.status = 'READY'")); + assertTrue(code.contains("!LOCAL_EMBEDDING_BASE_URL.equals(candidate.baseUrl())")); + assertTrue(code.contains("payload.path(\"fragment_id\")")); + assertTrue(code.contains("payload.path(\"asset_id\")")); + assertTrue(code.contains("payload.path(\"version_id\")")); + assertTrue(code.contains("payload.path(\"chunk_revision_id\")")); + assertTrue(code.contains("Governed vector lineage no longer matches MySQL")); + assertTrue(code.contains("d.dataset_code = 'production' and d.status = 'ACTIVE'")); + assertTrue(code.contains("g.status = 'INCLUDED'")); + } + @Test @Tag("dev") public void authorizedSearchScopesMysqlAndQdrantToKnowledgeIds() throws Exception { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/resources/agent/grounded-agent-golden.json b/backend/ruoyi-modules/ruoyi-aihr/src/test/resources/agent/grounded-agent-golden.json index e942797f..24a633a2 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/resources/agent/grounded-agent-golden.json +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/resources/agent/grounded-agent-golden.json @@ -1,48 +1,48 @@ [ { - "id":"reimbursement-500-colloquial","profile":"FINANCE_COMPLETE","query":"我有个报销500块钱,告诉我怎么走流程。","normalizedQuery":"我有个报销500块钱,告诉我怎么走流程。","intents":["PROCESS_GUIDANCE"],"domain":"FINANCE_REIMBURSEMENT","toolCodes":["KNOWLEDGE_SEARCH"],"evidenceKinds":["OPERATING_MANUAL"],"factTypes":[],"decisionStatus":"ANSWERED","forbiddenClaims":["200块私了","500块维修"],"forbiddenSourceKinds":["EXPERIENCE"],"composerCalled":true,"amountRaw":"500块钱","amountValue":500,"currency":"CNY","expectedToolCalls":1 + "id":"reimbursement-500-colloquial","profile":"FINANCE_COMPLETE","query":"我有个报销500块钱,告诉我怎么走流程。","normalizedQuery":"我有个报销500块钱,告诉我怎么走流程。","intents":["KNOWLEDGE_QA","PROCESS_GUIDANCE","ELIGIBILITY_CHECK"],"domain":"FINANCE_REIMBURSEMENT","toolCodes":["KNOWLEDGE_SEARCH"],"evidenceKinds":["OPERATING_MANUAL"],"factTypes":[],"decisionStatus":"PARTIAL","forbiddenClaims":["200块私了","500块维修"],"forbiddenSourceKinds":["EXPERIENCE"],"composerCalled":true,"amountRaw":"500块钱","amountValue":500,"currency":"CNY","missingSlots":["APPLICABILITY"],"expectedToolCalls":1 }, { - "id":"reimbursement-petty-purchase","profile":"FINANCE_AMBIGUOUS","query":"500元以下的零星采购怎么报销?","normalizedQuery":"500元以下的零星采购怎么报销?","intents":["PROCESS_GUIDANCE","ELIGIBILITY_CHECK"],"domain":"FINANCE_REIMBURSEMENT","toolCodes":["KNOWLEDGE_SEARCH"],"evidenceKinds":["OPERATING_MANUAL","FORMAL_POLICY"],"factTypes":[],"decisionStatus":"NEEDS_INPUT","forbiddenClaims":["单笔500元以下必然可报销","500块维修"],"forbiddenSourceKinds":["EXPERIENCE"],"composerCalled":false,"amountRaw":"500元","amountValue":500,"currency":"CNY","missingSlots":["金额口径待确认"],"expectedToolCalls":1 + "id":"reimbursement-petty-purchase","profile":"FINANCE_AMBIGUOUS","query":"500元以下的零星采购怎么报销?","normalizedQuery":"500元以下的零星采购怎么报销?","intents":["KNOWLEDGE_QA","PROCESS_GUIDANCE","ELIGIBILITY_CHECK"],"domain":"FINANCE_REIMBURSEMENT","toolCodes":["KNOWLEDGE_SEARCH"],"evidenceKinds":["OPERATING_MANUAL","FORMAL_POLICY"],"factTypes":[],"decisionStatus":"PARTIAL","forbiddenClaims":["单笔500元以下必然可报销","500块维修"],"forbiddenSourceKinds":["EXPERIENCE"],"composerCalled":true,"amountRaw":"500元","amountValue":500,"currency":"CNY","missingSlots":["APPLICABILITY"],"expectedToolCalls":1 }, { - "id":"reimbursement-five-hundred","profile":"FINANCE_AMBIGUOUS","query":"五百块的小额自采怎么走账?","normalizedQuery":"五百块的小额自采怎么走账?","intents":["PROCESS_GUIDANCE","ELIGIBILITY_CHECK"],"domain":"FINANCE_REIMBURSEMENT","toolCodes":["KNOWLEDGE_SEARCH"],"evidenceKinds":["OPERATING_MANUAL","FORMAL_POLICY"],"factTypes":[],"decisionStatus":"NEEDS_INPUT","forbiddenClaims":["无需审批","200块私了"],"forbiddenSourceKinds":["EXPERIENCE"],"composerCalled":false,"amountRaw":"五百块","amountValue":500,"currency":"CNY","missingSlots":["金额口径待确认"],"expectedToolCalls":1 + "id":"reimbursement-five-hundred","profile":"FINANCE_AMBIGUOUS","query":"五百块的小额自采怎么走账?","normalizedQuery":"五百块的小额自采怎么走账?","intents":["KNOWLEDGE_QA","PROCESS_GUIDANCE","ELIGIBILITY_CHECK"],"domain":"FINANCE_REIMBURSEMENT","toolCodes":["KNOWLEDGE_SEARCH"],"evidenceKinds":["OPERATING_MANUAL","FORMAL_POLICY"],"factTypes":[],"decisionStatus":"PARTIAL","forbiddenClaims":["无需审批","200块私了"],"forbiddenSourceKinds":["EXPERIENCE"],"composerCalled":true,"amountRaw":"五百块","amountValue":500,"currency":"CNY","missingSlots":["APPLICABILITY"],"expectedToolCalls":1 }, { - "id":"currency-prefix-preserved","profile":"AMOUNT_PARTIAL","query":"我有一笔¥500的报销应该怎么走流程?","normalizedQuery":"我有一笔¥500的报销应该怎么走流程?","intents":["PROCESS_GUIDANCE","ELIGIBILITY_CHECK"],"domain":"FINANCE_REIMBURSEMENT","toolCodes":["KNOWLEDGE_SEARCH"],"evidenceKinds":["FORMAL_POLICY"],"factTypes":[],"decisionStatus":"PARTIAL","forbiddenClaims":["单笔500元可直接报销"],"forbiddenSourceKinds":["EXPERIENCE"],"composerCalled":true,"amountRaw":"¥500","amountValue":500,"currency":"CNY","amountBasis":"PER_TRANSACTION","missingSlots":["PROCESS_STEPS"],"expectedToolCalls":1 + "id":"currency-prefix-preserved","profile":"AMOUNT_PARTIAL","query":"我有一笔¥500的报销应该怎么走流程?","normalizedQuery":"我有一笔¥500的报销应该怎么走流程?","intents":["KNOWLEDGE_QA","PROCESS_GUIDANCE","ELIGIBILITY_CHECK"],"domain":"FINANCE_REIMBURSEMENT","toolCodes":["KNOWLEDGE_SEARCH"],"evidenceKinds":["FORMAL_POLICY"],"factTypes":[],"decisionStatus":"PARTIAL","forbiddenClaims":["单笔500元可直接报销"],"forbiddenSourceKinds":["EXPERIENCE"],"composerCalled":true,"amountRaw":"¥500","amountValue":500,"currency":"CNY","amountBasis":"PER_TRANSACTION","missingSlots":["PROCESS_STEPS"],"expectedToolCalls":1 }, { - "id":"five-hundred-amount-partial","profile":"AMOUNT_PARTIAL","query":"五百块的采购额度按什么口径?","normalizedQuery":"五百块的采购额度按什么口径?","intents":["KNOWLEDGE_QA","ELIGIBILITY_CHECK"],"domain":"FINANCE_POLICY","toolCodes":["KNOWLEDGE_SEARCH"],"evidenceKinds":["FORMAL_POLICY"],"factTypes":[],"decisionStatus":"PARTIAL","forbiddenClaims":["一定按单笔"],"forbiddenSourceKinds":["EXPERIENCE"],"composerCalled":true,"amountRaw":"五百块","amountValue":500,"currency":"CNY","missingSlots":["PROCESS_STEPS"],"expectedToolCalls":1 + "id":"five-hundred-amount-partial","profile":"AMOUNT_PARTIAL","query":"五百块的采购额度按什么口径?","normalizedQuery":"五百块的采购额度按什么口径?","intents":["KNOWLEDGE_QA","ELIGIBILITY_CHECK"],"domain":"FINANCE_REIMBURSEMENT","toolCodes":["KNOWLEDGE_SEARCH"],"evidenceKinds":["FORMAL_POLICY"],"factTypes":[],"decisionStatus":"PARTIAL","forbiddenClaims":["一定按单笔"],"forbiddenSourceKinds":["EXPERIENCE"],"composerCalled":true,"amountRaw":"五百块","amountValue":500,"currency":"CNY","missingSlots":["PROCESS_STEPS"],"expectedToolCalls":1 }, { - "id":"strict-below-500","profile":"AMOUNT_PARTIAL","query":"不到500的零星采购怎么报销?","normalizedQuery":"不到500的零星采购怎么报销?","intents":["PROCESS_GUIDANCE","ELIGIBILITY_CHECK"],"domain":"FINANCE_REIMBURSEMENT","toolCodes":["KNOWLEDGE_SEARCH"],"evidenceKinds":["FORMAL_POLICY"],"factTypes":[],"decisionStatus":"PARTIAL","forbiddenClaims":["包含500元本数"],"forbiddenSourceKinds":["EXPERIENCE"],"composerCalled":true,"amountRaw":"不到500","amountValue":500,"currency":"CNY","comparator":"LT","missingSlots":["PROCESS_STEPS"],"expectedToolCalls":1 + "id":"strict-below-500","profile":"AMOUNT_PARTIAL","query":"不到500的零星采购怎么报销?","normalizedQuery":"不到500的零星采购怎么报销?","intents":["KNOWLEDGE_QA","PROCESS_GUIDANCE","ELIGIBILITY_CHECK"],"domain":"FINANCE_REIMBURSEMENT","toolCodes":["KNOWLEDGE_SEARCH"],"evidenceKinds":["FORMAL_POLICY"],"factTypes":[],"decisionStatus":"PARTIAL","forbiddenClaims":["包含500元本数"],"forbiddenSourceKinds":["EXPERIENCE"],"composerCalled":true,"amountRaw":"不到500","amountValue":500,"currency":"CNY","comparator":"LT","missingSlots":["PROCESS_STEPS"],"expectedToolCalls":1 }, { - "id":"monthly-limit-basis","profile":"AMOUNT_ONLY_COMPLETE","query":"每月500元自采额度是什么口径?","normalizedQuery":"每月500元自采额度是什么口径?","intents":["KNOWLEDGE_QA","ELIGIBILITY_CHECK"],"domain":"FINANCE_POLICY","toolCodes":["KNOWLEDGE_SEARCH"],"evidenceKinds":["FORMAL_POLICY"],"factTypes":[],"decisionStatus":"ANSWERED","forbiddenClaims":["单笔500元以下必然可报销"],"forbiddenSourceKinds":["EXPERIENCE"],"composerCalled":true,"amountRaw":"500元","amountValue":500,"currency":"CNY","amountBasis":"MONTHLY_TOTAL","expectedToolCalls":1 + "id":"monthly-limit-basis","profile":"AMOUNT_ONLY_COMPLETE","query":"每月500元自采额度是什么口径?","normalizedQuery":"每月500元自采额度是什么口径?","intents":["KNOWLEDGE_QA","ELIGIBILITY_CHECK"],"domain":"FINANCE_REIMBURSEMENT","toolCodes":["KNOWLEDGE_SEARCH"],"evidenceKinds":["FORMAL_POLICY"],"factTypes":[],"decisionStatus":"PARTIAL","forbiddenClaims":["单笔500元以下必然可报销"],"forbiddenSourceKinds":["EXPERIENCE"],"composerCalled":true,"amountRaw":"500元","amountValue":500,"currency":"CNY","missingSlots":["APPLICABILITY"],"expectedToolCalls":1 }, { - "id":"single-transaction-not-monthly","profile":"AMOUNT_PARTIAL","query":"单笔500元采购能否报销并怎么操作?","normalizedQuery":"单笔500元采购能否报销并怎么操作?","intents":["PROCESS_GUIDANCE","ELIGIBILITY_CHECK"],"domain":"FINANCE_REIMBURSEMENT","toolCodes":["KNOWLEDGE_SEARCH"],"evidenceKinds":["FORMAL_POLICY"],"factTypes":[],"decisionStatus":"PARTIAL","forbiddenClaims":["月累计额度等于单笔额度"],"forbiddenSourceKinds":["EXPERIENCE"],"composerCalled":true,"amountRaw":"500元","amountValue":500,"currency":"CNY","amountBasis":"PER_TRANSACTION","missingSlots":["PROCESS_STEPS"],"expectedToolCalls":1 + "id":"single-transaction-not-monthly","profile":"AMOUNT_PARTIAL","query":"单笔500元采购能否报销并怎么操作?","normalizedQuery":"单笔500元采购能否报销并怎么操作?","intents":["KNOWLEDGE_QA","PROCESS_GUIDANCE","ELIGIBILITY_CHECK"],"domain":"FINANCE_REIMBURSEMENT","toolCodes":["KNOWLEDGE_SEARCH"],"evidenceKinds":["FORMAL_POLICY"],"factTypes":[],"decisionStatus":"PARTIAL","forbiddenClaims":["月累计额度等于单笔额度"],"forbiddenSourceKinds":["EXPERIENCE"],"composerCalled":true,"amountRaw":"500元","amountValue":500,"currency":"CNY","amountBasis":"PER_TRANSACTION","missingSlots":["PROCESS_STEPS"],"expectedToolCalls":1 }, { "id":"single-month-basis-conflict","profile":"SOURCE_CONFLICT","query":"单笔500元和月累计500元到底按哪个口径?","normalizedQuery":"单笔500元和月累计500元到底按哪个口径?","intents":["ELIGIBILITY_CHECK"],"domain":"FINANCE_POLICY","toolCodes":["KNOWLEDGE_SEARCH"],"evidenceKinds":["FORMAL_POLICY"],"factTypes":[],"decisionStatus":"CONFLICT","forbiddenClaims":["按单笔执行","按月累计执行"],"forbiddenSourceKinds":["EXPERIENCE"],"composerCalled":false,"amountRaw":"500元","amountValue":500,"currency":"CNY","expectedToolCalls":1 }, { - "id":"no-evidence-finance","profile":"NO_EVIDENCE","query":"未收录的特殊采购怎么报销?","normalizedQuery":"未收录的特殊采购怎么报销?","intents":["PROCESS_GUIDANCE"],"domain":"FINANCE_REIMBURSEMENT","toolCodes":["KNOWLEDGE_SEARCH"],"evidenceKinds":[],"factTypes":[],"decisionStatus":"NO_EVIDENCE","forbiddenClaims":["可以直接报销","默认走财务系统"],"forbiddenSourceKinds":["EXPERIENCE"],"composerCalled":false,"missingSlots":["PROCESS_STEPS"],"expectedToolCalls":1 + "id":"no-evidence-finance","profile":"NO_EVIDENCE","query":"未收录的特殊采购怎么报销?","normalizedQuery":"未收录的特殊采购怎么报销?","intents":["KNOWLEDGE_QA","PROCESS_GUIDANCE"],"domain":"FINANCE_REIMBURSEMENT","toolCodes":["KNOWLEDGE_SEARCH"],"evidenceKinds":[],"factTypes":[],"decisionStatus":"NO_EVIDENCE","forbiddenClaims":["可以直接报销","默认走财务系统"],"forbiddenSourceKinds":["EXPERIENCE"],"composerCalled":false,"missingSlots":["PROCESS_STEPS"],"expectedToolCalls":1 }, { - "id":"over-limit-needs-input","profile":"NEEDS_INPUT","query":"这笔800元采购超额度了怎么处理?","normalizedQuery":"这笔800元采购超额度了怎么处理?","intents":["PROCESS_GUIDANCE","ELIGIBILITY_CHECK"],"domain":"FINANCE_REIMBURSEMENT","toolCodes":["KNOWLEDGE_SEARCH"],"evidenceKinds":["FORMAL_POLICY"],"factTypes":[],"decisionStatus":"NEEDS_INPUT","forbiddenClaims":["一定不能报销","一定可以报销"],"forbiddenSourceKinds":["EXPERIENCE"],"composerCalled":false,"amountRaw":"800元","amountValue":800,"currency":"CNY","missingSlots":["采购类别和累计口径待确认"],"expectedToolCalls":1 + "id":"over-limit-needs-input","profile":"NEEDS_INPUT","query":"这笔800元采购超额度了怎么处理?","normalizedQuery":"这笔800元采购超额度了怎么处理?","intents":["KNOWLEDGE_QA","PROCESS_GUIDANCE","ELIGIBILITY_CHECK"],"domain":"FINANCE_REIMBURSEMENT","toolCodes":["KNOWLEDGE_SEARCH"],"evidenceKinds":["FORMAL_POLICY"],"factTypes":[],"decisionStatus":"PARTIAL","forbiddenClaims":["一定不能报销","一定可以报销"],"forbiddenSourceKinds":["EXPERIENCE"],"composerCalled":true,"amountRaw":"800元","amountValue":800,"currency":"CNY","missingSlots":["PROCESS_STEPS","APPLICABILITY"],"expectedToolCalls":1 }, { - "id":"which-capabilities","profile":"CAPABILITY_DOCUMENT_ONLY","query":"你现在能查哪些业务能力?","normalizedQuery":"你现在能查哪些业务能力?","intents":["CAPABILITY_QUERY","KNOWLEDGE_QA"],"domain":"GENERAL","toolCodes":["KNOWLEDGE_SEARCH"],"evidenceKinds":["OPERATING_MANUAL"],"factTypes":[],"decisionStatus":"PARTIAL","forbiddenClaims":["CRM已接入","可以查询所有业务系统"],"forbiddenSourceKinds":["DATA_TOOL"],"composerCalled":true,"missingSlots":["CAPABILITY_REGISTRY"],"expectedToolCalls":1 + "id":"which-capabilities","profile":"CAPABILITY_DOCUMENT_ONLY","query":"你现在能查哪些业务能力?","normalizedQuery":"你现在能查哪些业务能力?","intents":["CAPABILITY_QUERY","KNOWLEDGE_QA"],"domain":"GENERAL","toolCodes":["CAPABILITY_QUERY","KNOWLEDGE_SEARCH"],"evidenceKinds":["OPERATING_MANUAL"],"factTypes":["CAPABILITY"],"decisionStatus":"ANSWERED","forbiddenClaims":["CRM已接入","可以查询所有业务系统"],"forbiddenSourceKinds":["DATA_TOOL"],"composerCalled":true,"expectedToolCalls":2 }, { - "id":"crm-document-is-not-capability","profile":"CAPABILITY_DOCUMENT_ONLY","query":"资料里提到CRM,系统是不是已经接入了?","normalizedQuery":"资料里提到CRM,系统是不是已经接入了?","intents":["CAPABILITY_QUERY","KNOWLEDGE_QA"],"domain":"GENERAL","toolCodes":["KNOWLEDGE_SEARCH"],"evidenceKinds":["OPERATING_MANUAL"],"factTypes":[],"decisionStatus":"PARTIAL","forbiddenClaims":["CRM已接入","已查询CRM"],"forbiddenSourceKinds":["DATA_TOOL"],"composerCalled":true,"missingSlots":["CAPABILITY_REGISTRY"],"expectedToolCalls":1 + "id":"crm-document-is-not-capability","profile":"CAPABILITY_DOCUMENT_ONLY","query":"资料里提到CRM,系统是不是已经接入了?","normalizedQuery":"资料里提到CRM,系统是不是已经接入了?","intents":["CAPABILITY_QUERY","KNOWLEDGE_QA"],"domain":"GENERAL","toolCodes":["CAPABILITY_QUERY","KNOWLEDGE_SEARCH"],"evidenceKinds":["OPERATING_MANUAL"],"factTypes":["CAPABILITY"],"decisionStatus":"ANSWERED","forbiddenClaims":["CRM已接入","已查询CRM"],"forbiddenSourceKinds":["DATA_TOOL"],"composerCalled":true,"expectedToolCalls":2 }, { - "id":"tasks-plus-process","profile":"TASKS_AND_PROCESS","query":"我这个月还有哪些待办,相关流程怎么处理?","normalizedQuery":"我这个月还有哪些待办,相关流程怎么处理?","intents":["LIVE_MY_WORK","PROCESS_GUIDANCE"],"domain":"WORK_MANAGEMENT","toolCodes":["MY_CURRENT_TASKS","KNOWLEDGE_SEARCH"],"evidenceKinds":["OPERATING_MANUAL"],"factTypes":["CURRENT_TASK"],"decisionStatus":"ANSWERED","forbiddenClaims":["还有未查询到的待办"],"forbiddenSourceKinds":["DATA_TOOL"],"composerCalled":true,"contextRelation":"NEW_TOPIC","expectedToolCalls":2 + "id":"tasks-plus-process","profile":"TASKS_AND_PROCESS","query":"我这个月还有哪些待办,相关流程怎么处理?","normalizedQuery":"我这个月还有哪些待办,相关流程怎么处理?","intents":["LIVE_MY_WORK","KNOWLEDGE_QA","PROCESS_GUIDANCE"],"domain":"WORK_MANAGEMENT","toolCodes":["MY_CURRENT_TASKS","KNOWLEDGE_SEARCH"],"evidenceKinds":["OPERATING_MANUAL"],"factTypes":["CURRENT_TASK"],"decisionStatus":"ANSWERED","forbiddenClaims":["还有未查询到的待办"],"forbiddenSourceKinds":["DATA_TOOL"],"composerCalled":true,"contextRelation":"NEW_TOPIC","expectedToolCalls":2 }, { - "id":"tasks-without-process-is-partial","profile":"TASKS_ONLY_PARTIAL","query":"我这个月有哪些待办,流程也告诉我?","normalizedQuery":"我这个月有哪些待办,流程也告诉我?","intents":["LIVE_MY_WORK","PROCESS_GUIDANCE"],"domain":"WORK_MANAGEMENT","toolCodes":["MY_CURRENT_TASKS","KNOWLEDGE_SEARCH"],"evidenceKinds":[],"factTypes":["CURRENT_TASK"],"decisionStatus":"PARTIAL","forbiddenClaims":["流程要求直接提交"],"forbiddenSourceKinds":["DATA_TOOL"],"composerCalled":true,"missingSlots":["PROCESS_STEPS"],"expectedToolCalls":2 + "id":"tasks-without-process-is-partial","profile":"TASKS_ONLY_PARTIAL","query":"我这个月有哪些待办,流程也告诉我?","normalizedQuery":"我这个月有哪些待办,流程也告诉我?","intents":["LIVE_MY_WORK","KNOWLEDGE_QA","PROCESS_GUIDANCE"],"domain":"WORK_MANAGEMENT","toolCodes":["MY_CURRENT_TASKS","KNOWLEDGE_SEARCH"],"evidenceKinds":[],"factTypes":["CURRENT_TASK"],"decisionStatus":"PARTIAL","forbiddenClaims":["流程要求直接提交"],"forbiddenSourceKinds":["DATA_TOOL"],"composerCalled":true,"missingSlots":["PROCESS_STEPS"],"expectedToolCalls":2 }, { "id":"second-item-reference","profile":"SECOND_ITEM","query":"第二项需要什么材料?","normalizedQuery":"第二项需要什么材料?","intents":["LIVE_MY_WORK","PROCESS_GUIDANCE"],"domain":"WORK_MANAGEMENT","toolCodes":["MY_CURRENT_TASKS","KNOWLEDGE_SEARCH"],"evidenceKinds":["OPERATING_MANUAL"],"factTypes":["CURRENT_TASK"],"decisionStatus":"ANSWERED","forbiddenClaims":["第一项需要身份证"],"forbiddenSourceKinds":["DATA_TOOL"],"composerCalled":true,"contextRelation":"REFERENCE_REQUEST","expectedFactRef":"fact_22222222222222222222222222222222","expectedToolCalls":2 @@ -57,36 +57,36 @@ "id":"expired-fact-rejected","profile":"EXPIRED_FACT","query":"我当前有哪些待办?","normalizedQuery":"我当前有哪些待办?","intents":["LIVE_MY_WORK"],"domain":"WORK_MANAGEMENT","toolCodes":["MY_CURRENT_TASKS"],"evidenceKinds":[],"factTypes":[],"decisionStatus":"NO_EVIDENCE","forbiddenClaims":["当前待办是提交材料"],"forbiddenSourceKinds":["DATA_TOOL"],"composerCalled":false,"missingSlots":["CURRENT_TASKS"],"expectedToolCalls":1 }, { - "id":"formal-source-conflict","profile":"SOURCE_CONFLICT","query":"催费后多久需要再次跟进?","normalizedQuery":"催费后多久需要再次跟进?","intents":["PROCESS_GUIDANCE"],"domain":"OPERATIONS","toolCodes":["KNOWLEDGE_SEARCH"],"evidenceKinds":["FORMAL_POLICY"],"factTypes":[],"decisionStatus":"CONFLICT","forbiddenClaims":["2小时后跟进","4小时后跟进"],"forbiddenSourceKinds":["EXPERIENCE"],"composerCalled":false,"expectedToolCalls":1 + "id":"formal-source-conflict","profile":"SOURCE_CONFLICT","query":"催费后多久需要再次跟进?","normalizedQuery":"催费后多久需要再次跟进?","intents":["KNOWLEDGE_QA","PROCESS_GUIDANCE"],"domain":"OPERATIONS","toolCodes":["KNOWLEDGE_SEARCH"],"evidenceKinds":["FORMAL_POLICY"],"factTypes":[],"decisionStatus":"CONFLICT","forbiddenClaims":["2小时后跟进","4小时后跟进"],"forbiddenSourceKinds":["EXPERIENCE"],"composerCalled":false,"expectedToolCalls":1 }, { - "id":"tool-failure-controlled","profile":"TOOL_FAILURE","query":"未知流程怎么处理?","normalizedQuery":"未知流程怎么处理?","intents":["PROCESS_GUIDANCE"],"domain":"OPERATIONS","toolCodes":["KNOWLEDGE_SEARCH"],"evidenceKinds":[],"factTypes":[],"decisionStatus":"NO_EVIDENCE","forbiddenClaims":["下游敏感异常"],"forbiddenSourceKinds":["EXPERIENCE"],"composerCalled":false,"missingSlots":["PROCESS_STEPS"],"expectedToolCalls":1 + "id":"tool-failure-controlled","profile":"TOOL_FAILURE","query":"未知流程怎么处理?","normalizedQuery":"未知流程怎么处理?","intents":["KNOWLEDGE_QA","PROCESS_GUIDANCE"],"domain":"OPERATIONS","toolCodes":["KNOWLEDGE_SEARCH"],"evidenceKinds":[],"factTypes":[],"decisionStatus":"NO_EVIDENCE","forbiddenClaims":["下游敏感异常"],"forbiddenSourceKinds":["EXPERIENCE"],"composerCalled":false,"missingSlots":["PROCESS_STEPS"],"expectedToolCalls":1 }, { - "id":"tool-timeout-controlled","profile":"TOOL_TIMEOUT","query":"查询一个超时流程","normalizedQuery":"查询一个超时流程","intents":["PROCESS_GUIDANCE"],"domain":"OPERATIONS","toolCodes":["KNOWLEDGE_SEARCH"],"evidenceKinds":[],"factTypes":[],"decisionStatus":"NO_EVIDENCE","forbiddenClaims":["迟到结果"],"forbiddenSourceKinds":["EXPERIENCE"],"composerCalled":false,"missingSlots":["PROCESS_STEPS"],"stopReasons":["TIME_BUDGET_EXCEEDED"],"expectedToolCalls":1 + "id":"tool-timeout-controlled","profile":"TOOL_TIMEOUT","query":"查询一个超时流程","normalizedQuery":"查询一个超时流程","intents":["KNOWLEDGE_QA","PROCESS_GUIDANCE"],"domain":"OPERATIONS","toolCodes":["KNOWLEDGE_SEARCH"],"evidenceKinds":[],"factTypes":[],"decisionStatus":"NO_EVIDENCE","forbiddenClaims":["迟到结果"],"forbiddenSourceKinds":["EXPERIENCE"],"composerCalled":false,"missingSlots":["PROCESS_STEPS"],"stopReasons":["TIME_BUDGET_EXCEEDED"],"expectedToolCalls":1 }, { "id":"unregistered-tool-falls-back","profile":"UNREGISTERED_TOOL","query":"查我的当前待办","normalizedQuery":"查我的当前待办","intents":["LIVE_MY_WORK"],"domain":"WORK_MANAGEMENT","toolCodes":["MY_CURRENT_TASKS"],"evidenceKinds":[],"factTypes":[],"decisionStatus":"NOT_HANDLED","forbiddenClaims":["当前待办"],"forbiddenSourceKinds":["DATA_TOOL"],"composerCalled":false,"expectedToolCalls":0 }, { - "id":"planner-model-failure-falls-back","profile":"MODEL_FAILURE","query":"催费后多久需要再次跟进?","normalizedQuery":"催费后多久需要再次跟进?","intents":[],"domain":"GENERAL","toolCodes":[],"evidenceKinds":[],"factTypes":[],"decisionStatus":"NOT_HANDLED","forbiddenClaims":["默认2小时"],"forbiddenSourceKinds":["EXPERIENCE"],"composerCalled":false,"expectedToolCalls":0 + "id":"planner-model-failure-falls-back","profile":"MODEL_FAILURE","query":"催费后多久需要再次跟进?","normalizedQuery":"催费后多久需要再次跟进?","intents":["KNOWLEDGE_QA"],"domain":"OPERATIONS","toolCodes":["KNOWLEDGE_SEARCH"],"evidenceKinds":[],"factTypes":[],"decisionStatus":"NOT_HANDLED","forbiddenClaims":["默认2小时"],"forbiddenSourceKinds":["EXPERIENCE"],"composerCalled":false,"expectedToolCalls":0 }, { - "id":"duplicate-tool-call-deduped","profile":"DUPLICATE_CALL","query":"报销流程怎么走?","normalizedQuery":"报销流程怎么走?","intents":["PROCESS_GUIDANCE"],"domain":"FINANCE_REIMBURSEMENT","toolCodes":["KNOWLEDGE_SEARCH","KNOWLEDGE_SEARCH"],"evidenceKinds":["OPERATING_MANUAL"],"factTypes":[],"decisionStatus":"ANSWERED","forbiddenClaims":["执行了两次检索"],"forbiddenSourceKinds":["EXPERIENCE"],"composerCalled":true,"stopReasons":["NO_NEW_EVIDENCE_TARGET"],"expectedToolCalls":1 + "id":"duplicate-tool-call-deduped","profile":"DUPLICATE_CALL","query":"报销流程怎么走?","normalizedQuery":"报销流程怎么走?","intents":["KNOWLEDGE_QA","PROCESS_GUIDANCE"],"domain":"FINANCE_REIMBURSEMENT","toolCodes":["KNOWLEDGE_SEARCH"],"evidenceKinds":["OPERATING_MANUAL"],"factTypes":[],"decisionStatus":"ANSWERED","forbiddenClaims":["执行了两次检索"],"forbiddenSourceKinds":["EXPERIENCE"],"composerCalled":true,"stopReasons":["NO_NEW_EVIDENCE_TARGET"],"expectedToolCalls":1 }, { - "id":"no-new-evidence-target-stops","profile":"NO_NEW_TARGET","query":"无资料的催费期限是多少?","normalizedQuery":"无资料的催费期限是多少?","intents":["PROCESS_GUIDANCE"],"domain":"OPERATIONS","toolCodes":["KNOWLEDGE_SEARCH"],"evidenceKinds":[],"factTypes":[],"decisionStatus":"NO_EVIDENCE","forbiddenClaims":["默认次日跟进"],"forbiddenSourceKinds":["EXPERIENCE"],"composerCalled":false,"missingSlots":["FOLLOW_UP_DEADLINE"],"stopReasons":["NO_NEW_EVIDENCE_TARGET"],"expectedToolCalls":1 + "id":"no-new-evidence-target-stops","profile":"NO_NEW_TARGET","query":"无资料的催费期限是多少?","normalizedQuery":"无资料的催费期限是多少?","intents":["KNOWLEDGE_QA","PROCESS_GUIDANCE"],"domain":"OPERATIONS","toolCodes":["KNOWLEDGE_SEARCH"],"evidenceKinds":[],"factTypes":[],"decisionStatus":"NO_EVIDENCE","forbiddenClaims":["默认次日跟进"],"forbiddenSourceKinds":["EXPERIENCE"],"composerCalled":false,"missingSlots":["FOLLOW_UP_DEADLINE"],"stopReasons":["NO_NEW_EVIDENCE_TARGET"],"expectedToolCalls":1 }, { - "id":"second-round-is-final-tool-round","profile":"TWO_ROUNDS_PARTIAL","query":"我这个月有哪些待办,相关流程和材料是什么?","normalizedQuery":"我这个月有哪些待办,相关流程和材料是什么?","intents":["LIVE_MY_WORK","PROCESS_GUIDANCE"],"domain":"WORK_MANAGEMENT","toolCodes":["MY_CURRENT_TASKS"],"evidenceKinds":["OPERATING_MANUAL"],"factTypes":["CURRENT_TASK"],"decisionStatus":"PARTIAL","forbiddenClaims":["材料要求已完整"],"forbiddenSourceKinds":["DATA_TOOL"],"composerCalled":true,"missingSlots":["REQUIRED_MATERIALS"],"stopReasons":["FINALIZE_AFTER_SECOND_TOOL_ROUND"],"expectedToolCalls":2 + "id":"complete-plan-avoids-unnecessary-refinement","profile":"TWO_ROUNDS_PARTIAL","query":"我这个月有哪些待办,相关流程和材料是什么?","normalizedQuery":"我这个月有哪些待办,相关流程和材料是什么?","intents":["LIVE_MY_WORK","KNOWLEDGE_QA","PROCESS_GUIDANCE"],"domain":"WORK_MANAGEMENT","toolCodes":["MY_CURRENT_TASKS","KNOWLEDGE_SEARCH"],"evidenceKinds":["OPERATING_MANUAL"],"factTypes":["CURRENT_TASK"],"decisionStatus":"PARTIAL","forbiddenClaims":["材料要求已完整"],"forbiddenSourceKinds":["DATA_TOOL"],"composerCalled":true,"missingSlots":["REQUIRED_MATERIALS"],"stopReasons":["NO_NEW_EVIDENCE_TARGET"],"expectedToolCalls":2 }, { - "id":"experience-cannot-answer-formal-process","profile":"EXPERIENCE_REJECTED","query":"500块维修费怎么报销?","normalizedQuery":"500块维修费怎么报销?","intents":["PROCESS_GUIDANCE"],"domain":"FINANCE_REIMBURSEMENT","toolCodes":["KNOWLEDGE_SEARCH"],"evidenceKinds":[],"factTypes":[],"decisionStatus":"NO_EVIDENCE","forbiddenClaims":["500块维修可以私了","200块私了"],"forbiddenSourceKinds":["EXPERIENCE"],"composerCalled":false,"amountRaw":"500块","amountValue":500,"currency":"CNY","missingSlots":["PROCESS_STEPS"],"expectedToolCalls":1 + "id":"experience-cannot-answer-formal-process","profile":"EXPERIENCE_REJECTED","query":"500块维修费怎么报销?","normalizedQuery":"500块维修费怎么报销?","intents":["KNOWLEDGE_QA","PROCESS_GUIDANCE","ELIGIBILITY_CHECK"],"domain":"FINANCE_REIMBURSEMENT","toolCodes":["KNOWLEDGE_SEARCH"],"evidenceKinds":[],"factTypes":[],"decisionStatus":"NO_EVIDENCE","forbiddenClaims":["500块维修可以私了","200块私了"],"forbiddenSourceKinds":["EXPERIENCE"],"composerCalled":false,"amountRaw":"500块","amountValue":500,"currency":"CNY","missingSlots":["PROCESS_STEPS"],"expectedToolCalls":1 }, { - "id":"amount-fragment-cannot-fill-process-slots","profile":"AMOUNT_MISLABELED","query":"500元以下零星采购的流程、材料和审批是什么?","normalizedQuery":"500元以下零星采购的流程、材料和审批是什么?","intents":["PROCESS_GUIDANCE","ELIGIBILITY_CHECK"],"domain":"FINANCE_REIMBURSEMENT","toolCodes":["KNOWLEDGE_SEARCH"],"evidenceKinds":["FORMAL_POLICY"],"factTypes":[],"decisionStatus":"PARTIAL","forbiddenClaims":["提交报销单","需要发票","主管审批"],"forbiddenSourceKinds":["EXPERIENCE"],"composerCalled":true,"amountRaw":"500元","amountValue":500,"currency":"CNY","missingSlots":["PROCESS_STEPS","REQUIRED_MATERIALS","APPROVAL_REVIEW"],"expectedToolCalls":1 + "id":"amount-fragment-cannot-fill-process-slots","profile":"AMOUNT_MISLABELED","query":"500元以下零星采购的流程、材料和审批是什么?","normalizedQuery":"500元以下零星采购的流程、材料和审批是什么?","intents":["KNOWLEDGE_QA","PROCESS_GUIDANCE","ELIGIBILITY_CHECK"],"domain":"FINANCE_REIMBURSEMENT","toolCodes":["KNOWLEDGE_SEARCH"],"evidenceKinds":["FORMAL_POLICY"],"factTypes":[],"decisionStatus":"PARTIAL","forbiddenClaims":["提交报销单","需要发票","主管审批"],"forbiddenSourceKinds":["EXPERIENCE"],"composerCalled":true,"amountRaw":"500元","amountValue":500,"currency":"CNY","missingSlots":["PROCESS_STEPS","REQUIRED_MATERIALS","APPROVAL_REVIEW"],"expectedToolCalls":1 }, { - "id":"steps-cannot-prove-amount-rule","profile":"STEPS_MISLABELED","query":"单笔500元采购是否适用并怎么走流程?","normalizedQuery":"单笔500元采购是否适用并怎么走流程?","intents":["PROCESS_GUIDANCE","ELIGIBILITY_CHECK"],"domain":"FINANCE_REIMBURSEMENT","toolCodes":["KNOWLEDGE_SEARCH"],"evidenceKinds":["OPERATING_MANUAL"],"factTypes":[],"decisionStatus":"PARTIAL","forbiddenClaims":["单笔500元适用"],"forbiddenSourceKinds":["EXPERIENCE"],"composerCalled":true,"amountRaw":"500元","amountValue":500,"currency":"CNY","amountBasis":"PER_TRANSACTION","missingSlots":["AMOUNT_BASIS"],"expectedToolCalls":1 + "id":"steps-cannot-prove-amount-rule","profile":"STEPS_MISLABELED","query":"单笔500元采购是否适用并怎么走流程?","normalizedQuery":"单笔500元采购是否适用并怎么走流程?","intents":["KNOWLEDGE_QA","PROCESS_GUIDANCE","ELIGIBILITY_CHECK"],"domain":"FINANCE_REIMBURSEMENT","toolCodes":["KNOWLEDGE_SEARCH"],"evidenceKinds":["OPERATING_MANUAL"],"factTypes":[],"decisionStatus":"PARTIAL","forbiddenClaims":["单笔500元适用"],"forbiddenSourceKinds":["EXPERIENCE"],"composerCalled":true,"amountRaw":"500元","amountValue":500,"currency":"CNY","amountBasis":"PER_TRANSACTION","missingSlots":["AMOUNT_BASIS"],"expectedToolCalls":1 } ] diff --git a/backend/script/sql/aihr_knowledge_mysql8.sql b/backend/script/sql/aihr_knowledge_mysql8.sql index 9cd20a78..3bfff050 100644 --- a/backend/script/sql/aihr_knowledge_mysql8.sql +++ b/backend/script/sql/aihr_knowledge_mysql8.sql @@ -373,3 +373,45 @@ CREATE TABLE IF NOT EXISTS `aihr_agent_run` ( KEY `idx_aihr_agent_run_user` (`tenant_id`, `user_id`, `create_time`), KEY `idx_aihr_agent_run_status` (`tenant_id`, `status`, `create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='数字师傅 Agent 最小运行审计'; + +CREATE TABLE IF NOT EXISTS `aihr_agent_shadow_run` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `shadow_run_id` varchar(64) NOT NULL COMMENT '影子运行号', + `legacy_run_id` varchar(64) DEFAULT NULL COMMENT '旧链路运行号', + `query_sha256` char(64) NOT NULL COMMENT '原问题哈希;不保存问题正文', + `tenant_id` varchar(20) NOT NULL COMMENT '租户编号', + `user_id` bigint NOT NULL COMMENT '认证用户', + `app_id` bigint DEFAULT NULL COMMENT '服务端复核的知识应用', + `project_scope_hash` char(64) DEFAULT NULL COMMENT '项目作用域哈希', + `authorization_snapshot` char(64) DEFAULT NULL COMMENT '服务端授权快照哈希', + `legacy_intent` varchar(64) DEFAULT NULL, + `legacy_tool_codes_json` json NOT NULL, + `legacy_status` varchar(32) DEFAULT NULL, + `legacy_evidence_count` int NOT NULL DEFAULT 0, + `legacy_fact_count` int NOT NULL DEFAULT 0, + `legacy_zero_evidence_generated` tinyint(1) NOT NULL DEFAULT 0, + `legacy_latency_ms` bigint NOT NULL DEFAULT 0, + `shadow_plan_source` varchar(64) DEFAULT NULL, + `shadow_intents_json` json NOT NULL, + `shadow_tool_codes_json` json NOT NULL, + `shadow_decision_status` varchar(32) DEFAULT NULL, + `shadow_evidence_count` int NOT NULL DEFAULT 0, + `shadow_fact_count` int NOT NULL DEFAULT 0, + `missing_codes_json` json NOT NULL, + `conflict_codes_json` json NOT NULL, + `citation_qualification_json` json NOT NULL, + `trace_codes_json` json NOT NULL, + `phase_latency_json` json NOT NULL COMMENT '仅数值型阶段耗时,不含问题或工具正文', + `shadow_latency_ms` bigint NOT NULL DEFAULT 0, + `shadow_handled` tinyint(1) NOT NULL DEFAULT 0, + `shadow_zero_evidence_generated` tinyint(1) NOT NULL DEFAULT 0, + `outcome` varchar(32) NOT NULL, + `error_code` varchar(64) DEFAULT NULL, + `complete_time` datetime NOT NULL, + `create_time` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_agent_shadow_run` (`shadow_run_id`), + KEY `idx_aihr_agent_shadow_query` (`tenant_id`, `query_sha256`, `create_time`), + KEY `idx_aihr_agent_shadow_user` (`tenant_id`, `user_id`, `create_time`), + KEY `idx_aihr_agent_shadow_outcome` (`tenant_id`, `outcome`, `create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='受约束Agent新旧链路影子对比;不保存问题答案或工具正文'; diff --git a/backend/script/sql/update/aihr_20260804_agent_shadow_comparison_mysql8.sql b/backend/script/sql/update/aihr_20260804_agent_shadow_comparison_mysql8.sql new file mode 100644 index 00000000..d61bdaa4 --- /dev/null +++ b/backend/script/sql/update/aihr_20260804_agent_shadow_comparison_mysql8.sql @@ -0,0 +1,59 @@ +-- Grounded Agent old/new shadow comparison. Additive and idempotent on MySQL 8. +-- Privacy boundary: query/answer/attachment/evidence/tool bodies are never stored. + +CREATE TABLE IF NOT EXISTS `aihr_agent_shadow_run` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `shadow_run_id` varchar(64) NOT NULL COMMENT '影子运行号', + `legacy_run_id` varchar(64) DEFAULT NULL COMMENT '旧链路运行号', + `query_sha256` char(64) NOT NULL COMMENT '原问题哈希;不保存问题正文', + `tenant_id` varchar(20) NOT NULL COMMENT '租户编号', + `user_id` bigint NOT NULL COMMENT '认证用户', + `app_id` bigint DEFAULT NULL COMMENT '服务端复核的知识应用', + `project_scope_hash` char(64) DEFAULT NULL COMMENT '项目作用域哈希', + `authorization_snapshot` char(64) DEFAULT NULL COMMENT '服务端授权快照哈希', + `legacy_intent` varchar(64) DEFAULT NULL, + `legacy_tool_codes_json` json NOT NULL, + `legacy_status` varchar(32) DEFAULT NULL, + `legacy_evidence_count` int NOT NULL DEFAULT 0, + `legacy_fact_count` int NOT NULL DEFAULT 0, + `legacy_zero_evidence_generated` tinyint(1) NOT NULL DEFAULT 0, + `legacy_latency_ms` bigint NOT NULL DEFAULT 0, + `shadow_plan_source` varchar(64) DEFAULT NULL, + `shadow_intents_json` json NOT NULL, + `shadow_tool_codes_json` json NOT NULL, + `shadow_decision_status` varchar(32) DEFAULT NULL, + `shadow_evidence_count` int NOT NULL DEFAULT 0, + `shadow_fact_count` int NOT NULL DEFAULT 0, + `missing_codes_json` json NOT NULL, + `conflict_codes_json` json NOT NULL, + `citation_qualification_json` json NOT NULL, + `trace_codes_json` json NOT NULL, + `phase_latency_json` json NOT NULL COMMENT '仅数值型阶段耗时,不含问题或工具正文', + `shadow_latency_ms` bigint NOT NULL DEFAULT 0, + `shadow_handled` tinyint(1) NOT NULL DEFAULT 0, + `shadow_zero_evidence_generated` tinyint(1) NOT NULL DEFAULT 0, + `outcome` varchar(32) NOT NULL, + `error_code` varchar(64) DEFAULT NULL, + `complete_time` datetime NOT NULL, + `create_time` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_agent_shadow_run` (`shadow_run_id`), + KEY `idx_aihr_agent_shadow_query` (`tenant_id`, `query_sha256`, `create_time`), + KEY `idx_aihr_agent_shadow_user` (`tenant_id`, `user_id`, `create_time`), + KEY `idx_aihr_agent_shadow_outcome` (`tenant_id`, `outcome`, `create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + COMMENT='受约束Agent新旧链路影子对比;不保存问题答案或工具正文'; + +SET @aihr_shadow_phase_latency_exists := ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = database() AND table_name = 'aihr_agent_shadow_run' + AND column_name = 'phase_latency_json' +); +SET @aihr_shadow_phase_latency_ddl := IF( + @aihr_shadow_phase_latency_exists = 0, + 'ALTER TABLE `aihr_agent_shadow_run` ADD COLUMN `phase_latency_json` json NULL COMMENT ''仅数值型阶段耗时,不含问题或工具正文'' AFTER `trace_codes_json`', + 'SELECT 1' +); +PREPARE aihr_shadow_phase_latency_stmt FROM @aihr_shadow_phase_latency_ddl; +EXECUTE aihr_shadow_phase_latency_stmt; +DEALLOCATE PREPARE aihr_shadow_phase_latency_stmt; diff --git a/docs/AIHR_GROUNDED_AGENT_TECHSPEC.md b/docs/AIHR_GROUNDED_AGENT_TECHSPEC.md index 689bd790..f34fa0bd 100644 --- a/docs/AIHR_GROUNDED_AGENT_TECHSPEC.md +++ b/docs/AIHR_GROUNDED_AGENT_TECHSPEC.md @@ -1,6 +1,6 @@ # 帮道受约束业务 Agent TechSpec -状态:`IMPLEMENTED_AND_LOCALLY_VERIFIED_SHADOW`。未接入现役 `/api/aihr/agent/**` 请求入口,未部署生产。 +状态:`IMPLEMENTED_AND_LOCALLY_VERIFIED_SHADOW`。已在现役 `/api/aihr/agent/**` 文本请求完成旧响应后异步旁路执行;默认 `OFF`,不改变旧响应,未部署生产。 ## 目标 @@ -82,3 +82,28 @@ Java 契约以 `AihrAgentGroundingDto` 为准。HTTP DTO 在兼容期保持 addi ## 验收 首批 30 条纯内存契约黄金集覆盖金额口径、`哪些` 路由、CRM 能力幻觉、零证据、待办与流程、多轮第二项、月份纠正、跨项目、过期 factRef、来源冲突、工具失败/超时、模型失败回退、重复调用停止和对抗性槽位误标。该集合验证内部契约,不代表本地 MySQL/Qdrant/真实模型效果;没有 100-300 条人工黄金集前不宣称正式召回率达标。 + +## HTTP Shadow 边界 + +- `aihr.agent.grounded-mode` 只允许 `OFF/SHADOW`,仓库与生产默认 `OFF`。`SHADOW` 复用现役认证主体、应用和项目输入,但在异步线程中重新解析并校验项目、空间与应用授权。 +- 旧 `AihrAgentOrchestrator` 先完整生成用户可见 `AgentResponse`;旁路的计划、工具、DecisionResult 或失败均不得改写 `answer/status/citations/data/contextVersion`。媒体和附件请求明确跳过。 +- 专用有界队列满时立即 `REJECTED`,watchdog 超时后取消任务;原子终态保证超时与迟到完成只记录一次。旁路失败不得阻断主请求。 +- `aihr_agent_shadow_run` 只保存 run ID、query SHA-256、授权范围哈希、结构化 intent/tool/status/reason、数量和耗时,不保存原始问题、答案、手机号、token、附件名、Citation/Fact 正文或工具响应正文。 +- 默认 `grounded-shadow-timeout-ms=4000` 是保守的旁路保护预算,不是模型服务 SLA。2026-08-04 的本地真实模型诊断仅通过进程环境临时放宽到 45000ms;样本显示规划耗时可超过 4 秒,因此在完成人工黄金集、延迟分位数和容量评估前不得开启生产 SHADOW,更不得切换客户端答案。 + +## 2026-08-04 本地实依赖验证 + +本次只在本地以 `SHADOW + 45000ms` 诊断运行,仓库默认值仍为 `OFF + 4000ms`,用户可见响应继续由旧 Orchestrator 生成。最近一组真实请求的 Semantic Planner 耗时约 `1.4-4.1s`、单次 shadow 总耗时约 `3.1-7.3s`;早期模型冷启动曾出现 `18.4s` 离群值。Qdrant 直接探测约 `0.2s`,当前主要延迟来自受约束模型规划、证据分类和旧知识查询审计,不应仅靠放宽 watchdog 掩盖。默认 4 秒预算仍不足以作为生产 SHADOW SLA,需先取得真实分位数和容量数据。 + +治理索引已完成本地幂等重建并通过当前事实核对:MySQL 有效 `production`、`PUBLISHED`、`HUMAN_VERIFIED` fragment 为 `224`,`aihr_knowledge_governed_v1` 为 `green`、`224` points、`1024` 维 Cosine,模型为 `BAAI/bge-m3`;224 个 points 与 MySQL fragment 一一对应,payload 必填治理字段缺失为 0,全部属于 `production/PUBLISHED/HUMAN_VERIFIED`。这只证明索引和治理过滤可用,不证明检索召回率达标。 + +| 脱敏场景组 | SHADOW 观察 | 当前结论 | +|---|---|---| +| `500元的一笔采购如何报销`、`500元以下的零星采购怎么报销`、`五百块的小额自采怎么走账` | 均调用 `KNOWLEDGE_SEARCH`;分别保留报销/采购领域、金额原文和口径缺口,`NO_EVIDENCE`,未调用 Composer | 正式来源缺失时安全失败关闭;未把月累计额度推成单笔资格,也未引用访谈/案例 | +| `当前账号能查询哪些内容` | `CAPABILITY_QUERY → ANSWERED`,1 个 `VERIFIED_FACT` | 只返回当前注册且授权的工具集合,不使用文档推断能力 | +| CRM 提及、催费话术 | CRM 场景走 `CAPABILITY_QUERY` 且 `requestedSupported=false`;催费话术走 `KNOWLEDGE_SEARCH`,当前资料不足时 `NO_EVIDENCE` | 文档提及 CRM 不会升级为系统已接入;粗粒度话术不会绕过规划或生成无依据流程 | +| 本月待办加流程、`第二项需要什么材料` | 首问与追问均执行 `MY_CURRENT_TASKS + KNOWLEDGE_SEARCH`;事实与文档分型,材料槽位缺失时 `PARTIAL`,不补齐步骤 | 多工具和 `REQUIRED_MATERIALS` 已在真实 HTTP shadow 证明;旧响应仍保持兼容,不被旁路改写 | +| 月份纠正、过期/跨作用域引用 | 既有 stateful 回归覆盖纠正后旧 ref 失效、TTL 重查和授权拒绝;本阶段继续保持服务端重鉴权 | 会话状态仅保存受控引用元数据,不能由客户端回传正文冒充事实 | +| 零正式证据、来源冲突 | 零合格证据记录 `ANSWER_GENERATION_SKIPPED`;来源冲突进入 `CONFLICT/NEEDS_INPUT` 或 fail-closed | 门禁生效;仍需更多真实冲突资料验证 | + +当前真实 HTTP 仍由旧 Orchestrator 返回用户可见状态,部分旧请求会显示 `CLARIFY/NEEDS_INPUT`;这不代表 shadow 未执行。审计行只记录 query hash、run ID、结构化差异、计数和阶段耗时,未发现原问题、答案、手机号、token、附件或工具正文。以上结果证明旁路隔离、工具分型、治理索引、失败关闭和隐私边界,不证明召回率达标,也不支持客户端接管;进入受控客户端灰度前仍需 100-300 条人工标注黄金集、真实延迟分位数、容量评估及来源冲突样本。 diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index 1e9e7d78..631facc0 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -2,7 +2,7 @@ 本文维护当前前后端业务接口和安全边界;旧演示流继续保留降级链,但不能作为生产或试点通过证据。 -> 2026-08-04 本地新增的 Grounded Agent 仍是影子运行时,尚未接入本页 `/api/aihr/agent/**` 现役入口,也没有改变客户端响应契约。其内部 DTO、双工具、证据门禁和结构化会话状态见 [AIHR_GROUNDED_AGENT_TECHSPEC.md](AIHR_GROUNDED_AGENT_TECHSPEC.md);生产行为仍以本页现役接口说明为准。 +> 2026-08-04 本地 Grounded Agent 已在 `/api/aihr/agent/**` 文本请求的旧响应完成后增加异步 SHADOW 旁路,但仓库与生产默认 `OFF`,旁路结果不进入客户端响应,媒体/附件请求跳过。其内部 DTO、双工具、证据门禁、结构化会话状态和隐私审计见 [AIHR_GROUNDED_AGENT_TECHSPEC.md](AIHR_GROUNDED_AGENT_TECHSPEC.md);尚未部署生产,生产行为仍以本页现役接口说明为准。 ## 第一阶段范围 diff --git a/docs/README.md b/docs/README.md index e1665769..c957aa0f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,7 +11,7 @@ 3. 专项 TechSpec 细化单个领域;实施计划可记录本地实现与验证,但部署和生产状态仍以审查文档、接口事实和真实环境证据为准。 4. `prototypes/` 只作视觉回归基准;会议纪要、历史 Prompt、`legacy/` 与 `archive/` 只作需求来源和追溯材料。 -“问”模块的产品边界见[《数字师傅工作 Agent 总体设计》](superpowers/specs/2026-07-24-digital-master-agent-design.md),现役旧链路的实施与验证进度见[《实施计划》](superpowers/plans/2026-07-24-digital-master-agent.md);2026-08-04 本地新增、尚未切流的 Grounded Agent 架构以[专项 TechSpec](AIHR_GROUNDED_AGENT_TECHSPEC.md)和[ADR-002](adr/ADR-002-GROUNDED-AGENT-EXECUTION.md)为准。工作助手相关术语、实施状态与文档关系集中在[《工作助手与今日工作成果迭代计划》](工作助手与今日工作成果迭代计划-20260721.md)。阶段一“练”的唯一后续施工范围见[《AI陪练完整交付计划》](AI陪练完整交付计划-20260724.md),公司消息现状见[九项能力设计](superpowers/specs/2026-07-24-company-message-nine-capabilities-design.md)与[大喇叭纠偏增量方案](银城大喇叭与问模块纠偏增量方案-20260722.md)。不得把本地影子实现、已部署但尚未完成 Agent 专项生产回归的包存在性,或已验证的全员文件消息和直通车闭环,外推为 Agent 正式验收、消息修订、强触达、工单流转、完整个人知识空间或完整试点已经完成。 +“问”模块的产品边界见[《数字师傅工作 Agent 总体设计》](superpowers/specs/2026-07-24-digital-master-agent-design.md),现役旧链路的实施与验证进度见[《实施计划》](superpowers/plans/2026-07-24-digital-master-agent.md);2026-08-04 本地 Grounded Agent 已以默认关闭的异步 SHADOW 旁路挂到现役文本入口,但尚未切换用户可见响应,其架构与运行边界以[专项 TechSpec](AIHR_GROUNDED_AGENT_TECHSPEC.md)和[ADR-002](adr/ADR-002-GROUNDED-AGENT-EXECUTION.md)为准。工作助手相关术语、实施状态与文档关系集中在[《工作助手与今日工作成果迭代计划》](工作助手与今日工作成果迭代计划-20260721.md)。阶段一“练”的唯一后续施工范围见[《AI陪练完整交付计划》](AI陪练完整交付计划-20260724.md),公司消息现状见[九项能力设计](superpowers/specs/2026-07-24-company-message-nine-capabilities-design.md)与[大喇叭纠偏增量方案](银城大喇叭与问模块纠偏增量方案-20260722.md)。不得把本地影子实现、已部署但尚未完成 Agent 专项生产回归的包存在性,或已验证的全员文件消息和直通车闭环,外推为 Agent 正式验收、消息修订、强触达、工单流转、完整个人知识空间或完整试点已经完成。 ## 当前项目文档 diff --git a/docs/adr/ADR-002-GROUNDED-AGENT-EXECUTION.md b/docs/adr/ADR-002-GROUNDED-AGENT-EXECUTION.md index d64ba7a4..4ed66faa 100644 --- a/docs/adr/ADR-002-GROUNDED-AGENT-EXECUTION.md +++ b/docs/adr/ADR-002-GROUNDED-AGENT-EXECUTION.md @@ -24,6 +24,14 @@ - 保留现有统一 Agent API、认证解析、知识授权、RAG 治理和审计。 - 金额候选/送模/复核成为确定性校验器的第一条纵向切片。 - 旧路径在影子验证期间继续承担兼容回退。 +- 新运行时先以默认 `OFF` 的异步 SHADOW 旁路挂在现役文本入口之后;旧响应先完成,旁路不得改变客户端契约,媒体/附件不进入影子执行。 +- SHADOW 使用有界队列、独立 watchdog 和单次原子终态;审计只保存 query hash、授权范围哈希、结构化代码、计数与耗时,不保存问题、答案、附件或工具正文。 - 工具必须声明 schema、授权、时效、幂等、审计和允许意图。 - `PARTIAL` 只能输出已覆盖 claim slots;零合格证据不调用答案模型。 - 客户端最终按 `DecisionStatus` 展示完整、部分、需补充、无依据和冲突状态。 + +## 本地验证更新(2026-08-04) + +- 治理 Qdrant `aihr_knowledge_governed_v1` 已在本地以 MySQL 有效 production fragments 幂等重建:224/224 points 一致,1024 维 Cosine,`BAAI/bge-m3`,payload 治理字段完整。 +- 真实 HTTP 仍只启用进程级 `SHADOW` 诊断,旧响应保持用户可见;金额变体、能力查询、CRM 能力边界、待办+流程和第二项材料已通过双工具/能力工具观测。正式来源缺失时为 `NO_EVIDENCE` 或 `PARTIAL`,不调用答案生成模型。 +- 最近样本 Planner 约 1.4-4.1 秒、shadow 总耗时约 3.1-7.3 秒,默认 4 秒仅是旁路保护预算,不是生产 SLA。尚未具备客户端灰度接管条件,也没有在缺少 100-300 条人工黄金集时宣称召回率达标。 diff --git a/docs/物业AI人力资源系统开发规格TechSpec.md b/docs/物业AI人力资源系统开发规格TechSpec.md index 576ac339..43fc87ce 100644 --- a/docs/物业AI人力资源系统开发规格TechSpec.md +++ b/docs/物业AI人力资源系统开发规格TechSpec.md @@ -12,7 +12,7 @@ > v1.8 发布前本地实施快照:项目名称选择、项目化会话、确认记录来源/状态、员工今日成果、主管项目成果和“成果投稿”界面名称已完成本地实现与 390×844 验证;该快照记录的是发布前状态,外部线索、工单和考勤投递仍保持 PENDING。 > v1.9 发布与本轮回填: v1.8 所列多项目、项目化确认采集、来源/状态、员工今日成果、主管项目成果和“成果投稿”界面名称已于 2026-07-21 部署。成果历史日期选择、服务端拒绝未来日期及主管手机号授权兜底已于 2026-07-22 发布,并完成远端服务、schema 与产物匹配复核;外部线索、工单和考勤投递仍保持 PENDING。 > v2.0 实施快照:“问”改由 `/api/aihr/agent/**` 统一规划意图并执行受控工具,知识 RAG 退回底层能力;新增 `aihr_agent_run` 最小路由审计。该增量已完成自动化、H5 构建和部分 390×844 浏览器验证,并随 2026-07-25 完整包部署;移动身份失败关闭语义又包含在 2026-07-29 定向发布的生产后端中。正式账号完整业务和真机验收仍未完成。 -> v2.1 本地影子增量:2026-08-04 新增 Grounded Agent 核心契约、`MY_CURRENT_TASKS + KNOWLEDGE_SEARCH` 双工具、证据/事实分型、有界执行、逐结论引用和结构化会话状态;现役 `/api/aihr/agent/**` 仍由 v2.0 路径处理,本增量未切流、未部署。详细契约见 [AIHR_GROUNDED_AGENT_TECHSPEC.md](AIHR_GROUNDED_AGENT_TECHSPEC.md)。 +> v2.1 本地影子增量:2026-08-04 新增 Grounded Agent 核心契约、`MY_CURRENT_TASKS + KNOWLEDGE_SEARCH` 双工具、证据/事实分型、有界执行、逐结论引用和结构化会话状态;现役 `/api/aihr/agent/**` 仍由 v2.0 路径生成用户可见响应,新运行时只在文本响应完成后以默认关闭的异步 SHADOW 旁路执行,未切流、未部署。详细契约见 [AIHR_GROUNDED_AGENT_TECHSPEC.md](AIHR_GROUNDED_AGENT_TECHSPEC.md)。 > 配套:需求见[《物业AI人力资源系统业务需求文档BRD》](物业AI人力资源系统业务需求文档BRD.md);2026-07 MVP 执行计划已归档到[《AI人力资源系统一期MVP版作战清单》](archive/2026-07-mvp-delivery/AI人力资源系统一期MVP版作战清单.md)。 > **优先级图例**:`P0`=2026-07-05 MVP 演示必需 · `P1`=一期必需 · `P2`=二期/推迟。 > 决策基线:若依基座 / 集中式前后端分离 / 本地登录 / 公有大模型API / 一期RAG / 组织人员外部同步(MVP 用快照) / 数据范围以项目为主体。 @@ -368,7 +368,7 @@ POST /api/aihr/agent/actions/{draftId}/dismiss - 全网工具必须先取得用户本次明确同意;写入只通过 30 分钟有效的 `draftId` 确认/忽略,并复用领域服务的 `expectedVersion + idempotencyKey + saveScope`。`aihr_agent_run` 只记录最小路由元数据。 - 旧 `/api/knowledge/query`、`query-media` 和 `/api/aihr/web-ai/**` 保留为底层/兼容接口。完整请求示例、错误码和当前发布边界见 [API_INTEGRATION.md](API_INTEGRATION.md)。 -2026-08-04 的 Grounded Agent 增量不改变以上 HTTP 契约:新 `SemanticQueryPlan`、Tool Registry、Evidence Evaluator、DecisionResult 和 Grounded Composer 目前只在内部测试链路串联。只有完成真实模型/授权知识库回归、客户端状态契约和独立发布验收后,才能逐步接管 v2.0 Orchestrator;不得从源码存在推断已切流。 +2026-08-04 的 Grounded Agent 增量不改变以上 HTTP 响应契约:新 `SemanticQueryPlan`、Tool Registry、Evidence Evaluator、DecisionResult 和 Grounded Composer 已在文本请求完成旧响应后通过异步 SHADOW 旁路串联,默认 `OFF`,其结果与失败均不进入客户端;媒体/附件请求跳过。只有完成真实模型/授权知识库回归、客户端状态契约和独立发布验收后,才能逐步接管 v2.0 Orchestrator;不得从旁路存在推断已切流或已部署。 ---