spb/air Public MIT
AIR — The Language of Accounting.
Python 100%
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