A multi-agent document-processing pipeline built on LangGraph. An unstructured document flows through a stateful graph of agents that classify it, extract its fields, validate the result against per-type rules, and summarize it — with a self-correcting retry loop that re-extracts when validation fails and flags a document for human review only after it has genuinely exhausted its options. When a person is in the loop, the graph pauses on that document rather than dropping it on a queue, and finishes it once the missing fields come back.
It runs with no API key: the default reasoning engine is deterministic rule-based logic, so the whole pipeline and its test suite work offline. Swap in the OpenAI engine for production without touching the graph.
┌───────────────────────────── self-correcting loop ─────────────────────────────┐
│ │
START ─► classify ─┬─(unknown / low confidence)─────────────────────────────────────────► summarize ─► END
│ ▲ │
└─(trusted type)─► extract ─► validate ─┬─(errors & budget remains)─► ──┘ │
▲ └─(clean, or budget spent)──────────┘
│ status:
engine.extract ok | needs_review
(rule-based | OpenAI)
With a human in the loop, one edge changes: a document with errors and no
passes left stops at a review node on its way to summarize, so a person can
complete it. See Handing a document to a human.
Real document pipelines fail on the messy 10%: a field labeled CC: instead of Chief Complaint:, a scan that classified fine but extracted badly. A straight-line classify → extract → done chain has nowhere to recover. Modeling the flow as a graph with conditional edges gives it three properties that matter:
- It self-corrects. When validation finds missing required fields, the graph routes back to extraction for a second, more lenient pass instead of emitting a broken record.
- It knows when to stop. Retries are bounded; a document that can't be completed is marked
needs_reviewrather than looped forever or trusted silently. - It's auditable. Every node appends to a
trace, so each result carries the exact path it took — which recruiters, and on-call engineers, both appreciate.
git clone https://github.com/saivarun161/langgraph-doc-pipeline.git
cd langgraph-doc-pipeline
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
docpipeline --samples # run the 6 bundled synthetic documentsExample output for one document:
── note-02-abbrev — type=clinical_note status=ok
fields:
patient_name: Aisha Khan
chief_complaint: Shortness of breath on exertion
...
trace:
• classify → clinical_note (confidence 1.00, budget 2)
• extract → 5 field(s): assessment, dob, mrn, patient_name, plan
• validate → 1 error(s), 0 warning(s)
• extract (retry 1) → 6 field(s): assessment, chief_complaint, dob, mrn, patient_name, plan
• validate → clean, 0 warning(s)
• summarize → status=ok
That trace is the whole point: the first extraction missed the CC: abbreviation, validation caught it, and the retry recovered it — automatically.
A single document's trace explains that document. A corpus needs different questions answered — what share came out clean, and is the retry loop actually rescuing anything or just burning passes?
docpipeline --dir ./inbox --metrics --workers 8── batch — 6 document(s) in 0.01s (531.0/s)
status: ok=5 needs_review=1 (17% review rate)
types: clinical_note=2, discharge_summary=1, lab_report=1, referral=1, unknown=1
retries: 1 retried, 1 recovered by the retry loop
skipped: 1 never reached extraction
mean conf: 0.79 mean passes: 1.00
completeness: 100% of required fields extracted
retried vs recovered_by_retry is the pair worth watching: retries that never recover are pure cost, and the gap between them tells you whether to raise --max-attempts or tighten --min-confidence.
The aggregate hides the thing you actually act on, though — a 17% review rate is a very different problem when it is one type failing most of the time than when it is every type failing occasionally. So --metrics also breaks the run down by type:
── by type
clinical_note n=2 review= 0% conf=1.00 passes=1.50 recovered=1/1
discharge_summary n=1 review= 0% conf=1.00 passes=1.00 recovered=0/0
lab_report n=1 review= 0% conf=1.00 passes=1.00 recovered=0/0
referral n=1 review= 0% conf=0.75 passes=1.00 recovered=0/0
unknown n=1 review=100% conf=0.00 passes=0.00 recovered=0/0
The same numbers are available as data:
from docpipeline import run_batch
batch = run_batch(docs, workers=8) # results stay in input order
batch.metrics.review_rate # 0.1667
batch.metrics.per_type["lab_report"] # TypeStats(documents=1, ok=1, ...)
batch.metrics.as_dict() # JSON-ready, with derived ratesAdd --json --metrics to get {"results": [...], "metrics": {...}} from the CLI. Without a reporting flag the JSON stays a bare list, so existing consumers are unaffected.
Extraction passes are earned, not granted. The retry pass is deliberately more lenient than the first — it accepts CC: for chief complaint, Impression: for assessment — and that leniency is only a good trade when the document was typed correctly to begin with. Spending it on a probably-misclassified document manufactures plausible-but-wrong fields, which is strictly worse than an honest needs_review.
So the budget scales with the classifier's confidence:
| Confidence | Passes (ceiling 2) | Behavior |
|---|---|---|
< 0.35 |
0 | never extracted; routed to review with the reason attached |
0.35 – 0.50 |
1 | one strict pass; no lenient retry |
> 0.50 |
2 | earns the retry that rescues messy-but-real documents |
Both knobs are tunable — --min-confidence for the trust threshold, --max-attempts for the ceiling — which is exactly what the batch metrics exist to tune against.
The types are not equally separable. A lab report is nearly unmistakable; a discharge summary shares most of its vocabulary with a clinical note. A single global threshold has to be set for the worst type, which means either wasting lenient passes on the easy ones or sending the hard ones to review too eagerly.
So --min-confidence is repeatable and accepts TYPE=VALUE: set a default, then override only the types that have earned something different.
docpipeline --dir ./inbox --min-confidence 0.30 --min-confidence discharge_summary=0.70The same policy works from the library, as a mapping with an optional "default" entry:
run_batch(docs, min_confidence={"discharge_summary": 0.70, "default": 0.30})A policy is validated when the pipeline is built, so a mistyped key fails immediately instead of silently doing nothing. "unknown" is rejected outright: those documents never reach extraction under any threshold, so a value there could only ever be a no-op — and a knob that does nothing is worse than one that refuses to exist.
Picking per-type numbers by hand is guesswork. --calibrate fits them to a run you have already measured:
docpipeline --dir ./inbox --calibrate── calibration — suggested --min-confidence per type
clinical_note 0.35 → 0.35 (unchanged) [3/5 routed correctly, 3 ok]
discharge_summary 0.35 → 0.80 [9/9 routed correctly, 5 ok]
Read the second row as: every discharge summary that scored below 0.80 was headed for review anyway, so extracting them was wasted work — raise the bar and all nine documents get routed correctly.
For each type it fits a one-dimensional decision stump over the observed confidences: a document is routed correctly if it scored at or above the threshold and finished ok, or scored below it and was headed for review anyway. The threshold with the most correct calls wins — and it must beat the one already in force strictly, so a fit that explains the data no better than the status quo is reported as (unchanged) rather than dressed up as advice.
It suggests; it never applies. Two reasons, both visible in the output above:
- Confidence is not always the lever. A clinical note with no
Plan:anywhere in it goes to review no matter how confidently it was typed. The3/5on the first row is the tell: no threshold separates that type's outcomes, because the failures have nothing to do with classification. - Small samples fit noise. Types with fewer than three documents are left out entirely rather than calibrated on a handful.
calibrate_thresholds(results) returns the same suggestions as data, and --json --calibrate puts them under a "calibration" key.
The trace explains a document once it is finished. That is the wrong shape when
someone is waiting on the run — the interesting moment is the retry firing,
not the record that it fired. --stream prints each node as it lands, stamped
with how far into the run it happened:
docpipeline --dir ./inbox --stream── note-02-abbrev — streaming
[ 0.000s] classify → clinical_note (confidence 1.00, budget 2)
[ 0.000s] extract → 5 field(s): assessment, dob, mrn, patient_name, plan
[ 0.001s] validate → 1 error(s), 0 warning(s)
[ 0.001s] extract (retry 1) → 6 field(s): assessment, chief_complaint, dob, mrn, patient_name, plan
[ 0.001s] validate → clean, 0 warning(s)
[ 0.001s] summarize → status=ok
── note-02-abbrev — type=clinical_note status=ok
fields:
...
The finished record follows, without repeating the trace that was just printed
line by line. --metrics and --calibrate work the same as in a batch run;
--json, --workers > 1, and --review do not, and say so — JSON cannot be
emitted progressively without changing what --json means, interleaving several
documents' nodes would make the order meaningless, and a run that stops to ask a
question is not a run you can watch scroll past.
The same events are available as data, one per node completion:
from docpipeline import stream_document
for event in stream_document(text):
print(event.node, event.visit, event.lines) # 'extract' 2 ('extract (retry 1) → ...',)
if event.is_final:
result = event.state # == run_document(text)visit is the cheapest way to spot a self-correction: the retry loop is the
only thing that takes a node past its first visit. Each event also carries an
accumulated state snapshot — immutable, so an event handed to a consumer keeps
showing the state as of that node — which means the last one is the result. A
caller never has to choose between watching the run and getting its outcome.
stream_pipeline(pipeline, text, doc_id) takes an already-compiled graph, for
streaming a whole corpus off one build.
The retry loop rescues what the pipeline can fix by itself. It cannot fix a
clinical note that has no plan recorded anywhere in it — no extraction pass
recovers what the document does not contain. Those come out needs_review,
which is honest but terminal: the record lands on a queue, and whatever a
reviewer does next happens outside the pipeline, unvalidated.
Building with interrupt_on_review=True compiles the graph with a LangGraph
checkpointer and an extra review node, and stops before finalizing such a
document. The run is persisted, so it can be picked up and finished:
from docpipeline import review_document
session = review_document(text, doc_id="note-07")
if session.paused:
print(session.request.missing) # ('plan',) — the blanks, as a form
session.resume({"plan": "Chest x-ray; azithromycin 5-day course"})
result = session.result # status 'ok'The correction goes back through the pipeline's own checks rather than around them, and the trace shows exactly where the machine stopped and the person started:
trace:
• classify → clinical_note (confidence 1.00, budget 2)
• extract → 5 field(s): assessment, chief_complaint, dob, mrn, patient_name
• validate → 1 error(s), 0 warning(s)
• extract (retry 1) → 5 field(s): assessment, chief_complaint, dob, mrn, patient_name
• validate → 1 error(s), 0 warning(s)
• review ← human supplied 1 field(s): plan
• review → resolved all 1 error(s)
• summarize → status=ok
Two things this deliberately will not do, for the same reason — a human in the loop is a source of input, not a bypass:
- Corrections are validated, not trusted. They run through the same
check_fieldsthe automated pass uses, so filling in three of four blanks still comes outneeds_review. A record is complete because it is complete, not because a person touched it. - Nobody is asked a question the pipeline cannot frame. Documents that never
reached extraction —
unknown, or classified below their threshold — are not paused on. With no fields to show and no type it believes in, the ask would not be "fill in this blank" but "tell me what this document is", which is re-classification rather than review.
The reviewer is not sitting inside the run, so on the command line the loop
splits in two. --review reports what is pending:
docpipeline --dir ./inbox --review── review — 1 document(s) paused for human input
note-07 [clinical_note, 2 pass(es)] needs: plan
resolved: 0 of 1 by supplied corrections
Then --corrections hands the answers back, keyed by document id:
echo '{"note-07": {"plan": "Chest x-ray; azithromycin 5-day course"}}' > fixes.json
docpipeline --dir ./inbox --corrections fixes.json── review — 1 document(s) paused for human input
note-07 [clinical_note, 2 pass(es)] needs: plan
resolved: 1 of 1 by supplied corrections
resolved next to asked is the pair to watch here, the same way retried vs
recovered_by_retry is for the automated loop: requests that never come back
resolved are a queue growing, not a loop closing. The file is validated before
the run starts, so a typo fails immediately rather than half-way through a
corpus. --json puts the requests and the resolved ids under a "review" key,
which is what makes the two commands scriptable as one workflow.
from docpipeline import run_document
result = run_document("Referred To: Cardiology\nReason for Referral: afib\nPatient Name: D O'Brien")
print(result["doc_type"]) # 'referral'
print(result["status"]) # 'ok'
print(result["fields"]) # {'patient_name': "D O'Brien", 'referred_to': 'Cardiology', ...}The graph calls a pluggable Engine; the default is keyless and rule-based. For LLM-quality classification and extraction, install the extra and select the OpenAI engine — the graph is unchanged, only the reasoning improves:
pip install -e ".[openai]"
export OPENAI_API_KEY=sk-...
docpipeline --samples --engine openai| Type | Required fields (must be present to pass) |
|---|---|
clinical_note |
patient_name, chief_complaint, assessment, plan |
lab_report |
patient_name, results |
discharge_summary |
patient_name, admission_date, discharge_date, diagnosis |
referral |
patient_name, referred_to, reason |
unknown |
— (routed straight to review) |
Missing demographics (mrn, dob) are warnings, not errors — a document is still usable without them. All sample documents are fictional and contain no real patient data.
src/docpipeline/
├── state.py # DocState: the shared graph state (trace uses an additive reducer)
├── engine.py # pluggable reasoning: RuleBasedEngine (keyless) | OpenAIEngine
├── agents.py # node functions (classify/extract/validate/summarize) + routing
│ # and attempt_budget: confidence → extraction passes
├── graph.py # assembles the StateGraph, conditional edges, retry loop
├── batch.py # corpus runner, BatchMetrics + TypeStats, threshold calibration
├── stream.py # PipelineEvent: node-by-node progress off LangGraph's update stream
├── review.py # ReviewSession: pause on needs_review, resume with a human's corrections
├── samples.py # bundled synthetic documents
├── cli.py # docpipeline command
└── data/ # sample_docs.jsonl
tests/ # engine, agents, graph (retry & routing), batch, stream, review, CLI, samples
.github/workflows/ci.yml # ruff + pytest on Python 3.11 and 3.12
Design choices worth calling out:
- Reasoning is pluggable; orchestration is not. Classification and extraction go through an
Engineinterface, so the deterministic and LLM backends are interchangeable. The graph — the part that's actually the contribution — stays identical. - Validation is code, not a model call. Checking required fields is deterministic business logic, so it lives in plain Python the tests pin exactly. Only the fuzzy work (classify, extract) is delegated to the engine.
- The retry changes behavior, so it can't loop pointlessly. The second extraction pass enables more lenient patterns (e.g. accepting
CC:); bounded attempts guarantee termination. unknownis a first-class outcome. A document that matches nothing is flagged for review, never forced into the closest wrong bucket.- Retries are rationed by confidence, not handed out flat. A weak classification buys fewer lenient passes — and below the threshold, none at all. Being wrong loudly beats being wrong plausibly.
- Thresholds are per type, and fitted rather than guessed. The types are not equally separable, so they do not share one number;
--calibratederives each from measured outcomes. It suggests rather than applies, and refuses to suggest a change that does not demonstrably route more documents correctly. - Streaming is a view of the run, not a second code path. The events come off LangGraph's own update stream, and the state they accumulate is asserted equal to what
run_documentreturns for every bundled document. Progress reporting that can disagree with the result is worse than none. - The human is a node, not an escape hatch. Review is a step inside the graph, so a corrected record is re-checked by the same code as an automatic one and the trace records who supplied what. A pipeline whose human path is looser than its machine path has only moved the failure somewhere it cannot be measured.
- The batch runner shares one compiled graph. The pipeline and the bundled engines hold no per-document state, so a corpus can be run across a thread pool; results are returned in input order regardless of completion order.
- LangGraph state machine with conditional edges + bounded retry loop
- Pluggable rule-based (keyless) and OpenAI engines
- Per-type validation with error/warning distinction
- CLI + library API, full test suite, CI
- Confidence-weighted routing and a batch runner with metrics
- Per-type confidence thresholds calibrated from batch metrics
- Streaming node-by-node progress via LangGraph events (
--stream,stream_document) - Human-in-the-loop interrupt on
needs_review(LangGraph checkpointer,--review/--corrections)
MIT — see LICENSE.
Built by Varun Kammadanam — backend + GenAI engineer (Java, Python, AWS, agentic systems).