feat(aihr): prepare grounded agent shadow validation
This commit is contained in:
+11
-2
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-1
@@ -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);
|
||||
|
||||
+25
-1
@@ -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);
|
||||
|
||||
+13
-2
@@ -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();
|
||||
|
||||
+2
@@ -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());
|
||||
|
||||
+3
-2
@@ -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);
|
||||
|
||||
+5
-2
@@ -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;
|
||||
|
||||
+10
-1
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+294
-28
@@ -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) {
|
||||
|
||||
+16
-7
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+55
-8
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+88
@@ -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();
|
||||
}
|
||||
}
|
||||
+167
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+132
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
@@ -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;
|
||||
}
|
||||
}
|
||||
+347
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
@@ -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);
|
||||
|
||||
+99
@@ -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);
|
||||
}
|
||||
}
|
||||
+11
-2
@@ -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;
|
||||
|
||||
+24
-3
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+10
-1
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+23
@@ -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;
|
||||
|
||||
+43
@@ -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<>();
|
||||
|
||||
+58
-20
@@ -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) {
|
||||
|
||||
+46
-5
@@ -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++) {
|
||||
|
||||
+5
-2
@@ -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);
|
||||
|
||||
+70
-17
@@ -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) {
|
||||
}
|
||||
|
||||
|
||||
+505
-32
@@ -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<>();
|
||||
|
||||
+1
@@ -56,6 +56,7 @@ class AihrAgentMediaTest {
|
||||
query,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
+43
-1
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -73,6 +73,7 @@ class AihrAgentWebResearchTest {
|
||||
},
|
||||
null,
|
||||
web,
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
+71
-3
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
+11
@@ -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(
|
||||
|
||||
+24
-1
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
+189
-12
@@ -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);
|
||||
}
|
||||
|
||||
+38
-8
@@ -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<>();
|
||||
|
||||
+36
-6
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+85
@@ -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");
|
||||
}
|
||||
}
|
||||
+60
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+121
@@ -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());
|
||||
}
|
||||
}
|
||||
+37
@@ -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);
|
||||
}
|
||||
}
|
||||
+33
@@ -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;
|
||||
}
|
||||
}
|
||||
+134
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
+36
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+106
@@ -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) {
|
||||
}
|
||||
}
|
||||
+22
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+22
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+55
@@ -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);
|
||||
|
||||
+29
@@ -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() {
|
||||
|
||||
+40
@@ -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
-1
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
+44
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+23
@@ -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 {
|
||||
|
||||
+24
-24
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user