
In a Peruvian general election the facts that should inform a vote are public but scattered. Each candidate files a sworn résumé (Declaración Jurada de Hoja de Vida) with the JNE, the electoral authority. Each party files a government plan as a PDF. Election-day logistics come from ONPE. For the 12 April 2026 election, the JNE's own API listed 7,236 candidacies across president, Senate, Chamber of Deputies and Andean Parliament, and the scraper downloaded 33 government plans. Almost nobody reads that material directly.
Voti (the project is called InfoVoto Perú 2026) is a chat assistant that answers questions about that record in plain Spanish: who is running, what a plan says about a topic, what a candidate declared about education, assets or court cases, what was said in a debate, and how voting works. It is built around one constraint: it informs and never recommends. Everything below describes what it does, not how anyone should vote.
I built it alone between March and early April 2026. This post is a reading of that code after the fact, including the places where it is weaker than the README.
The problem and the requirements
The intended user has a phone, a specific question and little time: "¿Qué propone el partido X en seguridad?", "¿Este candidato tiene sentencias?", "¿Dónde voto?". Three requirements followed from that.
- Answer only from retrieved data. The synthesizer prompt forbids inventing facts, and missing data should be reported as missing.
- Say where each fact came from. A sworn declaration is self-reported by the candidate; a press summary is not an official record. The answer has to keep those apart.
- Do not take sides. No vote recommendations, and no grave accusations ("corrupto", "delincuente") without a judicial record behind them.
A fourth, practical requirement was latency: up to two LLM calls, several tool calls to a database in another region and a vector search all had to fit in a wait a phone user would accept.
Data
The local scraper intercepted the JNE's VotoInformado API with Playwright and then fetched each résumé directly, collecting 7,146 unique résumés and 7,142 photos, by candidacy type: 108 presidential, 1,131 Senate, 5,469 Chamber of Deputies and 528 Andean Parliament. The 33 government-plan PDFs were OCR'd into Markdown and indexed in ChromaDB. A production status note from 1 April 2026 records 7,145 candidates loaded in the production Postgres database.
The project's summary says Voti is grounded in official JNE and ONPE data. The code is broader than that, and the post should be too. For the presidential race the database also holds political positions from Decide.pe, transparency alerts from VotaBienPerú and curated profiles from El Comercio. The synthesizer is instructed to attribute each of these by name ("Según Decide.pe", "Según VotaBienPerú"), separately from the JNE declaration. On the ONPE side, the downloaders for campaign finance and polling locations are marked as pending in the scraper README, so I make no claim that polling-location lookup by DNI ever returned real data, although the tool for it exists.
Architecture
The web client calls a FastAPI gateway that owns the agent. The gateway reaches the data through MCP (Model Context Protocol) servers and a small RAG index of its own.
flowchart LR
U["Citizen<br/>browser"] --> WEB["infovoto-web<br/>Next.js 14 proxy"]
WA["WhatsApp webhook<br/>(code only)"] -.-> GW
WEB -->|"X-API-Key + session"| GW["infovoto-gateway<br/>FastAPI + agent"]
GW --> PRE["preprocessor<br/>regex: intent, nicknames,<br/>fast-route"]
PRE --> R["Pass 1: router<br/>gemini-2.5-flash-lite, JSON"]
R --> POOL["MCP pool<br/>parallel calls,<br/>circuit breaker"]
POOL --> MCP["infovoto-mcp<br/>perfiles · planes · logistica<br/>proceso · debates (+ demo)"]
GW --> RAG[("ChromaDB<br/>36 presidential profiles")]
POOL --> S["Pass 2: synthesizer<br/>gemini-2.0-flash"]
RAG -.-> S
S --> F["output filter<br/>sanitize, mask DNI,<br/>warnings, disclaimer"]
MCP --> PG[("Postgres<br/>Supabase")]
GW <--> RD[("Redis<br/>Upstash")]
MCP layer. infovoto-mcp mounts several FastMCP servers as sub-applications of one FastAPI app, so one Cloud Run service exposes them all. Each one is flag-gated. Five carry electoral data, with 23 tools between them: perfiles (11 tools: profiles, records, assets ranking, comparisons, photos), planes (2: semantic search over plans, multi-party comparison), logistica (5), proceso (2: electoral rules) and debates (3). A sixth, demo, serves mock tools so the agent can be exercised without a database. The docs hub calls this "six servers", which is accurate only if the mock server is counted; the production MCP_URLS list has the five data servers. Two more domains, oversight case files and party finance, were written but moved to a _backlog/ folder because no real 2026 data was collected for them.
The gateway discovers each server at startup from its /metadata endpoint and builds the router's tool catalog from it, although the output filter still keeps a hand-written tool-to-server map for disclaimers.
LLM layer. Providers sit behind a small LLMPort protocol with Gemini, Claude and OpenAI adapters chosen by environment variable. There is no agent framework in between. Production ran Gemini only: gemini-2.5-flash-lite as the router in JSON mode and gemini-2.0-flash as the synthesizer.
Request pipeline
Every message goes through the same steps in InfoVotoAgent:
- Input guards. Regex prompt-injection patterns and a guardrail check; both return a fixed reply without a model call.
- Redis fetch, in parallel. Exact-match reply cache (24-hour TTL, scoped per user when the message contains personal markers such as a DNI), conversation history and resolved entities.
- Preprocessor. Regex intent classification and a nickname table that maps colloquial names to full candidate names and parties. Greetings get an instant reply with no model call.
- RAG, started early. A ChromaDB search over 36 presidential-candidate profiles (
all-MiniLM-L6-v2, ONNX) runs in a background thread during routing; it is skipped when the tools already returned a full profile. - Pass 1, routing. For a few unambiguous patterns (the list of presidential candidates, the election date, polling place when a DNI is known), a regex fast-route picks the tool and skips the router call. Otherwise the router returns a JSON list of tools and arguments.
- Tool calls. All selected tools are called in parallel through a persistent connection pool with a circuit breaker per dependency. Single-tool results are cached by tool name and a hash of the arguments.
- Pass-through or synthesis. If one tool returned pre-formatted content, such as a list, it is sent as is, with no second model call. Otherwise the synthesizer writes the answer from the tool results, truncated to a size limit, plus the RAG context.
- Validation and filtering, described in the next section.
Latency as a budget
Instead of a fixed timeout per stage, each request gets a deadline. Let $R(t)$ be the time left before it. A stage with cap $c$ that must leave $r$ seconds for what follows gets
$$ \tau = \min\bigl(c,\; R(t) - r\bigr), $$and if $R(t) - r \le 0.05$ s the stage is not started and the user receives a fallback message. The router, for example, uses $c = 5$ s and $r = 8$ s, reserving time for tools and synthesis. A router that answers in 0.5 s therefore leaves the extra time to later stages, and a fast-routed query skips that stage entirely. Above the budget sits a hard asyncio.wait_for at the request timeout (20 s by default), so nothing can run past it. Redis, GeoIP and the RAG index degrade gracefully: if they fail, the request continues without them.
The budget did not prevent every failure. A production note from 1 April records that once profiles were enriched with positions and alerts, the synthesizer context roughly tripled and Gemini on Cloud Run began hitting the then 8 s per-call cap, while local runs took 2 to 6 s. The cap is now 15 s.
Neutrality and grounding: what the code actually enforces
This is the part where the documentation and the code diverge most, so I describe the code.
The system prompt carries the policy: never express a preference or recommend a vote, and answer "¿por quién debería votar?" with an offer to compare proposals and records. That is a model instruction, so it is probabilistic.
The output filter (output_filter.py) is deterministic, but its docstring is explicit: it does not block responses, it only adds warnings. What it changes in the text:
- strips leaked internal markers and tool-call text the model sometimes echoed;
- removes any URL outside an allow-list (JNE, ONPE, voti.pe, YouTube), along with script tags and error tracebacks;
- masks 8-digit DNI numbers when the answer came from profile or logistics tools;
- appends a source disclaimer on the first message of a session, such as "Datos de la Declaración Jurada de Hoja de Vida ante el JNE. El candidato es responsable de su veracidad."
What it does not change: if the reply matches a vote-recommendation pattern ("deberías votar", "vota por", "el mejor candidato es", and others), the filter adds a sesgo_detectado warning ("VOTI no recomienda candidatos") that the web client shows next to the answer, but the reply text is still sent. The check for grave adjectives works the same way and is skipped when the answer came from the legal-record tools. The project summary and the docs hub say this filter neutralizes or blocks recommendations. The code does not do that. A warning beside the text is a weaker guarantee than removing the text, and the planning board still lists output scanners for endorsements as pending.
Evaluation
The gateway ships an evaluation harness: question sets by category, including 20 adversarial prompts, scripted multi-turn sessions, a stress runner and an LLM judge (Gemini or Claude Haiku) that scores tone, comprehension, safety and relevance. The tracker in the repository records a baseline on 22 March 2026 of 7.4/10 across 20 scripted sessions (100 turns) and 200 of 200 single questions answered without errors at a median latency of 2.1 s. The 1 April note reports 8.31/10 on 200 questions, run locally. These are judgments by a language model on test sets I wrote, not measurements of accuracy against ground truth, and they say nothing about real users. I do not have usage figures I can verify, so this post gives none.
The tracker is more informative about failures than its averages. The weakest sessions came from the agent asking for confirmation turn after turn instead of acting, and from questions the tools could not answer, such as excluded candidates or aggregates by gender, where the agent sometimes gave a generic answer instead of saying there was no data.
Deployment
Each service builds with Cloud Build and runs on Cloud Run, with Postgres in Supabase and Redis in Upstash so containers hold no state. During the election window the gateway and web ran with min-instances=1 to avoid cold starts. Afterwards the backend was taken down and the chat page switched to a bilingual demo-mode message. The recreate runbook in infovoto-infra lists where each asset lives (GitHub, Secret Manager, GCS) and redeploys with min-instances=0, so keeping the project available between elections costs little more than the landing page.
The gateway also contains a WhatsApp webhook with HMAC verification and phone-number hashing, wired to the same agent. The docs hub presents WhatsApp as a channel. The planning board lists it as pending, and I found nothing in the repositories showing it was opened to the public, so I treat it as implemented but not launched.
Limitations
- Neutrality depends on the prompt. The deterministic layer flags recommendations but does not remove them.
- Coverage is uneven. Rich data (positions, alerts, curated profiles, RAG) exists only for presidential candidates. For thousands of congressional candidates, the only source is the self-declared JNE résumé.
- Third-party sources are not official records. They are attributed, but their selection and wording are outside the JNE's control and outside mine.
- Evaluation is self-referential. Questions, sessions and judge prompts were written by the same person who wrote the agent.
Lessons
Write the policy where it can be tested, and describe it as it is. The most useful correction from re-reading the code was to my own summary: a filter that warns is still useful, but it is not one that blocks.
A time budget is easier to reason about than a set of timeouts. Slow requests became a question of which stage spent the reserve.
Pass-through matters as much as model choice. Skipping the synthesizer for lists and the router for obvious patterns removed whole model calls, which did more for latency than switching models.
Data gaps show up as agent bugs. Several low-scoring sessions were not reasoning failures: the tool had no data, and the model filled the silence.
The code, the diagrams and the runbook are public under the MIT license in the iDeepBrain infovoto repositories.