Estrarre Dati da PDF con l’AI: Fatture, Contratti e Documenti

Estrarre Dati da PDF con l’AI: Fatture, Contratti e Documenti

Perché estrarre dati da PDF con l’AI nel 2026

Il PDF è ancora, nel 2026, il formato predominante per qualsiasi documento aziendale che conti: fatture di fornitori, contratti di servizio, ordini di acquisto, estratti conto, bolle di consegna. Il problema è sempre lo stesso: il PDF è pensato per la lettura umana, non per la lettura macchina. Estrarne i dati con metodi tradizionali—regex su testo estratto con pdftotext, OCR classico, parser rule-based—richiede settimane di sviluppo per ogni nuovo formato e crolla al primo cambio di layout.

L’AI cambia le regole del gioco in modo radicale. Modelli come Claude sono in grado di capire la semantica del documento, non solo la sua struttura visiva. Puoi dargli una fattura mai vista prima e chiedere “dammi importo totale, fornitore, data e righe dettaglio in JSON”: lo farà correttamente, anche se il layout è diverso da qualsiasi documento che hai mai elaborato.

In questo articolo costruiamo un pipeline completo in Python: leggi il PDF, lo mandi a Claude API con le istruzioni di estrazione, ricevi JSON strutturato e lo salvi. Vedremo esempi su fatture, contratti e documenti generici. Il codice è pronto per la produzione e scalabile a centinaia di documenti al giorno. Se stai già usando l’AI per automatizzare altri processi, come descritto nella guida su AI per la contabilità, questo è il tassello tecnico che ti mancava.

Setup: librerie Python e Claude API

Prima di scrivere codice, installiamo le dipendenze necessarie. Useremo anthropic per le chiamate all’API, pymupdf (alias fitz) per estrarre il testo dai PDF, e pydantic per validare e tipizzare il JSON di output.

pip install anthropic pymupdf pydantic python-dotenv

Crea un file .env con la tua chiave API (non scrivere mai le credenziali direttamente nel codice):

ANTHROPIC_API_KEY=sk-ant-api03-...

La struttura del progetto è semplice:

pdf-extractor/
  .env
  extractor.py        # logica principale
  models.py           # Pydantic models per output strutturato
  prompts.py          # prompt templates per tipo documento
  sample_invoice.pdf  # file di test

Estrarre testo da PDF con PyMuPDF

Il primo step è trasformare il PDF in testo che Claude possa processare. PyMuPDF (importato come fitz) è la libreria più affidabile: gestisce PDF scansionati con OCR integrato, mantiene l’ordine di lettura, e è molto più veloce di alternative come pdfminer.

import fitz  # PyMuPDF
from pathlib import Path

def extract_text_from_pdf(pdf_path: str) -> str:
    # Estrae testo da un PDF mantenendo la struttura delle pagine.
    # Gestisce sia PDF nativi che scansionati.
    doc = fitz.open(pdf_path)
    pages_text = []

    for page_num, page in enumerate(doc):
        # Estrai testo con ordinamento naturale (left-to-right, top-to-bottom)
        text = page.get_text("text", sort=True)

        if text.strip():
            pages_text.append(f"--- PAGINA {page_num + 1} ---\n{text}")
        else:
            # Pagina vuota o scansionata: usa OCR integrato di PyMuPDF
            tp = page.get_textpage_ocr(flags=3, dpi=300, full=True)
            text_ocr = page.get_text("text", textpage=tp, sort=True)
            pages_text.append(f"--- PAGINA {page_num + 1} (OCR) ---\n{text_ocr}")

    doc.close()
    full_text = "\n\n".join(pages_text)

    # Limita a ~15.000 caratteri per non saturare la context window
    if len(full_text) > 15000:
        full_text = full_text[:15000] + "\n\n[...documento troncato per lunghezza...]"

    return full_text


# Test rapido
if __name__ == "__main__":
    text = extract_text_from_pdf("sample_invoice.pdf")
    print(text[:500])

Il parametro sort=True è fondamentale: senza di esso, PyMuPDF restituisce il testo nell’ordine in cui gli elementi sono stati disegnati nel PDF (spesso caotico), non nell’ordine visivo. La soglia dei 15.000 caratteri è un buon bilanciamento tra completezza e costo token—approfondiremo la gestione dei costi nella sezione dedicata.

Estrazione strutturata di fatture con Claude API

Questa è la parte centrale del progetto. La tecnica chiave è il tool use di Claude: invece di chiedere all’AI di restituire JSON in formato libero (che richiederebbe parsing fragile), definiamo uno schema preciso e Claude lo compila direttamente. È lo stesso approccio descritto nella guida su Tool Use con Claude API, applicato all’estrazione documenti.

import anthropic
import json
from typing import Optional
from pydantic import BaseModel, Field
from dotenv import load_dotenv

load_dotenv()
client = anthropic.Anthropic()

# Schema Pydantic per la fattura
class InvoiceLine(BaseModel):
    description: str
    quantity: Optional[float] = None
    unit_price: Optional[float] = None
    total: float

class Invoice(BaseModel):
    invoice_number: str
    invoice_date: str  # formato ISO: YYYY-MM-DD
    due_date: Optional[str] = None
    supplier_name: str
    supplier_vat: Optional[str] = None
    supplier_address: Optional[str] = None
    customer_name: str
    customer_vat: Optional[str] = None
    lines: list[InvoiceLine]
    subtotal: Optional[float] = None
    vat_amount: Optional[float] = None
    vat_rate: Optional[float] = None  # es. 22.0 per 22%
    total_amount: float
    currency: str = "EUR"
    payment_method: Optional[str] = None
    iban: Optional[str] = None

# Tool definition per Claude
INVOICE_TOOL = {
    "name": "extract_invoice_data",
    "description": "Estrai i dati strutturati da una fattura italiana o europea.",
    "input_schema": {
        "type": "object",
        "properties": {
            "invoice_number":   {"type": "string", "description": "Numero fattura"},
            "invoice_date":     {"type": "string", "description": "Data fattura YYYY-MM-DD"},
            "supplier_name":    {"type": "string", "description": "Nome fornitore"},
            "customer_name":    {"type": "string", "description": "Nome cliente"},
            "lines": {
                "type": "array",
                "items": {
                    "type": "object",
                    "properties": {
                        "description": {"type": "string"},
                        "quantity":    {"type": "number"},
                        "unit_price":  {"type": "number"},
                        "total":       {"type": "number"}
                    },
                    "required": ["description", "total"]
                }
            },
            "subtotal":     {"type": "number"},
            "vat_amount":   {"type": "number"},
            "vat_rate":     {"type": "number"},
            "total_amount": {"type": "number"},
            "currency":     {"type": "string", "default": "EUR"},
            "iban":         {"type": "string"}
        },
        "required": ["invoice_number", "invoice_date", "supplier_name", "customer_name", "lines", "total_amount"]
    }
}

def extract_invoice(pdf_path: str) -> Invoice:
    # Estrae dati strutturati da una fattura PDF.
    pdf_text = extract_text_from_pdf(pdf_path)

    response = client.messages.create(
        model="claude-opus-4-5",
        max_tokens=2048,
        tools=[INVOICE_TOOL],
        tool_choice={"type": "tool", "name": "extract_invoice_data"},
        messages=[{
            "role": "user",
            "content": f"Analizza questa fattura ed estrai tutti i dati:\n\nTESTO FATTURA:\n{pdf_text}"
        }]
    )

    for block in response.content:
        if block.type == "tool_use" and block.name == "extract_invoice_data":
            return Invoice(**block.input)

    raise ValueError("Claude non ha restituito dati strutturati")

# Uso
invoice = extract_invoice("fattura_fornitore.pdf")
print(json.dumps(invoice.model_dump(), indent=2, ensure_ascii=False))

Il parametro tool_choice={"type": "tool", "name": "extract_invoice_data"} è fondamentale: forza Claude a usare il tool specificato invece di rispondere in formato libero. Questo garantisce che l’output sia sempre JSON valido secondo lo schema definito. La tecnica del forced tool use è l’approccio più affidabile per l’estrazione strutturata da documenti.

Gestire contratti e documenti generici

I contratti hanno una struttura molto più variabile delle fatture. Qui la strategia cambia: invece di uno schema fisso, definiamo un schema flessibile con sezioni dinamiche. L’AI identifica autonomamente le sezioni rilevanti del contratto e le mappa in un dizionario chiave-valore.

Questo approccio si sposa perfettamente con pipeline RAG più complesse—una volta estratti i dati strutturati, puoi indicizzarli in un vector store come descritto nella guida su RAG con Claude API.

CONTRACT_TOOL = {
    "name": "extract_contract_data",
    "description": "Estrai i dati chiave da un contratto.",
    "input_schema": {
        "type": "object",
        "properties": {
            "contract_type": {"type": "string", "description": "Tipo (Fornitura, Consulenza, NDA, Affitto)"},
            "signing_date":  {"type": "string", "description": "Data firma YYYY-MM-DD"},
            "start_date":    {"type": "string", "description": "Data inizio YYYY-MM-DD"},
            "end_date":      {"type": "string", "description": "Data fine YYYY-MM-DD"},
            "parties": {
                "type": "array",
                "items": {
                    "type": "object",
                    "properties": {
                        "role":    {"type": "string"},
                        "name":    {"type": "string"},
                        "vat":     {"type": "string"},
                        "address": {"type": "string"}
                    },
                    "required": ["role", "name"]
                }
            },
            "value":               {"type": "number"},
            "payment_terms":       {"type": "string"},
            "notice_period_days":  {"type": "integer"},
            "auto_renewal":        {"type": "boolean"},
            "key_obligations": {
                "type": "array",
                "items": {"type": "string"},
                "description": "Lista delle 3-5 obbligazioni principali"
            },
            "jurisdiction": {"type": "string", "description": "Foro competente"},
            "summary":      {"type": "string", "description": "Riassunto in 2-3 frasi"}
        },
        "required": ["contract_type", "parties", "summary"]
    }
}

def extract_contract(pdf_path: str) -> dict:
    pdf_text = extract_text_from_pdf(pdf_path)

    response = client.messages.create(
        model="claude-opus-4-5",
        max_tokens=2048,
        tools=[CONTRACT_TOOL],
        tool_choice={"type": "tool", "name": "extract_contract_data"},
        messages=[{
            "role": "user",
            "content": f"Analizza questo contratto ed estrai i dati chiave:\n\n{pdf_text}"
        }]
    )

    for block in response.content:
        if block.type == "tool_use":
            return block.input

    raise ValueError("Nessun dato estratto")


# Elaborazione batch di una cartella
from pathlib import Path
from datetime import date
import json

def process_folder(folder: str, doc_type: str = "invoice") -> list:
    results = []
    pdf_files = list(Path(folder).glob("*.pdf"))
    print(f"Trovati {len(pdf_files)} PDF da elaborare...")

    for i, pdf_path in enumerate(pdf_files):
        print(f"[{i+1}/{len(pdf_files)}] {pdf_path.name}", end=" ... ")
        try:
            if doc_type == "invoice":
                data = extract_invoice(str(pdf_path)).model_dump()
            else:
                data = extract_contract(str(pdf_path))

            data["_source_file"] = pdf_path.name
            data["_processed_at"] = date.today().isoformat()
            results.append({"status": "ok", "data": data})
            print("OK")
        except Exception as e:
            results.append({"status": "error", "file": pdf_path.name, "error": str(e)})
            print(f"ERRORE: {e}")

    return results

Ottimizzare costi e token con Prompt Caching

Se elabori decine o centinaia di PDF al giorno, il costo API diventa un fattore. La tecnica più efficace è il Prompt Caching di Claude: puoi marcare il system prompt (con le istruzioni di estrazione) come cacheable, risparmiando fino al 90% sui token di input ripetuti. È la stessa tecnica approfondita nella guida su Prompt Caching in Claude API.

Per l’estrazione documenti, il flusso ottimizzato prevede:

  • System prompt cacheable: contiene le istruzioni generali di estrazione e il contesto del dominio (es. “sei un esperto di fatturazione italiana”)
  • Tool definition cacheable: lo schema JSON cambia raramente, ha senso tenerlo in cache
  • User message non cacheable: il testo del PDF specifico, che cambia a ogni chiamata
def extract_invoice_cached(pdf_text: str) -> dict:
    # Versione con prompt caching per batch processing.
    response = client.messages.create(
        model="claude-opus-4-5",
        max_tokens=2048,
        system=[
            {
                "type": "text",
                "text": "Sei un esperto di contabilità e fatturazione italiana.\nEstraiSempre le date in formato ISO YYYY-MM-DD.\nImporti come numeri decimali (1234.56), senza simboli valuta.",
                "cache_control": {"type": "ephemeral"}  # Questo blocco viene cachato
            }
        ],
        tools=[INVOICE_TOOL],
        tool_choice={"type": "tool", "name": "extract_invoice_data"},
        messages=[{
            "role": "user",
            "content": f"Estrai i dati da questa fattura:\n\n{pdf_text}"
        }]
    )

    # Controlla cache hit nelle usage metrics
    usage = response.usage
    if hasattr(usage, 'cache_read_input_tokens') and usage.cache_read_input_tokens > 0:
        print(f"  [Cache HIT: {usage.cache_read_input_tokens} token letti dalla cache]")

    for block in response.content:
        if block.type == "tool_use":
            return block.input

    raise ValueError("Nessun dato estratto")

Con questa ottimizzazione, ogni chiamata successiva alla prima risparmia i token del system prompt e dei tool definitions—su batch di 100 fatture, il risparmio può arrivare al 60-70% del costo totale. Per un progetto che si intreccia con workflow di automazione più ampi, considera di integrare questo estrattore come nodo in n8n + Claude per automazioni email, triggerando l’estrazione automaticamente quando arriva una fattura in PDF via email.

Validazione, errori e affidabilità in produzione

Un sistema di estrazione PDF che gira in produzione deve gestire i casi anomali: PDF corrotti, scansioni di bassa qualità, documenti in lingue inaspettate, fatture con layout non standard. Ecco le strategie più efficaci.

Il confidence scoring è la tecnica più utile: chiedi all’AI di valutare la propria certezza per ogni campo estratto. I campi con bassa confidenza vengono flaggati per revisione umana invece di essere salvati direttamente nel database. Questo è particolarmente importante per use case legali o contabili, dove un errore di estrazione ha conseguenze reali. Se stai costruendo un sistema documentale più ampio, leggi anche la guida su come l’AI elimina il caos dei documenti in azienda.

import logging
from dataclasses import dataclass, field

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger(__name__)

@dataclass
class ExtractionResult:
    success: bool
    data: dict = field(default_factory=dict)
    errors: list = field(default_factory=list)
    warnings: list = field(default_factory=list)
    requires_review: bool = False
    confidence: float = 1.0  # 0.0 - 1.0

def safe_extract_invoice(pdf_path: str) -> ExtractionResult:
    result = ExtractionResult(success=False)

    if not Path(pdf_path).exists():
        result.errors.append(f"File non trovato: {pdf_path}")
        return result

    file_size_mb = Path(pdf_path).stat().st_size / (1024 * 1024)
    if file_size_mb > 50:
        result.warnings.append(f"File grande ({file_size_mb:.1f}MB): potrebbero perdersi dettagli")

    try:
        pdf_text = extract_text_from_pdf(pdf_path)

        if len(pdf_text.strip()) < 100:
            result.warnings.append("PDF con poco testo: possibile scansione di bassa qualità")
            result.requires_review = True

        data = extract_invoice_cached(pdf_text)
        result.data = data
        result.success = True

        # Validazioni business logic
        if data.get("total_amount", 0) <= 0:
            result.warnings.append("Importo totale zero o negativo")
            result.requires_review = True

        if not data.get("supplier_vat"):
            result.warnings.append("Partita IVA fornitore non trovata")

        # Cross-check: somma righe vs totale
        if "lines" in data and "subtotal" in data:
            lines_sum = sum(line.get("total", 0) for line in data["lines"])
            if abs(lines_sum - data["subtotal"]) > 0.10:
                result.warnings.append(
                    f"Discrepanza: somma righe={lines_sum:.2f}, subtotale={data['subtotal']:.2f}"
                )
                result.requires_review = True
                result.confidence = 0.7

        logger.info(f"Estratta fattura {data.get('invoice_number', 'N/A')} da {Path(pdf_path).name}")

    except Exception as e:
        result.errors.append(f"Errore: {str(e)}")
        logger.exception(f"Errore per {pdf_path}")

    return result


def run_pipeline(input_folder: str, output_file: str = "results.json"):
    results = {"ok": [], "review": [], "errors": []}

    for pdf_path in Path(input_folder).glob("*.pdf"):
        r = safe_extract_invoice(str(pdf_path))

        if not r.success:
            results["errors"].append({"file": pdf_path.name, "errors": r.errors})
        elif r.requires_review:
            results["review"].append({"file": pdf_path.name, "data": r.data, "warnings": r.warnings})
        else:
            results["ok"].append({"file": pdf_path.name, "data": r.data})

    with open(output_file, "w", encoding="utf-8") as f:
        json.dump(results, f, indent=2, ensure_ascii=False)

    print(f"Risultati: {len(results['ok'])} OK | {len(results['review'])} da revisionare | {len(results['errors'])} errori")

Questa pipeline è il punto di partenza ideale per un servizio B2B. I clienti business—studi commerciali, agenzie, aziende con alto volume documentale—sono disposti a pagare cifre significative per automatizzare questo processo. Come discusso nell’articolo su come alzare le tariffe da freelance, i progetti AI con impatto misurabile sul risparmio di tempo giustificano facilmente tariffe a 4 cifre.

FAQ e Domande Frequenti

Claude riesce a leggere PDF scansionati (immagini)?

Sì, con qualche accorgimento. PyMuPDF ha un modulo OCR integrato basato su Tesseract che converte le pagine immagine in testo prima di passarle a Claude. Per qualità OCR migliore, puoi anche convertire le pagine PDF in immagini ad alta risoluzione e usare la vision capability di Claude (Claude Opus e Sonnet supportano input di immagini): mandi direttamente il PNG della pagina, senza passare per OCR. Questo approccio è più lento e costoso ma più accurato su documenti con tabelle complesse o layout visivi non standard.

Quanto costa estrarre 1.000 fatture al mese?

Con Claude Opus 4.5, una fattura media (500-800 token di input) costa circa $0.015 senza caching. Su 1.000 fatture, siamo intorno a $15/mese—cifra trascurabile rispetto al costo del lavoro manuale. Usando Haiku (il modello più economico) per documenti semplici e Opus solo per quelli complessi, puoi scendere sotto $5/mese. Con prompt caching attivo, il costo si riduce ulteriormente del 60-70% perché system prompt e tool definitions vengono riusati tra le chiamate.

Come gestisco PDF con più di 50 pagine?

Per contratti o documenti lunghi, la strategia migliore è lo splitting semantico: invece di mandare l’intero documento, identifichi le sezioni rilevanti (es. la prima pagina per dati anagrafici, le clausole economiche per i valori) e le mandi separatamente. PyMuPDF permette di estrarre singole pagine o range di pagine. In alternativa, puoi fare un primo passaggio con un modello rapido (Haiku) che identifica le pagine rilevanti, e un secondo passaggio con Opus solo su quelle. Questa strategia riduce sia latenza che costi.

I dati estratti sono conformi al GDPR?

Le fatture contengono dati personali (nome, indirizzo, P.IVA delle persone fisiche), quindi ricadono sotto il GDPR. Anthropic dichiara che i dati inviati tramite API non vengono usati per training. Per massima sicurezza: usa la Data Residency EU se disponibile nel tuo piano, anonimizza i dati di test, documenta il processing nei tuoi registri DPA. Per ambienti ad alta compliance (banking, healthcare), considera soluzioni on-premise con modelli open-source come LLaMA 3 o Mixtral.

Conclusione

L’estrazione dati da PDF con l’AI non è più un progetto di ricerca: è una soluzione pronta per la produzione che puoi implementare in un pomeriggio. I vantaggi rispetto agli approcci tradizionali sono evidenti—zero manutenzione per ogni nuovo formato di fattura, accuratezza superiore anche su documenti di bassa qualità, output strutturato e validato da Pydantic.

I prossimi passi naturali: integra la pipeline con un database (Postgres via Drizzle ORM se sei full-stack TypeScript), costruisci una web UI per il team contabile, e valuta di esporre il servizio via API REST come micro-SaaS. Il mercato B2B per l’automazione documentale è enorme e quasi inesplorato da sviluppatori indie.

Suggerimenti e Risorse

🔧 Tool: usa pymupdf4llm (il wrapper ufficiale di PyMuPDF per LLM) per ottenere output in Markdown invece di testo plain: mantiene tabelle, bold e heading che aiutano Claude a capire meglio la struttura del documento.

💡 Pro tip: per documenti con formato fisso (es. fatture sempre dallo stesso fornitore), aggiungi al system prompt un esempio reale del documento anonimizzato. Claude farà few-shot learning e la precisione salirà vicino al 100% anche su PDF di bassa qualità.

🎯 Business: il caso d’uso più remunerativo non è elaborare le tue fatture, ma costruire il servizio per i tuoi clienti. Studi di commercialisti, agenzie di factoring e PMI ad alto volume documentale pagano 200-500€/mese per un sistema del genere. Margini vicini all’80%.

Condividi

Articoli Recenti

Categorie popolari