./run pragma-due-volte-complessita-che-si-guadagna-il-posto
Pragma, due volte: ho buttato sei agenti e due database, e ci ho guadagnato una feature che pesa 1
Ho ricostruito da zero un assistente di project management via WhatsApp per la PA: da sei agenti e tre database a uno solo. Diario su quale complessità si è guadagnata il posto.
- status
- draft
- project
- PRAGMA2
- updated
- 2026-07-07
- tags
C’è un momento preciso in cui capisci se una scelta architetturale valeva la pena: quando la riscrivi da zero e devi decidere, riga per riga, se te la porti dietro. Pragma è un assistente conversazionale di project management per gli uffici della PA: la gente scrive su WhatsApp (o manda un audio, o fotografa un documento) e il sistema crea progetti, task, memo, scadenze. Niente pannelli da compilare — è il software che ascolta e agisce.
L’ho costruito due volte. La prima versione era una cattedrale: sei agenti AI specializzati che si parlavano, tre database, GPT-5.4 ovunque, ~340 test. La seconda l’ho rifatta da zero, più povera in quasi tutto — e proprio quella povertà le ha lasciato spazio per imparare una cosa che la cattedrale non sapeva fare. Questo è il diario di cosa ho perso, e di cosa non rimpiango.
La cattedrale (v1)
La prima Pragma era costruita su Agno, un framework multi-agente. Un agente “Reception” faceva da router e instradava ogni messaggio verso lo specialista giusto: Project Manager (16 tool), Knowledge (11 tool), Personal Assistant (13 tool), Communication. Sopra tutti, un Coordinator per le richieste composite — “fammi il report del progetto e mandalo via email”. Un Team dentro un Team.
return Team(
name="reception",
mode="route", # instrada verso UN membro
members=[pm_agent, knowledge_agent, assistant_agent,
communication_agent, coordinator, info_agent],
...
)
# ...e il coordinator, annidato, girava in mode="coordinate"
# con max_iterations=10 e share_member_interactions=True
Dietro, tre datastore con ruoli distinti: PostgreSQL come source of truth, Qdrant per la memoria semantica (RAG per-progetto), Neo4j per il grafo relazionale — chi lavora su cosa, quali progetti dipendono da quali. Il RAG era a quattro stadi: query expansion, HyDE, retrieval ibrido Qdrant + BM25, fusione RRF, rerank con l’LLM. Embeddings a 1536 dimensioni. Circa 13,6k righe di Python, 6,9k di TypeScript per la dashboard (Next.js 16 + shadcn/ui v4).
Sulla carta era bellissima. In pratica ogni messaggio pagava un pedaggio: il routing poteva sbagliare, il dual-write Postgres↔Neo4j introduceva bug di sincronizzazione, e i tool-call passavano dallo strato di astrazione di Agno — che era proprio dove nascevano i bug più fastidiosi da diagnosticare. Tre database significano tre StatefulSet da tenere vivi, tre backup, tre punti di rottura. Per un ente pubblico con qualche decina di utenti, era un motore da corsa per andare a fare la spesa.
Il rewrite (v2)
La seconda Pragma è un rewrite ground-up, non un refactor. La filosofia opposta: un agente, un database, model-agnostic.
Ho buttato Agno. Al suo posto un loop di tool-calling scritto a mano sopra una qualsiasi API OpenAI-compatible, con un cap secco di 5 tool-call per messaggio e timeout a 120s. Ho buttato Qdrant e Neo4j: tutto dentro Postgres, con pgvector per la ricerca semantica (embedding a 1024-dim di Mistral) e tsvector per il full-text, fusi con RRF e una soglia adattiva. Da tre datastore a uno. E ho reso il modello intercambiabile: la classe provider è un guscio sottile sopra /chat/completions, e scambi Mistral con OpenAI, Ollama o un endpoint self-hosted con tre variabili d’ambiente.
_PROVIDER_URLS = {
"mistral": "https://api.mistral.ai/v1",
"openai": "https://api.openai.com/v1",
"ollama": "http://localhost:11434/v1",
}
def get_llm_provider() -> LLMProvider:
provider = getattr(settings, "LLM_PROVIDER", "mistral").lower()
base_url = getattr(settings, "LLM_BASE_URL", "") or _PROVIDER_URLS.get(provider, "")
# PRAGMA_LLM_BASE_URL vince sempre → LiteLLM/vLLM/LocalAI out-of-the-box
return OpenAICompatibleProvider(base_url=base_url, ...)
Mistral Large di default (più economico di GPT-5.4 per il volume tipico), Groq per l’audio (Whisper) e le immagini (Vision su Llama4 Scout), PyMuPDF per i PDF, TTS Voxtral per i recap vocali. In più: Telegram accanto a WhatsApp, stesso pipeline normalize → dedup → queue → consumer per entrambi i canali. Frontend rifatto in Next.js 14 con Tailwind puro, senza shadcn. Il PM Agent oggi ha 29 tool, il DB 21 tabelle, il cluster gira su 10 pod.
La spina: quale complessità si è guadagnata il posto
Qui sta il cuore. Ho scambiato potenza per semplicità, e la domanda onesta è: cosa ho perso davvero?
Ho perso il ragionamento relazionale di Neo4j — le query cross-progetto tipo “tutti i task bloccati che dipendono da qualcosa fermo nella sezione X”. E ho perso la specializzazione multi-agente: un agente solo, con 29 tool nel contesto, a volte è più goffo di quattro agenti focalizzati. Queste due cose la cattedrale le faceva meglio.
Ma il resto non lo rimpiango. Il routing forzato su ogni messaggio? Era un costo pagato sul 100% delle richieste per un beneficio che serviva sul 5%. Il RAG a quattro stadi con rerank LLM? Precisione marginale, latenza e costo reali. Il dual-write Neo4j? Debito di sincronizzazione puro. La lezione controintuitiva è che gran parte di quella complessità non produceva valore per l’utente: produceva superfici di guasto.
E c’è il rovescio della medaglia, la cosa che mi ha convinto del rewrite. La versione povera ha avuto lo spazio mentale per una feature che la cattedrale non ha mai avuto: un motore di apprendimento procedurale. Ogni “procedura” della PA ha dei task tipici in un certo ordine. Quando un progetto reale devia — un task in più, uno mancante, uno fatto fuori sequenza — il sistema non lo ignora: lo confronta, lo registra, e incrementa un peso.
# confronta i task reali vs quelli attesi dalla procedura
match = _fuzzy_match(a_title, expected, threshold=0.85) # SequenceMatcher
...
if deviazione: # pattern già visto prima
if not obs_gia_contata_da_questo_progetto:
deviazione.peso += 1 # ← una deviazione ricorrente "pesa" di più
else:
deviazione = Deviazione(tipo=dev.tipo, peso=1, stato="osservata", ...)
Il fuzzy match a 0.85 assorbe le differenze di formulazione (“Invio protocollo” vs “Protocollazione”), e ogni progetto può contribuire una sola osservazione per deviazione — così il peso conta quante persone diverse fanno la stessa deviazione, non quante volte. Quando il peso supera una soglia, la deviazione ricorrente viene promossa a variante ufficiale della procedura. In pratica: il sistema guarda come la gente lavora davvero e aggiorna il modello di come dovrebbe funzionare. La cattedrale, con tutti i suoi agenti, non imparava niente. Questa qui impara. E la unità di misura di quell’apprendimento è un intero che parte da 1.
Gotcha onesti
- Il rewrite ha dimenticato le lezioni di sicurezza. Una review avversariale ha trovato 8 finding (3 critici): default JWT secret nel codice, webhook WhatsApp spoofabile quando la password è vuota, endpoint mutanti senza check di autorizzazione. Non erano bug nuovi — erano regressioni: hardening che la v1 aveva già accumulato e che ricostruendo da zero ho semplicemente lasciato indietro. Ripartire pulito ripulisce anche le difese.
- Drift README/realtà, versione compresa. Il comando di deploy taggava le immagini
v0.12.1, ma i doc interni di build parlavano div1.6.0, e la nota d’analisi di un altro sprint diceva0.8.0. Tre numeri per lo stesso sistema: segno che il versioning lo stavo aggiornando a mano e a strappi. - Il consumer è sequenziale. Un messaggio alla volta, ~4-12 msg/min. Regge per un ufficio, è un collo di bottiglia dichiarato per qualsiasi scala. Il fix (worker pool con lock per-utente su Redis) è progettato ma non ancora costruito.
- Test parity mancante. La v1 aveva ~340 test verdi. La v2, la copertura non l’ho verificata. Portarla a parità è il prerequisito che mi sono imposto prima di toccare qualsiasi hardening.
Come va
La v2 è il sistema che porto in produzione: multi-canale, più economico, mantenibile da una persona sola. Le fasi 1-6 (CRUD, chat, procedure, deviazioni, KB ibrida, auto-review) sono operative. Le fasi 7-11 (consumer parallelo, analytics, workflow con dipendenze) sono un catalogo di direzioni, non una roadmap committata — e nessuna è avviata. C’è una “v3” abbozzata su carta con una tesi che mi piace: Postgres-come-tutto + potenza on-demand, cioè recuperare il grafo con Apache AGE dentro lo stesso Postgres e il multi-agente solo quando serve davvero. Recupero della potenza, ma opt-in e misurato. Per ora è un documento, non codice.
Cosa ho imparato
Costruire due volte la stessa idea è il test di stress più onesto che conosca per un’architettura. Tre cose me le porto via concrete:
- La complessità va tassata sul beneficio marginale, non su quello che “potrebbe servire”. Sei agenti e tre database si giustificano se il 100% delle richieste ne ha bisogno. Se il 95% è “crea un task”, stai pagando un premio assicurativo su un rischio che non corri.
- Ogni datastore in più è un contratto di sincronizzazione. Consolidare tre in uno non ha tolto solo infra: ha tolto un’intera classe di bug che non esistono più perché non c’è più niente da sincronizzare.
- Un rewrite riparte pulito anche sulle difese. Le hardening non sono nel design, sono nelle cicatrici. Se butti il codice, butti anche le cicatrici — e le regressioni di sicurezza sono il prezzo nascosto della pagina bianca.
La versione più povera ha vinto. Non perché fosse più intelligente, ma perché era abbastanza leggera da lasciarmi spazio per aggiungere l’unica intelligenza che contava davvero. E quella, si misura in incrementi da 1.
Repository privati. Nomi di enti e persone anonimizzati.