Wiki tecnica sempre aggiornata con LLM: parser AST, freshness loop e linter sulla documentazione generata

Wiki tecnica sempre aggiornata con LLM: parser AST, freshness loop e linter sulla documentazione generata

La pipeline che descrivo qui vive nel mio laboratorio di automazione AI dal 4 marzo 2026, dove la tengo accesa come esercizio di production discipline su una codebase Symfony 7.2 di riferimento - un monolite self-hosted da circa 200.000 righe PHP, 1.400 classi, 380 controller, 94 entity Doctrine - che uso come campo di prova per tutto ciò che voglio portare in contesti clienti dopo averlo stressato. L'infrastruttura è un Hetzner CCX23 (4 vCPU AMD EPYC, 16 GB RAM, 80 GB NVMe, Debian 12), con PHP 8.3, Composer 2.7, nikic/php-parser 5.4 per l'analisi AST, Claude Sonnet 4.6 come modello di generazione via Anthropic Messages Batches API, e MkDocs Material come wiki target servito da Nginx. Il problema di partenza è noto a chi ha mai provato a scrivere documentazione interna: ogni volta che un metodo cambia firma, la pagina che lo descrive diventa falsa, e dopo sei mesi la wiki è un cimitero di verità precedenti. La prima versione ingenua della pipeline - "spara il file PHP al modello e chiedigli di descrivere cosa fa" - ha prodotto in ventiquattrore 340 pagine di Markdown verboso, pieno di frasi generiche tipo "questo metodo gestisce la logica di business del preventivo" e altrettanto obsolete alla prima modifica della firma. È lo stesso pattern che avevo abbozzato in un articolo precedente sull'automazione di documentazione con Claude API su Confluence: regge come proof-of-concept a basso costo, collassa appena la codebase inizia a vivere davvero. È da quel fallimento che è nato il resto di questa architettura.

Perché una pipeline LLM di documentazione tecnica produce AI slop?

La risposta in una riga è che un LLM alimentato con codice grezzo e prompt generico ottimizza per plausibilità linguistica, non per fedeltà strutturale al codice. Ci sono tre modi ricorrenti in cui la pipeline degenera. Il primo è la hallucination su tipi e firme: l'LLM descrive un metodo calculateDiscount come "applica sconti progressivi basati sul fatturato cliente" quando il corpo del metodo non contiene nulla di simile, semplicemente perché il nome del metodo evoca quel significato. Il secondo è il filler verbale: l'output si espande con paragrafi di contesto generico ("questo controller segue i principi REST") che sono veri in senso astratto e inutili in senso operativo. Il terzo è la drift non rilevata: la prima generazione è accettabile, ma le successive rigenerazioni su versioni modificate del codice lasciano pezzi della doc vecchia in piedi senza che nessuno se ne accorga, e in sei mesi la pagina torna a mentire.

La contromisura non è prompting più sofisticato: è spostare la source of truth dall'output del modello all'input che gli diamo. Prima di chiamare l'LLM, la pipeline estrae un contratto strutturato dal codice via AST - nome classe, firme complete con tipi, annotation, docblock esistenti, attributi Symfony, uso di interfacce - e quel contratto diventa input fisso del prompt. Il modello non descrive il codice: riempie slot di un template vincolante usando il contratto come unica fonte. Se la firma cambia, il contratto cambia, e la pagina viene rigenerata dal nuovo contratto - non ricucita sopra la versione precedente. Questa separazione tra estrazione deterministica e generazione testuale è l'unico motivo per cui la pipeline tiene dopo sei mesi di uso.

Se vuoi vedere come progetto pipeline AI di produzione dove il modello LLM è un componente vincolato e non un oracolo generico, nel mio hub sull'automazione AI per aziende trovi articoli che affrontano la stessa classe di problemi - knowledge base, report automatizzati, classificazione documentale - con la stessa metodologia: contratto strutturato prima del prompt, validazione dopo.

L'estrazione AST: il parser come contratto di ingresso

Il primo stadio è un comando Symfony che ricorre sulla directory src/, costruisce l'AST per ogni file PHP con nikic/php-parser e serializza una struttura dati canonica che rappresenta classe, metodi, proprietà e annotation. Ecco lo scheletro del visitor.

<?php
declare(strict_types=1);

namespace App\Docs\Extractor;

use PhpParser\Node;
use PhpParser\NodeVisitorAbstract;

final class ClassContractVisitor extends NodeVisitorAbstract
{
    private array $contract = [];

    public function enterNode(Node $node): null
    {
        if ($node instanceof Node\Stmt\Class_ && $node->name !== null) {
            $this->contract = [
                'fqn' => $node->namespacedName?->toString(),
                'kind' => 'class',
                'abstract' => $node->isAbstract(),
                'final' => $node->isFinal(),
                'extends' => $node->extends?->toString(),
                'implements' => array_map(fn($i) => $i->toString(), $node->implements),
                'attributes' => $this->extractAttributes($node->attrs),
                'docblock' => $node->getDocComment()?->getText(),
                'methods' => [],
            ];
        }
        if ($node instanceof Node\Stmt\ClassMethod) {
            $this->contract['methods'][] = [
                'name' => $node->name->toString(),
                'visibility' => $this->visibility($node),
                'static' => $node->isStatic(),
                'signature' => $this->signature($node),
                'attributes' => $this->extractAttributes($node->attrs),
                'docblock' => $node->getDocComment()?->getText(),
                'cyclomatic' => $this->cyclomatic($node),
            ];
        }
        return null;
    }

    public function contract(): array
    {
        return $this->contract;
    }
}

Il visitor produce per ogni file un array deterministico che include anche un campo cyclomatic calcolato con McCabe semplificato - numero di if, foreach, case, catch, operatori logici - perché una metrica di complessità locale serve poi al prompt per decidere quanta profondità di spiegazione chiedere all'LLM. Per una classe con un solo metodo di cinque righe il prompt chiede una descrizione compatta; per una classe con venti metodi e alta complessità ciclomatica chiede una narrazione articolata per macroaree. Lo strumento che uso internamente per queste analisi è lo stesso parser PHP di Nikita Popov che sta alla base di Psalm, PHPStan e Rector - una dipendenza ampiamente testata su codebase reali.

Il template vincolante: niente prosa generica, solo slot con contratto

Il prompt inviato all'LLM non è "descrivi questa classe". È un template Markdown con slot marker vincolanti, più il contratto estratto come blocco JSON di input, più regole esplicite su cosa il modello può e non può scrivere. Il template per una pagina di classe è questo:

# {{CLASS_FQN}}

**Ruolo nel dominio:** {{ROLE_ONE_LINE}}

## Responsabilità

{{RESPONSIBILITIES_BULLETS_3_TO_5}}

## API pubblica

{{METHOD_TABLE_FROM_CONTRACT}}

## Dipendenze iniettate

{{DEPENDENCIES_FROM_CONSTRUCTOR}}

## Note operative

{{OPERATIONAL_NOTES_IF_COMPLEX}}

Le regole di vincolo applicate via system prompt all'LLM sono quattro, e ogni regola è motivata. Regola uno: non inventare tipi. Tutti i tipi, i nomi di parametri, le firme, i decorator Symfony devono provenire testualmente dal contratto JSON - se il contratto non contiene un tipo, il modello scrive mixed esplicito, non inventa. Regola due: non emettere frasi che contengono pattern di filler: "questo metodo gestisce", "viene utilizzato per", "fa parte del layer di dominio" sono bandite dal system prompt con esempi negativi. Regola tre: il campo ROLE_ONE_LINE deve essere una frase sotto 120 caratteri - evita le classiche introduzioni gonfie. Regola quattro: OPERATIONAL_NOTES_IF_COMPLEX può essere omesso se la cyclomatic somma è sotto 8 - rimuove la tentazione del modello di riempire sezioni inutili. La quarta regola è la più importante: lo slot è esplicitamente optional. Un template che forza sempre una sezione spinge l'LLM a inventare contenuto.

La chiamata LLM con Messages Batches per il costo

Una codebase da 1.400 classi significa 1.400 chiamate LLM. Sincrone con prompt da 2-4k token ciascuna, a prezzo pieno Claude Sonnet 4.6, la prima passata costa 40-60 dollari. L'endpoint Messages Batches dell'API Anthropic dimezza quel costo perché le richieste non-urgent sono processate con tariffa scontata entro 24 ore. La pipeline raggruppa le classi in batch da 100, li sottomette come job batch, polla lo stato ogni 30 secondi, e alla chiusura del job scarica tutti i risultati in una volta.

use Anthropic\Anthropic;
use Anthropic\Batches\BatchCreateRequest;

$client = Anthropic::factory()->withApiKey(getenv('ANTHROPIC_API_KEY'))->make();

$requests = [];
foreach ($classContracts as $contract) {
    $requests[] = [
        'custom_id' => hash('sha1', $contract['fqn']),
        'params' => [
            'model' => 'claude-sonnet-4-6',
            'max_tokens' => 1200,
            'system' => $docsSystemPrompt,
            'messages' => [[
                'role' => 'user',
                'content' => $this->renderPromptFromTemplate($contract),
            ]],
        ],
    ];
}

$batch = $client->batches()->create(new BatchCreateRequest(['requests' => $requests]));
$batchId = $batch->id;

do {
    sleep(30);
    $status = $client->batches()->retrieve($batchId);
} while ($status->processing_status !== 'ended');

$results = $client->batches()->results($batchId);
foreach ($results as $line) {
    $this->persistPageMarkdown($line->custom_id, $line->result->message->content[0]->text);
}

Il custom_id uguale all'hash del FQN permette di ritrovare il risultato corretto per ogni classe senza ambiguità. Un secondo vantaggio pratico del batch è che i rate limit non ti mordono: sottometti 1.400 richieste in un colpo, Anthropic le schedula, e il tuo processo dorme invece di ritentare. Il prezzo scontato è l'argomento economico, ma l'argomento di ingegneria è uguale: la pipeline di documentazione non ha requisito di latenza, eseguirla di notte è perfetto.

Il linter sulla documentazione generata: non ti fidi del modello, ti fidi dei tuoi controlli

Dopo la generazione, un linter custom scorre ogni file Markdown e applica check deterministici prima di ammettere il commit. I check che uso sono cinque. Il primo verifica che ogni tipo PHP citato nel Markdown esista nel contratto JSON: se l'LLM ha scritto CustomerRepository ma la classe originale dipendeva da ClientRepository, il linter segnala hallucination e blocca. Il secondo cerca pattern di filler bandito con regex - "questo metodo", "viene utilizzato", "in generale" - e fallisce se ne trova più di uno per pagina. Il terzo conta le parole: una pagina sotto 60 parole è undercook e va rigenerata, sopra 600 è overcook e va tagliata. Il quarto verifica che tutti i marker {{...}} del template siano stati risolti: uno slot lasciato letterale significa prompt fallito. Il quinto verifica coerenza interna: se la sezione ## Dipendenze iniettate nomina un servizio, quel servizio deve comparire nel campo dependencies del contratto.

Questi check non sono esaustivi: un modello può scrivere una descrizione elegantemente falsa che passa tutti e cinque. Ma filtrano il 90% delle derive osservate nel mio laboratorio e forzano la rigenerazione del restante 10% con prompt più vincolante. Il principio è lo stesso che applico negli audit di codice AI-generated: l'output LLM non è mai trusted, passa sempre da un livello di validazione deterministica prima di raggiungere il sistema downstream - sia esso un wiki aziendale o un webhook di produzione. È letteralmente l'applicazione di LLM05 Improper Output Handling della Top 10 OWASP GenAI alla documentazione interna.

Pubblicazione su wiki e la review obbligatoria che non sembra review

Le pagine Markdown passate dal linter non vengono pushate direttamente sul main del wiki. Finiscono in un branch docs/auto-{timestamp} con Pull Request etichettata docs-autogen nel mio Gitea interno. La PR contiene la diff rispetto alla versione precedente, e un bot di review agganciato ai webhook del repo pubblica un commento strutturato: numero di pagine modificate, numero di pagine nuove, numero di pagine che avrebbero dovuto essere aggiornate ma non lo sono state (drift detection), esempio dei tre diff più significativi.

La review è obbligatoria nel senso che una pagina non raggiunge MkDocs fin quando un essere umano - nel mio caso, io, perché è il mio laboratorio, ma in un contesto di team sarebbe lo sviluppatore di riferimento per quel modulo - non ha almeno scorso il diff. La ragione non è burocratica. È che il 3-5% delle rigenerazioni contiene ancora un errore che il linter non ha catturato: una sfumatura semantica sbagliata, una nota operativa che descrive male una race condition, un esempio di uso che compila ma è irrealistico. Quel 3-5% è la differenza tra una wiki affidabile e una wiki tossica. La review non deve prendere un'ora: un auto-merge scatta quando il revisore clicca "looks good" senza commenti, il merge fa partire l'hook di deploy MkDocs, e dopo 40 secondi il wiki è aggiornato.

Rispetto a una soluzione che pubblica direttamente al modello su un endpoint AI di tipo MCP server, qui la separazione tra generazione e pubblicazione è deliberata: lo strato batch ha latenza di ore, la review umana è il checkpoint esplicito, e tutto passa da git - non c'è scorciatoia. Questo è il pattern che distingue production-grade da demo-grade: il deploy continuo di contenuti AI senza checkpoint umano è un incidente che aspetta di accadere.

Il freshness loop: sapere cosa è obsoleto senza rigenerare tutto

La parte più sottile della pipeline è capire quali pagine rigenerare oggi senza rifare tutte le 1.400 ogni notte. Ogni pagina Markdown conserva in un front-matter YAML l'hash SHA-256 del contratto JSON che ha prodotto la generazione. Un comando docs:scan ricalcola il contratto corrente per tutte le classi, confronta gli hash e produce la lista delle classi il cui contratto è cambiato. Solo quelle entrano nel batch di rigenerazione.

Il risultato operativo è che una nightly tipica rigenera 30-80 pagine su 1.400 - il volume medio di modifiche al codice della codebase di laboratorio - invece di riscrivere tutto. Il costo API scende di un ordine di grandezza e, cosa più importante, il noise nel wiki crolla: le PR diventano piccole e leggibili, il revisore vede il delta reale, non un rumore di 1.400 file Markdown toccati dove 1.370 sono identici al byte precedente. Il principio "ottimizza i cambiamenti, non le ri-generazioni totali" è applicabile a qualsiasi pipeline LLM su dati strutturati che cambiano lentamente: vale per la documentazione, vale per le FAQ, vale per i release note automatici, vale per i customer briefing generati da CRM.

Quando questa pipeline non si giustifica

Se la tua codebase è sotto le 50 classi o il tuo team è un singolo sviluppatore, costruire questo intero apparato - parser AST, template vincolante, linter, batch API, freshness loop, wiki MkDocs con PR workflow - è sproporzionato. Scrivi la documentazione a mano e vivi bene. Se la documentazione che ti serve è utente finale - manuali d'uso per un gestionale, guide passo-passo per un portale B2B - un LLM non è il tool: hai bisogno di uno UX writer che parla con i tuoi utenti, non di un parser AST. Se la wiki aziendale è già un caos di documenti Word e pagine Confluence decennali, automatizzare la generazione sopra quel substrato peggiora le cose: la pipeline produce doc pulita, ma convive con doc sporca e il lettore non distingue. Prima consolida, poi automatizza.

Il pattern si giustifica quando hai simultaneamente una codebase sopra le 300 classi, un team di almeno 5 sviluppatori che fanno onboarding frequente, un requisito di conformità che richiede documentazione tecnica tracciabile e aggiornata, e un budget AI mensile che permette 50-100 dollari di spesa ricorrente. In quel contesto il ritorno è netto: le ore che prima andavano in aggiornamento manuale della wiki - facendo male - diventano ore spese a scrivere codice, e la wiki smette di essere una promessa non mantenuta per trasformarsi in un asset reale di onboarding.

Quando un cliente ti dice che "la wiki interna è vecchia di due anni", quello che senti è il sintomo di un processo che ha sempre promesso manutenzione manuale e non l'ha mai ottenuta. Automatizzare con LLM non è la soluzione: è un pezzo di una soluzione che funziona solo se unito a un contratto di estrazione rigoroso, a template vincolanti, a un linter che non si fida dell'output e a una review umana che resta obbligatoria ma veloce. Senza uno solo di questi pezzi, la pipeline produce prosa plausibile e falsa al ritmo del tuo budget Anthropic - AI slop industrializzato. Con tutti e cinque, produce un wiki che regge nel tempo e accompagna davvero il team.

Se hai una codebase consolidata in PHP, Python, Node.js o altro linguaggio tipizzato e vuoi capire se questo pattern di automazione documentazione è adatto al tuo caso, il modulo di preventivo gratuito ti dà una prima lettura in 7 domande, 2 minuti. Ti dico se il tuo progetto rientra nelle cose che so fare bene e, se il caso richiede un profilo diverso, te lo dico e ti indico una direzione utile quando posso.

Ultima modifica: