Laravel

Integrare Laravel con un Chatbot NLU proprietario

Integrare Laravel con un Chatbot NLU proprietario
LT
Luca Terribili
Autore

Cerca su Google "integrare un chatbot in Laravel" e troverai centinaia di tutorial praticamente identici: una chiave API di OpenAI, due righe di cURL, una tabella messages nel database. Fine dell'articolo, fine del progetto.

Quello che segue parte da un presupposto diverso. Il chatbot che ho costruito non è un LLM. Non c'è nessun modello generativo dietro le risposte. È un sistema di Intelligenza Artificiale proprietario, scritto in Python, con un classificatore di intent basato su una rete neurale BiGRU addestrata in PyTorch e un motore di dialogo a regole dichiarative in YAML. Si chiama, nel codice, Cognitor. E Laravel — il backoffice che gestisce contenuti, articoli, aziende — non genera nemmeno una parola: fa da proxy autenticato verso questo sistema esterno.

Questo articolo prova a spiegare tre cose che quasi nessun tutorial affronta davvero: cosa c'è tecnicamente dietro un sistema NLU come questo (e perché l'ho pensato come una vera e propria alternativa custom a Rasa, costruita in PyTorch invece che configurata su un framework esistente), come si progetta un flusso API sicuro quando un sistema esterno deve poter leggere e scrivere dati reali in un backoffice — con il codice reale alla mano — e infine quali sono i limiti veri di un'architettura non-LLM, e perché in questo caso non sono un problema ma una scelta.

Cosa c'è dietro: PyTorch al posto di Rasa

Per capire Cognitor bisogna separare due componenti che in un LLM sono fusi in un'unica rete neurale, ma che in un sistema NLU classico restano distinti: la comprensione (NLU in senso stretto) e la gestione del dialogo (dialogue management).

La comprensione ha il compito di trasformare una frase in linguaggio naturale — "pubblica l'ultima bozza sul blog" — in una struttura dati: un intent (publish_draft) più eventuali entità estratte (quale bozza, quale dominio). Qui Cognitor non usa un modello a regole o un classificatore lineare: usa una rete neurale BiGRU (Bidirectional Gated Recurrent Unit), scritta e addestrata in PyTorch. Una BiGRU legge la frase in entrambe le direzioni — dall'inizio alla fine e viceversa — costruendo per ogni parola una rappresentazione che tiene conto sia del contesto che la precede sia di quello che la segue. È un'architettura più leggera di un Transformer, ma perfettamente adeguata per un problema di classificazione su un numero finito di intent, con inferenza rapida e nessuna dipendenza da GPU in produzione.

La gestione del dialogo, invece, resta dichiarativa: uno slot da riempire (per esempio "quale bozza vuoi pubblicare?") ha un tipo, una domanda associata e condizioni di uscita, scritte esplicitamente in YAML. Il dialogo è una macchina a stati con transizioni dichiarate, non generate da una rete neurale — anche se la rete neurale decide quale transizione innescare, interpretando l'intent dell'utente.

Perché "alternativa a Rasa" e non "clone di Rasa"

Chi ha lavorato con Rasa — il framework open source più noto per questo tipo di architettura — riconoscerà lo schema concettuale: una pipeline NLU separata dal Core che gestisce il dialogo, un domain.yml con intent/entità/slot, stories.yml/rules.yml per i percorsi di conversazione ammessi, e i forms per la raccolta strutturata di più informazioni (esattamente il meccanismo che, in Cognitor, gestisce la raccolta di uno slot come "quale bozza pubblicare").

La differenza è che Rasa è un framework generico da configurare, con la sua pipeline NLU predefinita (tipicamente basata su DIET o simili) e un ecosistema di plugin. Cognitor, invece, è stato scritto da zero: il classificatore BiGRU è un modello PyTorch che ho addestrato io su un dataset di frasi specifiche del dominio, non un componente preconfezionato. Questo ha un costo — niente community, niente tooling pronto, ogni miglioramento è lavoro diretto — ma anche un vantaggio netto: controllo totale sull'architettura del modello, sui dati di addestramento e sulle performance di inferenza, senza le astrazioni generiche che un framework come Rasa porta con sé per coprire casi d'uso che qui non servono.

Un dettaglio implementativo che mostra bene il confine tra rete neurale e regole dichiarate: lo slot di conferma, usato prima di pubblicare una bozza, è modellato come stringa e non come booleano. Un booleano ha solo due stati, ma servono tre — "non ha ancora risposto", "ha detto sì", "ha detto no". Con un booleano inizializzato a false, non c'è modo di distinguere "non ha risposto" da "ha detto no", e il bot rischierebbe di procedere (o bloccarsi) nel momento sbagliato. È un bug che un sistema a regole esplicite permette di individuare con precisione chirurgica — molto più difficile quando il comportamento emerge da un modello puramente probabilistico.

Anche i bug, in un'architettura così, raccontano il sistema. Durante lo sviluppo, scrivere "stop" mentre il bot raccoglieva uno slot veniva interpretato dalla BiGRU come valore letterale da salvare, invece che come comando di annullamento gestito a livello di regole. La causa: due liste separate di "comandi di stop", mantenute in punti diversi del codice, disallineate nel tempo. Il fix è stato unificare la fonte di verità in un solo posto — un bug tipico di sistemi ibridi rete-neurale-più-regole, non un errore del modello in sé.

Il flusso API, con il codice reale

La parte più solida del progetto, dal punto di vista ingegneristico, non è la rete neurale in sé ma il modo in cui Laravel e Cognitor si autenticano e si scambiano dati senza esporsi a vicenda più del necessario.

Direzione 1: dal browser a Cognitor (passando da Laravel)

Il widget di chat è un componente Vue 3 (ChatWidget.vue) montato nel layout dell'area amministrativa. Ogni messaggio scritto dall'operatore segue questo percorso:

Browser (Vue) 
  → POST /acp/chatbot/message 
  → ChatbotController (Laravel) 
  → CognitorClient::sendMessage() 
  → API Cognitor (Python)

Il browser non contatta mai direttamente Cognitor: Laravel fa da intermediario autenticato. Nessuna credenziale verso il motore NLU è mai esposta al frontend.

L'autenticazione verso Cognitor usa JWT con una gestione a più livelli, pensata per ridurre al minimo la ritrasmissione di credenziali sensibili: access token in cache per 25 minuti, refresh token in cache per 6 giorni, login completo solo quando anche il refresh è scaduto. Ecco il client HTTP reale che implementa questa logica:

// app/Services/CognitorClient.php

public function sendMessage(string $message, ?string $sessionId = null): array
{
    $payload = array_filter([
        'message' => $message,
        'session_id' => $sessionId,
    ], fn ($v) => $v !== null);

    $response = $this->post('/chatbot/message', $payload, $this->token());

    // Token scaduto lato server: invalido la cache e ritento una volta
    if ($response->status() === 401) {
        Cache::forget(self::TOKEN_CACHE_KEY);
        $response = $this->post('/chatbot/message', $payload, $this->token());
    }

    $response->throw();

    return $response->json();
}

private function token(): string
{
    if ($cached = Cache::get(self::TOKEN_CACHE_KEY)) {
        return $cached;
    }

    // Preferisco il refresh token cachato: evita di rimandare
    // username/password ad ogni scadenza dell'access token
    if ($refreshToken = Cache::get(self::REFRESH_TOKEN_CACHE_KEY)) {
        $response = Http::timeout(10)->post(
            rtrim(config('services.cognitor.base_url'), '/').'/auth/refresh',
            ['refresh_token' => $refreshToken]
        );

        if ($response->successful()) {
            return $this->storeTokens($response->json());
        }

        Cache::forget(self::REFRESH_TOKEN_CACHE_KEY);
    }

    $response = Http::asForm()->timeout(10)->post(
        rtrim(config('services.cognitor.base_url'), '/').'/auth/token',
        [
            'username' => config('services.cognitor.username'),
            'password' => config('services.cognitor.password'),
        ]
    );

    $response->throw();

    return $this->storeTokens($response->json());
}

Questo schema ha sostituito una prima versione che rifaceva login completo — quindi ritrasmetteva username e password in chiaro — ogni 25 minuti, a ogni scadenza dell'access token. Ogni credenziale che circola è un'occasione in più per un errore, un log non protetto, un replay: la versione corretta riduce drasticamente la frequenza con cui le credenziali vere e proprie viaggiano sulla rete.

Un altro dettaglio da manuale di graceful degradation: se Cognitor non risponde, l'utente non vede un errore HTTP 500 crudo, ma un messaggio conversazionale gestito all'interno del flusso di chat:

// app/Http/Controllers/ChatbotController.php

public function message(Request $request, CognitorClient $client)
{
    $request->validate([
        'message' => 'required|string|max:1000',
        'session_id' => 'nullable|string',
    ]);

    try {
        $data = $client->sendMessage($request->input('message'), $request->input('session_id'));
    } catch (\Throwable $e) {
        Log::warning('Errore chiamata Cognitor: '.$e->getMessage());

        return response()->json([
            'response' => 'L\'assistente non è al momento disponibile, riprova tra poco.',
            'session_id' => $request->input('session_id'),
            'error' => true,
        ]);
    }

    return response()->json($data);
}

Un dettaglio piccolo ma che cambia la percezione dell'affidabilità del sistema: un errore tecnico diventa un'informazione comprensibile, senza rompere l'esperienza conversazionale.

Infine, un punto architetturale che risponde a una domanda raramente posta esplicitamente in questo tipo di progetti: dove vive la memoria della conversazione? Non in Laravel. Non esiste una tabella conversations o messages nel database del backoffice. Il session_id mantiene il contesto multi-turno, ma lo stato vive interamente dentro Cognitor. Il backoffice non deve sapere nulla del come si è arrivati a un'azione, solo che azione eseguire.

Direzione 2: da Cognitor a Laravel (dove si concentra la sicurezza)

Quando un intent richiede di leggere o modificare dati reali del backoffice, è Cognitor a richiamare, verso l'esterno, endpoint Laravel dedicati sotto /api/chatbot/*. Un sistema esterno con capacità di scrittura su dati reali è, per definizione, una superficie d'attacco che va progettata con più attenzione di un normale endpoint CRUD.

Token scoped, mai onnipotenti. Ogni azione ha un'ability Sanctum dedicata, con throttling calibrato sul rischio: letture permissive, scritture strette.

// routes/api.php

// Sola lettura: throttle più permissivo
Route::middleware(['auth:sanctum', 'abilities:posts:read', 'throttle:30,1'])
    ->prefix('chatbot')->group(function () {
        Route::get('posts', [PostController::class, 'index']);
    });

// Genera contenuto: throttle più stretto (10 richieste/min)
Route::middleware(['auth:sanctum', 'abilities:articles:generate', 'throttle:10,1'])
    ->prefix('chatbot')->group(function () {
        Route::post('articles/generate', [ChatbotArticleController::class, 'generate']);
    });

// Pubblica in produzione: ability dedicata, throttle stretto
Route::middleware(['auth:sanctum', 'abilities:posts:publish', 'throttle:10,1'])
    ->prefix('chatbot')->group(function () {
        Route::post('posts/publish', [ChatbotContentController::class, 'publish']);
    });

Il token che Cognitor usa per autenticarsi verso Laravel viene generato con un comando artisan dedicato, che crea uno "user di servizio" con esattamente le ability necessarie — mai un ruolo amministrativo generico:

// app/Console/Commands/Chatbot/CreateCognitorToken.php

private const ABILITIES = [
    'posts:read', 'articles:generate', 'tutorials:generate',
    'domains:read', 'categories:read', 'tutorials:read', 'tutorials:search',
    'businesses:read', 'proposals:read', 'content:optimize',
    'posts:publish', 'proposals:dispatch',
];

public function handle(): int
{
    $user = User::firstOrCreate(
        ['email' => 'cognitor-bot@programmato.local'],
        [
            'first_name' => 'Cognitor',
            'last_name' => 'Bot',
            'username' => 'cognitor-bot',
            'password' => bcrypt(Str::random(40)),
        ]
    );

    $token = $user->createToken('cognitor-chatbot', self::ABILITIES)->plainTextToken;

    $this->info('Token creato per '.$user->email);
    $this->line($token);

    return self::SUCCESS;
}

Se in futuro un'azione venisse compromessa o usata in modo anomalo, il danno resterebbe contenuto a quella singola ability, non all'intero backoffice.

Conferma esplicita per l'unico effetto pubblico visibile. Pubblicare una bozza è l'unica azione con impatto reale all'esterno del sistema, e richiede conferma prima di essere eseguita. L'esecuzione stessa passa da una transazione per ogni post coinvolto:

// app/Http/Controllers/Api/ChatbotContentController.php

public function publish(Request $request): JsonResponse
{
    $domain = $this->resolveDomain($request->validate(['domain' => 'required|string'])['domain']);
    if (! $domain) {
        return response()->json(['message' => "Dominio non trovato."], 422);
    }

    $drafts = Post::query()
        ->where('status', 'draft')
        ->whereHas('domains', fn ($q) => $q->where('domains.id', $domain->id))
        ->get();

    $published = 0;
    foreach ($drafts as $post) {
        DB::transaction(function () use ($post, &$published) {
            $post->applyTransition('to_published');
            $published++;
        });
    }

    return response()->json([
        'status' => 'ok',
        'domain' => $domain->name,
        'count' => $published,
    ]);
}

Generazione asincrona, non in tempo reale. Quando il bot deve generare un articolo, non lo fa dentro la conversazione: crea una "proposal" in coda, che uno scheduler smista ogni 5 minuti verso un sistema esterno di generazione via RabbitMQ ("Friday Agent"), oppure che il bot può forzare a inviare subito.

// app/Http/Controllers/Api/ChatbotArticleController.php

public function generate(Request $request): JsonResponse
{
    $validated = $request->validate([
        'domain' => 'required|string',
        'category' => 'required|string',
        'titles' => 'required|array|min:1',
    ]);

    $domain = $this->resolveDomain($validated['domain']);
    $category = Category::whereJsonLikeInsensitive('title', $validated['category'])->first();

    $proposal = Proposal::create([
        'name' => "Chatbot: {$domain->name} / {$category->title} - ".now(),
        'task' => 'generate-article',
        'status' => 'pending',
        'titles' => array_map(fn ($title) => ['title' => $title], $validated['titles']),
        'options' => ['domain' => $domain->id, 'category' => $category->id],
    ]);

    return response()->json([
        'status' => 'queued',
        'proposal_id' => $proposal->id,
        'count' => count($validated['titles']),
    ]);
}

Questo disaccoppiamento tramite coda ha un vantaggio pratico non banale: se il sistema di generazione è lento o temporaneamente giù, la conversazione con l'utente non si blocca — resta fluida, e il contenuto arriverà quando sarà pronto.

Il lato dati: meno di quanto ci si aspetti

Chi si aspetta un database ricco di tabelle conversations, messages, chat_sessions resterà sorpreso. Le uniche tabelle coinvolte sono due: chatbots, con account condivisi con un altro sistema Java/Spring e password cifrata AES-256-GCM in formato compatibile byte per byte con la controparte Java, e domains.chatbot_id, che associa ogni sito alla sua istanza di chatbot. Il CRUD amministrativo che le accompagna (Inertia+Vue, permessi Spatie) non configura Cognitor: gestisce solo questi account condivisi.

I limiti: non è un LLM. E va bene così.

È giusto essere onesti sui limiti di un'architettura come questa, perché sono reali.

Una rete BiGRU addestrata su un set fisso di intent non generalizza come farebbe un LLM. Se un utente formula una richiesta in un modo mai visto nei dati di addestramento, il modello non "capisce comunque" per analogia: sbaglia classificazione, o non riconosce l'intent affatto. Ogni nuova capacità richiede lavoro esplicito — nuovi esempi di training, eventualmente un nuovo giro di addestramento — non emerge gratis da un modello pre-addestrato su miliardi di parole. Non ha conoscenza generale: non può improvvisare una spiegazione su un argomento fuori dal suo dominio.

Detto questo, per questo caso d'uso specifico questi limiti pesano meno di quanto sembri, e la scelta di non usare un LLM con function-calling non è stata una rinuncia, ma una decisione difendibile.

Il ventaglio di azioni che il bot deve compiere è finito ed enumerabile: cercare contenuti, generare un articolo, ottimizzare un testo, pubblicare una bozza, forzare l'invio di una coda. In questo scenario, la prevedibilità di un modello di classificazione batte la flessibilità di un modello generativo, per ragioni concrete: determinismo e auditabilità (lo stesso intent produce sempre lo stesso comportamento), superficie di attacco più piccola (un'ability Sanctum specifica per azione chiude la strada a prompt injection che tenterebbe di far invocare funzioni non autorizzate), costo e latenza (inferenza di una BiGRU su CPU è ordini di grandezza più economica e veloce di una chiamata a un LLM esterno), e nessuna dipendenza da un provider terzo — l'intero sistema, dal modello PyTorch alle regole di dialogo, resta sotto controllo diretto.

La domanda che mi sono posto io stesso — un LLM con function-calling non farebbe lo stesso lavoro con meno codice da mantenere? — resta legittima. La risposta onesta è: dipende da come cambierà il dominio. Se in futuro il ventaglio di richieste dovesse allargarsi, diventare meno prevedibile — un assistente più aperto, con richieste in linguaggio naturale mai previste in fase di progettazione — allora la bilancia comincerebbe a pendere verso un LLM. Ma per un'interfaccia conversazionale su un set fisso di azioni, alcune con effetti pubblici irreversibili, un sistema verticale in Python e PyTorch, deterministico e sotto controllo diretto, non è un compromesso tecnico: è la scelta giusta.

Vedi tutti →