./run pragma-due-volte-complessita-che-si-guadagna-il-posto
Pragma, twice: I threw out six agents and two databases, and gained a feature that weighs 1
I rebuilt a WhatsApp project-management assistant for the public sector from scratch: from six agents and three databases to just one. A diary on which complexity…
- status
- alive
- project
- PRAGMA2
- updated
- 2026-07-08
- tags
There’s a precise moment when you find out whether an architectural choice was worth it: when you rewrite it from scratch and have to decide, line by line, whether to carry it along. Pragma is a conversational project-management assistant for public-sector offices: people write on WhatsApp (or send an audio, or photograph a document) and the system creates projects, tasks, memos, deadlines. No forms to fill in — it’s software that listens and acts.
I built it twice. The first version was a cathedral: six specialized AI agents talking to each other, three databases, GPT-5.4 everywhere, ~340 tests. The second I rebuilt from scratch, poorer in almost everything — and it was precisely that poverty that gave it room to learn something the cathedral couldn’t do. This is the diary of what I lost, and what I don’t miss.
The cathedral (v1)
The first Pragma was built on Agno, a multi-agent framework. A “Reception” agent acted as router and dispatched every message to the right specialist: Project Manager (16 tools), Knowledge (11 tools), Personal Assistant (13 tools), Communication. Above them all, a Coordinator for composite requests — “make me the project report and send it by email.” A Team inside a 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
Behind it, three datastores with distinct roles: PostgreSQL as the source of truth, Qdrant for semantic memory (per-project RAG), Neo4j for the relational graph — who works on what, which projects depend on which. The RAG was four-stage: query expansion, HyDE, hybrid Qdrant + BM25 retrieval, RRF fusion, LLM rerank. 1536-dimension embeddings. About 13.6k lines of Python, 6.9k of TypeScript for the dashboard (Next.js 16 + shadcn/ui v4).
On paper it was beautiful. In practice every message paid a toll: routing could be wrong, the Postgres↔Neo4j dual-write introduced sync bugs, and the tool-calls went through Agno’s abstraction layer — which was exactly where the most annoying-to-diagnose bugs were born. Three databases mean three StatefulSets to keep alive, three backups, three points of failure. For a public agency with a few dozen users, it was a racing engine to go do the grocery shopping.
The rewrite (v2)
The second Pragma is a ground-up rewrite, not a refactor. The opposite philosophy: one agent, one database, model-agnostic.
I threw out Agno. In its place, a hand-written tool-calling loop over any OpenAI-compatible API, with a hard cap of 5 tool-calls per message and a 120s timeout. I threw out Qdrant and Neo4j: everything inside Postgres, with pgvector for semantic search (1024-dim Mistral embeddings) and tsvector for full-text, fused with RRF and an adaptive threshold. From three datastores to one. And I made the model interchangeable: the provider class is a thin shell over /chat/completions, and you swap Mistral for OpenAI, Ollama, or a self-hosted endpoint with three environment variables.
_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 by default (cheaper than GPT-5.4 for the typical volume), Groq for audio (Whisper) and images (Vision on Llama4 Scout), PyMuPDF for PDFs, Voxtral TTS for voice recaps. On top of that: Telegram alongside WhatsApp, the same normalize → dedup → queue → consumer pipeline for both channels. Frontend redone in Next.js 14 with pure Tailwind, no shadcn. The PM Agent today has 29 tools, the DB 21 tables, the cluster runs on 10 pods.
The thorn: which complexity earned its place
Here’s the heart of it. I traded power for simplicity, and the honest question is: what did I really lose?
I lost Neo4j’s relational reasoning — the cross-project queries like “all blocked tasks that depend on something stuck in section X.” And I lost multi-agent specialization: a single agent, with 29 tools in context, is sometimes clumsier than four focused agents. The cathedral did those two things better.
But the rest I don’t miss. The forced routing on every message? It was a cost paid on 100% of requests for a benefit needed on 5%. The four-stage RAG with LLM rerank? Marginal precision, real latency and cost. The Neo4j dual-write? Pure sync debt. The counterintuitive lesson is that most of that complexity produced no value for the user: it produced failure surfaces.
And there’s the flip side, the thing that convinced me of the rewrite. The poorer version had the mental space for a feature the cathedral never had: a procedural learning engine. Every public-sector “procedure” has typical tasks in a certain order. When a real project deviates — an extra task, a missing one, one done out of sequence — the system doesn’t ignore it: it compares it, records it, and increments a weight.
# 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", ...)
The fuzzy match at 0.85 absorbs wording differences (“Invio protocollo” vs “Protocollazione”), and each project can contribute only one observation per deviation — so the weight counts how many different people make the same deviation, not how many times. When the weight crosses a threshold, the recurring deviation is promoted to an official variant of the procedure. In practice: the system watches how people actually work and updates the model of how it should work. The cathedral, with all its agents, learned nothing. This one learns. And the unit of measure of that learning is an integer that starts at 1.
Honest gotchas
- The rewrite forgot the security lessons. An adversarial review found 8 findings (3 critical): default JWT secret in the code, spoofable WhatsApp webhook when the password is empty, mutating endpoints with no authorization check. They weren’t new bugs — they were regressions: hardening that v1 had already accumulated and that, rebuilding from scratch, I simply left behind. Starting clean also cleans out the defenses.
- README/reality drift, version included. The deploy command tagged images
v0.12.1, but the internal build docs talked aboutv1.6.0, and another sprint’s analysis note said0.8.0. Three numbers for the same system: a sign that I was bumping the versioning by hand and in fits. - The consumer is sequential. One message at a time, ~4-12 msg/min. It holds for an office, it’s a declared bottleneck for any scale. The fix (a worker pool with per-user locks on Redis) is designed but not yet built.
- Test parity missing. v1 had ~340 green tests. For v2, I haven’t verified coverage. Bringing it to parity is the prerequisite I’ve set myself before touching any hardening.
How it’s going
v2 is the system I’m taking to production: multi-channel, cheaper, maintainable by one person. Phases 1-6 (CRUD, chat, procedures, deviations, hybrid KB, auto-review) are operational. Phases 7-11 (parallel consumer, analytics, workflows with dependencies) are a catalog of directions, not a committed roadmap — and none are started. There’s a “v3” sketched on paper with a thesis I like: Postgres-as-everything + on-demand power, i.e. recovering the graph with Apache AGE inside the same Postgres and multi-agent only when it’s really needed. Recovering the power, but opt-in and measured. For now it’s a document, not code.
What I learned
Building the same idea twice is the most honest stress test I know for an architecture. Three things I take away concretely:
- Complexity should be taxed on marginal benefit, not on what “might come in handy.” Six agents and three databases are justified if 100% of requests need them. If 95% is “create a task,” you’re paying an insurance premium on a risk you don’t run.
- Every extra datastore is a sync contract. Consolidating three into one didn’t just remove infra: it removed an entire class of bugs that no longer exist because there’s nothing left to sync.
- A rewrite starts clean on the defenses too. Hardening isn’t in the design, it’s in the scars. If you throw out the code, you throw out the scars too — and security regressions are the hidden price of the blank page.
The poorer version won. Not because it was smarter, but because it was light enough to leave me room to add the only intelligence that really mattered. And that one is measured in increments of 1.
Private repositories. Agency and personal names anonymized.


