-
Notifications
You must be signed in to change notification settings - Fork 0
Architecture
Mailroom is a multi-agent legal document processing pipeline built on LangGraph. It ingests legal documents, classifies them, routes them to specialist agents for structured extraction, compiles matter records, and archives everything with a full audit trail.
flowchart TD
START([START]) --> INGEST
START -. "resume: manifest shows extraction done" .-> EXTRACT
INGEST["ingest-document<br/>claim file, read text, create manifest"]
CLASSIFY["classify-document<br/>SorterAgent"]
RETRY_CLASS["classify-document (retry)<br/>SorterAgent re-evaluation"]
REVIEW_CLASS["classify-document (reviewer)<br/>SorterReviewAgent second opinion<br/>(KANBAN-062 Lane A)"]
EXTRACT["extract-fields<br/>specialist dispatch"]
RETRY_EXTRACT["extract-fields (retry)<br/>specialist re-extraction"]
JUDGE["judge-verify<br/>gated completeness verification<br/>(KANBAN-063 Lane B)"]
ARBITER["arbitrate-verdict<br/>ArbiterAgent (KANBAN-063 Lane B)"]
BOSS["adjudicate-conflict<br/>BossAgent"]
REVIEW["route-for-review<br/>review bin (human)"]
REPORT["compile-report<br/>ReporterAgent"]
CATALOG["write-catalog<br/>SQLite documents + matters"]
ARCHIVE["archive-document<br/>archivist + hash-chained audit log"]
FAILED["FAILED"]
ENDX([END])
INGEST --> CLASSIFY
CLASSIFY -- "confidence >= high" --> EXTRACT
CLASSIFY -- "low <= confidence < high" --> REVIEW
CLASSIFY -- "confidence < low, attempts <= retry_max" --> RETRY_CLASS
CLASSIFY -- "unknown type / still low after retries" --> REVIEW
CLASSIFY -. "transient error, per-node budget left" .-> CLASSIFY
RETRY_CLASS -- "confidence >= high" --> EXTRACT
RETRY_CLASS -- "medium band exhausted (agent review)" --> REVIEW_CLASS
RETRY_CLASS -- "medium or still low confidence" --> REVIEW
REVIEW_CLASS -- "high-confidence reviewer verdict" --> EXTRACT
REVIEW_CLASS -- "anything else" --> REVIEW
EXTRACT -- "no conflict, judge gate off/skip" --> REPORT
EXTRACT -- "low confidence, attempts <= retry_max" --> RETRY_EXTRACT
EXTRACT -- "conflict detected" --> BOSS
EXTRACT -- "judge gate fires (grounded run)" --> JUDGE
EXTRACT -- "still low confidence" --> REVIEW
EXTRACT -. "transient error, per-node budget left" .-> EXTRACT
RETRY_EXTRACT -- "confidence >= low" --> REPORT
RETRY_EXTRACT -- "still low confidence" --> REVIEW
JUDGE -- "complete or skipped" --> REPORT
JUDGE -- "partial / incomplete" --> ARBITER
ARBITER -- "verdict stands" --> REPORT
ARBITER -- "re-extraction ordered" --> RETRY_EXTRACT
ARBITER -- "unresolvable" --> REVIEW
BOSS -- "approved" --> REPORT
BOSS -- "review" --> REVIEW
REVIEW -- "approved" --> REPORT
REVIEW -- "rejected" --> FAILED --> ENDX
REPORT --> CATALOG --> ARCHIVE --> ENDX
flowchart LR
subgraph IN["Input layer"]
INBOX["inbox bin<br/>(watcher / API upload)"]
end
subgraph ORCH["Orchestration — LangGraph state machine (graph/)"]
direction TB
NODES["ingest → classify → extract →<br/>report → catalog → archive<br/>retries, boss, human review"]
ROUTING["conditional routing<br/>graph/routing.py"]
end
subgraph AGENTS["Agent layer (agents/) — LLM specialists"]
SORTER["SorterAgent"]
SPEC["7 specialists<br/>contracts, corporate records,<br/>due diligence, correspondence,<br/>compliance, court opinions, insurance claims"]
BOSS["BossAgent"]
REPORTER["ReporterAgent"]
PDF["PDFTranscriber / ImageExtractor<br/>(procedural)"]
JUDGE["JudgeAgent<br/>(offline evaluators)"]
end
subgraph LLM["LLM layer (llm/)"]
CLI["get_llm() — provider-agnostic client"]
RETRY["retry + max_tokens caps"]
PROMPTS["Langfuse-managed prompts<br/>mailroom-* (with local fallback)"]
P["OpenRouter / Ollama / vLLM / generic"]
end
subgraph PERSIST["Persistence"]
BINS["filesystem bins"]
SQLITE["SQLite catalog + audit log"]
ARCHIVE2["archive/ + manifests/"]
end
subgraph OBS["Observability — Langfuse (observability/)"]
TRACES["one trace per document<br/>spans per node, session per matter"]
SCORES["task-spec scores<br/>schema_valid, completeness, correctness…"]
end
INBOX --> NODES
NODES --> SORTER & SPEC & BOSS & REPORTER & PDF
SORTER & SPEC & BOSS & REPORTER --> CLI
CLI --> RETRY --> PROMPTS --> P
NODES --> BINS --> SQLITE --> ARCHIVE2
NODES -.-> TRACES
TRACES --> SCORES
JUDGE -.-> SCORES
- Uses
watchdogto monitor/pipeline/inbox/for new files - Debounces file events to avoid double-processing
- Claims files via atomic
os.renameinto/pipeline/processing/<worker_id>/ - Spawns a LangGraph run per document in a daemon thread
- One graph execution per document
-
13 nodes forming a directed state machine:
ingest,classify,retry_classify,review_classify(agent second opinion on exhausted medium-band classifications — KANBAN-062 Lane A),extract,retry_extract,judge_verify+arbiter(gated completeness verification + arbitration — KANBAN-063 Lane B),human_review,boss_escalation,compile_report,catalog_write,archive - MemorySaver by default (stateless design: human-review resume re-invokes
the graph from the manifest); opt back into on-disk
SqliteSaver(data/checkpoints.db) viaMAILROOM_CHECKPOINTER=sqlite
- Thin OpenAI-compatible wrapper
- Provider-agnostic: OpenRouter, Ollama, vLLM, or any OpenAI-compatible endpoint
- Per-agent model selection from
config/taxonomy.yaml - Global provider override via
DEFAULT_PROVIDERenv var - Every chat completion goes through
retry_chat_completion(llm/retry.py): transient failures (connection errors, timeouts, 429, 5xx) are retried with exponential backoff + jitter from thellm_retry:config; 4xx client errors are never retried - Output generation is capped per agent by
max_tokensintaxonomy.yaml(bounds runaway reasoning-token output) - Agent system prompts are Langfuse-managed (
llm/prompts.py,mailroom-<agent_name>), fetched at runtime with the identical template shipped in code as fallback;scripts/sync_prompts.pypushes templates up - Structured calls (
_call_structured) always sendresponse_format={"type": "json_object"}and guarantee the literal tokenjsonin the messages — some providers (Qwen via Alibaba) reject requests without it
- SQLite (via SQLAlchemy 2.0 async + aiosqlite) by default — a single file, no server required
- Shared by the document/matter catalog and the audit log
- Three tables:
matters,documents,audit_log(documentscarries extracted data, trace id, and ascoresJSON column) -
DATABASE_URLenv var can switch to Postgres
- Four interchangeable tracing backends: Langfuse, Braintrust, the local cost-free Arize Phoenix, and
none - Selected via
OBSERVABILITY_PROVIDERenv (auto|langfuse|braintrust|phoenix|none);auto= Langfuse if key → Braintrust if key → local Phoenix →none(aligned with llm-entity-extraction's resolution chain — tracing never silently turns off) - Every LLM call is auto-traced:
llm/client.py:get_llmwraps the OpenAI client (langfuse.openaipatch orbraintrust.wrap_openai), capturing prompt, response, tokens, latency - One trace per document (
pipeline_trace), one span per node (traced_node),session_id = matter_id(or a run-scoped session for pilot runs), deterministic trace ids seeded from filenames -
Scores (
observability/scores.py): every run emits self-evident scores (parse_error,schema_valid,stage_completed, confidences); pilot runs add ground-truth scores (class/stage correctness, calibration error,expected_field_presence); score configs auto-created viaensure_score_configs() -
Run-log mirroring (
scripts/sync_langfuse_logs.py): fetch traces (with observations + scores) intodata/langfuse_logs/<run>/for offline analysis - Graceful noop fallback when no backend/keys are configured — pipeline runs unchanged
- Human-legible pipeline state:
lsany directory to see what's happening - Atomic rename for claim safety (no external locking needed)
- Archive organized by
matter_id/doc_type/
Document lands in /pipeline/inbox/. Watcher detects it, claims it atomically to /pipeline/processing/<worker_id>/. Manifest is created with PipelineStage.PROCESSING. PDFs are transcribed by PDFTranscriber — text-based PDFs directly (no LLM), scanned/garbled PDFs via an LLM markdown pass (pipeline.pdf_direct_chars_per_page controls the threshold). When the input agents' models are vision-capable (vision: config in taxonomy.yaml — Qwen etc.), PDFs are also rendered page-by-page to image data-URIs (llm/vision.py) and sent to the sorter/specialist prompts as multimodal image_url content, capped by vision.max_pages; if the pipeline is vision-capable the expensive LLM transcription pass is skipped for scanned PDFs (the page images carry the content) while doc_text is still stored for text-only paths/audit.
LLM call: reads document text, determines doc_type (contract,
corporate_record, due_diligence, correspondence, compliance_filing,
court_opinion, insurance_claim) and confidence score.
Conditional edge routing (graph/routing.py, thresholds from confidence: in taxonomy.yaml):
-
Confidence >=
high(0.95): clearly matches one class → straight to extraction -
low(0.70) <= Confidence <high(0.95): classified but not clearly confident (e.g. multi-topic/ambiguous documents whose form still fits a class) → route to/review/(human) instead of silently archiving -
Confidence <
low: retry (retry_classify) whileattempts <= retry_max -
Still low after retry / unknown doc type: route to
/review/(human)
Dynamic dispatch to the matching specialist agent based on doc_type. Each specialist:
- Has its own system prompt/personality
- Uses structured JSON output against a Pydantic schema
- Returns extraction data + confidence score
Same three-way branch as classification, plus a fourth path:
- Conflict with existing matter data: route to Boss escalation
- Low confidence: retry → still low → human review
- High confidence: proceed to report compilation
LLM call: compiles all extracted data into a clean matter-record summary.
Writes document and matter records to the database (best-effort — pipeline continues on failure).
- Moves file to
/archive/<matter_id>/<doc_type>/ - Writes manifest sidecar JSON
- Writes hash-chained audit log entry
- Marks manifest
PipelineStage.ARCHIVED
| Node | Agent | Purpose |
|---|---|---|
ingest |
— | Read file, create manifest, move to processing |
classify |
Sorter | Determine doc_type + confidence |
retry_classify |
Sorter | Re-classify with alternate prompt |
review_classify |
Sorter Reviewer | Agent second opinion when the medium band is exhausted (KANBAN-062) |
extract |
Specialist | Extract structured data per doc-type |
retry_extract |
Specialist | Re-extract with context from prior attempt |
judge_verify |
Judge (in-graph) | Gated completeness verification of grounded extractions (KANBAN-063) |
arbiter |
Arbiter | Adjudicate partial/incomplete judge verdicts (KANBAN-063) |
human_review |
— | Pause for human decision |
boss_escalation |
Boss (in-graph) | Adjudicate conflicts |
compile_report |
Reporter | Synthesize matter-record entry |
catalog_write |
— | Write to database catalog |
archive |
Archivist | Move to archive, write audit log |
classify ─┬─ confidence >= high ──────▶ extract
├─ low <= conf < high ─────▶ human_review
├─ attempts <= retry_max ──▶ retry_classify
└─ otherwise ──────────────▶ human_review
retry_classify ─┬─ confidence >= high ────────────▶ extract
├─ medium band exhausted (Lane A) ─▶ review_classify
└─ medium or still low ────────────▶ human_review
review_classify ─┬─ high-confidence reviewer verdict ─▶ extract
└─ anything else ────────────────────▶ human_review
extract ─┬─ no conflict, judge gate off/skip ──▶ compile_report
├─ conflict detected ─────────────────▶ boss_escalation
├─ judge gate fires (grounded run) ───▶ judge_verify
├─ attempts <= retry_max ─────────────▶ retry_extract
└─ otherwise ─────────────────────────▶ human_review
judge_verify ─┬─ complete or skipped ────▶ compile_report
├─ partial / incomplete ───▶ arbiter
└─ hard failure ───────────▶ human_review
arbiter ─┬─ verdict stands ─────────▶ compile_report
├─ re-extraction ordered ──▶ retry_extract
└─ unresolvable ───────────▶ human_review
boss_escalation ─┬─ approved ─▶ compile_report
└─ review ───▶ human_review
human_review ─┬─ approved ─▶ compile_report
└─ rejected ─▶ END (failed)
LangGraph checkpoints the full state after each node. The checkpointer is
MemorySaver by default (graph/build_graph.py:_build_checkpointer()) —
the pipeline is deliberately stateless across restarts because human-review
resume re-invokes the graph fresh from the document manifest (this also kills
the unbounded per-doc checkpoint growth a persistent saver accumulates). Set
MAILROOM_CHECKPOINTER=sqlite to opt back into the on-disk SqliteSaver at
data/checkpoints.db for debugging/resume-across-restart experiments.
Every state transition writes an AuditLogEntry to the database. Each entry:
- Contains
prev_hash(SHA-256 of the prior entry) - Contains
entry_hash(SHA-256 ofprev_hash+ entry content) - Forms a tamper-evident chain — modifying any entry breaks all subsequent hashes
- Is independent of Langfuse (the audit log is the compliance record)
- Can be verified via the
/audit/{doc_id}API endpoint orschemas/audit.py:verify_chain()
Before any LLM judge runs, grounded extractions are scored deterministically by observability/field_scoring.py — a field-type-aware scorer that is cheap, reproducible, and costs no API calls. Each field is compared according to its type (doc_classes[].field_types in taxonomy.yaml): id/date/money are parsed and normalized then exact-matched (a one-day-off date scores 0, not 0.95); name uses Jaro-Winkler + token-set ratio over normalized text (uppercase, punctuation/suffix-stripped); free_text uses SQuAD-style token F1; entity_list fields use optimal bipartite matching (scipy Hungarian) with precision/recall/F1, so reordered lists score correctly. An optional sentence-transformers embedding cosine similarity rescues lexically-distant-but-semantically-equal name/free-text fields below embedding_rescue_below.
Judge escalation is gated by per-field-type bands (field_scoring.type_bands), calibrated by scripts/calibrate_field_scoring.py against labeled ground truth: date/id are never (decisive both ways), money/free_text have calibrated numeric cutoffs, and name/entity-list trust only perfect scores ([0.5, 1.0]) — near-misses escalate to the LLM judge because Jaro-Winkler/token-set are typo-tolerant by design. observability/langfuse_field_scoring.py attaches extraction_field_score, extraction_overall_score, extraction_needs_judge_review, entity_list_precision, and entity_list_recall to the document trace, and on grounded runs graph/build_graph.py suppresses the pipeline-result generation entirely when the verdict is unambiguous — saving both LLM-as-judge evaluator calls.
The judge agent (agents/judge.py, offline — not in the document graph) audits pipeline output against the task specification. scripts/run_quality_judges.py runs it over a pilot report and attaches scores to each sample's trace:
| Judge | Measures | Scores |
|---|---|---|
classification |
Is the sorter's assigned class correct for the document (audited against the taxonomy spec)? |
classification_correct, classification_quality
|
completeness |
Did the specialist capture every field the document states? |
completeness, completeness_label
|
correctness |
Are extracted values factually accurate (no fabrication)? |
extraction_correctness, extraction_correctness_label
|
The same rubrics are configured as two independent live LLM-as-a-Judge evaluators in the Langfuse project (scripts/sync_evaluators.py): the pipeline emits a single pipeline-result generation per document trace, and two observation rules independently evaluate it. mailroom-pipeline-judge returns a CORRECT/PARTIAL/MISS verdict — PARTIAL for substantially correct runs with limited material gaps, MISS reserved for wrong class/stage, contradictions, failed runs, or broad omission; mailroom-pipeline-quality returns a proportional 0.0-1.0 quality score, so partial-but-useful extractions are not flattened into MISS. The quality score never replaces or alters the run verdict. Grounded runs use a labeled, pretty-printed expected-fields input block and a cleaned schema-only output, cutting ~90% of judge tokens. Live runs without ground truth use visible source text. The script also ensures an LLM connection for the judge provider exists (OpenRouter key from .env) and prunes any stale mailroom evaluators/rules.
The pilot samples are mirrored into Langfuse datasets — one per source corpus (scripts/sync_dataset.py): mailroom-pilot (original samples), mailroom-pilot-legalbench, mailroom-pilot-atticus, and mailroom-pilot-pileoflaw. One item per sample with document text, ground truth (expected_doc_class, expected_stage, expected_fields) and manifest metadata — for experiments and judge calibration.
Production runs additionally emit self-evident scores with no ground truth (parse_error, schema_valid, stage_completed, guardrail_triggered, confidence values) from observability/scores.py, and pilot runs add ground-truth scores (class_correct, stage_correct, confidence_calibration_error, expected_field_presence). All score configs are auto-created in Langfuse by ensure_score_configs().
pipeline/guards.py validates agent output deterministically before routing: classification must be a taxonomy enum with a [0,1] confidence; extractions must JSON-parse and validate against their Pydantic schema. Violations clamp confidence below the confidence.low routing threshold so bad output goes to retry/review, are logged, recorded on state (extraction_guardrail), and scored (guardrail_triggered).
pipeline/logging.py:setup_logging() configures structlog in every entrypoint and script: level LOG_LEVEL (default INFO), renderer LOG_FORMAT (pretty|json); noisy third-party loggers silenced to WARNING.
The Boss agent has two separate invocation paths sharing one persona:
-
In-graph (
boss_escalationnode): synchronously adjudicates conflicts within a single document's run. -
Ops-monitor (
pipeline/ops_monitor.py): separate scheduled process (default every 5 minutes) that queries the catalog for systemic issues: stuck documents, error-rate spikes, review backlogs.
Mailroom — Multi-Agent Legal Document Processing Pipeline. Built with LangGraph and OpenRouter; SQLite by default, Postgres optional.
- Repo docs/ — canonical docs (architecture, agents, configuration, API, deployment, local models)
- Sister Repositories — the llm-mailroom umbrella map