# ============================================================================= # Projet : AIR — Accounting Intermediate Representation # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : pipeline.py # Description : Ingestion pipeline — extract, validate against the AIR schema, route by confidence. # ============================================================================= """Ingestion pipeline. source text -> Extractor -> schema validation -> routing | | (Pydantic) auto-approve OR human inbox Routing rules (docs/research/llm-structured-extraction.md): - schema-invalid extraction -> ALWAYS to the human inbox (with the errors) - confidence < threshold -> human inbox - confidence >= threshold -> auto-approved, ready to compile The threshold is configurable per home; the default is deliberately conservative. The LLM's output NEVER goes to the ledger directly — even auto-approved documents go through the deterministic compiler. """ from __future__ import annotations import enum from dataclasses import dataclass, field from datetime import datetime from decimal import Decimal from typing import Any from pydantic import ValidationError from core.events import AirDocument from ingestion.extractor import ExtractionResult, Extractor DEFAULT_CONFIDENCE_THRESHOLD = Decimal("0.85") class Route(str, enum.Enum): AUTO_APPROVED = "auto_approved" NEEDS_REVIEW = "needs_review" @dataclass class IngestionOutcome: route: Route extraction: ExtractionResult document: AirDocument | None = None # set when schema-valid validation_errors: list[str] = field(default_factory=list) reasons: list[str] = field(default_factory=list) def _stamp_meta( events: list[dict[str, Any]], extraction: ExtractionResult, ingested_at: datetime | None, ) -> list[dict[str, Any]]: """Attach traceability metadata to every extracted event.""" stamped = [] for event in events: meta = dict(event.get("meta") or {}) meta["llm"] = { "model": extraction.extractor, "confidence": str(extraction.confidence), } if ingested_at is not None: meta["timestamps"] = {"ingested": ingested_at.isoformat()} stamped.append({**event, "meta": meta}) return stamped def ingest( text: str, extractor: Extractor, threshold: Decimal = DEFAULT_CONFIDENCE_THRESHOLD, ingested_at: datetime | None = None, ) -> IngestionOutcome: """Run one document through extract -> validate -> route.""" extraction = extractor.extract(text) outcome = IngestionOutcome(route=Route.NEEDS_REVIEW, extraction=extraction) events = _stamp_meta(extraction.events, extraction, ingested_at) try: outcome.document = AirDocument.model_validate({"events": events}) except ValidationError as exc: outcome.validation_errors = [ f"{'.'.join(str(p) for p in err['loc'])}: {err['msg']}" for err in exc.errors() ] outcome.reasons.append( "extraction does not validate against the AIR schema" ) return outcome # schema-invalid ALWAYS goes to a human if extraction.confidence < threshold: outcome.reasons.append( f"confidence {extraction.confidence} below threshold {threshold}" ) return outcome outcome.route = Route.AUTO_APPROVED return outcome