How Nammu reads a 300-page expediente: smart document segmentation
A Costa Rican expediente export is a wall of pages — demanda, resoluciones, notificaciones, sentencia, all concatenated. Before any analysis can run, Nammu must split it into typed, navigable units. Here is the two-phase pipeline that does it without an LLM on every page.
Inventum P5
Engineering · Galictis-Legal
July 10, 2026
10 min read
A Costa Rican expediente export is rarely one document. It is a chronological compilation: a demanda, the court's admission resolution, a dozen notification acts, a contestación, evidence exhibits, more resoluciones — all concatenated into a single PDF with no bookmarks and no metadata boundaries. Smart Document Segmentation is the pass that turns that wall of pages into a navigable, typed index the moment OCR finishes, without waiting for a human to scroll through it first.
That typed index — document_index.json — is also the foundation everything else reads from. Jurisdiction detection, Trayectorias timelines, and case-level AI analysis all consume the doc_kind labels this module produces. Getting segmentation wrong propagates errors through every downstream feature.
Two phases: cheap rules first, LLM only when genuinely needed
The module mirrors the same design philosophy as the jurisdiction classifier: a fast, free, rule-based pass runs first; a paid LLM call only ever refines what the rules genuinely could not resolve. Most pages are unambiguous — a page that opens with “SENTENCIA N° 015-2025” does not need an LLM to tell you it is a sentencia. The LLM is reserved for the hard 5–10%: vision-described scans and generic fallbacks where the extra reasoning actually earns its cost.
- Always runs — no LLM, near-zero cost
- Matches known document-opening phrases against page heads only
- Ordered most-specific-first to avoid shadow matches
- Gap-fill: every unmatched page inherits the open segment
- Collapses consecutive same-type runs into one navigable block
- Only for segments Phase 1 flagged as low-confidence or generic
- Single batched call — not one LLM call per page
- Receives page snippet, rule guess, authorship register, full taxonomy
- Fail-closed: defaults to explicit generic labels, never guesses
- Output rejected if it violates the authorship-consistency guard
The core algorithm
segment_document() is the Phase-1 engine. For each page, five steps run in order:
Strip noise and normalize
Page-number prefixes (page_N:) and folio stamps (N. N) are stripped. Text is lowercased, accent-stripped, and whitespace-collapsed. Crucially, the °/º degree-sign glyphs are folded to a space — NFKD accent-stripping alone does not touch them, which caused a real false-negative before this step was added: “SENTENCIA N° 015” and “SENTENCIA N 015” now normalize identically.
Detect authorship register
Before matching any document type, the page's grammatical voice is classified as tribunal, parte, mp, or unknown. This is a structural guard, not a type hint — used to block entire families of anchors from firing in the wrong register (see §Authorship guard).
Match an anchor against the page head
The normalized head of the page — never the whole page, never loose body text — is checked against the anchor list. This is intentional: a document that merely mentions “la demanda” in its narrative body never falsely opens a new demanda segment.
Gap-fill: inherit the open segment
A page with no anchor match simply inherits whatever segment is already open. The output is always a total cover of every page — no orphan pages, no unclassified stretches, even though only a minority of pages carry an explicit anchor.
Collapse and merge runs
Adjacent boundaries of the same type collapse into one document. Then, if collapse_runs=True (the default), consecutive same-type documents are merged into one navigable block with a count — 24 individual notification acts become “notificación pp. 9–51, count=24” rather than 24 separate rows.
caratula_datos_generales; otherwise it opens as a low-confidence escrito_generico for the LLM pass to refine. Either way, nothing is ever left completely unclassified.The authorship-register guard
Before any anchor fires, _detect_authorship() reads grammatical and institutional voice cues — not document-type names — to classify a page's author as tribunal, parte, mp, or unknown:
- Tribunal register: opens with a court name, or contains ordering language ("se resuelve", "por tanto"), or matches the canonical Costa Rican judicial self-dating formula.
- Party register: opens with a salutation to the court ("señores del juzgado") or a first-person filing verb ("comparezco", "vengo a…").
- MP register: mentions "Ministerio Público" or "Fiscalía" in the first ~70 characters.
cumplimiento_prevencion, subsanación) were firing on tribunal documents, because a court's ordering language sometimes echoes the same vocabulary a party would use to comply with it. Fixing this phrase-by-phrase would be a losing chase. The structural fix: any anchor whose taxonomy family is actos_de_parte is automatically skipped when the detected register is tribunal — an entire class of false positive, closed at the mechanism level. The same guard applies to the LLM's own output: the LLM cannot assign a party-act slug to a tribunal-registered head either.Single-phrase anchors vs. co-occurrence
Most anchors are simple: a slug maps to phrases, checked against the page head, ordered most-specific-first. But several document types cannot be reliably identified from one phrase alone, because Costa Rican procedure reuses the same generic vocabulary — “recurso de apelación”, “sentencia” — across civil, penal, familia, and notarial matters. This module has no jurisdiction parameter: segmentation runs before, and independently of, jurisdiction detection.
For these, _COOCCURRENCE_ANCHORS requires two or more independent phrase groups to all hit on the same page before pinning a type. A notarial appeal-inadmissibility filing must show both an appeal phrase and a notarial-context word (“notarial”/“notario”/“disciplinario”) before being tagged apelacion_inadmision_notarial.
recurso_familia / sentencia_familia / sentencia — purely because familia's generic anchors fire on the same bare “recurso”/“sentencia” vocabulary a notarial filing also uses. Co-occurrence is the fix, applied in both directions.A _COOCCURRENCE_NEGATION_BLOCKERS mechanism handles the one case where even two-group co-occurrence is not enough: a tribunal sentencia can narrate, deep in its reasoning, that no such incident exists in a different expediente — satisfying both phrase groups while being the opposite of a genuine filing. The fix is an explicit “unless the page also says 'no consta ningún incidente'” exclusion, scoped narrowly to the one anchor that has shown this failure mode.
One-document guards: statutes, fee letters, and continuation pages
Three situations get a document-level short-circuit before the per-page loop runs, because per-page anchor matching would misfire repeatedly across an entire attached document:
Reference-law / statute attachments
A full Código or Ley text attached as an exhibit narrates its own “recurso”/“sentencia”/“audiencia” articles throughout — that is the statute’s own content, not a case filing. If page 0 matches the constitutional enactment formula or an uncited Ley title (explicitly excluding filing citations like “de conformidad con el Código Civil”), the entire PDF becomes one ley_referencia segment. Per-page matching is skipped entirely.
Fee-proposal / engagement letters
An attorney’s “Propuesta de Servicios Legales” lists, as its own service bullets, exactly the phrases that open real filings — “interponer los recursos de apelación que correspondan”, “al emitirse la sentencia”. Once page 0 is confirmed as such a letter, later pages of that PDF cannot spuriously open a demanda/recurso_familia segment from the service-list language — while genuine attachments bundled after the letter are still detected normally.
Notarial continuation pages
A multi-page notarial filing narrates its argument in the body (“…mediante sentencia número 396-02…”), which fires the generic familia/civil sentencia anchors on continuation pages that carry no notarial co-occurrence signal of their own (the letterhead appears only on page 0). Once page 0 is confirmed notarial, the generic leak-prone anchors are suppressed on later pages of that same filing — but a genuinely new second notarial filing later in the same PDF is still detected.
All three guards share the same shape: establish identity once from the document's opening page, then suppress the specific anchors known to misfire on that document's own body text for the rest of it.
Track-reference suppression
Some doc_kinds are explicitly about another track without themselves being a new instance of it. A defectos_querella (court flags defects in a querella) or subsanación_querella (party fixes those defects) will almost always re-quote querella-track language on the same page — but that is a reference to the existing querella, not a second querella filing.
_TRACK_ROLE encodes this as a table (references: [...] → which root track a given type points at), and _suppress_track_root_cosegments() drops any co-segment that is the root of a track already referenced by the primary segment. Extending this to a new track is one table row, not a new bespoke rule.
This suppression pass runs twice in build_document_index() — once right after the rule pass and once after the LLM pass — because a changed primary type can newly qualify or disqualify a co-segment that was not relevant before.
Filename as last-resort tie-breaker
refine_segment_types() applies filename-pattern rules — but only ever to upgrade an already-generic fallback type (escrito_generico, indeterminado, requerimiento_generico). A segment whose text anchor already resolved to querella is left alone regardless of what the filename says. This is the same “Rank-5, corroborator only” discipline documented for case-level process-type detection — the two modules independently arrived at the same rule: a filename can break a tie among weak evidence, but it can never outrank text actually read from the page.
The taxonomy — jurisdiction-agnostic by design
Every recognized doc_kind slug is grouped into one of seven authorship-shaped families. Civil, penal, laboral, familia, notarial, and tránsito slugs all coexist inside the same families — segmentation never asks “which jurisdiction is this?” That question belongs entirely to case_classifier.py, downstream.
| Family | Meaning | Authorship |
|---|---|---|
| actos_de_parte | Filings made by a litigant or party | parte |
| actos_del_tribunal | Court-issued acts — autos, resoluciones, sentencias | tribunal |
| actos_del_ministerio_publico | Fiscalía-authored acts — acusación, sobreseimiento | mp |
| prueba | Evidence exhibits — documental, pericial, testimonial, médica… | — |
| actos_de_comunicacion | Notifications, edictos, citaciones | — |
| actos_instrumentales | Instruments — poderes, certificaciones, escrituras | — |
| control | Cover sheets, generic and fallback types, fee proposals | — |
This is a deliberate layering: segmentation identifies what kind of act a page is; jurisdiction detection later decides which branch of law the case belongs to, using the resulting doc_kind bag as one of its ranked signals (Rank 2 and 3 in the waterfall described in the jurisdiction-detection post). Keeping the two questions separate is what let the notarial/familia collision bug in §5 even be diagnosed cleanly.
The authorship map (_SLUG_AUTHORSHIP) is derived automatically from which family a slug belongs to — no hand-maintained per-slug list — which is what powers the tribunal/party guard without needing to keep two vocabularies in sync.
Phase 2: the LLM confirmation pass
llm_confirm_segments() only ever runs on segments Phase 1 flagged as confidence == "low" (vision-OCR'd boundaries) or generic (escrito_generico / resolucion_generica / indeterminado). For each candidate it sends the LLM a ~700-character snippet of the opening page, the rule layer's own guess, the independently-detected authorship register with an explicit ⚠️CONFLICTO_AUTORIA flag when register and slug disagree, and the full taxonomy grouped by family.
The prompt is explicitly fail-closed: unidentifiable party filings default to escrito_generico, tribunal acts to resolucion_generica, Fiscalía acts to requerimiento_generico. A small set of high-stakes slugs (demanda, sentencia, auto_apertura_juicio, declaratoria_herederos) require an unambiguous heading — the LLM is instructed never to assign them without one.
tribunal, that reclassification is rejected and the rule-layer guess is kept — a structural backstop, not just a prompt instruction. After the LLM runs, co-segment suppression and run-collapsing both re-execute, since a changed primary type can change which co-segments are now noise.What the output looks like
build_document_index() writes a sidecar next to every processed PDF:
{
"schema_version": 1,
"file_name": "Expediente-0031-PRINCIPAL.pdf",
"num_pages": 307,
"proceso_type": "ordinario",
"method": "rules+llm",
"document_segments": [
{
"type": "caratula_datos_generales",
"page_start": 0, "page_end": 0,
"confidence": "high",
"source": "anchor",
"count": 1
},
{
"type": "demanda",
"page_start": 1, "page_end": 8,
"confidence": "high",
"source": "anchor"
},
{
"type": "notificacion",
"page_start": 9, "page_end": 51,
"confidence": "high",
"source": "anchor",
"count": 24
},
...
]
}method is only ever stamped “rules+llm” if the LLM actually reclassified at least one segment. A case that needed no LLM correction is honestly reported as “rules” — downstream consumers and auditors can always see whether the more expensive pass contributed.
Three downstream consumers read this sidecar directly: case_classifier.py (jurisdiction / process-type roll-up), classify_document_segments() (case-relevance flags layered on top), and the Trayectorias timeline builder (anchoring milestones to specific act types).
Guiding principles
The same discipline documented across jurisdiction detection and analysis gating — the specifics change, the philosophy does not.
Match only the head, never the body
A document's own type is announced in its opening lines. A body mention of another document type is narrative, not a boundary.
Specific before generic, always
Every anchor list is ordered most-specific-first so a narrow, unambiguous phrase is never shadowed by a broader one that happens to appear earlier.
Corroborate ambiguous vocabulary with co-occurrence
When the same phrase is legitimately used across several branches, the fix is requiring a second independent signal on the same page — not a wider match window.
Filenames and LLM output are corroborators, never overrides
A filename can break a tie among weak or generic evidence. It can never outrank text actually read from the page.
Total cover, no orphan pages
Every page ends up in some segment. Silently skipping an unreadable or unmatched page is never acceptable for a legal filing.
Fail-closed, not fail-guessing
Every classifier — rule or LLM — defaults to an explicit generic label rather than a specific but unearned guess when the evidence doesn't support one.
Every anchor is grounded in a real expediente
Not idealized headers — actual workspace files, cited in-code by workspace name and page offset. Every fix traces back to a concrete misclassification it closed.
Want to see segmentation running on your own expedientes?
We can run a live session with real case files and walk through what the segmenter produces — typed index, confidence scores, and all.
Request a demo →