Knowledge base a supporto di un sistema di ticketing

Guida completa per sviluppare un knowledge base con Laravel 13: obiettivi, scelte architetturali, codice funzionante e costi.

C’è un paradosso in quasi tutti i servizi di assistenza: l’azienda spende mesi a decidere se scrivere una knowledge base, mentre la knowledge base è già stata scritta. Sta nei ticket chiusi. Ogni problema che un cliente ha avuto, ogni soluzione che ha funzionato, ogni causa che qualcuno ha diagnosticato: è tutto lì, in decine di migliaia di conversazioni che nessuno rileggerà mai perché sono nel formato sbagliato.

Questa guida descrive come trasformare quell’archivio in una chat che risponde ai clienti, costruita con Laravel e MySQL. Non è un chatbot generico collegato a un modello: è un sistema che distilla i ticket risolti in unità di conoscenza verificate, le indicizza e le serve a un assistente conversazionale che risponde solo con quel materiale, citando le fonti e ammettendo quando non sa.

Affronteremo gli obiettivi, la scelta architetturale che decide tutto il resto (perché non si “addestra” un modello sui ticket), il problema concreto e poco discusso di fare ricerca semantica su MySQL invece che su PostgreSQL, il codice completo, il ciclo di revisione umana, la conformità al GDPR, le metriche e i costi.

L’idea: la conoscenza esiste già, è nel formato sbagliato

Un ticket risolto contiene la risposta a un problema reale, formulato con le parole vere di un cliente vero. È materiale di qualità superiore a qualsiasi articolo scritto a tavolino, per un motivo semplice: descrive i problemi che le persone hanno davvero avuto, non quelli che il product manager immaginava avrebbero avuto.

Però è materiale grezzo, e grezzo significa inutilizzabile così com’è. Una conversazione di assistenza è un percorso, non una risposta: contiene ipotesi sbagliate, tentativi falliti, note interne, richieste di chiarimento, e in fondo — se va bene — la cosa che ha funzionato. Contiene anche dati personali, riferimenti a un singolo account, e a volte concessioni commerciali che non devono essere ripetute a nessun altro.

Il progetto consiste quindi in due sistemi, non uno: una pipeline di distillazione che trasforma i ticket in conoscenza, e una chat che serve quella conoscenza. Chi salta il primo e collega direttamente un modello all’archivio dei ticket costruisce qualcosa che funziona nella demo e diventa un problema legale in produzione.

Obiettivi del sistema

  1. Rispondere autonomamente alle domande già risolte in passato. Se un problema è stato risolto venti volte, la ventunesima non dovrebbe occupare un operatore.
  2. Costruire la knowledge base senza un progetto di redazione. Il contenuto si genera dai ticket che state già gestendo: il costo marginale di partenza è la revisione, non la scrittura.
  3. Non inventare mai. Ogni affermazione deve essere riconducibile a un’unità di conoscenza citata. “Non lo so, le apro un ticket” deve essere una risposta di prima classe, non un fallimento.
  4. Passare all’operatore senza far ripetere il problema. Quando la chat non sa, la conversazione diventa il corpo del ticket. È il dettaglio che rende accettabile un assistente che a volte si arrende.
  5. Restare dentro l’infrastruttura esistente. Laravel, MySQL, la coda che avete già. Nessun database vettoriale in più da gestire, monitorare e mettere in sicurezza.
  6. Essere conforme per costruzione. I dati personali non devono entrare nell’indice, non solo non uscirne.

La scelta che decide tutto: RAG, non addestramento

Quando si dice “un’AI istruita dalle informazioni contenute nei ticket”, si intendono due cose molto diverse, e sceglierne una sbagliata compromette il progetto prima di iniziare.

La prima è il fine-tuning: si prende un modello e lo si riaddestra sui ticket, così che la conoscenza finisca nei suoi pesi. È l’interpretazione istintiva ed è quasi sempre la scelta sbagliata per questo scopo. Il fine-tuning insegna bene lo stile e il formato, ma è un modo inefficiente e inaffidabile di inserire fatti: il modello non sa da dove viene ciò che dice, quindi non può citare nulla; non si può correggere un fatto sbagliato se non riaddestrando; non si può cancellare un dato su richiesta di un cliente; e ogni aggiornamento della conoscenza richiede un nuovo ciclo di addestramento, mentre l’assistenza cambia ogni settimana.

La seconda è il RAG (Retrieval-Augmented Generation): la conoscenza resta nel database, viene recuperata al momento della domanda e passata al modello nel contesto. Il modello non “sa” nulla: legge e riassume materiale che gli viene fornito. Da qui discendono tutte le proprietà che servono a un sistema di assistenza: le risposte sono citabili e quindi verificabili; una correzione si applica istantaneamente modificando un record; una cancellazione è una DELETE; e la knowledge base si aggiorna in tempo reale mentre i ticket vengono chiusi.

Il costo di questa scelta è che il sistema è forte quanto il suo recupero: se la ricerca non trova l’unità giusta, il modello non può inventarsela (e non deve). Ecco perché la metà tecnica di questo articolo riguarda la ricerca e non la generazione.

Perché i ticket grezzi non si danno in pasto a una chat

La tentazione, una volta scelta la strada del RAG, è di indicizzare direttamente le conversazioni dei ticket. È veloce da fare e produce una demo convincente. Quattro problemi la rendono insostenibile in produzione.

Il rumore supera il segnale. In una conversazione di dieci messaggi, la soluzione sta in uno. Gli altri nove — “ha provato a riavviare?”, “le chiedo un momento”, “non funziona nemmeno così” — vengono indicizzati con lo stesso peso, e il recupero restituisce sistematicamente il tentativo fallito invece della soluzione.

Le risposte sbagliate sono indistinguibili da quelle giuste. Se un operatore ha suggerito una procedura errata al terzo messaggio e quella corretta al settimo, il modello vede due procedure e nessun segnale su quale abbia funzionato.

I dati personali sono ovunque. Email, numeri di telefono, IBAN, indirizzi, riferimenti a ordini. Indicizzarli significa costruire un sistema che può restituire i dati di un cliente a un altro cliente. Non è un rischio teorico: è il modo tipico in cui questi progetti finiscono male.

Le eccezioni diventano regole. “Le riconosciamo un rimborso in via del tutto eccezionale” è la frase più pericolosa che possa finire in una knowledge base. Il modello non ha modo di sapere che era eccezionale, e la ripeterà.

La soluzione è interporre una fase di distillazione: un modello legge la conversazione completa del ticket risolto e produce un’unità di conoscenza strutturata — problema con le parole del cliente, sintomi, causa accertata, soluzione che ha effettivamente funzionato, condizioni di applicabilità — insieme a una serie di semafori di rischio: contiene dati personali? è una concessione commerciale? è un intervento manuale non riproducibile? Solo le unità che superano questi controlli, e la prima approvazione umana, entrano nell’indice. I ticket grezzi non vengono mai serviti alla chat.

C’è un effetto collaterale prezioso: siccome la distillazione produce una firma del problema, i ticket che descrivono lo stesso caso confluiscono in un’unica unità con un contatore. Ventimila ticket non diventano ventimila articoli: diventano una o due migliaia di unità, ordinate per frequenza. E quella classifica per frequenza è, gratuitamente, il vostro piano di revisione.

Ricerca semantica su MySQL: il problema, e come si risolve

Qui sta la parte tecnicamente più interessante, e quella su cui si trova meno materiale, perché quasi tutta la letteratura sul RAG dà per scontato PostgreSQL con pgvector.

MySQL ha il tipo VECTOR ma non la funzione per usarlo

MySQL 9 ha introdotto il tipo di dato VECTOR(N), che memorizza fino a 16.383 valori in virgola mobile a precisione singola, con le funzioni di conversione STRING_TO_VECTOR() e VECTOR_TO_STRING(). Sembra tutto risolto, finché non si legge la riga decisiva del manuale a proposito della funzione che calcola la distanza fra due vettori: «DISTANCE() è disponibile solo per gli utenti di MySQL HeatWave su OCI e MySQL AI; non è inclusa nelle distribuzioni MySQL Commercial o Community».

Tradotto: su un’installazione MySQL normale potete memorizzare i vettori, ma non potete interrogarli per similarità. In più il tipo VECTOR non può essere usato come chiave di alcun tipo, quindi non esiste nemmeno un indice vettoriale su cui appoggiarsi.

Le quattro strade

OpzioneRicerca vettorialeCostoQuando ha senso
MySQL Community + calcolo applicativoSì, gestita in PHPZero servizi in piùFino a qualche decina di migliaia di frammenti: la maggioranza dei casi
MySQL HeatWave / MySQL AINativa, con indiceVincolo di piattaforma (OCI)Se siete già su quella infrastruttura
MariaDB 11.8 LTSNativa, con indice HNSWCambio di motoreCorpus molto grandi, con compatibilità MySQL
Motore esterno (Scout)DelegataUn servizio in piùSe serve già per altre ricerche

Restiamo su MySQL Community, che è il vincolo posto in partenza, e vediamo che è una scelta perfettamente difendibile — a patto di progettare il recupero con criterio.

La chiave di volta: un indice IVF fatto con una colonna intera

L’approccio ingenuo è scaricare tutti i vettori e calcolare il coseno in PHP. Funziona, ma non scala: su un corpus da 50.000 frammenti a 1.536 dimensioni una scansione completa in PHP puro richiede oltre due secondi, che per una chat sono inaccettabili.

La soluzione è la stessa che usano i database vettoriali veri, solo implementata con gli strumenti che MySQL offre: un indice IVF (inverted file). Si esegue un k-means sui vettori, si ottengono per esempio 256 centroidi, e si salva su ogni frammento il numero del cluster a cui appartiene — in una normalissima colonna SMALLINT indicizzata. A ogni domanda si calcolano le distanze dai soli 256 centroidi (operazione istantanea, stanno in cache in meno di 64 KB), si scelgono gli 8 più vicini e si estraggono da MySQL solo i frammenti di quei cluster: circa il 3% del corpus.

La ricerca esatta diventa così una ricerca approssimata su poche migliaia di vettori invece che su cinquantamila. È esattamente il compromesso che fanno HNSW e IVF nei motori dedicati, e alla scala di una knowledge base aziendale la perdita di richiamo è trascurabile perché il ramo lessicale, che vedremo fra poco, recupera ciò che sfugge.

La quantizzazione: da 6 KB a 256 byte per frammento

Il secondo acceleratore riguarda la dimensione dei vettori. Un embedding a 1.536 dimensioni in virgola mobile occupa 6.144 byte. Per la fase di scansione non serve tutta quella precisione: basta troncare alle prime 256 dimensioni, rinormalizzare e quantizzare a interi con segno a 8 bit. Il risultato occupa 256 byte, ventiquattro volte meno, e con vettori normalizzati il prodotto scalare fra due interi resta una buona approssimazione del coseno.

Sono numeri misurati, non stimati. Con PHP 8.4, su un singolo core, una scansione lineare in PHP puro impiega:

Vettori scansionatiDimensioniTempo
2.000256~17 ms
10.000256~78 ms
50.000256~367 ms
50.0001.536~2.160 ms

Con il cluster probing si scansionano circa 1.500–2.000 vettori a 256 dimensioni: siamo sotto i 20 millisecondi, contro i 2,2 secondi della scansione completa a piena precisione. La precisione persa nella quantizzazione viene recuperata alla fine, riscorando i venti finalisti con il vettore completo — un’operazione su venti elementi, quindi gratuita.

Il ramo lessicale, e un problema specifico dell’italiano

La ricerca vettoriale da sola non basta, per la stessa ragione di sempre: i codici errore, le sigle e i numeri di versione non hanno un significato che un embedding possa rappresentare. “Errore E-4021” va trovato con una ricerca per parole, e MySQL offre MATCH ... AGAINST con indici FULLTEXT su InnoDB.

Qui però c’è una limitazione che conviene conoscere prima di scoprirla in produzione: l’indice full-text di InnoDB non fa stemming, e la sua lista di stop word predefinita è in inglese. In italiano questo significa che “accedere” non trova “accesso”, che “configurazione” non trova “configurare”, e che parole vuotissime come “come”, “quando” e “perché” contribuiscono al punteggio come se fossero termini significativi. Il minimo di lunghezza dei token è inoltre di tre caratteri.

Si compensa su tre fronti, tutti economici: una lista di stop word italiane applicata a livello applicativo, la ricerca in modalità booleana con troncamento (config*), e soprattutto le frasi di ricerca alternative che la fase di distillazione genera per ogni unità — tre-sei modi diversi in cui un cliente potrebbe descrivere lo stesso problema, indicizzati insieme al contenuto. È la compensazione più efficace, perché sposta il lavoro di normalizzazione linguistica dal momento della query, dove costa latenza, al momento della scrittura, dove è gratuito.

La fusione e il riordino finale

Restano due classifiche, una lessicale e una semantica, con punteggi su scale incomparabili. Si fondono con Reciprocal Rank Fusion: ogni frammento riceve 1 / (k + posizione) da ciascuna classifica in cui compare e i contributi si sommano. Non ci sono pesi da tarare, la scala dei punteggi non conta, e i documenti che compaiono in entrambe le liste emergono naturalmente — che è il comportamento che si vuole.

Sui venti sopravvissuti si applica infine un modello di reranking, che legge davvero la coppia domanda-frammento e riordina in base alla pertinenza reale. È l’operazione che più migliora la precisione nelle prime tre posizioni, ed è anche la più costosa: per questo si applica a venti elementi e non all’intero corpus.

L’architettura in nove componenti

  1. Innesco — alla chiusura di un ticket parte un job di distillazione.
  2. Anonimizzazione — i dati personali vengono sostituiti prima che il testo esca dal database.
  3. Distillazione — un agent produce l’unità di conoscenza strutturata con i semafori di rischio.
  4. Deduplica e rinforzo — i ticket sullo stesso problema confluiscono in un’unica unità con un contatore.
  5. Revisione umana — nessuna unità entra nell’indice senza una prima approvazione.
  6. Indicizzazione — chunking, embedding, quantizzazione, assegnazione al cluster IVF.
  7. Recupero ibrido — cluster probing, full-text, RRF, riscoring esatto, reranking.
  8. Chat — agent conversazionale con lo strumento di ricerca, citazioni obbligatorie e streaming.
  9. Anello di ritorno — le domande a cui la chat non ha saputo rispondere diventano la lista dei buchi.

Implementazione passo passo

1. Installazione

composer require laravel/ai
php artisan make:agent TicketDistiller --structured
php artisan make:agent SupportChat
php artisan make:tool SearchKnowledgeBase
DB_CONNECTION=mysql
ANTHROPIC_API_KEY=sk-ant-...
QUEUE_CONNECTION=redis

2. Lo schema dati

Le unità di conoscenza sono separate dai ticket da cui derivano — collegate, ma non dipendenti. È questa separazione che rende possibile cancellare un ticket su richiesta del cliente senza perdere ciò che si è imparato. Notate le due colonne aggiunte con SQL grezzo: il tipo VECTOR per il vettore completo e un VARBINARY(256) per la versione quantizzata che verrà scansionata in memoria.

<?php

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

return new class extends Migration
{
    public function up(): void
    {
        // L'unità di conoscenza: NON è il ticket, è ciò che si è imparato dal ticket.
        // I ticket grezzi non vengono mai serviti alla chat.
        Schema::create('knowledge_units', function (Blueprint $table) {
            $table->id();

            $table->text('problem');            // il problema con le parole del cliente
            $table->text('symptoms')->nullable();
            $table->text('cause')->nullable();
            $table->longText('solution');       // la soluzione che ha effettivamente funzionato
            $table->text('applicability')->nullable(); // "solo su piano Business", "solo versione >= 4.2"

            $table->string('product', 80)->nullable();
            $table->string('category', 60)->nullable();
            $table->string('locale', 5)->default('it');

            // draft -> pending_review -> approved -> stale|archived
            // Solo 'approved' finisce nell'indice interrogato dalla chat.
            $table->string('status', 20)->default('pending_review')->index();

            $table->string('dedup_key', 64)->index();   // firma del problema, per fondere i duplicati
            $table->unsignedInteger('ticket_count')->default(1);
            $table->unsignedInteger('total_handle_seconds')->default(0);
            $table->timestamp('last_seen_at')->nullable();
            $table->timestamp('review_due_at')->nullable();

            // Semafori di rischio prodotti dalla distillazione
            $table->boolean('contains_pii')->default(false);
            $table->boolean('is_commercial_exception')->default(false);
            $table->boolean('is_one_off')->default(false);

            $table->unsignedInteger('served_count')->default(0);
            $table->unsignedInteger('helpful_yes')->default(0);
            $table->unsignedInteger('helpful_no')->default(0);

            $table->foreignId('approved_by')->nullable()->constrained('users')->nullOnDelete();
            $table->timestamp('approved_at')->nullable();
            $table->timestamps();
        });

        // Da quali ticket deriva ogni unità: serve per l'audit e per il diritto
        // alla cancellazione (si scollega la fonte senza perdere la conoscenza).
        Schema::create('knowledge_unit_ticket', function (Blueprint $table) {
            $table->foreignId('knowledge_unit_id')->constrained()->cascadeOnDelete();
            $table->foreignId('ticket_id')->constrained()->cascadeOnDelete();
            $table->primary(['knowledge_unit_id', 'ticket_id']);
        });

        Schema::create('knowledge_chunks', function (Blueprint $table) {
            $table->id();
            $table->foreignId('knowledge_unit_id')->constrained()->cascadeOnDelete();
            $table->unsignedSmallInteger('position');
            $table->text('content');
            $table->string('content_hash', 64)->index();

            // Il "cestino" IVF: l'indice semantico approssimato realizzato con
            // una banale colonna intera indicizzata. È la chiave di volta su MySQL.
            $table->unsignedSmallInteger('cluster_id')->nullable();

            $table->timestamps();

            $table->unique(['knowledge_unit_id', 'position']);
            $table->index(['cluster_id']);
        });

        // MySQL 9 ha il tipo VECTOR ma il Blueprint di Laravel non lo espone su
        // questo driver: si aggiunge con una istruzione grezza.
        //  - embedding:  vettore completo, per il riscoring esatto dei finalisti
        //  - embedding_small: 256 dimensioni quantizzate a int8, per la scansione
        //    in memoria. 1.536 float = 6 KB; 256 int8 = 256 byte, 24 volte meno.
        DB::statement('ALTER TABLE knowledge_chunks
            ADD COLUMN embedding VECTOR(1536) NULL,
            ADD COLUMN embedding_small VARBINARY(256) NULL');

        DB::statement('ALTER TABLE knowledge_chunks
            ADD FULLTEXT INDEX ft_content (content)');

        Schema::create('chat_conversations', function (Blueprint $table) {
            $table->id();
            $table->uuid('public_id')->unique();
            $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
            $table->string('locale', 5)->default('it');
            $table->string('product', 80)->nullable();
            $table->foreignId('escalated_ticket_id')->nullable()->constrained('tickets')->nullOnDelete();
            $table->timestamps();
        });

        Schema::create('chat_messages', function (Blueprint $table) {
            $table->id();
            $table->foreignId('chat_conversation_id')->constrained()->cascadeOnDelete();
            $table->string('role', 12);          // user | assistant
            $table->text('content');
            $table->json('cited_units')->nullable();
            $table->boolean('answered')->nullable();   // false = la KB non copriva
            $table->tinyInteger('rating')->nullable(); // -1 | 1
            $table->unsignedInteger('input_tokens')->nullable();
            $table->unsignedInteger('output_tokens')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('chat_messages');
        Schema::dropIfExists('chat_conversations');
        Schema::dropIfExists('knowledge_chunks');
        Schema::dropIfExists('knowledge_unit_ticket');
        Schema::dropIfExists('knowledge_units');
    }
};

3. L’anonimizzazione

Primo dei tre strati di protezione, e il solo deterministico. Non pretende di essere perfetto: gli altri due sono le istruzioni date al modello di distillazione e la revisione umana prima dell’approvazione.

<?php

namespace App\Services\Kb;

class PiiRedactor
{
    /**
     * Primo strato, deterministico: si esegue PRIMA che il testo del ticket esca
     * dal database. Non è perfetto e non pretende di esserlo — il secondo strato
     * è il modello di distillazione, istruito a non trascrivere dati personali,
     * e il terzo è la revisione umana prima dell'approvazione.
     * Tre strati imperfetti in serie valgono più di uno solo che si presume perfetto.
     */
    private const PATTERNS = [
        'EMAIL' => '/[\w.+-]+@[\w-]+\.[\w.-]+/u',
        'IBAN' => '/\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b/u',
        'CARTA' => '/\b(?:\d[ -]*?){13,19}\b/u',
        'CF' => '/\b[A-Z]{6}\d{2}[A-EHLMPRST]\d{2}[A-Z]\d{3}[A-Z]\b/iu',
        'PIVA' => '/\bIT ?\d{11}\b/iu',
        'TELEFONO' => '/(?:\+39[\s.-]?)?\b3\d{2}[\s.-]?\d{6,7}\b/u',
        'URL_TOKEN' => '/[?&](?:token|key|password|secret)=[^\s&]+/iu',
        'IP' => '/\b(?:\d{1,3}\.){3}\d{1,3}\b/u',
    ];

    public function redact(string $text): string
    {
        foreach (self::PATTERNS as $label => $pattern) {
            $text = preg_replace($pattern, "[{$label}]", $text) ?? $text;
        }

        return $text;
    }

    public function containsPii(string $text): bool
    {
        foreach (self::PATTERNS as $pattern) {
            if (preg_match($pattern, $text)) {
                return true;
            }
        }

        return false;
    }
}

4. L’agent di distillazione

È il componente più importante del sistema, e le sue istruzioni valgono più di qualsiasi ottimizzazione della ricerca. Il concetto da trasmettere al modello è che un ticket non è conoscenza: bisogna separare ciò che vale per il prossimo cliente da ciò che valeva solo per questo.

<?php

namespace App\Ai\Agents;

use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Attributes\MaxTokens;
use Laravel\Ai\Attributes\Model;
use Laravel\Ai\Attributes\Provider;
use Laravel\Ai\Attributes\Temperature;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Promptable;
use Stringable;

#[Provider(Lab::Anthropic)]
#[Model('claude-sonnet-5')]
#[Temperature(0)]
#[MaxTokens(3000)]
class TicketDistiller implements Agent, HasStructuredOutput
{
    use Promptable;

    public function instructions(): Stringable|string
    {
        return <<<'PROMPT'
        Ricevi la conversazione completa di un ticket di assistenza già risolto.
        Il tuo compito è distillarne una unità di conoscenza riutilizzabile.

        Un ticket NON è conoscenza. Contiene tentativi falliti, ipotesi scartate,
        note interne e dettagli irripetibili di un singolo cliente. Il tuo lavoro
        è separare ciò che vale anche per il prossimo cliente da ciò che valeva
        solo per questo.

        Regole:
        - Descrivi il problema con le PAROLE DEL CLIENTE, non con la terminologia
          interna. È il testo su cui verrà fatta la ricerca.
        - Riporta come soluzione SOLO il passaggio che ha effettivamente risolto,
          non l'intera sequenza dei tentativi. Se dalla conversazione non risulta
          chiaro che cosa abbia funzionato, imposta is_reusable a false.
        - Non trascrivere MAI dati personali: nomi, email, telefoni, indirizzi,
          identificativi di account, numeri d'ordine. Se un dato serve alla
          comprensione, sostituiscilo con un segnaposto generico.
        - Segnala con is_commercial_exception le risoluzioni ottenute con una
          concessione (rimborso, sconto, deroga): sono decisioni aziendali, non
          conoscenza tecnica, e non devono essere ripetute automaticamente.
        - Segnala con is_one_off i casi risolti da un intervento manuale non
          riproducibile dal cliente né dal primo livello.
        - Scrivi applicability solo se dalla conversazione risultano condizioni
          esplicite (piano, versione, sistema operativo, area geografica).
        PROMPT;
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'is_reusable' => $schema->boolean()
                ->description('false se il ticket non contiene conoscenza riutilizzabile: nessuna soluzione chiara, richiesta puramente amministrativa, o conversazione senza esito.')
                ->required(),

            'problem' => $schema->string()
                ->description('Il problema in prima persona, con il vocabolario del cliente. Una o due frasi.')
                ->required(),

            'symptoms' => $schema->string()
                ->description('Che cosa osserva concretamente l\'utente: messaggi di errore, comportamenti, codici.')
                ->nullable(),

            'cause' => $schema->string()
                ->description('La causa accertata, se emerge dalla conversazione. Null se non è stata accertata.')
                ->nullable(),

            'solution' => $schema->string()
                ->description('La procedura che ha risolto, in passi numerati, riproducibile da un\'altra persona.')
                ->required(),

            'applicability' => $schema->string()
                ->description('Condizioni in cui la soluzione vale. Null se vale sempre.')
                ->nullable(),

            'category' => $schema->string()
                ->enum(['accesso', 'fatturazione', 'configurazione', 'guasto', 'prestazioni', 'integrazioni', 'commerciale', 'altro'])
                ->required(),

            'search_phrases' => $schema->array()
                ->items($schema->string())
                ->description('Da tre a sei modi diversi in cui un cliente potrebbe descrivere questo stesso problema. Alimentano l\'indice lessicale.')
                ->required(),

            'is_commercial_exception' => $schema->boolean()->required(),
            'is_one_off' => $schema->boolean()->required(),

            'confidence' => $schema->number()
                ->description('Da 0 a 1: quanto la conversazione supporta questa distillazione.')
                ->required(),
        ];
    }
}

5. Il job di distillazione, con deduplica e gestione dei conflitti

Tre comportamenti meritano attenzione. Le concessioni commerciali vengono scartate a priori. I problemi già noti non generano duplicati ma rinforzano l’unità esistente. E quando un nuovo ticket propone una soluzione diversa da quella già approvata, il sistema non media e non sovrascrive: apre una segnalazione di conflitto per un umano. È la differenza fra una knowledge base che migliora e una che deriva.

<?php

namespace App\Jobs\Kb;

use App\Ai\Agents\TicketDistiller;
use App\Models\KnowledgeUnit;
use App\Models\Ticket;
use App\Services\Kb\PiiRedactor;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\Backoff;
use Illuminate\Queue\Attributes\Tries;
use Illuminate\Support\Str;

#[Tries(3)]
#[Backoff([30, 120, 600])]
class DistillTicket implements ShouldQueue
{
    use Queueable;

    public function __construct(public Ticket $ticket) {}

    public function handle(PiiRedactor $redactor): void
    {
        $transcript = $this->ticket->messages()
            ->where('visibility', '!=', 'internal_confidential')
            ->orderBy('created_at')
            ->get()
            ->map(fn ($m) => strtoupper($m->author_type).': '.$redactor->redact($m->body))
            ->implode("\n\n");

        $unit = (new TicketDistiller)->prompt(<<<PROMPT
        CATEGORIA DICHIARATA: {$this->ticket->category}
        PRODOTTO: {$this->ticket->product}

        CONVERSAZIONE:
        {$transcript}
        PROMPT);

        // Tre motivi per scartare: nessuna conoscenza, distillazione incerta,
        // oppure una concessione commerciale che non va mai ripetuta in automatico.
        if (! $unit['is_reusable'] || $unit['confidence'] < 0.6 || $unit['is_commercial_exception']) {
            $this->ticket->update(['kb_status' => 'not_distillable']);

            return;
        }

        $key = $this->dedupKey($unit);

        $knowledge = KnowledgeUnit::firstOrNew(['dedup_key' => $key]);

        if ($knowledge->exists) {
            // Il problema è già noto: non si crea un duplicato, si rafforza
            // l'unità esistente. È così che la frequenza diventa una priorità.
            $knowledge->increment('ticket_count');
            $knowledge->increment('total_handle_seconds', $this->ticket->handle_seconds ?? 0);
            $knowledge->update(['last_seen_at' => now()]);

            // Se la nuova soluzione differisce da quella approvata, non si media
            // e non si sovrascrive: si apre una segnalazione per un umano.
            if ($knowledge->status === 'approved'
                && ! $this->equivalent($knowledge->solution, $unit['solution'])) {
                $knowledge->conflicts()->create([
                    'ticket_id' => $this->ticket->id,
                    'proposed_solution' => $unit['solution'],
                ]);
            }
        } else {
            $knowledge->fill([
                'problem' => $unit['problem'],
                'symptoms' => $unit['symptoms'],
                'cause' => $unit['cause'],
                'solution' => $unit['solution'],
                'applicability' => $unit['applicability'],
                'category' => $unit['category'],
                'product' => $this->ticket->product,
                'locale' => $this->ticket->locale,
                'search_phrases' => $unit['search_phrases'],
                'is_one_off' => $unit['is_one_off'],
                'contains_pii' => $redactor->containsPii($unit['problem'].$unit['solution']),
                'total_handle_seconds' => $this->ticket->handle_seconds ?? 0,
                'last_seen_at' => now(),
                'review_due_at' => now()->addMonths(6),
                // Niente pubblicazione automatica: la prima approvazione è umana.
                'status' => 'pending_review',
            ])->save();
        }

        $knowledge->tickets()->syncWithoutDetaching([$this->ticket->id]);
        $this->ticket->update(['kb_status' => 'distilled']);
    }

    /** Firma stabile del problema: categoria, prodotto e radici delle parole chiave. */
    private function dedupKey(array $unit): string
    {
        $words = Str::of($unit['problem'])
            ->lower()
            ->replaceMatches('/[^\p{L}\s]/u', ' ')
            ->explode(' ')
            ->filter(fn ($w) => mb_strlen($w) >= 5)
            ->map(fn ($w) => mb_substr($w, 0, 6))   // troncamento: stemming del povero
            ->unique()->sort()->take(6)->implode('-');

        return hash('sha256', "{$unit['category']}|{$words}");
    }

    private function equivalent(string $a, string $b): bool
    {
        similar_text(
            Str::of($a)->lower()->squish(),
            Str::of($b)->lower()->squish(),
            $percent
        );

        return $percent > 75;
    }
}

6. Il vector store applicativo

Compressione, decompressione, coseno e cluster probing. È il componente che sostituisce la funzione DISTANCE() assente da MySQL Community.

<?php

namespace App\Services\Kb;

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;

/**
 * MySQL Community ha il tipo VECTOR ma NON la funzione DISTANCE(): il calcolo
 * della similarità va fatto fuori dal database. Invece di scansionare tutto il
 * corpus a ogni domanda, si usa una IVF (inverted file index) realizzata con una
 * banale colonna intera indicizzata: ogni frammento appartiene a un cluster, e
 * la ricerca esamina solo i cluster più vicini alla domanda.
 */
class VectorStore
{
    public function __construct(
        private int $smallDim = 256,   // dimensioni ridotte per la scansione
        private int $probes = 8,       // quanti cluster esaminare per query
    ) {}

    /**
     * Comprime un embedding in 256 byte: troncamento alle prime N dimensioni,
     * rinormalizzazione e quantizzazione a interi con segno.
     * 1.536 float (6 KB) diventano 256 byte: ventiquattro volte meno memoria,
     * con una perdita di precisione che il riscoring finale recupera.
     */
    public function compress(array $embedding): string
    {
        $truncated = array_slice($embedding, 0, $this->smallDim);
        $norm = sqrt(array_sum(array_map(fn ($v) => $v * $v, $truncated))) ?: 1.0;

        return pack('c*', ...array_map(
            fn ($v) => max(-127, min(127, (int) round($v / $norm * 127))),
            $truncated
        ));
    }

    /** @return array<int, float> */
    public function decompress(string $packed): array
    {
        return array_values(unpack('c*', $packed));
    }

    public function cosine(array $a, array $b): float
    {
        $dot = 0.0;
        $count = min(count($a), count($b));

        for ($i = 0; $i < $count; $i++) {
            $dot += $a[$i] * $b[$i];
        }

        return $dot;   // vettori già normalizzati: il prodotto scalare è il coseno
    }

    /**
     * Recupero semantico approssimato.
     *
     * @return array<int, float>  id del frammento => punteggio
     */
    public function nearest(array $queryEmbedding, array $filters = [], int $limit = 40): array
    {
        $query = $this->decompress($this->compress($queryEmbedding));
        $clusters = $this->nearestClusters($query);

        $rows = DB::table('knowledge_chunks as c')
            ->join('knowledge_units as u', 'u.id', '=', 'c.knowledge_unit_id')
            ->whereIn('c.cluster_id', $clusters)
            ->where('u.status', 'approved')
            ->when($filters['product'] ?? null, fn ($q, $p) => $q->where('u.product', $p))
            ->when($filters['locale'] ?? null, fn ($q, $l) => $q->where('u.locale', $l))
            ->select('c.id', 'c.embedding_small')
            ->get();

        $scores = [];

        foreach ($rows as $row) {
            $scores[$row->id] = $this->cosine($query, $this->decompress($row->embedding_small));
        }

        arsort($scores);

        return array_slice($scores, 0, $limit, preserve_keys: true);
    }

    /** I centroidi stanno in cache: sono 256 vettori da 256 byte, meno di 64 KB. */
    private function nearestClusters(array $query): array
    {
        $centroids = Cache::rememberForever('kb:centroids', fn () => DB::table('kb_centroids')
            ->pluck('vector', 'id')
            ->map(fn ($v) => $this->decompress($v))
            ->all());

        $distances = [];

        foreach ($centroids as $id => $centroid) {
            $distances[$id] = $this->cosine($query, $centroid);
        }

        arsort($distances);

        return array_slice(array_keys($distances), 0, $this->probes);
    }
}

7. Il ricalcolo dei cluster

Un k-means che gira una volta a settimana da cron. Fra un’esecuzione e l’altra i frammenti nuovi vengono assegnati al centroide più vicino fra quelli esistenti, il che è sufficiente finché il corpus non cambia natura.

<?php

namespace App\Console\Commands;

use App\Services\Kb\VectorStore;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;

class RebuildKbClusters extends Command
{
    protected $signature = 'kb:cluster {--clusters=256} {--iterations=12}';

    protected $description = 'Ricalcola i centroidi IVF e riassegna i frammenti ai cluster';

    /**
     * K-means su vettori già normalizzati. Si esegue una volta a settimana:
     * fra un'esecuzione e l'altra i frammenti nuovi vengono assegnati al
     * centroide più vicino esistente, il che è sufficiente finché il corpus
     * non cambia natura.
     */
    public function handle(VectorStore $store): int
    {
        $k = (int) $this->option('clusters');

        $chunks = DB::table('knowledge_chunks')
            ->whereNotNull('embedding_small')
            ->pluck('embedding_small', 'id')
            ->map(fn ($v) => $store->decompress($v));

        if ($chunks->count() < $k * 4) {
            $this->warn("Corpus troppo piccolo per {$k} cluster: la scansione completa è più efficiente.");

            return self::SUCCESS;
        }

        $centroids = $chunks->random($k)->values()->all();
        $assignments = [];

        for ($i = 0; $i < (int) $this->option('iterations'); $i++) {
            $assignments = [];

            foreach ($chunks as $id => $vector) {
                $best = 0;
                $bestScore = -INF;

                foreach ($centroids as $c => $centroid) {
                    $score = $store->cosine($vector, $centroid);

                    if ($score > $bestScore) {
                        $bestScore = $score;
                        $best = $c;
                    }
                }

                $assignments[$id] = $best;
            }

            $centroids = $this->recompute($chunks, $assignments, $k, count($centroids[0]));
            $this->info('Iterazione '.($i + 1).' completata.');
        }

        DB::transaction(function () use ($assignments, $centroids, $store) {
            foreach (collect($assignments)->groupBy(fn ($c) => $c) as $cluster => $ids) {
                DB::table('knowledge_chunks')
                    ->whereIn('id', $ids->keys())
                    ->update(['cluster_id' => $cluster]);
            }

            DB::table('kb_centroids')->truncate();
            DB::table('kb_centroids')->insert(
                collect($centroids)->map(fn ($c, $i) => [
                    'id' => $i,
                    'vector' => $store->compress($this->normalize($c)),
                ])->all()
            );
        });

        Cache::forget('kb:centroids');
        $this->info('Centroidi ricostruiti.');

        return self::SUCCESS;
    }

    private function recompute($chunks, array $assignments, int $k, int $dim): array
    {
        $sums = array_fill(0, $k, array_fill(0, $dim, 0.0));
        $counts = array_fill(0, $k, 0);

        foreach ($assignments as $id => $cluster) {
            foreach ($chunks[$id] as $d => $value) {
                $sums[$cluster][$d] += $value;
            }

            $counts[$cluster]++;
        }

        foreach ($sums as $c => $sum) {
            if ($counts[$c] > 0) {
                $sums[$c] = $this->normalize(array_map(fn ($v) => $v / $counts[$c], $sum));
            }
        }

        return $sums;
    }

    private function normalize(array $vector): array
    {
        $norm = sqrt(array_sum(array_map(fn ($v) => $v * $v, $vector))) ?: 1.0;

        return array_map(fn ($v) => $v / $norm, $vector);
    }
}

8. La ricerca ibrida

Il punto in cui tutto converge: recupero semantico approssimato, recupero lessicale compensato, fusione RRF, riscoring esatto, reranking.

<?php

namespace App\Services\Kb;

use App\Models\KnowledgeChunk;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;

class HybridSearch
{
    public function __construct(private VectorStore $vectors) {}

    /**
     * Su MySQL i due rami sono asimmetrici e vanno compensati:
     *  - il ramo lessicale (MATCH ... AGAINST) non fa stemming per l'italiano,
     *    quindi "accedere" non trova "accesso". Si compensa espandendo la query
     *    con le riformulazioni prodotte in fase di distillazione.
     *  - il ramo semantico è approssimato (IVF), quindi si recupera largo e si
     *    riscora dopo con il vettore a piena precisione.
     */
    public function search(string $question, array $filters = [], int $limit = 5): Collection
    {
        $embedding = Str::of($question)->toEmbeddings();

        $semantic = $this->vectors->nearest($embedding, $filters, limit: 40);
        $lexical = $this->lexical($question, $filters, limit: 40);

        $fusedIds = $this->fuse([array_keys($semantic), $lexical]);

        $chunks = KnowledgeChunk::with('unit')
            ->whereIn('id', $fusedIds)
            ->get()
            ->sortBy(fn ($c) => array_search($c->id, $fusedIds))
            ->values();

        // Riscoring esatto sul vettore completo, solo sui finalisti.
        $chunks = $this->rescore($chunks, $embedding);

        return $chunks->count() > $limit
            ? $chunks->rerank('content', $question, limit: $limit)
            : $chunks;
    }

    /** @return array<int, int> */
    private function lexical(string $question, array $filters, int $limit): array
    {
        // Le stop word italiane non sono nella lista predefinita di InnoDB:
        // si rimuovono qui, altrimenti "come" e "quando" dominano il punteggio.
        $terms = Str::of($question)
            ->lower()
            ->replaceMatches('/[^\p{L}\p{N}\s-]/u', ' ')
            ->explode(' ')
            ->filter(fn ($w) => mb_strlen($w) >= 3 && ! in_array($w, config('kb.stopwords')))
            ->map(fn ($w) => $w.'*')      // il troncamento supplisce alla mancanza di stemming
            ->take(12)
            ->implode(' ');

        if ($terms === '') {
            return [];
        }

        return DB::table('knowledge_chunks as c')
            ->join('knowledge_units as u', 'u.id', '=', 'c.knowledge_unit_id')
            ->whereRaw('MATCH(c.content) AGAINST (? IN BOOLEAN MODE)', [$terms])
            ->where('u.status', 'approved')
            ->when($filters['product'] ?? null, fn ($q, $p) => $q->where('u.product', $p))
            ->when($filters['locale'] ?? null, fn ($q, $l) => $q->where('u.locale', $l))
            ->orderByRaw('MATCH(c.content) AGAINST (? IN BOOLEAN MODE) DESC', [$terms])
            ->limit($limit)
            ->pluck('c.id')
            ->all();
    }

    /** Reciprocal Rank Fusion: nessun peso da tarare, nessuna scala da normalizzare. */
    private function fuse(array $rankings, int $k = 60, int $keep = 20): array
    {
        $scores = [];

        foreach ($rankings as $ranking) {
            foreach (array_values($ranking) as $rank => $id) {
                $scores[$id] = ($scores[$id] ?? 0) + 1 / ($k + $rank + 1);
            }
        }

        arsort($scores);

        return array_slice(array_keys($scores), 0, $keep);
    }

    private function rescore(Collection $chunks, array $embedding): Collection
    {
        return $chunks->sortByDesc(function ($chunk) use ($embedding) {
            return $this->vectors->cosine($embedding, $chunk->embedding ?? []);
        })->values();
    }
}

9. Lo strumento di ricerca per la chat

Esporre la ricerca come strumento invece che precaricare i risultati nel prompt ha un vantaggio preciso: il modello può riformulare e cercare di nuovo se il primo tentativo non produce nulla di pertinente, esattamente come farebbe un operatore. Notate il formato della risposta: identificativo esplicito perché il modello possa citarlo, e condizioni di applicabilità in evidenza perché non le ignori.

<?php

namespace App\Ai\Tools;

use App\Services\Kb\HybridSearch;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use Stringable;

class SearchKnowledgeBase implements Tool
{
    public function __construct(private HybridSearch $search) {}

    public function description(): Stringable|string
    {
        return 'Cerca nella knowledge base aziendale, costruita a partire dai ticket di '
             . 'assistenza già risolti. Usa questo strumento PRIMA di ogni risposta di '
             . 'natura tecnica o procedurale. Puoi chiamarlo più volte con formulazioni '
             . 'diverse se la prima ricerca non restituisce nulla di pertinente.';
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'query' => $schema->string()
                ->description('Il problema da cercare, riformulato in modo autonomo e completo: la ricerca non vede la conversazione precedente.')
                ->required(),
            'product' => $schema->string()
                ->description('Filtra per prodotto, se il cliente lo ha indicato.')
                ->nullable(),
        ];
    }

    public function handle(Request $request): Stringable|string
    {
        $chunks = $this->search->search($request['query'], [
            'product' => $request['product'] ?? null,
            'locale' => app()->getLocale(),
        ], limit: 4);

        if ($chunks->isEmpty()) {
            return 'NESSUN RISULTATO. La knowledge base non contiene informazioni su questo '
                 . 'problema: dichiaralo al cliente e proponi l\'apertura di un ticket.';
        }

        // Il formato conta: identificativo esplicito perché il modello possa citarlo,
        // e condizioni di applicabilità in evidenza perché non le ignori.
        return $chunks->map(fn ($chunk) => sprintf(
            "[KU-%d] %s\n%s%s",
            $chunk->knowledge_unit_id,
            $chunk->unit->problem,
            $chunk->content,
            $chunk->unit->applicability ? "\nVALE SOLO SE: {$chunk->unit->applicability}" : ''
        ))->implode("\n\n---\n\n");
    }
}

10. L’agent conversazionale

Istruzioni che vincolano il comportamento più che il tono: cercare sempre prima di rispondere, citare sempre, arrendersi esplicitamente dopo due tentativi, e non promettere mai nulla di commerciale.

<?php

namespace App\Ai\Agents;

use App\Ai\Tools\SearchKnowledgeBase;
use App\Models\ChatConversation;
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\Contracts\Agent;
use Laravel\Ai\Contracts\Conversational;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Messages\Message;
use Laravel\Ai\Promptable;
use Stringable;

#[Provider(Lab::Anthropic)]
#[Model('claude-sonnet-5')]
#[Temperature(0.2)]
#[MaxTokens(1500)]
#[MaxSteps(6)]
class SupportChat implements Agent, Conversational, HasTools
{
    use Promptable;

    public function __construct(public ChatConversation $conversation) {}

    public function instructions(): Stringable|string
    {
        return <<<'PROMPT'
        Sei l'assistente di primo livello dell'assistenza clienti. Rispondi solo
        con le informazioni restituite dallo strumento di ricerca nella knowledge
        base, che raccoglie le soluzioni dei ticket già risolti.

        Come lavorare:
        1. Prima di ogni risposta tecnica chiama search_knowledge_base. Non
           rispondere mai a memoria, nemmeno se conosci la risposta.
        2. Se la prima ricerca non restituisce nulla di pertinente, riprova una
           volta con parole diverse: il cliente potrebbe aver usato un termine
           che nella knowledge base è scritto in altro modo.
        3. Cita sempre le fonti usate con il loro identificativo, nella forma
           [KU-123], subito dopo l'affermazione che ne deriva.
        4. Se dopo due ricerche non hai materiale sufficiente, dillo chiaramente
           e proponi di aprire un ticket. Non improvvisare: una risposta inventata
           genera un secondo contatto e distrugge la fiducia nello strumento.
        5. Rispetta le condizioni di applicabilità. Se una soluzione vale solo per
           un piano o una versione, chiedi conferma al cliente prima di proporla.

        Come scrivere:
        - In italiano, dando del lei, in non più di 150 parole.
        - Procedure in passi numerati.
        - Mai promettere tempi, rimborsi, sconti o deroghe: per qualsiasi
          richiesta di natura commerciale proponi il passaggio a un operatore.
        - Mai chiedere password, codici di accesso o dati di pagamento.
        PROMPT;
    }

    public function tools(): iterable
    {
        return [
            app(SearchKnowledgeBase::class),
        ];
    }

    /**
     * La memoria della conversazione. Si tengono gli ultimi venti messaggi:
     * più indietro il costo cresce senza migliorare le risposte, perché nelle
     * conversazioni di assistenza il contesto utile è quasi sempre recente.
     */
    public function messages(): iterable
    {
        return $this->conversation->messages()
            ->latest()->limit(20)->get()
            ->reverse()
            ->map(fn ($m) => new Message($m->role, $m->content))
            ->all();
    }
}

11. Il controller: streaming ed escalation

Lo streaming non è un vezzo estetico: fa comparire la prima parola in meno di un secondo mentre la risposta completa ne richiede da tre a dieci, ed è la differenza fra una chat che sembra istantanea e una che sembra rotta. L’escalation, invece, è la funzione che rende accettabile un assistente imperfetto: la conversazione diventa il corpo del ticket e il cliente non racconta due volte lo stesso problema.

<?php

namespace App\Http\Controllers;

use App\Ai\Agents\SupportChat;
use App\Models\ChatConversation;
use Illuminate\Http\Request;
use Laravel\Ai\Responses\StreamedAgentResponse;

class SupportChatController extends Controller
{
    public function message(Request $request, ChatConversation $conversation)
    {
        $validated = $request->validate([
            'message' => ['required', 'string', 'min:3', 'max:1500'],
        ]);

        $conversation->messages()->create([
            'role' => 'user',
            'content' => $validated['message'],
        ]);

        // Streaming: la prima parola compare in meno di un secondo, mentre la
        // risposta completa richiede dai tre ai dieci secondi. Su una chat di
        // assistenza è la differenza fra "istantaneo" e "non funziona".
        return (new SupportChat($conversation))
            ->stream($validated['message'])
            ->then(function (StreamedAgentResponse $response) use ($conversation) {
                preg_match_all('/\[KU-(\d+)\]/', $response->text, $matches);

                $cited = array_values(array_unique(array_map('intval', $matches[1])));

                $conversation->messages()->create([
                    'role' => 'assistant',
                    'content' => $response->text,
                    'cited_units' => $cited,
                    // Nessuna citazione significa che la KB non ha coperto il caso:
                    // è il segnale che alimenta la lista dei buchi da colmare.
                    'answered' => $cited !== [],
                    'input_tokens' => $response->usage?->inputTokens,
                    'output_tokens' => $response->usage?->outputTokens,
                ]);

                if ($cited !== []) {
                    \App\Models\KnowledgeUnit::whereIn('id', $cited)->increment('served_count');
                }
            });
    }

    /**
     * Passaggio a un operatore. La conversazione diventa il corpo del ticket:
     * il cliente non deve raccontare due volte lo stesso problema, ed è il
     * dettaglio che rende accettabile una chat che a volte non sa rispondere.
     */
    public function escalate(ChatConversation $conversation)
    {
        $ticket = $conversation->user->tickets()->create([
            'subject' => str($conversation->messages()->where('role', 'user')->first()->content)->limit(80),
            'body' => $conversation->messages()
                ->orderBy('created_at')
                ->get()
                ->map(fn ($m) => ($m->role === 'user' ? 'CLIENTE' : 'ASSISTENTE').': '.$m->content)
                ->implode("\n\n"),
            'product' => $conversation->product,
            'source' => 'chat_escalation',
        ]);

        $conversation->update(['escalated_ticket_id' => $ticket->id]);

        return redirect()->route('tickets.show', $ticket);
    }
}

12. I test

Si testano i comportamenti che proteggono il sistema — lo scarto delle concessioni commerciali, la deduplica, l’anonimizzazione — con l’agent sostituito da una versione fake. E si testa il recupero contro un corpus di riferimento: domande reali prese dai ticket, con l’unità che deve comparire fra i primi tre risultati.

<?php

use App\Ai\Agents\TicketDistiller;
use App\Jobs\Kb\DistillTicket;
use App\Models\KnowledgeUnit;
use App\Models\Ticket;
use App\Services\Kb\PiiRedactor;

it('non crea conoscenza da una concessione commerciale', function () {
    TicketDistiller::fake([
        [
            'is_reusable' => true,
            'problem' => 'Mi è stato addebitato il rinnovo dopo la disdetta',
            'symptoms' => null,
            'cause' => 'Disdetta inviata dopo la data di rinnovo',
            'solution' => 'È stato concesso un rimborso in via eccezionale.',
            'applicability' => null,
            'category' => 'fatturazione',
            'search_phrases' => ['addebito dopo disdetta'],
            'is_commercial_exception' => true,
            'is_one_off' => false,
            'confidence' => 0.95,
        ],
    ]);

    $ticket = Ticket::factory()->resolved()->create();

    (new DistillTicket($ticket))->handle(new PiiRedactor);

    expect(KnowledgeUnit::count())->toBe(0)
        ->and($ticket->fresh()->kb_status)->toBe('not_distillable');
});

it('rafforza l\'unità esistente invece di duplicarla', function () {
    $payload = [
        'is_reusable' => true,
        'problem' => 'Non riesco più ad accedere, la password non viene accettata',
        'symptoms' => 'Messaggio "credenziali non valide"',
        'cause' => 'Blocco dopo cinque tentativi',
        'solution' => "1. Attendere 15 minuti\n2. Usare il link di reimpostazione",
        'applicability' => null,
        'category' => 'accesso',
        'search_phrases' => ['password rifiutata', 'account bloccato'],
        'is_commercial_exception' => false,
        'is_one_off' => false,
        'confidence' => 0.9,
    ];

    TicketDistiller::fake([$payload, $payload]);

    foreach (Ticket::factory()->resolved()->count(2)->create() as $ticket) {
        (new DistillTicket($ticket))->handle(new PiiRedactor);
    }

    expect(KnowledgeUnit::count())->toBe(1)
        ->and(KnowledgeUnit::first()->ticket_count)->toBe(2);
});

it('rimuove i dati personali prima della distillazione', function () {
    $redacted = (new PiiRedactor)->redact(
        'Scrivimi a mario.rossi@example.com o al 348 1234567, IBAN IT60X0542811101000000123456'
    );

    expect($redacted)
        ->not->toContain('mario.rossi@example.com')
        ->not->toContain('IT60X0542811101000000123456')
        ->toContain('[EMAIL]');
});

// Il test che conta davvero: il corpus di riferimento. Domande reali prese dai
// ticket, con l'unità di conoscenza che DEVE comparire fra i primi tre risultati.
it('recupera l\'unità attesa per le domande del corpus', function (string $question, string $expected) {
    $results = app(App\Services\Kb\HybridSearch::class)->search($question, limit: 3);

    expect($results->pluck('unit.dedup_key'))->toContain($expected);
})->with('kb_corpus');

Il ciclo di revisione: la voce di costo che nessuno preventiva

La distillazione automatica di ventimila ticket produce, dopo la deduplica, qualcosa come mille-duemila unità di conoscenza. Approvarle una per una a tre minuti l’una significa fra le cinquanta e le cento ore di lavoro qualificato. È il vero collo di bottiglia del progetto, e chi non lo mette a budget si ritrova con un archivio di unità in stato pending_review che nessuno serve mai.

La mitigazione è nella distribuzione delle frequenze. Le unità sono ordinate per numero di ticket da cui derivano, e in un servizio di assistenza tipico le prime duecento coprono la maggioranza netta del volume. Approvare quelle duecento costa una decina di ore e mette in produzione un sistema che risponde alla maggior parte delle domande; la coda lunga si approva nei mesi successivi, guidata dai buchi che la chat segnala da sola. È la sequenza corretta, ed è anche l’unica che permette di partire in una settimana anziché in un trimestre.

A regime, il carico si riduce a pochi elementi al giorno: le unità nuove derivanti dai ticket nuovi, le segnalazioni di conflitto, e le unità che superano la scadenza di revisione. È mezz’ora al giorno, non un ruolo a tempo pieno.

Privacy e conformità

Costruire una knowledge base sui ticket significa trattare dati che i clienti hanno fornito per un’altra finalità. Quattro punti da chiarire prima di scrivere il codice, non dopo.

Minimizzazione. L’anonimizzazione deterministica avviene prima che il testo lasci il database, le istruzioni di distillazione vietano esplicitamente la trascrizione di dati personali, e il campo contains_pii segnala per la revisione manuale ciò che è comunque passato. Tre strati imperfetti in serie valgono più di uno solo che si presume perfetto.

Cancellazione. La tabella di collegamento fra unità e ticket permette di rispondere a una richiesta di cancellazione eliminando il ticket e il collegamento, senza distruggere la conoscenza distillata — che a quel punto è un contenuto anonimo e generico, non un dato personale. Se invece l’unità contiene ancora dati riferibili, il semaforo contains_pii vi dice esattamente quali unità rivedere.

Trasferimento al fornitore del modello. Il testo dei ticket viene inviato a un fornitore esterno. Va coperto da un accordo per il trattamento dei dati, e va verificato che i dati non siano usati per l’addestramento; se il vincolo è la localizzazione, esistono opzioni di inferenza su regione specifica, o l’uso di un modello ospitato internamente per la sola fase di distillazione.

Trasparenza verso il cliente. La chat deve dichiararsi automatica, e il passaggio a un operatore umano deve essere sempre disponibile e visibile. È un requisito normativo in progressiva estensione, ed è comunque una buona pratica: la fiducia in un assistente automatico dipende più dalla facilità con cui lo si può scavalcare che dalla qualità delle sue risposte.

Le metriche

MetricaCome si calcolaChe cosa dice
Tasso di risoluzione autonomaConversazioni chiuse senza escalation / conversazioni totaliIl beneficio economico diretto
Tasso di coperturaRisposte con almeno una citazione / risposte totaliQuanto la KB copre le domande reali
Tasso di distillazioneTicket che producono un’unità riutilizzabile / ticket chiusiLa salute della pipeline di alimentazione
Coda di revisioneUnità in pending_review e loro età mediaSe il presidio umano sta reggendo
Efficacia per unitàValutazioni positive / totali, su almeno 10 votiQuali unità riscrivere
Domande senza rispostaMessaggi con answered = false, raggruppati per temaI buchi da colmare per primi

L’ultima riga è la più importante e la più trascurata: le domande a cui la chat non ha saputo rispondere sono un elenco di problemi reali, formulati dai clienti, che la vostra azienda non sa ancora documentare. È il piano di lavoro del mese successivo, e si costruisce da solo.

I costi

Distillazione

Una conversazione media di ticket pesa circa 1.500 token, a cui si sommano istruzioni e schema (circa 600) e un output strutturato di 500. Con Claude Sonnet 5 (2 $ per milione in input, 10 $ in output) la distillazione di un ticket costa circa 0,009 $. Il recupero iniziale di ventimila ticket storici costa quindi attorno ai 185 $, che scendono a poco più di 90 $ usando la Batch API, che sconta del 50% l’elaborazione asincrona — ed è esattamente il caso d’uso, visto che nessuno sta aspettando. Con Claude Haiku 4.5 (1 $ / 5 $) le stesse cifre si dimezzano ulteriormente.

Indicizzazione

Trascurabile. Dopo la deduplica restano una o due migliaia di unità, cioè qualche migliaio di frammenti: gli embedding costano nell’ordine dei centesimi di dollaro per milione di token, quindi l’indicizzazione completa costa quanto un caffè. Sullo spazio: seimila frammenti a 6 KB (vettore pieno) più 256 byte (vettore compresso) occupano meno di 40 MB, e i 256 centroidi in cache stanno in 64 KB.

Esercizio della chat

Un turno di conversazione con tool calling richiede due chiamate al modello: la prima decide la ricerca, la seconda formula la risposta leggendo i risultati. In totale circa 3.700 token in ingresso e 310 in uscita, cioè 0,010 $ per turno con Sonnet 5 e circa 0,005 $ con Haiku 4.5. Una conversazione da tre turni costa quindi 0,03 $, e cinquemila conversazioni al mese costano attorno ai 160 $ (o 80 $ con Haiku). Il prompt caching sulle istruzioni, identiche a ogni turno, riduce ulteriormente la quota di input.

Reranking e infrastruttura

Il reranking ha un costo per chiamata dipendente dal provider ed è la voce che cresce linearmente con il traffico: se il volume è alto conviene applicarlo solo quando la fusione produce più di dieci candidati, o metterlo in cache sulle domande ricorrenti. L’infrastruttura, invece, è a costo zero: MySQL ce l’avete, la coda pure, e non serve alcun servizio vettoriale aggiuntivo.

Sviluppo e presidio

Le due voci che pesano davvero. Lo sviluppo di un sistema completo come quello descritto è nell’ordine delle tre-quattro settimane-uomo. Il presidio è di una decina di ore per la prima approvazione delle unità ad alta frequenza, più circa mezz’ora al giorno a regime. Confrontate queste cifre con i 160 $ al mese di API e avrete l’ordine di priorità corretto per le vostre discussioni interne.

I vantaggi

DimensioneSenza il sistemaCon il sistema
Costo di partenza della KBMesi di redazioneOre di revisione su contenuto già generato
AggiornamentoProgetto periodico che slittaAutomatico a ogni ticket chiuso
Copertura orariaOrario di ufficioContinua
Coerenza delle risposteVariabile per operatoreFonte unica, citata e verificabile
EscalationIl cliente ripete tuttoLa conversazione diventa il ticket
Visibilità sui buchiNessunaElenco automatico delle domande senza risposta
InfrastrutturaNessun servizio in più: Laravel e MySQL esistenti

Sette errori da evitare

  1. Indicizzare i ticket grezzi. Rumore, risposte sbagliate, dati personali ed eccezioni commerciali finiscono tutti nell’indice con lo stesso peso della soluzione corretta.
  2. Pensare al fine-tuning. Insegna lo stile, non i fatti, e toglie citabilità, correggibilità e cancellabilità: le tre proprietà che rendono utilizzabile un sistema di assistenza.
  3. Pubblicare senza revisione umana. La prima approvazione è il momento in cui si intercettano i dati personali sfuggiti, le eccezioni mascherate e le soluzioni che non funzionano più.
  4. Affidarsi alla sola ricerca semantica su MySQL. Senza il ramo lessicale i codici errore e le sigle di prodotto diventano irrecuperabili, e sono proprio le richieste più precise.
  5. Ignorare l’assenza di stemming in InnoDB. “Accedere” non trova “accesso”: va compensato con troncamento, stop word italiane e frasi di ricerca alternative.
  6. Rendere l’escalation difficile. Un assistente automatico da cui non si esce facilmente produce più insoddisfazione di quanta ne risolva.
  7. Non guardare le domande senza risposta. È il dato più prezioso che il sistema produce, e quello che nessuno apre.

Un piano di avvio in 90 giorni

Giorni 1–20: la distillazione. Schema dati, anonimizzazione, agent di distillazione. Si lancia il recupero su un campione di duemila ticket e si legge il risultato a mano: è qui che si tarano le istruzioni, non dopo. L’obiettivo di questa fase è un solo numero — la percentuale di unità distillate che un revisore giudica pubblicabili senza modifiche.

Giorni 21–45: il recupero. Chunking, embedding, quantizzazione, cluster IVF, ricerca ibrida e il corpus di test con cinquanta domande reali. Si misura quante volte l’unità corretta compare fra i primi tre risultati, e si itera sul chunking guardando quel numero.

Giorni 46–70: la revisione. Interfaccia di approvazione e approvazione delle prime duecento unità per frequenza. È lavoro poco entusiasmante e assolutamente determinante.

Giorni 71–90: la chat. Agent, strumento di ricerca, streaming, escalation e metriche. Si apre prima agli operatori interni per due settimane — che è anche il modo più economico di fare collaudo — e poi ai clienti, magari su un solo prodotto.

Conclusioni

Un sistema di knowledge base alimentato dai ticket non è un chatbot con sopra un database: è una catena di trasformazione che parte da conversazioni disordinate e arriva a conoscenza citabile, con un umano nel punto giusto della catena. Le due decisioni che ne determinano l’esito sono entrambe controintuitive: non addestrare il modello ma recuperare al momento della domanda, e non indicizzare i ticket ma ciò che si è imparato da essi.

Sul piano tecnico, il vincolo di MySQL — che non offre la funzione di distanza vettoriale nelle distribuzioni Community — si aggira senza compromessi sostanziali: un indice IVF realizzato con una colonna intera, vettori quantizzati a 256 byte e la fusione con la ricerca full-text producono un recupero che risponde in decine di millisecondi su corpus da decine di migliaia di frammenti. Non serve un database vettoriale finché non serve davvero, e per una knowledge base aziendale quel momento è più lontano di quanto si creda.

Resta il consiglio operativo: partite dalla distillazione e dalla revisione delle prime duecento unità, non dalla chat. La chat è la parte visibile e la più facile da costruire; la qualità di ciò che dirà è già interamente decisa a monte.