SPB Git

spb/air Public MIT

AIR — The Language of Accounting.

Python 100%
9.2 KB · 234 lines python
Raw Blame History
1# =============================================================================2# Projet : AIR — Accounting Intermediate Representation3# Auteur : Simon-Pierre Boucher4# Contact : contact@spboucher.ai5# Fichier : extractor.py6# Description : Extractors — source text to AIR event data. Mock (offline) and Claude (optional).7# =============================================================================8"""Extractors: understanding, separated from rule application.910An extractor reads source text (an invoice, an email, OCR output) and11produces candidate AIR event data plus a confidence score. It NEVER produces12journal entries — that is the deterministic compiler's job (CLAUDE.md core13principle).1415Two implementations:16- MockExtractor (default for tests/offline): deterministic parser for a17  simple key:value fixture format. No network, no API key, ever.18- ClaudeExtractor (optional): Claude API with schema-constrained structured19  output. Only constructed when the user supplies/holds an Anthropic API key;20  nothing in the test suite touches it.2122Design follows docs/research/llm-structured-extraction.md: schema-validated23output, confidence scoring, low confidence routes to human approval.24"""25from __future__ import annotations2627import abc28import json29from dataclasses import dataclass, field30from decimal import Decimal, InvalidOperation31from typing import Any323334class ExtractionError(RuntimeError):35    pass363738@dataclass(frozen=True, slots=True)39class ExtractionResult:40    """Candidate AIR data + how sure the extractor is about it."""4142    events: list[dict[str, Any]]        # candidate EconomicEvent payloads43    confidence: Decimal                 # 0..1, per-document44    extractor: str                      # e.g. "mock", "claude:claude-opus-5"45    notes: str = ""464748class Extractor(abc.ABC):49    @abc.abstractmethod50    def extract(self, text: str) -> ExtractionResult: ...515253# --- Mock extractor (offline, deterministic) -----------------------------------54class MockExtractor(Extractor):55    """Parses the AIR fixture format — deterministic, no network.5657    Format (one event per block, blank-line separated):5859        type: Sale60        id: evt_x61        date: 2026-07-1562        amount: 1000.00 CAD63        jurisdiction: CA-QC64        confidence: 0.956566    Unknown or malformed blocks lower confidence instead of crashing —67    mirroring how a real extractor degrades on noisy documents.68    """6970    def extract(self, text: str) -> ExtractionResult:71        events: list[dict[str, Any]] = []72        confidences: list[Decimal] = []73        blocks = [b for b in text.split("\n\n") if b.strip()]74        for i, block in enumerate(blocks):75            fields: dict[str, str] = {}76            for line in block.splitlines():77                line = line.strip()78                if not line or line.startswith("#") or ":" not in line:79                    continue80                key, _, value = line.partition(":")81                fields[key.strip().lower()] = value.strip()82            if "type" not in fields:83                continue84            event: dict[str, Any] = {85                "id": fields.get("id", f"evt_extracted_{i:03d}"),86                "type": fields["type"],87                "date": fields.get("date", ""),88            }89            if "description" in fields:90                event["description"] = fields["description"]91            if "amount" in fields:92                parts = fields["amount"].split()93                event["amount"] = {94                    "amount": parts[0],95                    "currency": parts[1] if len(parts) > 1 else "CAD",96                }97            if "jurisdiction" in fields:98                event["tax"] = {"jurisdiction": fields["jurisdiction"]}99                if fields.get("exempt", "").lower() == "true":100                    event["tax"]["exempt"] = True101            if "related_event" in fields:102                event["related_event"] = fields["related_event"]103            if "immediate" in fields:104                event["payment"] = {105                    "immediate": fields["immediate"].lower() == "true"106                }107            events.append(event)108            try:109                confidences.append(Decimal(fields.get("confidence", "0.9")))110            except InvalidOperation:111                confidences.append(Decimal("0.5"))112        if not events:113            raise ExtractionError("mock extractor found no events in the source text")114        return ExtractionResult(115            events=events,116            confidence=min(confidences),117            extractor="mock",118        )119120121# --- Claude extractor (optional — requires an Anthropic API key) --------------------122EXTRACTION_SCHEMA: dict[str, Any] = {123    "type": "object",124    "properties": {125        "events": {126            "type": "array",127            "items": {128                "type": "object",129                "properties": {130                    "id": {"type": "string"},131                    "type": {"type": "string", "enum": [132                        "Sale", "Purchase", "Refund", "PaymentReceived",133                        "PaymentSent", "OwnerContribution", "LoanReceived",134                    ]},135                    "date": {"type": "string", "description": "ISO 8601 date"},136                    "description": {"type": "string"},137                    "amount": {138                        "type": "object",139                        "properties": {140                            "amount": {"type": "string",141                                       "description": "decimal as string, never a float"},142                            "currency": {"type": "string"},143                        },144                        "required": ["amount", "currency"],145                        "additionalProperties": False,146                    },147                    "jurisdiction": {"type": "string"},148                    "exempt": {"type": "boolean"},149                    "related_event": {"type": "string"},150                    "immediate_payment": {"type": "boolean"},151                },152                "required": ["id", "type", "date", "amount"],153                "additionalProperties": False,154            },155        },156        "confidence": {157            "type": "string",158            "description": "overall extraction confidence 0..1 as a decimal string",159        },160        "notes": {"type": "string"},161    },162    "required": ["events", "confidence"],163    "additionalProperties": False,164}165166SYSTEM_PROMPT = """You extract economic events from business documents into AIR \167(Accounting Intermediate Representation).168169Rules:170- You describe WHAT HAPPENED economically. You NEVER produce journal entries, \171account codes, or debits/credits — a deterministic compiler applies those rules.172- All amounts and rates are decimal STRINGS ("19.99"), never numbers.173- Do not compute taxes; report the pre-tax amount and the jurisdiction.174- Report an honest overall confidence (0..1). If any field is uncertain or \175illegible, lower it — low-confidence extractions are reviewed by a human."""176177178class ClaudeExtractor(Extractor):179    """LLM extraction via the Claude API (structured outputs).180181    OPTIONAL: requires the `anthropic` package and an API key (resolved by the182    SDK from the environment). Never used in tests — MockExtractor is the183    offline default.184    """185186    def __init__(self, model: str = "claude-opus-5"):187        try:188            import anthropic189        except ImportError as exc:  # pragma: no cover190            raise ExtractionError(191                "the 'anthropic' package is required for LLM extraction: "192                "pip install anthropic — or use the mock extractor (--mock)"193            ) from exc194        self._client = anthropic.Anthropic()195        self.model = model196197    def extract(self, text: str) -> ExtractionResult:  # pragma: no cover198        response = self._client.messages.create(199            model=self.model,200            max_tokens=16000,201            system=SYSTEM_PROMPT,202            output_config={"format": {"type": "json_schema",203                                      "schema": EXTRACTION_SCHEMA}},204            messages=[{"role": "user", "content":205                       f"Extract the economic events from this document:\n\n{text}"}],206        )207        if response.stop_reason == "refusal":208            raise ExtractionError("the model declined this document (refusal)")209        payload = json.loads(210            next(b.text for b in response.content if b.type == "text")211        )212        events: list[dict[str, Any]] = []213        for raw in payload["events"]:214            event: dict[str, Any] = {215                "id": raw["id"], "type": raw["type"], "date": raw["date"],216                "amount": raw["amount"],217            }218            if raw.get("description"):219                event["description"] = raw["description"]220            if raw.get("jurisdiction"):221                event["tax"] = {"jurisdiction": raw["jurisdiction"],222                                "exempt": bool(raw.get("exempt"))}223            if raw.get("related_event"):224                event["related_event"] = raw["related_event"]225            if raw.get("immediate_payment"):226                event["payment"] = {"immediate": True}227            events.append(event)228        return ExtractionResult(229            events=events,230            confidence=Decimal(payload["confidence"]),231            extractor=f"claude:{self.model}",232            notes=payload.get("notes", ""),233        )234