NAMMU

← Blog
EngineeringPipeline

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.

IP

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.

Phase 1Rule-based anchors
  • 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
Phase 2LLM confirmation
  • 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:

1

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.

2

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).

3

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.

4

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.

5

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.

Guaranteeing page 0 always opens something: if no anchor fires on page 0 at all (a scanned cover page can OCR to near-nothing), the algorithm still forces a boundary rather than leaving the opening pages unclassified. If the case's process type was already detected or the head literally contains “datos generales”/“clase de asunto”, it is labeled 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.
Bug 1 — documented in-code: anchors for party compliance acts (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.

Regression this prevented: before the notarial co-occurrence anchors existed, 8 of 21 real documents in a genuine notary-disciplinary workspace were mis-tagged as 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.

FamilyMeaningAuthorship
actos_de_parteFilings made by a litigant or partyparte
actos_del_tribunalCourt-issued acts — autos, resoluciones, sentenciastribunal
actos_del_ministerio_publicoFiscalía-authored acts — acusación, sobreseimientomp
pruebaEvidence exhibits — documental, pericial, testimonial, médica…
actos_de_comunicacionNotifications, edictos, citaciones
actos_instrumentalesInstruments — poderes, certificaciones, escrituras
controlCover 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.

The LLM's answer is not trusted blindly. Its output goes through the same authorship-consistency guard as the rule layer: if the LLM assigns a party-act slug to a segment whose independently-detected register is 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.

1

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.

2

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.

3

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.

4

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.

5

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.

6

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.

7

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 →