# ============================================================================= # Projet : AIR — Accounting Intermediate Representation # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : approval.py # Description : Human approval queue — file-backed inbox inside the AIR home. # ============================================================================= """Approval queue: the human-in-the-loop stage. Low-confidence or schema-invalid extractions land here instead of the ledger. The queue lives inside the AIR home: /inbox/pending/.json /inbox/approved/.json /inbox/rejected/.json Approving stamps the approver + timestamp into every event's meta (full traceability: who let this into the books) and returns the AirDocument ready for compilation. Nothing is ever deleted — items move between folders. """ from __future__ import annotations import json from dataclasses import dataclass from datetime import datetime from pathlib import Path from typing import Any from core.events import AirDocument from ingestion.pipeline import IngestionOutcome _AUTHOR = "Simon-Pierre Boucher " @dataclass(frozen=True, slots=True) class InboxItem: id: str status: str # pending | approved | rejected confidence: str reasons: list[str] validation_errors: list[str] events: list[dict[str, Any]] source_excerpt: str class ApprovalQueue: def __init__(self, home: str | Path): self.root = Path(home) / "inbox" for status in ("pending", "approved", "rejected"): (self.root / status).mkdir(parents=True, exist_ok=True) # -- intake ------------------------------------------------------------------ def submit(self, outcome: IngestionOutcome, source_text: str) -> str: """File a needs-review outcome into the pending inbox. Returns item id.""" events = ( [json.loads(e.model_dump_json(exclude_none=True)) for e in outcome.document.events] if outcome.document is not None else outcome.extraction.events ) item_id = f"inbox_{len(list(self.root.rglob('*.json'))):05d}" payload = { "_author": _AUTHOR, "id": item_id, "status": "pending", "confidence": str(outcome.extraction.confidence), "extractor": outcome.extraction.extractor, "reasons": outcome.reasons, "validation_errors": outcome.validation_errors, "events": events, "source_excerpt": source_text[:2000], } (self.root / "pending" / f"{item_id}.json").write_text( json.dumps(payload, indent=2, default=str), encoding="utf-8" ) return item_id # -- inspection --------------------------------------------------------------- def _load(self, status: str) -> list[InboxItem]: items = [] for path in sorted((self.root / status).glob("*.json")): data = json.loads(path.read_text(encoding="utf-8")) items.append(InboxItem( id=data["id"], status=data["status"], confidence=data["confidence"], reasons=data.get("reasons", []), validation_errors=data.get("validation_errors", []), events=data.get("events", []), source_excerpt=data.get("source_excerpt", ""), )) return items def pending(self) -> list[InboxItem]: return self._load("pending") # -- decisions ---------------------------------------------------------------- def _move(self, item_id: str, new_status: str, mutate: dict[str, Any]) -> dict[str, Any]: source = self.root / "pending" / f"{item_id}.json" if not source.exists(): raise KeyError(f"no pending inbox item '{item_id}'") data = json.loads(source.read_text(encoding="utf-8")) data["status"] = new_status data.update(mutate) target = self.root / new_status / f"{item_id}.json" target.write_text(json.dumps(data, indent=2, default=str), encoding="utf-8") source.unlink() return data def approve(self, item_id: str, approver: str, approved_at: datetime | None = None) -> AirDocument: """Human approval: stamp approver + timestamp, return the document. The returned document still goes through the deterministic compiler — approval authorizes compilation, it never writes to the ledger itself. """ when = approved_at.isoformat() if approved_at else None data = self._move(item_id, "approved", {"approver": approver, "approved_at": when}) events = [] for event in data["events"]: meta = dict(event.get("meta") or {}) meta["approver"] = approver timestamps = dict(meta.get("timestamps") or {}) if when: timestamps["approved"] = when if timestamps: meta["timestamps"] = timestamps events.append({**event, "meta": meta}) return AirDocument.model_validate({"events": events}) def reject(self, item_id: str, reason: str = "") -> None: self._move(item_id, "rejected", {"rejection_reason": reason})