feat(aihr): prepare grounded agent shadow validation

This commit is contained in:
key
2026-08-04 21:24:25 +08:00
parent b653164a46
commit 901f1aeb3c
64 changed files with 3574 additions and 199 deletions
@@ -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:
@@ -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.
}
}
}
}
@@ -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);
@@ -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<String> 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<String> output = modelService.tryChat(SYSTEM_PROMPT, input, 0.0);
Optional<String> 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<GroundedClaim> 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<String> 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<GroundedClaim> 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<String> missing) {
return missing == null || missing.isEmpty() ? "请补充当前问题所需的适用信息。"
: "请补充:" + String.join("、", missing);
@@ -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<String> 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> 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<String> output = modelService.tryChat(SYSTEM_PROMPT, objectMapper.writeValueAsString(input), 0.0);
Optional<String> output = modelService.tryStructuredJson(
SYSTEM_PROMPT, objectMapper.writeValueAsString(input), 600, CONTEXT_RESOLUTION_TIMEOUT);
return output.flatMap(this::parse);
} catch (RuntimeException ex) {
return Optional.empty();
@@ -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());
@@ -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);
@@ -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<String> output = modelService.tryChat(SYSTEM_PROMPT,
objectMapper.writeValueAsString(modelInput(originalQuery, requested, candidates)), 0.0);
Optional<String> 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;
@@ -196,7 +196,8 @@ public final class AihrAgentGroundingDto {
List<String> missingInformation,
List<String> conflicts,
List<String> observations,
String auditRef
String auditRef,
Map<String, Long> 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<DocumentEvidence> evidence,
List<VerifiedFact> facts, List<String> missingInformation, List<String> conflicts,
List<String> observations, String auditRef) {
this(callId, toolCode, status, evidence, facts, missingInformation, conflicts, observations,
auditRef, Map.of());
}
}
@@ -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<String> ROOT_FIELDS = Set.of(
@@ -37,10 +41,14 @@ public class AihrAgentSemanticPlanner {
);
private static final Set<String> ACTION_FIELDS = Set.of("id", "toolCode", "arguments", "evidenceNeeds");
private static final Set<String> NEED_FIELDS = Set.of("code", "required", "description");
private static final Set<String> TOOLS = Set.of("KNOWLEDGE_SEARCH", "MY_CURRENT_TASKS");
private static final Set<String> TOOLS = Set.of("KNOWLEDGE_SEARCH", "MY_CURRENT_TASKS", "CAPABILITY_QUERY");
private static final Map<String, Set<String>> 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<String> CAPABILITY_CODES = Set.of(
"CRM", "KNOWLEDGE", "CURRENT_TASKS", "TRAINING", "WORK_ORDERS"
);
private static final Set<String> 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<String> FINANCE_TERMS = Set.of(
"报销", "借款", "发票", "采购", "自采", "财务", "报备", "审批"
);
private static final Set<String> PROCESS_TERMS = Set.of(
"怎么", "如何", "流程", "步骤", "走账", "办理", "手续", "操作"
);
private static final Set<String> ELIGIBILITY_TERMS = Set.of(
"能否", "是否", "可以", "能不能", "条件", "适用", "额度", "限额", "超额", "以下", "以内"
);
private static final Set<String> KNOWLEDGE_TERMS = Set.of(
"制度", "规定", "标准", "手册", "话术", "催费", "跟进", "要求", "时限", "多久"
);
private static final Set<String> 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<EntityCandidate> 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<String> output = modelService.tryChat(SYSTEM_PROMPT, modelInput(original, candidates), 0.0);
return output.map(value -> parse(original, candidates, value)).orElse(fallback);
Optional<String> 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<EntityCandidate> candidates) {
private String modelInput(String original, ResolvedContext resolvedContext, List<EntityCandidate> candidates,
PlanningHint hint) {
try {
Map<String, Object> 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<EntityCandidate> candidates, String raw) {
private SemanticQueryPlan parse(String original, List<EntityCandidate> 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<String> intents = enumSet(root.path("intents"), INTENTS);
if (changesProtectedFacts(original, normalized, candidates)) {
return fallback(original, candidates, hint, "DETERMINISTIC_FALLBACK_PROTECTED_FACT_REJECTED");
}
Set<String> modelIntents = enumSet(root.path("intents"), INTENTS);
String domain = root.path("domain").asText();
if (intents.isEmpty() || !DOMAINS.contains(domain)) return fallback(original, candidates);
List<EvidenceNeed> needs = evidenceNeeds(root.path("evidenceNeeds"));
List<ToolCall> 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<String> intents = reconciledIntents(modelIntents, hint);
domain = reconciledDomain(domain, hint);
List<EvidenceNeed> needs = ensureEvidenceNeeds(
evidenceNeeds(root.path("evidenceNeeds")), intents, candidates, hint.materialNeed());
List<ToolCall> 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<String> 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<String> 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<EvidenceNeed> ensureEvidenceNeeds(List<EvidenceNeed> modelNeeds, Set<String> intents,
List<EntityCandidate> candidates, boolean materialNeed) {
Map<String, EvidenceNeed> 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<ToolCall> ensureExecutableActions(String original, String domain, Set<String> intents,
List<EvidenceNeed> needs, List<ToolCall> modelActions,
List<EntityCandidate> candidates) {
List<ToolCall> result = new ArrayList<>();
Set<String> 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<String, String> 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<String, String> 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<ToolCall> 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<EvidenceNeed> needs) {
java.util.function.Predicate<String> 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<String> needCodes(List<EvidenceNeed> needs,
java.util.function.Predicate<String> 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<EntityCandidate> deterministicCandidates(String original) {
List<EntityCandidate> 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<EntityCandidate> candidates) {
String currentGoal = resolved == null ? "" : clean(resolved.currentGoal());
String signalText = currentGoal.isBlank() || currentGoal.equals(original)
? original : original + " " + currentGoal;
Set<String> 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<String> reconciledIntents(Set<String> modelIntents, PlanningHint hint) {
if (!hint.executable()) return modelIntents;
Set<String> 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<String> terms) {
for (String term : terms) if (value.contains(term)) return true;
return false;
}
private static boolean changesProtectedFacts(String original, String normalized, List<EntityCandidate> 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<EntityCandidate> 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<EntityCandidate> candidates, PlanningHint hint,
String planSource) {
Set<String> intents = hint.intents();
List<EvidenceNeed> needs = ensureEvidenceNeeds(List.of(), intents, candidates, hint.materialNeed());
List<ToolCall> 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<String> intents, String domain, String confidence, String source,
boolean materialNeed) {
private boolean executable() {
return intents != null && !intents.isEmpty();
}
private Map<String, Object> asModelInput() {
return Map.of(
"intents", intents == null ? Set.of() : intents,
"domain", domain,
"confidence", confidence,
"source", source,
"materialNeed", materialNeed
);
}
}
private static Set<String> enumSet(JsonNode node, Set<String> allowed) {
@@ -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;
};
}
}
@@ -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<String> 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<String> callHashes = new LinkedHashSet<>();
Set<String> 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<String, Long> 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<String, Long> 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<ToolCall> refinementCalls(SemanticQueryPlan plan, DecisionResult decision,
Set<String> attemptedNeeds, Set<String> callHashes,
int remainingCalls) {
@@ -195,14 +233,15 @@ public class AihrGroundedAgentRuntime {
List<String> 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<String, String> 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<String> missingInformation, List<String> conflicts,
List<String> observations, List<String> citationIds, List<String> factRefs) {
List<String> observations, List<String> citationIds, List<String> factRefs,
Map<String, Long> 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<ActionTrace> actions, List<String> 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)));
}
}
@@ -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<Long> 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<Long> spaces) {
List<String> 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();
}
}
@@ -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<String> values) {
return json(safeCodes(values));
}
private String jsonMap(Map<String, Integer> 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<String, Long> 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<String> safeCodes(List<String> values) {
Set<String> 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<String> legacyToolCodes,
String legacyStatus,
int legacyEvidenceCount,
int legacyFactCount,
boolean legacyZeroEvidenceGenerated,
long legacyLatencyMs,
String shadowPlanSource,
List<String> shadowIntents,
List<String> shadowToolCodes,
String shadowDecisionStatus,
int shadowEvidenceCount,
int shadowFactCount,
List<String> missingCodes,
List<String> conflictCodes,
Map<String, Integer> citationQualification,
List<String> traceCodes,
Map<String, Long> 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 <T> List<T> copy(List<T> values) {
return values == null ? List.of() : List.copyOf(values);
}
}
}
@@ -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<T>(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 <T> void submit(Callable<T> task, long timeoutMs, Consumer<Completion<T>> 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 <T> boolean dispatchCompletion(AtomicBoolean terminal, Consumer<Completion<T>> completion,
Completion<T> 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();
}
}
}
@@ -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;
}
}
@@ -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<String, Long> 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<ShadowExecution> 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<String> 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<String, Long> 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<String, Integer> qualification(GroundedAnswer answer, DecisionResult decision) {
if (answer == null || decision == null) return Map.of();
Set<String> citations = decision.verifiedEvidence().stream().map(value -> value.citationId())
.collect(java.util.stream.Collectors.toSet());
Set<String> facts = decision.verifiedFacts().stream().map(value -> value.factRef())
.collect(java.util.stream.Collectors.toSet());
List<GroundedClaim> 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<String, Integer> 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<String> legacyToolCodes,
String legacyStatus,
int legacyEvidenceCount,
int legacyFactCount,
boolean legacyZeroEvidenceGenerated,
long legacyLatencyMs,
boolean media,
ShadowProgress progress
) {
}
private static final class ShadowProgress {
private final AtomicReference<String> 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<String, Long> phaseLatencyMs
) {
private static ShadowExecution skipped(long latencyMs, String errorCode, Map<String, Long> timings) {
return new ShadowExecution(null, null, null, latencyMs, "STATE_NOT_SAVED", errorCode,
Map.copyOf(timings));
}
private static ShadowExecution failed(long latencyMs, String errorCode, Map<String, Long> timings) {
return new ShadowExecution(null, null, null, latencyMs, "STATE_NOT_SAVED", errorCode,
Map.copyOf(timings));
}
}
}
@@ -51,6 +51,11 @@ public class AihrAgentToolRegistry {
}
}
public List<AihrAgentTool.ToolDefinition> definitions() {
return tools.values().stream().map(AihrAgentTool::definition)
.sorted(java.util.Comparator.comparing(AihrAgentTool.ToolDefinition::code)).toList();
}
public List<ToolResult> execute(SemanticQueryPlan plan, ExecutionContext context) {
validate(plan);
validateContext(context);
@@ -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<String, String> BUSINESS_CAPABILITIES = Map.of(
"KNOWLEDGE_SEARCH", "企业知识资料",
"MY_CURRENT_TASKS", "本人的当前待办"
);
private static final Map<String, String> CAPABILITY_TO_TOOL = Map.of(
"KNOWLEDGE", "KNOWLEDGE_SEARCH",
"CURRENT_TASKS", "MY_CURRENT_TASKS"
);
private final ObjectProvider<AihrAgentToolRegistry> registryProvider;
private final AihrGroundedAgentPolicy policy;
private final Clock clock;
@Autowired
public AihrCapabilityQueryAgentTool(ObjectProvider<AihrAgentToolRegistry> registryProvider,
AihrGroundedAgentPolicy policy) {
this(registryProvider, policy, Clock.systemUTC());
}
AihrCapabilityQueryAgentTool(ObjectProvider<AihrAgentToolRegistry> 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<String> 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<String> 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<String, Object> 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);
}
}
@@ -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;
@@ -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<DocumentEvidence> candidates = result.citations().stream()
.filter(citation -> "DOCUMENT".equals(citation.sourceType()))
.map(AihrKnowledgeSearchAgentTool::evidence)
.toList();
long classificationStarted = System.nanoTime();
List<DocumentEvidence> evidence = supportClassifier == null ? candidates
: supportClassifier.classify(query, call.evidenceNeeds(), candidates);
Map<String, Long> 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<String> effectiveProjects(ExecutionContext context) {
return context.projectCode() == null ? context.authorizedProjectCodes() : Set.of(context.projectCode());
}
}
@@ -229,12 +229,21 @@ public final class AihrKnowledgeQueryDto {
List<Citation> citations,
List<RetrievalCandidate> retrievalCandidates,
List<String> usedSpaceCodes,
boolean noEvidence
boolean noEvidence,
java.util.Map<String, Long> 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<Citation> citations,
List<RetrievalCandidate> retrievalCandidates, List<String> usedSpaceCodes,
boolean noEvidence) {
this(requestId, queryPlan, citations, retrievalCandidates, usedSpaceCodes, noEvidence,
java.util.Map.of());
}
}
}
@@ -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
@@ -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<AppRow> 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;
@@ -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<OrgPersonRow> 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<String> roles = new LinkedHashSet<>();
Set<String> 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<String> roles = new LinkedHashSet<>();
Set<String> projectCodes = new LinkedHashSet<>();
@@ -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<RetrievalCandidate> candidates) {
if (candidates == null || candidates.isEmpty()) return;
List<CandidateTraceRow> rows = new ArrayList<>();
for (RetrievalCandidate candidate : candidates) {
if (candidate == null || candidate.fragmentId() == null || candidate.fragmentId() <= 0) continue;
List<ChannelContribution> 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) {
@@ -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<Long> 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<Long> frozenSpaceIds) {
QueryRequest request = validate(rawRequest);
AihrKnowledgePrincipal principal = projectPrincipal(requireTrustedPrincipal(trustedPrincipal),
request.projectCode());
AuthenticatedApp app = appService.requireSessionApp(principal.tenantId(), trustedAppId);
Set<Long> currentSpaceIds = accessService.resolveInternalSpaceIds(
principal, app, request.spaceCodes(), "READ");
Set<Long> allowed = frozenSpaceIds == null ? Set.of() : Set.copyOf(frozenSpaceIds);
Set<Long> 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<Long> 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<Long> spaceIds = accessService.resolveInternalSpaceIds(principal, app, request.spaceCodes(), "READ");
String requestId = UUID.randomUUID().toString().replace("-", "");
long started = System.nanoTime();
List<String> 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<SnippetResponse> snippets = evidenceResponse == null ? List.of() : evidenceResponse.snippets();
long citationGateStarted = System.nanoTime();
List<Citation> citations = withCitationIds(requestId, citations(
app.tenantId(), spaceIds, snippets, formalPolicyOnly, request.queryText()));
Map<String, Long> timings = new LinkedHashMap<>(execution == null ? Map.of() : execution.timings());
timings.put("CITATION_GATE", elapsedMillis(citationGateStarted));
List<RetrievalCandidate> 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<Citation> withCitationIds(String requestId, List<Citation> citations) {
List<Citation> result = new ArrayList<>();
for (int index = 0; index < citations.size(); index++) {
@@ -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<String> ROOT_FIELDS = Set.of("primaryCandidateId", "mentions");
private static final Set<String> MENTION_FIELDS = Set.of(
"id", "rawText", "start", "end", "value", "currency", "comparator", "semanticRole",
@@ -72,8 +75,8 @@ public class AihrRagAmountSemanticPlanner {
return deterministic;
}
try {
Optional<String> response = modelService.tryChat(
SYSTEM_PROMPT, modelInput(deterministic), 0.0);
Optional<String> 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);
@@ -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<String> 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<InputStream> response = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(15))
HttpResponse<byte[]> 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) {
}
@@ -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<Long> 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<Long> 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<Long> 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<Long> 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<String> callEmbeddings(EmbeddingRuntime runtime, List<String> fragments) throws Exception {
return callEmbeddings(runtime, fragments, Duration.ofSeconds(60));
}
private List<String> callEmbeddings(EmbeddingRuntime runtime, List<String> 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<String> 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<Long> allowedKnowledgeIds,
boolean formalPolicyOnly, QueryPlan plan, boolean composeAnswer) {
boolean formalPolicyOnly, QueryPlan plan, boolean composeAnswer,
SearchBudget searchBudget) {
long searchStarted = System.nanoTime();
Map<String, Long> timings = new LinkedHashMap<>();
try {
boolean requiresFormal = formalPolicyOnly || !plan.requiredAuthorities().isEmpty();
List<CandidateAccumulator> 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<Long> 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<CandidateAccumulator> rerankPool = eligible.stream().limit(RETRIEVAL_RERANK_K).toList();
eligible.stream().skip(RETRIEVAL_RERANK_K)
.forEach(candidate -> candidate.reject("OUTSIDE_RERANK_K"));
List<CandidateAccumulator> ranked = rerankCandidates(queryText, rerankPool);
long rerankStarted = System.nanoTime();
List<CandidateAccumulator> 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<CandidateAccumulator> 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<Long> allowedKnowledgeIds,
boolean formalPolicyOnly, QueryPlan plan,
SearchBudget searchBudget) {
long searchStarted = System.nanoTime();
Map<String, Long> timings = new LinkedHashMap<>();
try {
boolean requiresFormal = formalPolicyOnly || !plan.requiredAuthorities().isEmpty();
List<CandidateAccumulator> 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<Long> 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<CandidateAccumulator> eligible = candidates.stream()
.filter(candidate -> candidate.decisionReason == null)
.sorted(Comparator.comparingDouble(CandidateAccumulator::fusionScore).reversed())
.toList();
timings.put("RETRIEVAL_GATE", elapsedSearchMillis(gateStarted));
List<CandidateAccumulator> 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<CandidateAccumulator> 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<CandidateAccumulator> 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<KnowledgeHit> 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<CandidateAccumulator> retrieveGovernedCandidates(String category, String queryText,
String retrievalQueryText,
Set<Long> allowedKnowledgeIds,
QueryPlan plan, SearchBudget searchBudget,
Map<String, Long> timings) throws Exception {
List<GovernedVectorScope> scopes = governedVectorScopes(allowedKnowledgeIds);
if (scopes.isEmpty()) return List.of();
List<QueryVariant> variants = queryVariants(queryText, retrievalQueryText, plan);
Map<GovernedVectorGroup, List<GovernedVectorScope>> 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<String, CandidateAccumulator> candidates = new LinkedHashMap<>();
for (Map.Entry<GovernedVectorGroup, List<GovernedVectorScope>> 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<String> 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<Long> knowledgeIds = entry.getValue().stream().map(GovernedVectorScope::knowledgeId)
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
Map<String, List<GovernedVectorMatch>> matchesByVariant = new LinkedHashMap<>();
LinkedHashMap<Long, GovernedVectorMatch> uniqueMatches = new LinkedHashMap<>();
for (int index = 0; index < variants.size(); index++) {
long qdrantStarted = System.nanoTime();
List<GovernedVectorMatch> 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<Long, GovernedFragmentRow> rows;
try {
rows = hydrateGovernedFragments(group, knowledgeIds, uniqueMatches.values());
} finally {
mergeTiming(timings, "GOVERNED_HYDRATION", elapsedSearchMillis(hydrationStarted));
}
for (QueryVariant variant : variants) {
List<GovernedVectorMatch> 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<CandidateAccumulator> fused = candidates.values().stream()
.peek(CandidateAccumulator::finishFusion)
.sorted(Comparator.comparingDouble(CandidateAccumulator::fusionScore).reversed())
.toList();
timings.put("FUSION", elapsedSearchMillis(fusionStarted));
return fused;
}
private List<GovernedVectorScope> governedVectorScopes(Set<Long> allowedKnowledgeIds) {
List<Object> 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<GovernedVectorMatch> queryGovernedQdrantCollection(GovernedVectorGroup group, String category,
String embeddingJson, int limit,
Set<Long> 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<String> 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<GovernedVectorMatch> 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<Long, GovernedFragmentRow> hydrateGovernedFragments(GovernedVectorGroup group,
Set<Long> knowledgeIds,
java.util.Collection<GovernedVectorMatch> matches) {
if (matches.isEmpty()) return Map.of();
List<Long> fragmentIds = matches.stream().map(GovernedVectorMatch::fragmentId).distinct().toList();
List<Object> args = new ArrayList<>();
args.add(group.generation());
args.add(group.generation());
args.add(tenantId());
args.addAll(knowledgeIds);
args.addAll(fragmentIds);
List<GovernedFragmentRow> 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<Long, GovernedFragmentRow> 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<String, Long> searchTimings(Map<String, Long> values, long started) {
Map<String, Long> 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<String, Long> 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<CandidateAccumulator> retrieveCandidates(String category, String queryText,
String retrievalQueryText,
Set<Long> allowedKnowledgeIds, QueryPlan plan,
boolean includePublishedCandidates) {
boolean includePublishedCandidates,
SearchBudget searchBudget, Map<String, Long> timings) {
List<QueryVariant> variants = queryVariants(queryText, retrievalQueryText, plan);
Map<String, CandidateAccumulator> 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<String, List<KnowledgeHit>> 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<CandidateAccumulator> fused = candidates.values().stream()
.peek(CandidateAccumulator::finishFusion)
.sorted(Comparator.comparingDouble(CandidateAccumulator::fusionScore).reversed())
.toList();
timings.put("FUSION", elapsedSearchMillis(fusionStarted));
return fused;
}
private static List<QueryVariant> queryVariants(String queryText, String retrievalQueryText, QueryPlan plan) {
@@ -3713,7 +4082,9 @@ public class AihrSopSeedService {
}
}
private List<CandidateAccumulator> rerankCandidates(String queryText, List<CandidateAccumulator> candidates) {
private List<CandidateAccumulator> rerankCandidates(String queryText, List<CandidateAccumulator> candidates,
SearchBudget searchBudget,
Map<String, Long> timings) {
if (candidates.size() < 2) return candidates;
Optional<ChatRuntime> 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<String> response = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(15))
HttpResponse<String> 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<Long> allowedKnowledgeIds,
boolean formalPolicyOnly,
boolean includePublishedCandidates) {
return vectorHits(category, variants, limit, allowedKnowledgeIds, formalPolicyOnly,
includePublishedCandidates, LEGACY_SEARCH_BUDGET, new LinkedHashMap<>());
}
private Map<String, List<KnowledgeHit>> vectorHits(String category, List<QueryVariant> variants, int limit,
Set<Long> allowedKnowledgeIds,
boolean formalPolicyOnly,
boolean includePublishedCandidates,
SearchBudget searchBudget,
Map<String, Long> timings) {
if (variants == null || variants.isEmpty()) return Map.of();
List<String> 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<String> embeddings = callEmbeddings(runtime, queries);
long embeddingStarted = System.nanoTime();
List<String> embeddings;
try {
embeddings = callEmbeddings(runtime, queries, searchBudget.embeddingTimeout());
} finally {
mergeTiming(timings, "VECTOR_EMBEDDING", elapsedSearchMillis(embeddingStarted));
}
if (embeddings.size() != variants.size()) continue;
Map<String, List<KnowledgeHit>> results = new LinkedHashMap<>();
for (int index = 0; index < variants.size(); index++) {
List<KnowledgeHit> hits = new ArrayList<>();
for (VectorMatch match : queryQdrant(
category, embeddings.get(index), limit, allowedKnowledgeIds)) {
long qdrantStarted = System.nanoTime();
List<VectorMatch> 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<VectorMatch> queryQdrant(String category, String embeddingJson, int limit, Set<Long> allowedKnowledgeIds) throws Exception {
return queryQdrant(category, embeddingJson, limit, allowedKnowledgeIds, Duration.ofSeconds(3));
}
private List<VectorMatch> queryQdrant(String category, String embeddingJson, int limit,
Set<Long> allowedKnowledgeIds, Duration requestTimeout) throws Exception {
List<RolloutVectorScope> scopes = rolloutVectorScopes(allowedKnowledgeIds);
List<VectorMatch> matches = new ArrayList<>();
Set<Long> 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<String, Set<Long>> 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<VectorMatch> queryQdrantCollection(String collection, String category, String embeddingJson,
int limit, Set<Long> knowledgeIds,
Long generation) throws Exception {
return queryQdrantCollection(collection, category, embeddingJson, limit, knowledgeIds, generation,
Duration.ofSeconds(3));
}
private List<VectorMatch> queryQdrantCollection(String collection, String category, String embeddingJson,
int limit, Set<Long> 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<String> response = qdrantRequest("POST", "/collections/" + collection + "/points/query", body);
HttpResponse<String> 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<String> qdrantRequest(String method, String path, JsonNode body) throws Exception {
return qdrantRequest(method, path, body, Duration.ofSeconds(3));
}
private HttpResponse<String> 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<RetrievalCandidate> candidates) {
List<RetrievalCandidate> candidates, Map<String, Long> 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<RetrievalCandidate> 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<ChannelContribution> contributions = new ArrayList<>();
@@ -56,6 +56,7 @@ class AihrAgentMediaTest {
query,
null,
null,
null,
null
);
}
@@ -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
);
}
@@ -73,6 +73,7 @@ class AihrAgentWebResearchTest {
},
null,
web,
null,
null
);
}
@@ -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<Duration> timeout = new AtomicReference<>();
AihrModelSeedService model = new AihrModelSeedService(objectMapper, null) {
@Override
public Optional<String> 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;
}
@@ -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<String> tryChat(String systemPrompt, String userPrompt, double temperature) {
public Optional<String> tryStructuredJson(String systemPrompt, String userPrompt, int maxTokens,
Duration timeout) {
return Optional.of(output);
}
};
@@ -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(
@@ -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<Duration> timeout = new AtomicReference<>();
AihrModelSeedService model = new AihrModelSeedService(objectMapper, null) {
@Override
public Optional<String> 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<String> tryChat(String systemPrompt, String userPrompt, double temperature) {
public Optional<String> tryStructuredJson(String systemPrompt, String userPrompt, int maxTokens,
Duration timeout) {
return Optional.of(output);
}
};
@@ -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<String> tryChat(String systemPrompt, String userPrompt, double temperature) {
public Optional<String> tryStructuredJson(String systemPrompt, String userPrompt, int maxTokens,
Duration timeout) {
input.set(userPrompt);
return Optional.of(response);
}
@@ -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<String, Object> 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<String> tryChat(String systemPrompt, String userPrompt, double temperature) {
public Optional<String> 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<String> tryChat(String systemPrompt, String userPrompt, double temperature) {
public Optional<String> tryStructuredJson(String systemPrompt, String userPrompt, int maxTokens,
Duration timeout) {
return Optional.empty();
}
};
@@ -441,16 +465,22 @@ class AihrGroundedAgentGoldenTest {
}
@Override
public Optional<String> tryChat(String systemPrompt, String userPrompt, double temperature) {
public Optional<String> tryStructuredJson(String systemPrompt, String userPrompt, int maxTokens,
Duration timeout) {
calls++;
try {
JsonNode input = objectMapper.readTree(userPrompt);
List<Map<String, Object>> confirmed = new ArrayList<>();
List<Map<String, Object>> 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<String> claimTypes = new LinkedHashSet<>();
@@ -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<AihrAgentTool> 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;
}
@@ -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");
}
}
@@ -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;
}
}
}
@@ -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<AihrAgentShadowDispatcher.Completion<String>> 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());
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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<Object> task = invocation.getArgument(0);
Consumer<Completion<Object>> 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<Object>> 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
) {
}
}
@@ -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);
}
}
}
@@ -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<AihrAgentToolRegistry> provider = mock(ObjectProvider.class);
AtomicReference<AihrAgentToolRegistry> 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<String> 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<Long> 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) {
}
}
@@ -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<Long> 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<Long> frozenSpaceIds) {
evidenceCalls++;
this.trustedPrincipal = trustedPrincipal;
this.trustedAppId = trustedAppId;
this.frozenSpaceIds = frozenSpaceIds;
return result;
}
@@ -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<List> rows = ArgumentCaptor.forClass(List.class);
verify(jdbc).batchUpdate(anyString(), rows.capture(), eq(200), any());
assertThat(rows.getValue()).hasSize(2);
}
}
@@ -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);
@@ -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<String> 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() {
@@ -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<String> roles) {
LoginUser login = new LoginUser();
login.setTenantId("000000");
@@ -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<String> capturedPrompt) {
return new AihrModelSeedService(objectMapper, null) {
@Override
public Optional<String> tryChat(String systemPrompt, String userPrompt, double temperature) {
public Optional<String> 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);
}
};
@@ -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));
}
}
@@ -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 {
@@ -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
}
]
@@ -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新旧链路影子对比;不保存问题答案或工具正文';
@@ -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;
+26 -1
View File
@@ -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 条人工标注黄金集、真实延迟分位数、容量评估及来源冲突样本。
+1 -1
View File
@@ -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);尚未部署生产,生产行为仍以本页现役接口说明为准。
## 第一阶段范围
+1 -1
View File
@@ -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 正式验收、消息修订、强触达、工单流转、完整个人知识空间或完整试点已经完成。
## 当前项目文档
@@ -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 条人工黄金集时宣称召回率达标。
@@ -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;不得从旁路存在推断已切流或已部署。
---