tool ai estrazione dati

Guida completa a un tool di estrazione dati con Laravel 13 e il Laravel AI SDK: obiettivi, scelte architetturali motivate, structured output con Claude, codice funzionante e costi reali per documento.

In quasi tutte le aziende esiste una categoria di dati che nessun database contiene: sono i dati che arrivano sotto forma di PDF, scansioni, email, allegati e pagine web. Sono dati che esistono, che qualcuno ha già scritto, e che ciononostante vengono ridigitati a mano ogni giorno da una persona pagata per farlo. Un tool di estrazione dati basato su AI serve esattamente a chiudere questo divario: prendere un documento non strutturato e restituire un record pulito, tipizzato e verificabile, pronto per finire in una tabella.

Questa guida spiega come costruirlo con Laravel 13 e il Laravel AI SDK, usando i modelli Claude di Anthropic come motore di estrazione. Non è una demo da cinque minuti: affronteremo gli obiettivi da fissare prima di scrivere codice, le scelte architetturali ottimali e le loro motivazioni, cosa è realisticamente estraibile, come farlo, quanto costa e quali sono gli errori che fanno naufragare questi progetti dopo il primo mese di produzione. Il codice è completo e funzionante, costruito attorno a un caso d’uso concreto: l’estrazione dei dati dalle fatture passive.

Indice

Il problema: perché il parsing tradizionale non basta più

Chiunque abbia provato a estrarre dati da documenti reali conosce la traiettoria. Si comincia con una regex. Funziona sui primi dieci documenti. Poi arriva un fornitore che scrive “Nr. Fattura” invece di “N. Fattura”, uno che mette la data in alto a destra invece che in alto a sinistra, uno che manda una scansione ruotata di tre gradi, e uno che allega un PDF in cui il testo è in realtà un’immagine. A quel punto la regex diventa venti regex, poi diventa un file da milleduecento righe che nessuno vuole toccare, e infine diventa una persona che apre i documenti e ricopia i valori a mano perché “è più veloce”.

Il motivo strutturale è che i sistemi tradizionali di estrazione ragionano sulla posizione e sulla forma letterale del dato, mentre un documento comunica attraverso il significato. Un umano capisce che “Imponibile”, “Totale netto” e “Base imponibile IVA” indicano la stessa cosa senza aver bisogno di un dizionario. Un modello linguistico, oggi, fa lo stesso. Ed è questo, e non la generazione di testo, il motivo per cui l’estrazione dati è il caso d’uso AI con il ritorno più rapido e più misurabile in ambito aziendale: il risultato è verificabile riga per riga, il risparmio è calcolabile in ore-uomo e il rischio è contenibile con controlli deterministici a valle.

Obiettivi del tool: cosa deve fare, e cosa non deve fare

Prima di aprire l’editor conviene mettere per iscritto cinque obiettivi. Sono quelli che, nella pratica, determinano se il progetto sopravvive al passaggio in produzione.

  1. Restituire una struttura dati garantita, non del testo. L’output del tool deve essere un oggetto con campi tipizzati che il resto dell’applicazione può consumare senza parsing difensivo. Se il consumatore del dato deve scrivere preg_match sulla risposta dell’AI, il tool ha fallito.
  2. Dichiarare la propria incertezza. Un tool che sbaglia il 3% delle volte e lo dice è utilizzabile. Un tool che sbaglia l’1% delle volte in silenzio è pericoloso, perché sposta l’errore a valle dove nessuno lo cerca. La confidenza deve essere un campo di primo livello dell’output.
  3. Essere idempotente e tracciabile. Lo stesso documento caricato due volte non deve essere elaborato (né pagato) due volte, e per ogni estrazione deve restare traccia di quale modello l’ha prodotta, con quale prompt, con quanti token e in quanto tempo.
  4. Non bloccare l’utente. L’estrazione da un PDF di venti pagine richiede decine di secondi. Nessuna richiesta HTTP sincrona deve dipendere da un modello linguistico.
  5. Prevedere l’umano nel ciclo. L’obiettivo non è l’automazione totale, è spostare la persona dalla digitazione alla revisione delle sole eccezioni. Un tool che porta il carico manuale dal 100% al 7% ha già vinto; un tool che pretende il 100% di automazione finisce spento.

Vale la pena esplicitare anche cosa il tool non deve fare: non deve interpretare, dedurre o completare. Se un campo non è sul documento, la risposta corretta è null. Questa singola regola, ripetuta nelle istruzioni del modello e verificata a valle, elimina la maggior parte delle allucinazioni che si osservano nei sistemi di estrazione mal progettati.

Cosa è possibile estrarre con un tool AI

La domanda giusta non è “quali formati”, ma “quali dati”. Un modello multimodale accetta in input testo, PDF nativi, PDF scansionati e immagini; ciò che cambia da caso a caso è la qualità ottenibile e il tipo di controllo che serve a valle.

Documenti amministrativi e contabili

È il territorio con il ROI più immediato: fatture attive e passive, note di credito, DDT, ricevute, estratti conto, note spese, ordini d’acquisto. Da ognuno si estraggono anagrafiche (ragione sociale, partita IVA, codice fiscale, IBAN), riferimenti (numero, data, scadenza, ordine collegato), righe di dettaglio con quantità e prezzi unitari, e totali con aliquote IVA. Il vantaggio operativo di questa categoria è che i dati sono auto-verificabili: se imponibile più IVA non fa il totale, l’estrazione è sbagliata e il sistema può accorgersene da solo.

Documenti legali e contrattuali

Contratti, NDA, polizze assicurative, condizioni generali, capitolati. Qui si estraggono date di decorrenza e scadenza, durata e rinnovo tacito, preavviso di disdetta, massimali, penali, foro competente, clausole di riservatezza e di esclusiva. Il valore non è tanto nel singolo contratto quanto nella possibilità di interrogare l’intero archivio: sapere quali dei 400 contratti attivi si rinnovano tacitamente nei prossimi 90 giorni è un’informazione che, senza estrazione automatica, semplicemente non esiste in azienda.

Testi non strutturati e contenuti web

Email, ticket di assistenza, recensioni, curriculum, schede prodotto di fornitori, pagine di concorrenti, verbali di riunione, trascrizioni di call. Da questi si estraggono entità (persone, aziende, prodotti, luoghi), classificazioni (categoria, priorità, sentiment, lingua), intenzioni (richiesta di preventivo, reclamo, disdetta) e attributi normalizzati. Il caso classico è il catalogo: mille schede prodotto scritte in mille modi diversi che devono diventare mille righe con gli stessi ventidue attributi.

Documenti tecnici e semi-strutturati

Referti, certificazioni, schede di sicurezza, bolle di trasporto, rapporti di intervento, log e report generati da sistemi legacy che producono solo PDF. È la categoria in cui l’AI compete non con una persona, ma con un’integrazione che non esiste e che nessuno vi darà mai.

I limiti reali

È onesto dire dove la qualità cala: tabelle che si spezzano su più pagine con intestazioni ripetute, documenti in cui il significato dipende dalla posizione grafica (planimetrie, schemi), scansioni a bassissima risoluzione o fortemente inclinate, scrittura a mano corsiva, e documenti molto lunghi in cui il dato cercato è in un punto qualsiasi di duecento pagine. In questi casi la soluzione non è un prompt migliore: è dividere il documento in blocchi più piccoli e trattare ogni blocco come un’estrazione a sé.

Come è possibile estrarre i dati: tre strategie a confronto

Prima di scegliere, vale la pena mettere le opzioni una accanto all’altra, perché l’AI non è sempre la risposta corretta.

ApproccioQuando convieneCosto di avvioCosto di manutenzioneTolleranza alla variabilità
Parser deterministico (regex, XPath, librerie PDF)Formato unico, controllato da voi, che non cambia maiBassoAlto e crescenteNulla
OCR con template (zonal OCR, motori tradizionali)Pochi fornitori, layout stabili, volumi altissimiAlto (un template per fornitore)AltoBassa
LLM con structured outputMolti mittenti, layout eterogenei, campi semanticiMolto bassoBassoMolto alta

La regola pratica: se il documento è generato da un sistema che controllate voi, usate un parser deterministico e risparmiate i soldi dell’API. Se il documento arriva dall’esterno in forme che non potete imporre, l’LLM con output strutturato è oggi la scelta con il miglior rapporto tra qualità, tempo di implementazione e costo. Nulla vieta di combinarli: molti sistemi maturi provano prima il parser e chiamano il modello solo quando il parser fallisce.

Le scelte ottimali e le loro motivazioni

Laravel 13 e il Laravel AI SDK, non un client HTTP artigianale

Laravel 13, rilasciato a marzo 2026 e basato su PHP 8.3–8.5, ha introdotto un AI SDK di prima parte (laravel/ai) che copre generazione di testo, agenti con tool calling, output strutturato, streaming, allegati ed esecuzione in coda dietro un’unica API, con driver per Anthropic, OpenAI, Gemini, Bedrock, Azure, Mistral, Ollama e altri.

La motivazione per usarlo invece di chiamare l’endpoint HTTP a mano è meno banale di quanto sembri. Un’integrazione artigianale funziona finché non dovete: normalizzare gli errori dei provider, gestire il retry con backoff, orchestrare i cicli di tool calling, serializzare gli allegati, spostare l’elaborazione in coda mantenendo le callback, e soprattutto testare il tutto senza chiamare l’API vera. Tutto questo, nell’SDK, esiste già. E la parte sui test non è un dettaglio: senza un meccanismo di fake, una suite che copre l’estrazione diventa lenta, costosa e non deterministica, cioè viene disattivata.

Il secondo motivo è la portabilità. Il provider è un attributo sulla classe. Cambiare modello — perché ne esce uno migliore, perché il prezzo scende, perché un cliente esige inferenza su una region specifica — è una riga di codice, non una riscrittura.

Claude come motore di estrazione, e quale versione

Per l’estrazione documentale i criteri di scelta del modello sono tre: capacità multimodale nativa sui PDF, aderenza rigorosa allo schema, e tendenza bassa a “riempire i buchi”. I modelli Claude sono una scelta solida su tutti e tre, e supportano lo structured output con garanzia di conformità allo schema JSON.

Sulla scelta della versione, la logica è economica prima che tecnica. Al listino Anthropic di agosto 2026:

  • Claude Haiku 4.5 — 1 $ per milione di token in input, 5 $ in output. Adatto a documenti puliti, campi pochi e non ambigui, volumi molto alti.
  • Claude Sonnet 5 — 2 $ / 10 $. È il punto di equilibrio per la stragrande maggioranza dei casi di estrazione documentale, ed è il modello che useremo.
  • Claude Opus 5 — 5 $ / 25 $. Si giustifica su documenti legali complessi, ragionamenti multi-passo o quando l’errore ha un costo elevato.

Due leve abbattono il conto in modo significativo. Il prompt caching fa pagare le riletture della parte statica del prompt a un decimo del prezzo di input, e in un estrattore le istruzioni sono identiche a ogni chiamata. La Batch API sconta del 50% input e output per l’elaborazione asincrona: perfetta per il recupero dell’arretrato, dove nessuno sta aspettando davanti a uno schermo.

Structured output, non “rispondimi in JSON”

È la scelta architetturale più importante dell’intero progetto. Chiedere a un modello di rispondere in JSON dentro il prompt produce, statisticamente, JSON valido quasi sempre — ed è proprio il “quasi” il problema: prima o poi arriva la risposta con il blocco markdown attorno, la virgola finale, il commento esplicativo prima della graffa, il campo che diventa stringa invece che numero. A quel punto nel codice compaiono i workaround, e i workaround diventano il sistema.

Lo structured output sposta il vincolo dal prompt al protocollo: si dichiara uno schema JSON e la piattaforma garantisce che l’output vi sia conforme. Nel Laravel AI SDK questo si traduce nell’implementare l’interfaccia HasStructuredOutput e nel definire un metodo schema() costruito con il generatore JsonSchema di Laravel — con tipi, enum, pattern, formati, campi obbligatori e nullabili, oggetti annidati e array di oggetti.

Un chiarimento essenziale, però, perché è la fonte più comune di falsa sicurezza: lo schema garantisce la forma, non la verità. Il modello non potrà restituire una stringa dove avete chiesto un numero, ma potrà restituire il numero sbagliato. La validazione di merito resta responsabilità vostra ed è deterministica: date coerenti, partita IVA di undici cifre, totali che quadrano. Schema e validazione sono due strati distinti e servono entrambi.

Le descrizioni dello schema sono parte del prompt

Un dettaglio che vale molti punti di accuratezza: il campo description di ogni proprietà dello schema viene letto dal modello. È lì, e non nel prompt generale, che va scritto “importo decimale senza separatore delle migliaia” o “partita IVA validata con lo strumento dedicato”. L’istruzione arriva al modello nel momento esatto in cui sta compilando quel campo, ed è molto più efficace di una regola generica sepolta in fondo alle istruzioni.

Coda, non richiesta sincrona

Un’estrazione da PDF impiega dai cinque ai sessanta secondi. Mettere questa attesa dentro una richiesta HTTP significa saturare i worker PHP, andare incontro ai timeout del reverse proxy e perdere il lavoro (già pagato) al primo errore di rete. L’estrazione va in coda, con un numero di tentativi e un backoff progressivo, così che un 429 o un 529 momentaneo diventino una riprova automatica invece che un fallimento visibile all’utente. In Laravel 13 questo si configura con gli attributi #[Tries] e #[Backoff] direttamente sulla classe del job.

Tool calling per ancorare il modello alla realtà

Il tool calling è spesso presentato come il modo per far compiere azioni al modello. Nell’estrazione dati il suo valore è un altro e più sottile: dare al modello un modo per verificare quello che ha letto. Se il modello può interrogare l’anagrafica fornitori o validare il codice di controllo di una partita IVA mentre estrae, un errore di riconoscimento caratteri viene intercettato durante l’estrazione e non tre passaggi più a valle. È la differenza tra un modello che indovina e un modello che controlla.

Conservare la risposta grezza, i token e il modello

Costa quasi nulla e salva il progetto. Quando fra otto mesi qualcuno chiederà perché quella fattura è stata registrata con l’IBAN sbagliato, l’unica risposta utile è la risposta grezza del modello, la versione usata e i token consumati. Senza questi dati ogni indagine è una congettura, e ogni cambio di modello un salto nel buio perché non esiste una baseline con cui confrontarsi.

L’architettura in sette componenti

Il flusso completo, dall’upload al record contabile, attraversa questi passaggi:

  1. Ingresso — upload, casella email dedicata, cartella monitorata o webhook. Il file viene salvato su disco e ne viene calcolato il checksum SHA-256.
  2. Deduplica — il checksum è una chiave unica: lo stesso documento non entra due volte nel sistema.
  3. Coda — un job accoda l’estrazione con tentativi e backoff configurati.
  4. Agent — istruzioni, schema di output e strumenti di verifica; riceve il documento come allegato nativo.
  5. Validazione di merito — regole deterministiche Laravel sul payload restituito, più i controlli di coerenza aritmetica.
  6. Instradamento — confidenza alta e controlli superati significa extracted; qualsiasi altra combinazione significa review.
  7. Persistenza e osservabilità — payload validato, risposta grezza, modello, token, durata e costo calcolato.

Implementazione passo passo

1. Installazione e configurazione

composer require laravel/ai
php artisan vendor:publish --provider="Laravel\Ai\AiServiceProvider"
php artisan migrate

php artisan make:agent InvoiceExtractor --structured
php artisan make:tool VatNumberValidator

Nel file .env serve la sola chiave del provider scelto:

ANTHROPIC_API_KEY=sk-ant-...
QUEUE_CONNECTION=redis

2. La tabella dei documenti

La migration merita attenzione perché la struttura della tabella incarna le decisioni prese sopra: checksum unico per l’idempotenza, colonna di stato per l’instradamento, payload validato separato dalla risposta grezza, e le colonne di osservabilità.

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('documents', function (Blueprint $table) {
            $table->id();
            $table->foreignId('team_id')->constrained()->cascadeOnDelete();

            $table->string('disk')->default('documents');
            $table->string('path');
            $table->string('original_name');
            $table->string('mime_type', 100);
            $table->unsignedBigInteger('size_bytes');

            // sha256 del file: evita di ri-processare (e ri-pagare) lo stesso documento
            $table->string('checksum', 64)->unique();

            $table->string('type', 40)->nullable();          // invoice, ddt, contract...
            $table->string('status', 20)->index()            // pending|processing|extracted|review|failed
                  ->default('pending');

            $table->json('extracted')->nullable();           // payload validato
            $table->json('raw_response')->nullable();        // risposta grezza del modello
            $table->decimal('confidence', 4, 3)->nullable();
            $table->text('failure_reason')->nullable();

            $table->string('model')->nullable();
            $table->unsignedInteger('input_tokens')->nullable();
            $table->unsignedInteger('output_tokens')->nullable();
            $table->unsignedInteger('duration_ms')->nullable();

            $table->foreignId('reviewed_by')->nullable()->constrained('users')->nullOnDelete();
            $table->timestamp('reviewed_at')->nullable();

            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('documents');
    }
};

3. L’agent di estrazione

È il cuore del tool. Notate tre cose. La temperature è impostata a zero: nell’estrazione non vogliamo creatività, vogliamo ripetibilità. Le istruzioni contengono regole negative esplicite (“non dedurre”, “null è una risposta corretta”) perché è così che si sopprimono le allucinazioni. E ogni campo dello schema ha una descrizione che il modello leggerà mentre lo compila.

<?php

namespace App\Ai\Agents;

use App\Ai\Tools\VatNumberValidator;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Attributes\MaxSteps;
use Laravel\Ai\Attributes\MaxTokens;
use Laravel\Ai\Attributes\Model;
use Laravel\Ai\Attributes\Provider;
use Laravel\Ai\Attributes\Temperature;
use Laravel\Ai\Attributes\Timeout;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Promptable;
use Stringable;

#[Provider(Lab::Anthropic)]
#[Model('claude-sonnet-5')]
#[Temperature(0)]
#[MaxTokens(8000)]
#[MaxSteps(6)]
#[Timeout(180)]
class InvoiceExtractor implements Agent, HasStructuredOutput, HasTools
{
    use Promptable;

    public function instructions(): Stringable|string
    {
        return <<<'PROMPT'
        Sei un contabile esperto in ciclo passivo italiano. Estrai i dati dalla
        fattura allegata e restituiscili nello schema richiesto.

        Regole non negoziabili:
        - Trascrivi SOLO ciò che è scritto nel documento. Non dedurre, non stimare,
          non completare valori mancanti con la tua conoscenza pregressa.
        - Se un campo non è presente o non è leggibile, restituisci null.
          "null" è una risposta corretta; un valore inventato è un errore grave.
        - Gli importi sono numeri decimali senza simbolo di valuta e senza
          separatore delle migliaia (1.234,56 EUR diventa 1234.56).
        - Le date sono in formato ISO 8601 (YYYY-MM-DD).
        - Verifica SEMPRE la partita IVA del fornitore con lo strumento
          vat_number_validator prima di restituirla.
        - Il campo confidence riflette quanto sei certo della lettura complessiva:
          usa valori bassi per scansioni sfocate, documenti ruotati o parzialmente
          coperti da timbri.
        PROMPT;
    }

    public function tools(): iterable
    {
        return [
            new VatNumberValidator,
        ];
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'document_type' => $schema->string()
                ->enum(['fattura', 'nota_credito', 'ddt', 'altro'])
                ->description('Tipologia del documento così come dichiarata sul documento stesso.')
                ->required(),

            'invoice_number' => $schema->string()
                ->description('Numero della fattura, trascritto letteralmente.')
                ->nullable(),

            'invoice_date' => $schema->string()
                ->format('date')
                ->description('Data di emissione in formato YYYY-MM-DD.')
                ->nullable(),

            'due_date' => $schema->string()
                ->format('date')
                ->description('Data di scadenza del pagamento in formato YYYY-MM-DD.')
                ->nullable(),

            'currency' => $schema->string()
                ->pattern('^[A-Z]{3}$')
                ->description('Codice valuta ISO 4217, ad esempio EUR.')
                ->required(),

            'supplier' => $schema->object(fn ($schema) => [
                'name' => $schema->string()->required(),
                'vat_number' => $schema->string()
                    ->description('Partita IVA validata tramite lo strumento dedicato.')
                    ->nullable(),
                'tax_code' => $schema->string()->nullable(),
                'address' => $schema->string()->nullable(),
                'iban' => $schema->string()->nullable(),
            ])->required(),

            'lines' => $schema->array()
                ->items($schema->object(fn ($schema) => [
                    'description' => $schema->string()->required(),
                    'quantity' => $schema->number()->nullable(),
                    'unit_price' => $schema->number()->nullable(),
                    'vat_rate' => $schema->number()
                        ->description('Aliquota IVA in percentuale, ad esempio 22.')
                        ->nullable(),
                    'total' => $schema->number()->required(),
                ]))
                ->description('Una voce per ogni riga di dettaglio presente sul documento.')
                ->required(),

            'totals' => $schema->object(fn ($schema) => [
                'net' => $schema->number()->required(),
                'vat' => $schema->number()->required(),
                'gross' => $schema->number()->required(),
            ])->required(),

            'confidence' => $schema->number()
                ->description('Da 0 a 1. Sotto 0.8 il documento verrà rivisto da un umano.')
                ->required(),

            'notes' => $schema->string()
                ->description('Anomalie riscontrate: campi illeggibili, totali non quadranti, pagine mancanti.')
                ->nullable(),
        ];
    }
}

4. Uno strumento per verificare, non per agire

Il validatore di partita IVA implementa l’algoritmo di Luhn previsto dalla normativa italiana e, in più, interroga l’anagrafica. Il messaggio di ritorno è scritto per essere letto da un modello: non dice solo “non valida”, spiega cosa fare (“rileggi il documento, potrebbe esserci un errore di riconoscimento caratteri”). È un dettaglio che cambia sensibilmente il tasso di correzione automatica.

<?php

namespace App\Ai\Tools;

use App\Models\Supplier;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use Stringable;

class VatNumberValidator implements Tool
{
    public function description(): Stringable|string
    {
        return 'Valida una partita IVA italiana con il controllo del codice di controllo '
             . 'e verifica se il fornitore è già presente in anagrafica. '
             . 'Usa questo strumento ogni volta che leggi una partita IVA su un documento.';
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'vat_number' => $schema->string()
                ->description('La partita IVA letta sul documento, solo cifre, senza prefisso IT.')
                ->required(),
        ];
    }

    public function handle(Request $request): Stringable|string
    {
        $vat = preg_replace('/\D/', '', (string) $request['vat_number']);

        if (strlen($vat) !== 11) {
            return "NON VALIDA: una partita IVA italiana ha 11 cifre, ne sono state lette ".strlen($vat).'.';
        }

        if (! $this->checksumIsValid($vat)) {
            return "NON VALIDA: il codice di controllo di {$vat} non è corretto. "
                 . 'Rileggi il documento: potrebbe esserci un errore di riconoscimento caratteri.';
        }

        $supplier = Supplier::where('vat_number', $vat)->first();

        return $supplier
            ? "VALIDA: {$vat} corrisponde al fornitore già in anagrafica \"{$supplier->name}\"."
            : "VALIDA: {$vat} è formalmente corretta ma non è presente in anagrafica.";
    }

    private function checksumIsValid(string $vat): bool
    {
        $sum = 0;

        foreach (str_split(substr($vat, 0, 10)) as $index => $digit) {
            $digit = (int) $digit;

            if ($index % 2 === 1) {
                $digit *= 2;

                if ($digit > 9) {
                    $digit -= 9;
                }
            }

            $sum += $digit;
        }

        return (10 - $sum % 10) % 10 === (int) $vat[10];
    }
}

5. Il job: dove avviene la validazione di merito

Questo è lo strato che separa un prototipo da un sistema di produzione. Lo schema ha già garantito che i tipi siano corretti; qui verifichiamo che i valori abbiano senso, controlliamo che imponibile più IVA faccia il totale con una tolleranza di due centesimi per gli arrotondamenti, e instradiamo il documento verso l’archiviazione automatica o verso la revisione umana.

<?php

namespace App\Jobs;

use App\Ai\Agents\InvoiceExtractor;
use App\Models\Document;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\Backoff;
use Illuminate\Queue\Attributes\Tries;
use Illuminate\Support\Facades\Validator;
use Laravel\Ai\Files;
use Laravel\Ai\Responses\AgentResponse;
use Throwable;

#[Tries(3)]
#[Backoff([10, 60, 300])]
class ExtractDocumentData implements ShouldQueue
{
    use Queueable;

    public function __construct(public Document $document) {}

    public function handle(): void
    {
        $this->document->update(['status' => 'processing']);

        $startedAt = hrtime(true);

        $response = (new InvoiceExtractor)->prompt(
            'Estrai i dati dal documento allegato.',
            attachments: [
                Files\Document::fromStorage($this->document->path, disk: $this->document->disk),
            ],
        );

        $this->store($response, $startedAt);
    }

    protected function store(AgentResponse $response, int $startedAt): void
    {
        $payload = $response->toArray();

        // Lo schema garantisce la FORMA, non la CORRETTEZZA: qui verifichiamo il merito.
        $validator = Validator::make($payload, [
            'invoice_date' => ['nullable', 'date_format:Y-m-d', 'before_or_equal:today'],
            'due_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:invoice_date'],
            'currency' => ['required', 'string', 'size:3'],
            'supplier.vat_number' => ['nullable', 'digits:11'],
            'lines' => ['required', 'array', 'min:1'],
            'totals.net' => ['required', 'numeric'],
            'totals.vat' => ['required', 'numeric', 'min:0'],
            'totals.gross' => ['required', 'numeric'],
            'confidence' => ['required', 'numeric', 'between:0,1'],
        ]);

        $quadra = $validator->passes() && abs(
            round($payload['totals']['net'] + $payload['totals']['vat'], 2)
            - round($payload['totals']['gross'], 2)
        ) < 0.02;

        $this->document->update([
            'extracted' => $payload,
            'raw_response' => $response->raw?->json(),
            'confidence' => $payload['confidence'],
            'model' => $response->model ?? null,
            'input_tokens' => $response->usage?->inputTokens,
            'output_tokens' => $response->usage?->outputTokens,
            'duration_ms' => (int) ((hrtime(true) - $startedAt) / 1_000_000),
            'failure_reason' => $validator->passes() ? null : $validator->errors()->toJson(),
            'status' => ($validator->passes() && $quadra && $payload['confidence'] >= 0.8)
                ? 'extracted'
                : 'review',
        ]);
    }

    public function failed(?Throwable $e): void
    {
        $this->document->update([
            'status' => 'failed',
            'failure_reason' => $e?->getMessage(),
        ]);
    }
}

6. Upload idempotente

Il checksum calcolato prima del salvataggio, unito a firstOrCreate, garantisce che un utente che ricarica lo stesso PDF per la terza volta non generi tre chiamate al modello e tre righe in contabilità.

<?php

namespace App\Http\Controllers;

use App\Http\Requests\StoreDocumentRequest;
use App\Jobs\ExtractDocumentData;
use App\Models\Document;
use Illuminate\Http\RedirectResponse;

class DocumentController extends Controller
{
    public function store(StoreDocumentRequest $request): RedirectResponse
    {
        $file = $request->file('document');
        $checksum = hash_file('sha256', $file->getRealPath());

        // Idempotenza: lo stesso PDF caricato due volte non viene pagato due volte.
        $document = Document::firstOrCreate(
            ['checksum' => $checksum],
            [
                'team_id' => $request->user()->currentTeam->id,
                'disk' => 'documents',
                'path' => $file->store('inbox', 'documents'),
                'original_name' => $file->getClientOriginalName(),
                'mime_type' => $file->getMimeType(),
                'size_bytes' => $file->getSize(),
                'type' => 'invoice',
            ],
        );

        if ($document->wasRecentlyCreated) {
            ExtractDocumentData::dispatch($document);
        }

        return back()->with('status', $document->wasRecentlyCreated
            ? 'Documento in elaborazione: ti avvisiamo appena i dati sono pronti.'
            : 'Questo documento era già stato caricato.');
    }
}

La validazione dell’upload va tenuta stretta: tipi MIME espliciti e un tetto di dimensione. Oltre le venti-trenta pagine conviene dividere il PDF e trattare ogni blocco come un’estrazione indipendente, sia per la qualità sia per il costo.

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreDocumentRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'document' => [
                'required',
                'file',
                'mimetypes:application/pdf,image/jpeg,image/png',
                'max:20480', // 20 MB: oltre questa soglia conviene splittare il PDF
            ],
        ];
    }
}

7. Il modello e il costo per documento

Un accessor calcola il costo reale della singola estrazione a partire dai token effettivamente consumati. Sembra un vezzo; è invece l’unico modo per rispondere alla domanda che arriverà dalla direzione al secondo mese: “quanto ci costa questa cosa per documento?”.

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;

class Document extends Model
{
    protected $guarded = [];

    protected function casts(): array
    {
        return [
            'extracted' => 'array',
            'raw_response' => 'array',
            'confidence' => 'float',
            'reviewed_at' => 'datetime',
        ];
    }

    // Costo reale della singola estrazione, calcolato dai token effettivi.
    protected function costEur(): Attribute
    {
        return Attribute::get(function (): ?float {
            if (! $this->input_tokens) {
                return null;
            }

            $prices = config("ai.prices.{$this->model}"); // USD per milione di token

            return round((
                $this->input_tokens / 1_000_000 * $prices['input'] +
                $this->output_tokens / 1_000_000 * $prices['output']
            ) * config('ai.usd_eur'), 4);
        });
    }
}

8. I test, cioè la parte che rende il tool manutenibile

L’SDK permette di sostituire l’agent con una versione fake che restituisce payload predefiniti, e di asserire che sia stato interrogato con il prompt atteso. Questo consente di testare la parte più delicata del sistema — l’instradamento verso la revisione umana — senza spendere un centesimo e in modo perfettamente deterministico.

<?php

use App\Ai\Agents\InvoiceExtractor;
use App\Jobs\ExtractDocumentData;
use App\Models\Document;

it('mette in revisione le fatture i cui totali non quadrano', function () {
    InvoiceExtractor::fake([
        [
            'document_type' => 'fattura',
            'invoice_number' => '2026/0142',
            'invoice_date' => '2026-07-14',
            'due_date' => '2026-08-13',
            'currency' => 'EUR',
            'supplier' => ['name' => 'Acme Srl', 'vat_number' => '00743110157'],
            'lines' => [['description' => 'Canone SaaS', 'total' => 1000.00]],
            'totals' => ['net' => 1000.00, 'vat' => 220.00, 'gross' => 1500.00], // non quadra
            'confidence' => 0.97,
        ],
    ]);

    $document = Document::factory()->create(['status' => 'pending']);

    (new ExtractDocumentData($document))->handle();

    expect($document->fresh()->status)->toBe('review');
    InvoiceExtractor::assertPrompted(fn ($prompt) => $prompt->contains('Estrai i dati'));
});

it('accetta una fattura coerente e ad alta confidenza', function () {
    InvoiceExtractor::fake([
        [
            'document_type' => 'fattura',
            'invoice_number' => '2026/0143',
            'invoice_date' => '2026-07-14',
            'due_date' => '2026-08-13',
            'currency' => 'EUR',
            'supplier' => ['name' => 'Acme Srl', 'vat_number' => '00743110157'],
            'lines' => [['description' => 'Canone SaaS', 'total' => 1000.00]],
            'totals' => ['net' => 1000.00, 'vat' => 220.00, 'gross' => 1220.00],
            'confidence' => 0.96,
        ],
    ]);

    $document = Document::factory()->create(['status' => 'pending']);

    (new ExtractDocumentData($document))->handle();

    expect($document->fresh())
        ->status->toBe('extracted')
        ->extracted->toHaveKey('invoice_number', '2026/0143');
});

La suite andrebbe estesa con un piccolo corpus di documenti reali anonimizzati e i relativi risultati attesi, da eseguire non a ogni commit ma prima di ogni cambio di modello o di prompt. È l’equivalente di un test di regressione per una componente che, a differenza del codice, può cambiare comportamento senza che voi tocchiate una riga.

9. Il recupero dell’arretrato

Per elaborare migliaia di documenti già archiviati la priorità si ribalta: non conta la latenza, conta il costo unitario. Il metodo queue() dell’SDK accoda l’esecuzione con le callback di successo e di errore, e sul lato provider è il caso d’uso in cui la Batch API dimezza il conto.

<?php

use App\Ai\Agents\InvoiceExtractor;
use App\Models\Document;
use Laravel\Ai\Files;

// Elaborazione notturna dell'arretrato: nessuno aspetta, quindi si punta
// al costo minimo per documento invece che alla latenza minima.
Artisan::command('documents:backfill {--limit=500}', function () {
    Document::where('status', 'pending')
        ->limit((int) $this->option('limit'))
        ->each(function (Document $document) {
            (new InvoiceExtractor)
                ->queue(
                    'Estrai i dati dal documento allegato.',
                    attachments: [Files\Document::fromStorage($document->path)],
                )
                ->then(fn ($response) => $document->update([
                    'extracted' => $response->toArray(),
                    'status' => 'extracted',
                ]))
                ->catch(fn ($e) => $document->update([
                    'status' => 'failed',
                    'failure_reason' => $e->getMessage(),
                ]));
        });
})->purpose('Estrae i dati dai documenti ancora non elaborati');

Esempio completo: dall’upload al record contabile

Vediamo il percorso di un documento reale. Un fornitore invia una fattura PDF di due pagine, generata da un gestionale che nessuno di noi controlla, con la partita IVA stampata in piccolo nel piè di pagina e i totali in una tabella a destra.

Secondo 0. Il file arriva via upload. Il controller calcola sha256, verifica che non sia già presente, lo salva su disco e accoda ExtractDocumentData. L’utente riceve subito una conferma: nessuna attesa.

Secondo 1. Il worker preleva il job, marca il documento come processing e invia il PDF come allegato nativo all’agent. Il documento non viene convertito in testo: il modello lo legge come documento, mantenendo la struttura visiva delle tabelle.

Secondo 4. Il modello legge la partita IVA e, come da istruzioni, chiama vat_number_validator. Al primo tentativo ha letto 00743110l57 — una “l” al posto di un “1”, classico errore di riconoscimento su scansione. Lo strumento risponde che le cifre sono dieci e non undici. Il modello rilegge la zona del documento e corregge in 00743110157, che questa volta supera il controllo del codice di controllo e risulta anche già presente in anagrafica.

Secondo 9. L’agent restituisce l’output strutturato, garantito conforme allo schema:

{
  "document_type": "fattura",
  "invoice_number": "2026/0142",
  "invoice_date": "2026-07-14",
  "due_date": "2026-08-13",
  "currency": "EUR",
  "supplier": {
    "name": "Acme Componenti Srl",
    "vat_number": "00743110157",
    "tax_code": null,
    "address": "Via Torino 14, 20123 Milano MI",
    "iban": "IT60X0542811101000000123456"
  },
  "lines": [
    { "description": "Canone licenza SaaS - luglio 2026", "quantity": 1, "unit_price": 890.00, "vat_rate": 22, "total": 890.00 },
    { "description": "Ore di configurazione", "quantity": 4, "unit_price": 95.00, "vat_rate": 22, "total": 380.00 }
  ],
  "totals": { "net": 1270.00, "vat": 279.40, "gross": 1549.40 },
  "confidence": 0.94,
  "notes": "Partita IVA letta nel piè di pagina, corretta dopo verifica con lo strumento."
}

Secondo 10. Il job valida: le date sono coerenti e nell’ordine giusto, la partita IVA ha undici cifre, c’è almeno una riga di dettaglio, e soprattutto 1270,00 + 279,40 = 1549,40, quindi i totali quadrano. La confidenza è 0,94, sopra la soglia di 0,80. Lo stato diventa extracted e il documento prosegue verso la registrazione contabile senza intervento umano.

Un secondo documento, scansionato storto da un fax, restituisce confidenza 0,61 e un campo notes che segnala il numero fattura parzialmente coperto da un timbro. Il job lo instrada su review. L’operatore lo apre, vede i campi già precompilati, corregge il solo numero e conferma. Tempo impiegato: dodici secondi contro i due minuti della digitazione completa. È qui, e non nell’automazione totale, che sta il grosso del risparmio.

I vantaggi, in termini misurabili

Vale la pena tradurre i benefici in grandezze che si possono mettere in un foglio di calcolo, perché è così che questi progetti vengono approvati o bocciati.

DimensioneProcesso manualeCon il tool AI
Tempo per documento90–180 secondi di digitazione0 secondi (automatico) o ~15 secondi (revisione)
Tasso di errore1–3% da digitazione, non rilevabileErrori residui intercettati dai controlli di coerenza
Latenza operativaOre o giorni (dipende dalla coda della persona)Secondi, 24 ore su 24
Scalabilità sui picchiLineare nel personaleLineare nei worker
Onboarding di un nuovo fornitoreNuovo template o nuove regoleZero configurazione
TracciabilitàNessunaPayload, risposta grezza, modello, token, costo

Il vantaggio meno visibile e più duraturo è il sesto: un archivio di dati strutturati che prima non esisteva. Una volta che tremila fatture sono diventate tremila record con righe di dettaglio, diventano possibili analisi che nessuno aveva mai chiesto perché erano irrealizzabili — la spesa per categoria merceologica, la variazione dei prezzi unitari per fornitore, i pagamenti in scadenza nei prossimi trenta giorni.

Quanto costa davvero

Facciamo i conti su una fattura tipica di due pagine. L’input è composto dal documento (circa 2.500 token per un PDF di due pagine) più le istruzioni e lo schema (circa 900 token). L’output strutturato pesa attorno ai 700 token.

Con Claude Sonnet 5 a 2 $ per milione di token in input e 10 $ in output, la singola estrazione costa circa 0,0138 $, cioè poco più di un centesimo. Su diecimila documenti al mese si tratta di circa 138 $. Attivando il prompt caching sulla parte statica del prompt — istruzioni e schema, identici a ogni chiamata — la quota di input cachata scende a un decimo del prezzo, e con la Batch API sull’arretrato il totale si dimezza ulteriormente.

Il paragone che conta non è però con zero, ma con il costo attuale: diecimila documenti a due minuti l’uno sono 333 ore di lavoro al mese. Anche con la revisione umana sul 10% dei casi, il rapporto resta di due ordini di grandezza. Ciò detto, il costo dell’API è la voce minore del progetto: il tempo di sviluppo, la costruzione del corpus di test e la messa a punto del processo di revisione pesano molto di più, ed è su quelli che va fatta la stima.

Cinque errori che fanno fallire questi progetti

  1. Fidarsi dello schema come se fosse una validazione. Lo schema garantisce che invoice_date sia una stringa in formato data, non che sia la data giusta. Senza uno strato di validazione deterministica a valle il sistema produce dati sbagliati con la stessa sicurezza con cui produce quelli giusti.
  2. Non prevedere la revisione umana fin dal primo giorno. Aggiungerla dopo significa riprogettare il modello dati, gli stati e l’interfaccia. Prevederla subito costa una colonna e un valore di stato.
  3. Non conservare la risposta grezza. È l’unico appiglio quando bisogna capire cosa è andato storto, ed è impossibile ricostruirla a posteriori.
  4. Ottimizzare il modello prima del prompt e dello schema. Passare a un modello più costoso è la prima cosa che viene in mente e quasi sempre la meno efficace. Nella maggior parte dei casi il salto di qualità arriva da descrizioni di campo più precise, da regole negative esplicite e da uno strumento di verifica — tutti interventi a costo zero.
  5. Trattarlo come un progetto chiuso. I modelli cambiano, i documenti in ingresso cambiano, i campi richiesti cambiano. Senza un corpus di test e una metrica di accuratezza monitorata nel tempo, la qualità degrada silenziosamente e nessuno se ne accorge finché non arriva un errore contabile.

Checklist prima di andare in produzione

  • Uno schema con descrizioni su ogni campo e nullabilità esplicita dove il dato può mancare.
  • Istruzioni con regole negative: non dedurre, non completare, null è ammesso.
  • Temperatura a zero.
  • Validazione deterministica a valle, inclusi i controlli di coerenza aritmetica.
  • Soglia di confidenza esplicita e uno stato dedicato alla revisione umana.
  • Idempotenza tramite checksum del file.
  • Coda con tentativi e backoff progressivo.
  • Persistenza di risposta grezza, modello, token, durata e costo.
  • Suite di test con agent fake, più un corpus di documenti reali anonimizzati per le regressioni.
  • Prompt caching attivo e Batch API per le elaborazioni non urgenti.
  • Politica di conservazione dei documenti e verifica della conformità GDPR sui dati inviati al provider.

Conclusioni

Un tool AI di estrazione dati non è un progetto di ricerca: è un pezzo di infrastruttura applicativa ordinaria, fatto di una coda, un modello dati, uno schema e una validazione. Ciò che l’AI aggiunge è la sola parte che il codice tradizionale non sapeva fare — leggere un documento che non ha mai visto prima — mentre tutto il resto resta ingegneria del software normale, e va trattato come tale.

Con Laravel 13 e il suo AI SDK il divario tra prototipo e produzione si è ridotto a poche ore di lavoro: output strutturato garantito, tool calling, allegati, esecuzione in coda e test con agent fake sono tutti disponibili di serie. La differenza tra un tool che funziona in demo e uno che regge diecimila documenti al mese non sta nel modello scelto, ma nei tre strati che gli stanno attorno: uno schema scritto con cura, una validazione deterministica che non si fida mai, e un percorso di revisione umana progettato dal primo giorno.

Il consiglio operativo per iniziare è di partire in piccolo e in modo verificabile: un solo tipo di documento, cinque campi, cento documenti reali con i risultati attesi. Se l’accuratezza su quei cento è accettabile, l’estensione a tutto il resto è una questione di schema, non di architettura.

Ercole Palmeri e ClaudeAI