Files
erp/.claude/skills/dolibarr-sandbox-write/replay-packs/2026-07-11-books-regularization/verify-provenance.py
T
arcodangeandClaude Fable 5 ccc8b7912f chore(write-skill): preserve the 2026-07-11 books-regularization replay pack
The pack (manifests, prelude, runbook, verify-provenance.py PoC) lived
only in an ephemeral session scratchpad while erp#41/#42/#43/#44 now
reference it as fixtures and the prod replay is still pending. 36/36
provenance checks were green at rehearsal time; PDFs are re-fetchable
via arcodange-email-ingest (documented in the pack README).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-12 14:05:01 +02:00

120 lines
5.4 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Provenance check — every critical value in the write pack must be
mechanically corroborated by (a) the source PDF text and/or (b) a FRESH
bank-feed pull. No value is trusted because an LLM typed it.
Exit 0 = all green; any FAIL = exit 1."""
import json, re, subprocess, sys, os
SCRATCH = os.path.dirname(os.path.abspath(__file__))
PDF = os.path.join(SCRATCH, "pdfs")
ERP = os.path.expanduser("~/Work/Arcodange/erp")
BC = os.path.join(ERP, ".claude/skills/arcodange-bank-reco/scripts/bank-curl.sh")
results = []
def check(name, ok, detail=""):
results.append((name, ok, detail))
def pdftext(fname):
p = subprocess.run(["pdftotext", "-layout", os.path.join(PDF, fname), "-"],
capture_output=True, text=True)
return p.stdout
def norm(t): # 1 234,56 / 1,234.56 / 219.50 -> comparable tokens
return t.replace(" ", " ").replace(",", ".")
def has_amount(text, amount):
# match 219.50 or 219,50, tolerant to thousand separators/spacing
a = f"{amount:.2f}"
pat = a.replace(".", r"[.,]")
return re.search(pat, norm(text)) is not None
# ---------- (a) PDF-anchored facts ----------
DOCS = {
"F1045_ARCODANGE_2026-06-30.pdf": {
"ref": "F1045", "date_str": "30/06/2026",
"amounts": [214.70, 42.94, 257.64],
"line_hint": "Apport d'affaire"},
"F1046_ARCODANGE_2026-06-29.pdf": {
"ref": "F1046", "date_str": "29/06/2026",
"amounts": [219.50, 43.90, 263.40],
"line_hint": "Apport d'affaire"},
"Invoice-9BF0758D-695749.pdf": {
"ref": "9BF0758D-695749", "date_str": "April 12, 2026",
"amounts": [180.00],
"line_hint": "Claude Pro"},
"invoice-MSTRL-API-814045-001.pdf": {
"ref": "MSTRL-API-814045-001", "date_str": "2 avr. 2026",
"amounts": [143.90, 28.78, 172.68],
"line_hint": "Le Chat Pro"},
}
for fname, exp in DOCS.items():
t = pdftext(fname)
if not t.strip():
check(f"{fname}: text layer", False, "empty pdftotext"); continue
check(f"{fname}: ref {exp['ref']}", exp["ref"] in t)
check(f"{fname}: date {exp['date_str']}", exp["date_str"] in t)
for a in exp["amounts"]:
check(f"{fname}: amount {a:.2f}", has_amount(t, a))
check(f"{fname}: line '{exp['line_hint']}'", exp["line_hint"] in t)
# ---------- (b) fresh bank-feed corroboration ----------
def bank(pathsvc, path):
p = subprocess.run([BC, pathsvc, path], capture_output=True, text=True)
return json.loads(p.stdout) if p.returncode == 0 and p.stdout.strip() else {}
org = bank("qonto", "/v2/organization")
acct = next(a["id"] for a in org["organization"]["bank_accounts"] if a["status"] == "active")
qtx = []
for window in [("2026-04-01", "2026-04-15"), ("2026-06-28", "2026-06-30")]:
d = bank("qonto", f"/v2/transactions?bank_account_id={acct}&settled_at_from={window[0]}T00:00:00Z&settled_at_to={window[1]}T23:59:59Z&per_page=100&current_page=1")
qtx += d.get("transactions", [])
qonto_feed = {(t["transaction_id"].split("transaction-")[-1]): (float(t["amount"]), t["side"]) for t in qtx}
envf = os.path.join(ERP, ".claude/skills/dolibarr/.env")
wpid = ""
for line in open(envf):
if line.startswith("WISE_PROFILE_ID"):
wpid = line.split("=", 1)[1].strip().strip('"')
wact = bank("wise", f"/v1/profiles/{wpid}/activities?since=2026-05-25T00:00:00.000Z&until=2026-06-30T23:59:59.000Z")
wise_feed = {}
for a in wact.get("activities", []):
if a.get("type") == "TRANSFER":
m = re.search(r"([\d,]+(?:\.\d{1,2})?)", a.get("primaryAmount", ""))
if not m:
continue
amt = float(m.group(1).replace(",", "")) # "2,195.97"->2195.97 ; "2,147"->2147.0 (comma = thousands sep)
wise_feed[str((a.get("resource") or {}).get("id", ""))] = amt
manA = json.load(open(os.path.join(SCRATCH, "manifest-A-km-payments.json")))
manB = json.load(open(os.path.join(SCRATCH, "manifest-B-suppliers.json")))
EXPECT_A = {"2159468139": 2147.00, "2210434850": 2195.97} # full wire amounts (invoice pays remaining 2145.92; delta = FX, booked separately)
for op in manA:
tx = op["input"]["transaction_id"]
check(f"manifest A: Wise tx {tx} exists in fresh feed", tx in wise_feed,
f"feed={wise_feed.get(tx)}")
if tx in wise_feed:
check(f"manifest A: Wise tx {tx} amount == feed", abs(wise_feed[tx] - EXPECT_A[tx]) < 0.005,
f"feed {wise_feed[tx]} vs expected wire {EXPECT_A[tx]}")
for op in manB:
if op["op"] != "payment":
# invoice op: amounts corroborated by the F1045 PDF above
li = op["input"]["lines"][0]
check("manifest B: F1045 line HT in PDF", has_amount(pdftext("F1045_ARCODANGE_2026-06-30.pdf"), float(li["price_ht"])))
check("manifest B: F1045 ref_supplier matches PDF ref", op["input"]["ref_supplier"] == "F1045")
continue
tx = op["input"]["transaction_id"]; amt = float(op["input"]["amount"])
check(f"manifest B: Qonto tx …{tx[-12:]} exists in fresh feed", tx in qonto_feed, f"known={list(qonto_feed)[:2]}…")
if tx in qonto_feed:
famt, side = qonto_feed[tx]
check(f"manifest B: tx …{tx[-12:]} amount {amt:.2f} == feed", abs(famt - amt) < 0.005 and side == "debit",
f"feed {famt} {side}")
# ---------- report ----------
w = max(len(n) for n, _, _ in results)
fails = 0
for n, ok, det in results:
print(f"{'PASS' if ok else 'FAIL':4} {n:<{w}} {det if not ok else ''}")
fails += 0 if ok else 1
print(f"\n{len(results)-fails}/{len(results)} checks green")
sys.exit(1 if fails else 0)