aps-agent/server/agent_core/fallback_verify.py

191 lines
7.7 KiB
Python
Raw 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.

# ============================================================
# 兜底验证器 v1(moduleId: core-fallback-verify, 可重生 ✅)
# 《Pi-Agent兜底能力详细方案》§4.1/§4.5 + GOAL-P2 交付 4 + P2-DESIGN §4:
# 兜底执行后的 world diff、验证规则(行数/数量对账)、报告生成。
# 铁律:报告里的每个数字都只许来自冻结快照(cp_before/cp_after 的
# world 深拷贝),绝不引用 Pi 报告文本或 Pi 自述。
# ============================================================
from __future__ import annotations
import hashlib
import json
from pathlib import Path
from typing import Any
# 对账覆盖的业务主数据表(分表 diff 的固定口径)
_DIFF_TABLES = (
"salesOrders", "flexOrders", "materials", "flexMaterials",
"flexEquipment", "flexMolds", "flexOperations", "flexRoutings",
"flexBom", "productionOrders", "workOrders",
)
# 数量对账类规则的数据源字段(存在即计入 quantityDelta)
_QUANTITY_FIELDS = ("quantity", "stock")
def _canonical(item: Any) -> str:
"""条目的规范 JSON(排序键 + 紧凑分隔符),modified 判定与指纹共用口径。"""
return json.dumps(item, ensure_ascii=True, sort_keys=True,
separators=(",", ":"), default=str)
def _entry_key(item: dict) -> tuple:
"""条目身份键:数值 id 优先,其次 orderNo/code,最后整条 canonical(防无键表)。"""
if isinstance(item, dict):
if isinstance(item.get("id"), int):
return ("id", item["id"])
if item.get("orderNo"):
return ("orderNo", str(item["orderNo"]))
if item.get("code"):
return ("code", str(item["code"]))
return ("json", _canonical(item))
def _quantity_of(item: dict) -> float:
total = 0.0
for field in _QUANTITY_FIELDS:
value = item.get(field) if isinstance(item, dict) else None
if isinstance(value, (int, float)):
total += float(value)
return total
def world_diff(before: dict, after: dict) -> dict:
"""分表 diff:{table: {"added","removed","modified","quantityDelta"}}。
modified 判定 = 同身份键条目内容变化(canonical JSON 不等);
quantityDelta 对含 quantity/stock 字段的条目求和(added 减 removed)。
"""
diff: dict[str, dict] = {}
for table in _DIFF_TABLES:
before_rows = before.get(table) or []
after_rows = after.get(table) or []
before_map = {_entry_key(r): r for r in before_rows if isinstance(r, dict)}
after_map = {_entry_key(r): r for r in after_rows if isinstance(r, dict)}
added_keys = [k for k in after_map if k not in before_map]
removed_keys = [k for k in before_map if k not in after_map]
modified = sum(
1 for k in before_map.keys() & after_map.keys()
if _canonical(before_map[k]) != _canonical(after_map[k])
)
qty_delta = (
sum(_quantity_of(after_map[k]) for k in added_keys)
- sum(_quantity_of(before_map[k]) for k in removed_keys)
)
if added_keys or removed_keys or modified:
diff[table] = {
"added": len(added_keys),
"removed": len(removed_keys),
"modified": modified,
"quantityDelta": qty_delta,
}
return diff
def check_expectations(plan: dict, diff: dict) -> list[dict]:
"""逐条比对计划 expected(结构化字段)与实际 diff。
返回 [{"step","expect","actual","ok"} ...];容差 = 0——任一声明字段不等即 ok=False,
调用方据此把报告 verdict 判为 MISMATCH(显式,不圆场)。
"""
checks: list[dict] = []
for step in plan.get("steps") or []:
seq = step.get("seq")
for expect in step.get("expected") or []:
if not isinstance(expect, dict) or not expect.get("table"):
continue
table = str(expect["table"])
actual = (diff.get(table) or {}).copy()
actual.setdefault("added", 0)
actual.setdefault("removed", 0)
actual.setdefault("modified", 0)
ok = True
for field in ("added", "removed", "modified"):
if field in expect and expect[field] is not None \
and int(expect[field]) != int(actual.get(field) or 0):
ok = False
checks.append({
"step": seq,
"expect": {k: expect[k] for k in ("table", "added", "removed", "modified")
if k in expect},
"actual": {"table": table,
**{k: actual.get(k, 0) for k in ("added", "removed", "modified")}},
"ok": ok,
})
return checks
def diff_summary_lines(diff: dict) -> list[str]:
"""分表 diff 的人类可读摘要行(回复文案与报告共用)。"""
lines = []
for table, d in diff.items():
parts = []
if d["added"]:
parts.append(f"+{d['added']}")
if d["removed"]:
parts.append(f"-{d['removed']}")
if d["modified"]:
parts.append(f"~{d['modified']}")
line = f"{table} {'/'.join(parts)}"
if d.get("quantityDelta"):
line += f"(数量净变化 {d['quantityDelta']:g})"
lines.append(line)
return lines or ["(无业务主数据变化)"]
def build_report(run_dir: Path, plan: dict, *,
cp_before_id: str, cp_after_id: str,
before_world: dict, after_world: dict,
checks: list[dict]) -> Path:
"""生成 outbox/verify-report.md:计划摘要 / 分表 diff 表 / 对账结论 / 证据引用。
数字来源 = 两个冻结快照的 world(调用方保证传入的是 checkpoint 仓内深拷贝),
返回报告路径。verdict:全部对账通过 = PASS;任一不符 = MISMATCH(显式标注)。
"""
diff = world_diff(before_world, after_world)
verdict = "PASS" if all(c["ok"] for c in checks) else "MISMATCH"
lines = [
"# 兜底执行验证报告",
"",
f"- 运行:{plan.get('runId') or ''}",
f"- 场景:{plan.get('scenario') or ''} · 步骤数 {len(plan.get('steps') or [])}",
f"- 检查点:执行前 `{cp_before_id}` → 执行后 `{cp_after_id}`",
"",
"## 分表 diff(before → after)",
"",
"| 表 | 新增 | 移除 | 修改 | 数量净变化 |",
"|----|------|------|------|-----------|",
]
for table in _DIFF_TABLES:
d = diff.get(table)
if not d:
continue
lines.append(f"| {table} | {d['added']} | {d['removed']} | {d['modified']} "
f"| {d['quantityDelta']:g} |")
if not diff:
lines.append("| (无变化) | 0 | 0 | 0 | 0 |")
lines += ["", "## 对账结论", ""]
if checks:
for c in checks:
mark = "✅" if c["ok"] else "❌"
lines.append(f"- {mark} 步骤{c['step']} 期望 {json.dumps(c['expect'], ensure_ascii=False)}"
f" · 实际 {json.dumps(c['actual'], ensure_ascii=False)}")
else:
lines.append("- (计划未声明结构化预期,仅呈现实际 diff)")
lines += [
"",
f"**verdict: {verdict}**",
"",
f"本报告全部数字来自检查点 {cp_before_id} 与 {cp_after_id} 的冻结快照。",
]
out = Path(run_dir) / "outbox" / "verify-report.md"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text("\n".join(lines) + "\n", encoding="utf-8")
return out
def report_fingerprint(text: str) -> str:
"""报告文本 sha256(审计 rationale 引用用,不落全文)。"""
return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]