feat(agent): add verifier claim checks

This commit is contained in:
aruo
2026-07-08 02:33:02 +08:00
parent c5e496e715
commit 1b31e78be5
19 changed files with 1247 additions and 115 deletions
@@ -204,19 +204,22 @@ public class AgentLoggingHook extends MessagesModelHook {
private String summarizeVerifierThought(String verifierOutput) {
try {
JsonNode root = objectMapper.readTree(verifierOutput);
int claimCount = root.path("claim_checks").isArray() ? root.path("claim_checks").size() : 0;
int factCount = root.path("facts_checked").isArray() ? root.path("facts_checked").size() : 0;
int tracedFactCount = 0;
if (root.path("facts_checked").isArray()) {
for (JsonNode factNode : root.path("facts_checked")) {
if (factNode.path("evidence_refs").isArray() && factNode.path("evidence_refs").size() > 0) {
JsonNode tracedNodes = root.path("claim_checks").isArray() ? root.path("claim_checks") : root.path("facts_checked");
if (tracedNodes.isArray()) {
for (JsonNode node : tracedNodes) {
if (node.path("evidence_refs").isArray() && node.path("evidence_refs").size() > 0) {
tracedFactCount++;
}
}
}
return "verdict=%s, score=%s, critical_fact_count=%s, facts_checked=%d, traced_facts=%d".formatted(
return "verdict=%s, score=%s, critical_fact_count=%s, claim_checks=%d, facts_checked=%d, traced_facts=%d".formatted(
root.path("verdict").asText("UNKNOWN"),
root.path("groundedness_score").asText("0.0"),
root.path("critical_fact_count").asText("0"),
claimCount,
factCount,
tracedFactCount
);
@@ -650,12 +650,18 @@ public class ChatService {
try {
JsonNode root = objectMapper.readTree(sanitizeJsonPayload(verifierOutput));
List<Map<String, Object>> factsChecked = parseFactsChecked(root.path("facts_checked"));
List<Map<String, Object>> claimChecks = parseClaimChecks(root.path("claim_checks"));
List<Map<String, Object>> factsChecked = claimChecks.isEmpty()
? parseFactsChecked(root.path("facts_checked"))
: mapClaimChecksToFactsChecked(claimChecks);
String verdict = effectiveVerifierVerdict(root.path("verdict").asText("LOW_CONFID"));
int criticalFactCount = root.path("critical_fact_count").asInt(countCriticalFacts(factsChecked));
return new VerifierDecision(
root.path("verdict").asText("LOW_CONFID"),
verdict,
root.path("groundedness_score").asDouble(0.0),
root.path("critical_fact_count").asInt(0),
criticalFactCount,
claimChecks,
factsChecked,
root.path("rationale").asText(""),
round
@@ -678,6 +684,104 @@ public class ChatService {
return trimmed;
}
private String effectiveVerifierVerdict(String modelVerdict) {
String verdict = normalizeVerdict(modelVerdict);
Map<String, Object> parseStatus = VerifierContextHolder.getExecutorOutputParseStatus();
String parseState = parseStatus == null ? "" : String.valueOf(parseStatus.getOrDefault("status", ""));
if (("missing".equals(parseState) || "malformed".equals(parseState)) && "PASS".equals(verdict)) {
return "LOW_CONFID";
}
Map<String, Object> gatekeeperResult = VerifierContextHolder.getGatekeeperResult();
if (gatekeeperResult == null || !"fail".equals(String.valueOf(gatekeeperResult.get("status")))) {
return verdict;
}
if (containsRule(gatekeeperResult.get("failed_rules"), ExecutorGatekeeperService.RULE_INVOCATION_REF)) {
return "REJECT";
}
return "PASS".equals(verdict) ? "LOW_CONFID" : verdict;
}
private String normalizeVerdict(String verdict) {
if ("PASS".equals(verdict) || "LOW_CONFID".equals(verdict) || "REJECT".equals(verdict)) {
return verdict;
}
return "LOW_CONFID";
}
private boolean containsRule(Object rulesValue, String ruleId) {
if (!(rulesValue instanceof List<?> rules)) {
return false;
}
return rules.stream().anyMatch(rule -> ruleId.equals(String.valueOf(rule)));
}
private int countCriticalFacts(List<Map<String, Object>> factsChecked) {
return (int) factsChecked.stream()
.filter(fact -> Boolean.TRUE.equals(fact.get("is_critical")))
.count();
}
private List<Map<String, Object>> parseClaimChecks(JsonNode claimChecksNode) {
List<Map<String, Object>> claimChecks = new ArrayList<>();
if (!claimChecksNode.isArray()) {
return claimChecks;
}
for (JsonNode claimNode : claimChecksNode) {
Map<String, Object> claimCheck = new LinkedHashMap<>();
claimCheck.put("claim_id", claimNode.path("claim_id").asText(""));
claimCheck.put("claim_text", claimNode.path("claim_text").asText(""));
claimCheck.put("claim_type", claimNode.path("claim_type").asText(""));
claimCheck.put("verification", normalizeClaimVerification(claimNode.path("verification").asText("unsupported")));
claimCheck.put("detail", claimNode.path("detail").asText(""));
claimCheck.put("evidence_refs", parseEvidenceRefs(claimNode.path("evidence_refs")));
claimChecks.add(claimCheck);
}
return claimChecks;
}
private String normalizeClaimVerification(String verification) {
return switch (verification) {
case "direct_observation", "reasonable_inference", "overstated", "unsupported",
"external_unknown", "contradicted" -> verification;
default -> "unsupported";
};
}
private List<Map<String, Object>> mapClaimChecksToFactsChecked(List<Map<String, Object>> claimChecks) {
List<Map<String, Object>> factsChecked = new ArrayList<>();
for (Map<String, Object> claimCheck : claimChecks) {
String claimId = String.valueOf(claimCheck.getOrDefault("claim_id", ""));
String claimText = String.valueOf(claimCheck.getOrDefault("claim_text", ""));
String claimType = String.valueOf(claimCheck.getOrDefault("claim_type", ""));
Map<String, Object> fact = new LinkedHashMap<>();
fact.put("fact", claimId.isBlank() ? claimText : claimId + ": " + claimText);
fact.put("is_critical", isCriticalClaimType(claimType));
fact.put("verification", mapClaimVerificationToFactVerification(
String.valueOf(claimCheck.getOrDefault("verification", "unsupported"))));
fact.put("detail", claimCheck.getOrDefault("detail", ""));
fact.put("evidence_refs", claimCheck.getOrDefault("evidence_refs", List.of()));
factsChecked.add(fact);
}
return factsChecked;
}
private boolean isCriticalClaimType(String claimType) {
return "root_cause".equals(claimType)
|| "symptom".equals(claimType)
|| "impact".equals(claimType)
|| "risk".equals(claimType);
}
private String mapClaimVerificationToFactVerification(String verification) {
return switch (verification) {
case "direct_observation" -> "direct_evidence";
case "reasonable_inference", "overstated" -> "indirect_support";
case "contradicted" -> "contradicted";
default -> "no_evidence";
};
}
private List<Map<String, Object>> parseFactsChecked(JsonNode factsNode) {
List<Map<String, Object>> factsChecked = new ArrayList<>();
if (!factsNode.isArray()) {
@@ -723,7 +827,7 @@ public class ChatService {
}
private VerifierDecision buildVerifierFallbackDecision(int round, String rationale) {
return new VerifierDecision("LOW_CONFID", 0.0, 0, List.of(), rationale, round);
return new VerifierDecision("LOW_CONFID", 0.0, 0, List.of(), List.of(), rationale, round);
}
private String extractStateText(Optional<OverAllState> stateOptional, String key) {
@@ -748,6 +852,7 @@ public class ChatService {
verifierEvaluation.put("verdict", decision.verdict());
verifierEvaluation.put("groundedness_score", decision.groundednessScore());
verifierEvaluation.put("critical_fact_count", decision.criticalFactCount());
verifierEvaluation.put("claim_checks", decision.claimChecks());
verifierEvaluation.put("facts_checked", decision.factsChecked());
verifierEvaluation.put("rationale", decision.rationale());
verifierEvaluation.put("round", round);
@@ -1005,6 +1110,7 @@ public class ChatService {
String verdict,
double groundednessScore,
int criticalFactCount,
List<Map<String, Object>> claimChecks,
List<Map<String, Object>> factsChecked,
String rationale,
int round
@@ -1,4 +1,4 @@
你是质量闸 verifier。你的任务是对 `executor_final_answer` 做一次基于现有证据的事实校验。
你是质量闸 verifier。你的任务是对 Executor 的结构化 claims 做一次基于现有证据的可推导性校验。
边界约束:
- 不做新的检索
@@ -9,7 +9,7 @@
## 输入字段
- `original_query`:用户原始问题
- `executor_final_answer`:本轮 Executor 最终答案
- `executor_final_answer`:Executor 原始输出,仅用于 debug/fallback;当结构化输出有效时,不得从这里抽取额外确认事实
- `executor_structured_output`:如果 Executor 输出了合法证据归因 JSON,这里会提供解析后的对象。结构包含 `claims`、`hypotheses`、`recommended_actions`、`missing_info`;兼容旧版时可能包含 `user_facing_answer`
- `executor_output_parse_status`:Executor 输出解析状态,包含 `status` 和 `detail`。`status` 可能是 `valid` / `missing` / `malformed`
- `tool_trace_summary`:基于真实工具调用整理出的证据索引。每一项都带有:
@@ -25,52 +25,47 @@
## 任务步骤
### 步骤一:提取关键事实
### 步骤一:确定校验对象
如果 `executor_output_parse_status.status="valid"` 且 `executor_structured_output.claims` 存在:
- 优先逐条校验 `executor_structured_output.claims`
- 每个 claim 至少形成一条 `facts_checked`
- 每个 claim 至少形成一条 `claim_checks`
- 必须检查 claim 的 `evidence_bindings` 是否能对应到 `tool_trace_summary` 中真实存在的 trace、tool 或 source_invocation_ids
- 如果 claim 声称 direct/indirect 支撑,但 evidence binding 不存在、无法定位、或 excerpt 与工具摘要不匹配,不得判为 `direct_evidence`
- 不得从 `executor_final_answer` 中抽取不在 claims 里的额外确认事实
如果 `executor_structured_output.user_facing_answer` 存在,则必须扫描它:
- 如果其中出现 confirmed-sounding facts(确认式事实、根因、指标值、错误码、服务名、修复结论)
- 且这些事实没有出现在 `executor_structured_output.claims`
- 必须额外加入 `facts_checked` 并按工具证据校验
如果 structured output 缺失或 malformed:
- 不得通过扫描 `executor_final_answer` 生成 `PASS`
- 输出 `LOW_CONFID`
- `groundedness_score = 0.0`
- `claim_checks = []`
- `facts_checked = []`
- `rationale` 说明结构化输出不可用
如果 structured output 缺失或 malformed,则回退到旧逻辑:提取并校验 `executor_final_answer` 里的全部实质性结论。关键事实至少包括:
- 每一个根因结论
- 每一个错误码、接口、组件归属或语义判断
- 每一个明确的修复建议、参数建议、排查步骤
- 每一个“证据来源陈述”
覆盖要求:
- 不允许只抽取一个总括性事实替代整段答案
- 如果答案给出多个根因,必须逐条拆成多个 `fact`
- 如果答案给出多条修复建议,必须逐条拆成多个 `fact`
- 只有寒暄、流程衔接语、与结论无关的话,才可以不纳入 `facts_checked`
### 步骤二:逐条校验事实
每条事实必须输出:
- `fact`
- `is_critical`
### 步骤二:逐条校验 claim
每条 claim check 必须输出:
- `claim_id`
- `claim_text`
- `claim_type`
- `verification`
- `detail`
- `evidence_refs`
`verification` 只允许以下四个值:
- `direct_evidence`
- `indirect_support`
- `no_evidence`
`claim_checks[*].verification` 只允许以下六个值:
- `direct_observation`
- `reasonable_inference`
- `overstated`
- `unsupported`
- `external_unknown`
- `contradicted`
结构化 claim 的校验规则:
- claim 有真实 evidence binding,且工具摘要直接包含该事实 → `direct_evidence`
- claim 有真实 evidence binding,但工具摘要只能支持方向或背景 → `indirect_support`
- claim 无法绑定真实 trace、invocation 或 excerpt → `no_evidence`
- claim 与工具摘要冲突,或编造了不存在的关键实体、服务、错误码、指标值 → `contradicted`
- claim 有真实 evidence binding,且工具摘要直接包含该事实 → `direct_observation`
- claim 有真实 evidence binding,工具摘要没有逐字说明但可以合理推出 → `reasonable_inference`
- claim 有部分依据,但写成唯一根因、确认根因或说得过满 → `overstated`
- claim 无法绑定真实 trace、invocation 或 excerpt → `unsupported`
- claim 引入证据外的新服务名、订单号、错误码、指标值、根因 → `external_unknown`
- claim 与工具摘要冲突 → `contradicted`
`hypotheses` 和 `missing_info` 默认不是 confirmed facts,不应因为它们承认缺证据而惩罚。
但如果 `user_facing_answer` 把 hypothesis 写成确认结论,必须按 confirmed fact 校验。
### 步骤三:补齐 evidence_refs
`evidence_refs` 必须是数组,数组元素必须引用 `tool_trace_summary` 中真实存在的证据项。每个元素包含:
@@ -94,24 +89,26 @@
- 若 `failed_rules` 包含 `evidence.invocation_ref`,倾向 `REJECT`
- 否则至少输出 `LOW_CONFID`
1. 若任一关键事实(`is_critical=true`)为 `contradicted`
1. 若任一关键 claim 为 `contradicted`
- `verdict = "REJECT"`
- `groundedness_score = 0.0`
2. 否则,若所有关键事实均为 `direct_evidence` 或 `indirect_support`
且至少一条关键事实为 `direct_evidence`
2. 否则,若所有关键 claims 均为 `direct_observation` 或 `reasonable_inference`
且至少一条关键 claim 为 `direct_observation`
- `verdict = "PASS"`
3. 否则,若不存在 `contradicted`
且存在关键事实为 `no_evidence`
或所有关键事实都只有 `indirect_support`
且存在关键 claim 为 `unsupported` / `external_unknown` / `overstated`
或所有关键 claim 都只有 `reasonable_inference`
- `verdict = "LOW_CONFID"`
### 步骤五:计算 groundedness_score
只统计 `is_critical=true` 的事实,映射如下:
- `direct_evidence = 1.0`
- `indirect_support = 0.6`
- `no_evidence = 0.0`
只统计关键 claim,映射如下:
- `direct_observation = 1.0`
- `reasonable_inference = 0.6`
- `overstated = 0.3`
- `unsupported = 0.0`
- `external_unknown = 0.0`
- `contradicted = 0.0`
规则:
@@ -120,14 +117,28 @@
- 保留 2 位小数
- 分数范围必须在 `[0.0, 1.0]`
### 步骤六:PASS 前覆盖性自检
### 步骤六:facts_checked 兼容输出
你必须同时输出 `facts_checked`,用于旧链路兼容。
映射规则:
- `direct_observation` → `direct_evidence`
- `reasonable_inference` → `indirect_support`
- `overstated` → `indirect_support`
- `unsupported` → `no_evidence`
- `external_unknown` → `no_evidence`
- `contradicted` → `contradicted`
`facts_checked[*].fact` 使用 `{claim_id}: {claim_text}`。
### 步骤七:PASS 前覆盖性自检
在输出 `PASS` 前,必须再次检查:
- `facts_checked` 是否覆盖了 `executor_final_answer` 的全部实质性结论
- 是否遗漏了单独出现的根因、修复建议、参数建议、排查步骤
- `claim_checks` 是否覆盖了 `executor_structured_output.claims` 中的全部 claims
- 是否存在 `gatekeeper_result.status="fail"`
- 是否存在 malformed/missing structured output
如有明显遗漏,即使已校验事实都有证据,也不得输出 `PASS`。
### 步骤七:处理 retry_context
### 步骤八:处理 retry_context
若 `retry_context` 不为空:
- 优先检查上一轮缺失证据点是否已补足
- 不要扩展与缺口无关的新事实
@@ -141,9 +152,28 @@
"verdict": "PASS",
"groundedness_score": 0.8,
"critical_fact_count": 2,
"claim_checks": [
{
"claim_id": "claim-1",
"claim_text": "ERR_TIMEOUT 表示请求超时",
"claim_type": "symptom",
"verification": "direct_observation",
"detail": "知识库文档明确给出该错误码定义",
"evidence_refs": [
{
"trace_ref": "trace-1",
"tool_name": "lookup_knowledge",
"topic_domain": "api",
"source_invocation_ids": [101, 104],
"note": "trace-1 的文档摘要直接给出错误码定义"
}
]
}
],
"hypothesis_checks": [],
"facts_checked": [
{
"fact": "ERR_TIMEOUT 表示请求超时",
"fact": "claim-1: ERR_TIMEOUT 表示请求超时",
"is_critical": true,
"verification": "direct_evidence",
"detail": "知识库文档明确给出该错误码定义",
@@ -164,7 +194,9 @@
输出要求:
- `verdict` 只能是 `PASS` / `LOW_CONFID` / `REJECT`
- `groundedness_score` 必须是 JSON number
- `critical_fact_count` 必须等于 `facts_checked` 中 `is_critical=true` 的数量
- `critical_fact_count` 必须等于关键 claim 的数量;兼容期也应等于 `facts_checked` 中 `is_critical=true` 的数量
- `claim_checks` 可以为空数组,但字段不能缺失
- `facts_checked` 可以为空数组,但字段不能缺失
- 每条 `claim_checks[*]` 都必须包含 `evidence_refs`
- 每条 `facts_checked[*]` 都必须包含 `evidence_refs`
- 不得输出 schema 之外的字段