aps-agent/server/agent_core/evidence.py

153 lines
6.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# 统一 EvidenceItem 与可追溯链 v1(moduleId: core-evidence, 可重生 ✅)
# plan.md §3.4 / 矩阵「结论、动作、报告可追溯和可复算」:
# 把 run-id、算法版本、种子、知识版本、用户确认串成一条可复算链
# ============================================================
from __future__ import annotations # 前向类型引用
import hashlib # 链哈希
import json # 规范化序列化
from dataclasses import dataclass, field, asdict # 结构化记录
from typing import Any # 类型标注
# 证据引用语法:<kind>:<id>(与既有 schedule-version:<id> 协议兼容)
_EVIDENCE_KINDS = frozenset({
"schedule-version", "run", "algorithm", "seed", "knowledge", "confirm", "report",
})
def evidence_ref(kind: str, ref_id: Any) -> str:
"""生成统一证据引用:<kind>:<id>(kind 白名单,防注入)。"""
if kind not in _EVIDENCE_KINDS:
raise ValueError(f"unknown evidence kind: {kind!r}")
return f"{kind}:{ref_id}"
def parse_evidence_ref(ref: str) -> tuple[str, str]:
"""解析证据引用为 (kind, id);非法引用抛 ValueError。"""
if not isinstance(ref, str) or ":" not in ref:
raise ValueError(f"malformed evidence ref: {ref!r}")
kind, _, ref_id = ref.partition(":")
if kind not in _EVIDENCE_KINDS:
raise ValueError(f"unknown evidence kind: {kind!r}")
return kind, ref_id
@dataclass
class EvidenceItem:
"""一条结构化证据:可追溯链的原子元素。
kind: 证据种类(schedule-version/run/algorithm/seed/knowledge/confirm/report)
ref: 人类可读引用(如 run-<id>)
version: 版本号(算法/知识/种子版本)
runId: 求解运行 ID
engine: 引擎类型(RULE/CP/GA/HYBRID/EXTERNAL)
seed: 随机种子(复算用)
inputsHash: 输入包哈希(复算用)
knowledgeVersion: 知识库版本(RAG 出处)
confirmId: 用户确认令牌(P2/P3 门禁)
note: 审批意见/备注
meta: 额外元数据
"""
kind: str
ref: str
version: str | None = None
runId: str | None = None
engine: str | None = None
seed: Any = None
inputsHash: str | None = None
knowledgeVersion: str | None = None
confirmId: str | None = None
note: str | None = None
meta: dict[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
if self.kind not in _EVIDENCE_KINDS:
raise ValueError(f"unknown evidence kind: {self.kind!r}")
def _canonical_json(value: Any) -> str:
return json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"), default=str)
def trace_chain(items: list[EvidenceItem | dict[str, Any]]) -> dict[str, Any]:
"""把多条证据串成可复算链。
chainHash = SHA256(规范化 JSON 串联),任何元素/顺序变化都会断链。
返回 {items, chainHash, count}。
"""
normalized: list[dict[str, Any]] = []
for item in items:
if isinstance(item, EvidenceItem):
normalized.append(asdict(item))
elif isinstance(item, dict):
normalized.append(dict(item))
else:
raise TypeError(f"evidence item must be EvidenceItem or dict, got {type(item)}")
chain_hash = hashlib.sha256(_canonical_json(normalized).encode("utf-8")).hexdigest()
return {"items": normalized, "chainHash": chain_hash, "count": len(normalized)}
def verify_chain(items: list[EvidenceItem | dict[str, Any]], expected_hash: str) -> bool:
"""校验链哈希(可复算/可追溯)。"""
return trace_chain(items)["chainHash"] == expected_hash
def verify_audit_trace(events: list[dict[str, Any]]) -> dict[str, Any]:
"""审计链完整性校验(矩阵 114:trace 断链检测,重算链哈希逐条比对)。
遍历 ALGO_RUN 审计的 traceChainHash,重算链哈希逐条比对:
- 结构校验:哈希格式(64 位十六进制)、traceCount 与 traceSummary 条数一致、
summary 条目 kind 白名单 + ref 非空;
- 链重算:若审计 rationale 携带 canonical traceItems(ALGO_RUN 写入时落盘),
用 trace_chain 重算并与期望 traceChainHash 比对(verify_chain 语义);
旧记录(无 traceItems)仅结构校验,避免误报。
返回 {"ok", "checked", "broken"};broken 明细:{eventId, expected, actual, reason}。
"""
import re as _re
_hex64 = _re.compile(r"^[0-9a-f]{64}$")
broken: list[dict[str, Any]] = []
checked = 0
for ev in events:
rationale = ev.get("rationale") or {}
expected = rationale.get("traceChainHash")
if not expected:
continue # 非 trace 审计(无链)跳过
checked += 1
event_id = ev.get("id")
summary = rationale.get("traceSummary")
count = rationale.get("traceCount")
if not (isinstance(expected, str) and _hex64.match(expected)):
broken.append({"eventId": event_id, "expected": str(expected),
"actual": "invalid-hash", "reason": "bad-hash-format"})
continue
if not isinstance(summary, list):
broken.append({"eventId": event_id, "expected": str(count),
"actual": "missing-summary", "reason": "missing-summary"})
continue
if not isinstance(count, int) or count != len(summary):
broken.append({"eventId": event_id, "expected": str(count),
"actual": str(len(summary)), "reason": "count-mismatch"})
continue
bad = next((it for it in summary
if not isinstance(it, dict)
or it.get("kind") not in _EVIDENCE_KINDS
or not isinstance(it.get("ref"), str) or not it["ref"]), None)
if bad is not None:
broken.append({"eventId": event_id, "expected": "valid-kind/ref",
"actual": str(bad), "reason": "bad-summary-entry"})
continue
items = rationale.get("traceItems") # canonical 全量条目(写入时落盘)
if isinstance(items, list) and items:
actual = trace_chain(items)["chainHash"] # 重算链哈希(可复算)
if actual != expected:
broken.append({"eventId": event_id, "expected": expected,
"actual": actual, "reason": "chain-hash-mismatch"})
return {"ok": len(broken) == 0, "checked": checked, "broken": broken}
def schedule_version_item(version_id: Any, **kwargs: Any) -> EvidenceItem:
"""构造 schedule-version 证据项(与既有协议兼容)。"""
return EvidenceItem(kind="schedule-version", ref=str(version_id), version=str(version_id), **kwargs)