aps-agent/server/agent_core/evidence.py

153 lines
6.9 KiB
Python
Raw Permalink Normal View History

# ============================================================
# 统一 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)