How Nammu's Chat knows which law it’s talking about
Every jurisdiction gets its own system-prompt persona, its own legal-safety guardrails, and its own normativa retrieval collection. Here is how the two-layer prompt assembly, the deterministic routing pipeline, and the Nexus.PJ jurisprudence copilot all fit together — and where the honest gaps are.
Inventum P5
Engineering · Galictis-Legal
July 10, 2026
9 min read
Every jurisdiction Nammu supports gets its own Chat persona — a system prompt grounded in that branch's governing code, carrying that branch's legal-safety guardrails, and retrieved against that branch's own vectorized normativa. A single shared grounding layer appended to all of them keeps the “answer only from documents, not from memory” discipline consistent across every branch. Here is how the assembly works, how routing decides which path a user turn takes, and where the honest gaps are today.
Two layers, not one giant prompt
There is no single hardcoded chat system prompt, and no giant per-jurisdiction if/elif ladder either. resolve_chat_system_prompt() assembles the prompt by concatenating two independently-sourced layers:
Source: config/legal/<branch>/*_prompts_rev*.yaml → CHAT_SYSTEM_PROMPT
- Varies per jurisdiction — tone, governing code, branch-specific ethical warnings
- Written from scratch per branch, not a template with swapped-in nouns
- 7 branches today: civil, penal, laboral, familia, notarial, tránsito, contencioso
- Agrario and constitucional fall to the built-in generic fallback
Source: config/legal/prompts_rev01.yaml → CHAT_SYSTEM_GUARDRAILS
- Appended to every branch persona — jurisdiction-agnostic
- Grounding discipline: answer only from workspace documents and retrieved normativa, not training-data memory
- A separate CHAT_SYSTEM_GUARDRAILS_MILVUS variant adapts citation rules for legacy Milvus mode
- A fix here automatically applies to all 7 branches at once
Discovery globs config/legal/**/*_prompts_rev*.yaml, keeps the highest-revision file per branch folder, and maps folder names to schema slugs. Today there are exactly 7 branch prompt files — the same set of branches that have loaded process schemas. Agrario and constitucional have neither a schema nor a chat persona; a case in either falls to the built-in generic fallback.
Every resolved system prompt and its metadata (source: branch vs. fallback; which file) is logged per turn to intermediate_data/legal/<workspace>/chat_system_prompt_log.jsonl — a per-case audit trail so any answer's provenance is always reconstructable after the fact.
What actually differs across branch personas
Each branch's persona is written from scratch against its own governing code — not a template with swapped-in nouns. Penal and familia illustrate why the difference is not cosmetic:
Penal persona
Governing code: CPP Ley 7594
Eres un asistente jurídico experimentado en derecho procesal penal de Costa Rica (Código Procesal Penal, Ley N.º 7594).
Respeta la presunción de inocencia y no presentes a ninguna persona imputada como culpable.
Familia persona
Governing codes: CF 5476 · CPF 9747
Eres un asistente jurídico experimentado en derecho de familia de Costa Rica (Código de Familia, Ley N.º 5476; Código Procesal de Familia, Ley N.º 9747).
Ante menores de edad, prioriza el interés superior del menor. No sugieras conciliación ante violencia doméstica ni relaciones de poder desigual.
Penal's persona carries a specific constitutional guardrail (never present the accused as guilty — presumption of innocence). Familia's carries an entirely different pair (best interest of the minor; no conciliation suggestions in violence contexts) that would be meaningless — or actively wrong — bolted onto a penal persona. This is the same discipline as Trayectorias' milestone notes fields: the legal-safety instruction lives at the layer where it is operationally relevant, not as a blanket disclaimer.
BORRADOR (draft) for the lawyer, and every one flags cited articles as SINALEVI_VERIFIED but not yet ATTORNEY_VERIFIED — the same draft-pending-sign-off caveat that runs through the schemas, the analyses, and the Trayectorias milestones.process_type is a documented gap, not a silent omission
Only jurisdiction selects the persona. process_type — which drives UI analysis caps and Trayectorias template selection — is not wired into resolve_chat_system_prompt(). A civil ordinario and a civil sumario case get the identical civil persona in Chat today. The retrieval code's own docstring names exactly why:
Routing key is jurisdiction branch → collection; process_type drives app behaviour (UI caps, trayectorias, prompts) but is NOT used as a vector filter because normativa chunks are ingested at branch level without process_tags metadata.
The root cause is upstream of the prompt layer: normativa chunks in Milvus are ingested per branch, not per process_type, so there is no finer-grained normativa slice to retrieve even if the prompt wanted one.
How a turn is routed
There is no LLM-driven router that freely picks among case documents, normativa, and jurisprudence search. Routing is a fixed, ordered pipeline — deterministic and auditable before any model sees the message.
is_jurisprudence_question()
Regex gate — runs first, before any mode detection or retrieval
Yes: → jurisprudence_copilot_turn (skips workspace pipeline entirely)
No: ↓ continue to mode detection
detect_chat_mode()
Auto-detects grounding mode from API client and Milvus collection availability
Yes: Legacy: workspace-named Milvus collection exists and has data → milvus_turn
No: Normal: branch normativa collection or none → workspace_turn
workspace_turn
FTS5 BM25 case-document search + retrieve_jurisdiction_context() normativa RAG
The jurisprudence regex gate
is_jurisprudence_question() is a deliberately narrow, high-precision regex classifier — not an LLM call. It operates in three tiers: strong explicit signals (jurisprudencia, casación, nexuspj, criterio jurisprudencial…), a co-occurrence requirement for sala mentions (requires both a court name and a research-intent word — prevents “la Sala Primera resolvió admitir el escrito” from false-firing just because a sala is named), and explicit holdings phrasing. This gate is jurisdiction-agnostic — the same regex applies to a penal case asking about Sala Tercera and a civil case asking about Sala Primera casación. If it matches, the turn goes to the jurisprudence copilot and skips the workspace pipeline entirely — the two paths are mutually exclusive per turn.
Normativa retrieval — one collection per branch, never process_type-filtered
retrieve_jurisdiction_context() embeds the user's query, searches the branch's Milvus normativa collection, and formats the top hits into context text. No metadata filter is ever applied — consistent with the process_type gap above. Branches with no vectorized collection (agrario, constitucional — again, the same two branches with no schema and no chat persona) are skipped cleanly via uses_vectorized_normativa() rather than erroring.
One label worth noting precisely: the user-facing context header says “Normativa y jurisprudencia,” but what is injected there is normativa statute chunks only — no case-law text is ever retrieved into regular chat turns. The label is aspirational, not currently accurate.
Chat as a copilot to analyses — mostly not yet
There is no tool-calling anywhere in the codebase. A repo-wide check for the standard OpenAI tool-calling surface (tools=, function_call, tool_choice) returns zero matches. Every chat completion call is a plain messages= call. There is no mechanism for the model to decide mid-conversation “let me run compute_legal_plazo for this case” — analyses run only via their own dedicated UI triggers, entirely outside the chat loop.
| Capability | Status | Notes |
|---|---|---|
| Chat reads already-computed controversia / hechos / estado / plazo as context | Partial | Only inside the jurisprudence copilot, not regular chat |
| Chat can trigger a live analysis computation | Not yet | Not implemented — analyses run only via their own UI triggers |
| LLM tool / function-calling for any analysis | Not yet | Zero matches for tools= / function_call / tool_choice in the codebase |
| poder_especial / confidence_provenance reachable from chat | Not yet | Not used anywhere in chat |
| Analysis sidecars used in normal chat Q&A | Not yet | Chat re-derives answers from raw docs + normativa instead |
controversy field the dedicated analysis agent already computed (and which the lawyer may have already reviewed and possibly edited). Analysis-as-context is a real but narrow today: controversy/facts/estado/plazo feed the jurisprudence copilot only, not regular chat.The Nexus.PJ jurisprudence copilot
The copilot's own module docstring states it plainly: “Nexus.PJ query copilot — structured search briefs from Chat (no live scraping).” There is no API integration with Nexus.PJ, no scraping, and no jurisprudence vector index in Milvus today. The copilot's entire job is to turn a natural-language question into a well-formed search plan that a human lawyer then executes in a real browser tab.
The search-brief LLM call
A single static system prompt — not jurisdiction-profiled — instructs the model to return one JSON object: a plain-language summary, a primary query (free text plus optional norm/article/date range), up to 3 alternate queries, court filter hints, and free-text notes for anything Nexus's advanced search does not expose via URL parameters.
The prompt carries one hard prohibition:
NO inventes números de sentencia, expedientes ni extractos de fallos. Si el contexto del caso es escaso, indícalo en summary y propón búsquedas genéricas prudentes.
The call uses only the last 4 turns of memory (vs. 6 for regular chat — a tighter window, since search-plan refinement needs less back-context than substantive legal Q&A) and temperature=0.2 for reproducible, conservative query construction. If the model's output is not valid JSON, a deterministic fallback brief is built directly from the raw user text as the primary query — the copilot always returns something usable rather than failing the turn.
From JSON brief to browser action
URL construction builds the q parameter using Nexus's own query-token syntax (norma:, articulo:, @desde/@hasta), so the buttons open a real, pre-filled Nexus.PJ search rather than a generic homepage. Every response also carries an explicit disclaimer and a “paste the holding back into chat” hint — the workflow is deliberately human-in-the-loop: the copilot plans the search, the lawyer runs it and judges the results, and can then feed a real holding back into the conversation.
Jurisdiction shows up in the copilot indirectly: since the case-context block (_gather_case_context_for_jurisprudence()) pulls from already-computed controversy/facts/estado/plazo — which were themselves generated under branch-specific analysis prompts — the copilot inherits jurisdiction flavor through the case facts it has available to reason from, even though its own search strategy is identical for every branch.
Guiding principles
Jurisdiction profiles tone and legal-safety guardrails, not retrieval scope
The persona changes per branch. The grounding sources (FTS case docs + branch normativa RAG) are structurally identical across branches — only which normativa collection gets searched changes.
process_type is a documented, explicit gap
Not a silent omission — the retrieval code names exactly why it is not filtered on (no process_tags metadata at ingest time), matching the transparency standard set across the other architecture docs.
No hallucinated case law, ever
The jurisprudence copilot is a search-planning assistant with a hard prohibition on inventing sentencia or expediente numbers. The gap between "help me find the right search" and "tell me what the jurisprudence says" is deliberate and enforced in the system prompt itself.
Routing is deterministic and auditable, not model-improvised
A turn's path (jurisprudence copilot vs. workspace vs. legacy Milvus) is decided by regex and config lookups before any LLM sees the message. Every resolved system prompt is logged per turn.
Analyses-as-context is real but narrow today
Already-computed controversy/facts/estado/plazo feed the jurisprudence copilot's case context, but nothing in chat can invoke, refresh, or reason natively over the full analysis surface. A clear, documented direction for future tool-calling work, not a currently-shipped capability.
Want to see the Chat agent working on real case files?
We can run a live session with a case from your jurisdiction — including the Trayectorias tab, the analysis panel, and the Nexus.PJ jurisprudence copilot.
Request a demo →