@ :~/lab/tribunale-che-non-ha-mai-parlato-con-un-ospite$
← cd ~/lab

./run tribunale-che-non-ha-mai-parlato-con-un-ospite

Il bot ha giudicato sé stesso contro il mio staff per tre mesi. Non ha mai parlato con un ospite.

Ho costruito un tribunale a 3 agenti LLM per far competere un concierge WhatsApp contro lo staff vero. Ha misurato per tre mesi senza mai andare live. Poi ho buttato tutto e ricostruito da zero.

status
draft
project
gv-concierge-v2
updated
2026-07-07
tags
#llm-judge#whatsapp#shadow-mode#microservizi#monolite#concierge-ai
generate cover # alt: Diagramma di un tribunale a tre agenti LLM che confronta la risposta di un bot con quella dello staff di un hotel

Per tre mesi ho avuto un sistema che, ogni notte, prendeva le risposte del mio bot e quelle vere dello staff, le metteva una accanto all’altra, e le faceva giudicare da un tribunale di tre modelli LLM. Accusa, difesa, giudice. Verdetto su sette dimensioni pesate. Report al mattino. Funzionava benissimo. E non è mai servito a niente — perché quel bot, in tre mesi, non ha mai scritto una parola a un ospite vero.

Questo è il diario di come una misura elegante può diventare la scusa perfetta per non spedire mai, e di cosa ho fatto quando l’ho capito.

Il contesto

Sto costruendo un concierge WhatsApp per una piccola guesthouse di lusso a Roma (sei camere). L’idea è automatizzare il ciclo di vita dell’ospite: risposte, codici delle serrature smart, clima, prenotazioni. Non un chatbot generico — un sistema che sa chi ti scrive, in che stanza stai, a che punto del soggiorno sei.

C’era una Legge #1, dettata dal proprietario e non derogabile: il bot non parla a un ospite finché non è PROVATO che funziona. Provato = un eval dimostra qualità ≥ staff per N giorni, capability per capability. Fino ad allora: solo shadow — genera, misura, non invia. Il go-live è un cancello che apre l’owner, non una fase del piano.

Legge sana. Il problema è come l’ho interpretata per tre mesi.

Il Tribunale (la parte di cui vado fiero)

Il cuore della v1 è un valutatore avversariale che chiamo il Tribunale. Per ogni coppia (risposta_bot, risposta_staff) dello stesso messaggio storico:

  • Procuratore e Avvocato girano in parallelo — uno attacca la risposta del bot, l’altro la difende.
  • Giudice (un modello Opus) legge entrambi in sequenza ed emette un verdetto indipendente. Non fa la media dell’accusa e della difesa: giudica.

Le sette dimensioni non pesano uguale, e il punteggio finale non è quello che l’LLM dichiara di sé — è ricalcolato in modo deterministico dai pesi. Il vincitore è una soglia, non un’opinione:

DIMENSION_WEIGHTS = {
    "lingua": 0.20, "accuratezza": 0.18, "completezza": 0.15,
    "tono": 0.15, "contesto": 0.12, "brevita": 0.10, "CTA": 0.10,
}

def determine_winner(bot: float, staff: float, threshold: float = 0.5) -> str:
    diff = bot - staff
    if diff >= threshold:  return "bot"     # BOT_BETTER
    if diff <= -threshold: return "staff"   # STAFF_BETTER
    return "draw"                            # COMPARABLE

Un paio di scelte di cui vado ancora fiero: il giudice valuta anche lo staff sulle stesse 7 dimensioni, così il confronto è like-for-like — stessa scala, stessi pesi, nessuna corsia preferenziale. E c’è una guardia di ground-truth sui fatti di casa (orari, codici) applicata simmetricamente: se taglio l’accuratezza del bot per un fatto sbagliato, taglio quella dello staff con lo stesso metro, altrimenti una regola “onesta” ribalterebbe il verdetto in una sola direzione.

I numeri che ne sono usciti valgono oro. Il volume vero degli ospiti: services 47%, general 26%, prenotazioni/accesso ~14%, codici d’accesso 6% — il valore non è nelle rare domande di stato, è nel grounding. Le dimensioni più deboli del bot: CTA 5.5, completezza 5.53, contesto 5.71, accuratezza 6.02. E la scoperta che ha deciso la v2: iniettare lo stato dell’ospite prima dell’LLM ha dato +0.6 sulle dimensioni-target, con un giorno di lavoro.

La decisione dura

Qui arriva la spina. Il Tribunale funzionava. Ma tutto intorno al Tribunale era una torre: nove microservizi (orchestrator, message-parser, context, response-engine, lock, climate, command, config, alert-bridge) per una guesthouse da sei camere. Ogni messaggio faceva 5 hop HTTP. Un audio passava per Whisper, un’immagine per un modello vision, un PDF per un OCR, e il synthesizer cuciva tutto: 30-40 secondi di latenza da cascata di specialisti LLM.

Ma il vero danno non era la latenza. Era che la misura era sparsa su quei servizi: schema in un posto, env in un altro, cronjob fragili, immagini da tenere sincronizzate. Passavo ore a sessione solo per far ripartire la pipeline di valutazione. E finché la misura è fragile, la prova non arriva mai. E finché la prova non arriva, il cancello del go-live non si apre. Tre mesi di shadow non erano prudenza: erano un sistema troppo complicato per dimostrare qualcosa di sé stesso in tempo utile.

Il 13 giugno il proprietario ha chiuso la discussione con una riga: “Sostieni che si possa fare meglio? Provalo.” Ho fermato le patch e ho ricostruito da zero.

La v2: stesso cervello, 1/10 della complessità

La regola della ricostruzione era brutale: il vecchio repo diventa sola lettura — fonte di dati, codice provato e lezioni — ma non si copia la sua architettura. Si riparte puliti.

  • 1 servizio (monolite modulare Python/FastAPI: ingest + Twin + Brain + delivery) + 1 worker + 1 Postgres. Niente service mesh, niente network policy, niente cinque immagini.
  • Deterministic-first: l’80% delle risposte deve arrivare da stato/playbook, senza LLM. L’LLM solo per la coda lunga (services/general).
  • Il Guest Twin al centro, caricato a ogni messaggio, deciso prima dell’LLM.

Il Twin è la parte che mi ha insegnato di più sulla disciplina. È un resolver strict, deny-by-default: attacca una prenotazione a una conversazione solo se appartiene senza ambiguità a quell’ospite ed è rilevante oggi. Nel dubbio, non attacca:

# _select_relevant: ambiguità -> not-found. Wrong attachment is worse than none.
active = [r for r in cands if r["checkin_date"] <= today <= r["checkout_date"]]
if len(active) > 1:
    logger.warning("guest_state ambiguous -> not attaching")
    return _NOT_FOUND

Attaccare la prenotazione sbagliata è peggio che non attaccarne nessuna: un codice della serratura dato alla persona sbagliata non è un bug di qualità, è un incidente di sicurezza. Il resolver non solleva mai eccezioni — degrada a not-found. Anche il grounding sulla knowledge base è estrattivo e locale (embedding MiniLM, coglie cosa direbbe verbatim): sotto una soglia di cosine 0.55, si astiene e la palla resta allo staff.

Gotcha onesti

  • La v2 non parla ancora a nessuno — di proposito. Il modulo di consegna è, letteralmente, questo: """Delivery — outbound. Unreachable while GV_SHADOW_MODE=true (LAW #1). (later)""". Uno stub. Tutta l’architettura esiste per non spedire finché la prova non c’è.
  • L’eval-lite della v2 è più onesto ma più povero. Non è il Tribunale a 3 agenti: è similarità semantica bot-vs-staff con embedding locali, zero costo metered, un comando, idempotente. Il docstring lo ammette senza pudore: la similarità non è correttezza — un bot corretto ma più lungo può somigliare meno a una risposta staff secca. Il giudice LLM vero torna dopo, gated da un flag. Ho barattato profondità per affidabilità della misura: meglio un termometro grezzo che segna ogni giorno che uno fine che si rompe ogni sessione.
  • Il vecchio non è morto, è congelato. Il KUBECONFIG del vecchio cluster è rinominato config.disabled apposta, così una kubectl distratta non tocca la produzione storica.

Come va

La v2 è a F0: scheletro provabile in shadow. Twin portato e provato, state-sync read-only dal PMS, ingest dei messaggi in shadow, eval-lite che gira in-process. Le fasi F1-F2 (i playbook deterministici e il grounding sul 73% del volume) sono il vero salto di qualità e sono ancora davanti. Il go-live verso gli ospiti resta un cancello dell’owner, capability per capability: clima → codici → stop umano su prenotazioni, Questura e pagamenti.

Cosa ho imparato

Che un sistema di misura può essere così bello da diventare un alibi. Il Tribunale non era il problema — era la cosa migliore che avevo costruito. Il problema erano i nove microservizi intorno, che rendevano la misura talmente fragile che la prova non arrivava mai, e quindi il bot restava in shadow all’infinito, al sicuro, inutile.

La lezione controintuitiva: la complessità che ti fa sentire prudente è spesso la stessa che ti impedisce di spedire. Ho tenuto l’idea del giudizio avversariale (torna, gated) e ho buttato l’impalcatura. Un termometro che segna ogni giorno vale più di un tribunale che si convoca una volta al mese. La prova arriva solo se puoi misurare sempre.