Send-API Compare Map · Fan-out · Reduce

Multi-Document Comparison with the Send API

A LangGraph map-reduce that fans out one parallel, typed extraction per document, then synthesizes them into a comparison table. The pattern behind “compare these proposals / contracts / papers.”

Plan Fan-out (Send) Parallel extract Synthesize
Dynamic fan-out Typed structured output Retrieve-within-branch Live NDJSON streaming
127.0.0.1:8320 · Compare

Compare

qwen.qwen3-235b-a22b 3 documents
Map-reduce trajectory
Planned typed extraction
Vendortext Total costtext Timelinetext Strengthslist Has SLAbool
Fan-out — 3 documents in parallelSend API2/3 done
Acme Systemsdone
Fixed $240k, 16 weeks
99.9% uptime SLA
2.8s
Bluepeakdone
T&M capped at $360k
Training included
3.3s · retrieved 2/15
Cirrus Labsextracting
running…
Synthesizing the comparison…
Comparison table
FieldAcme SystemsBluepeakCirrus Labs
VendorAcme Systems Inc.Bluepeak ConsultingCirrus Labs
Total cost$240,000 fixed~$310k (cap $360k)$275,000 fixed
Timeline16 weeks22 weeks18 weeks
Has SLAYesYesYes
SummaryFastest & fixed priceDeepest modernizationBalanced middle option

The Compare view — live map-reduce trajectory, the parallel fan-out grid, and the synthesized comparison table.

What it is

One parallel pass over a stack of documents

Give it several proposals, contracts, papers or resumes and a set of fields to pull. It extracts a typed record from each document in parallel, lays them side by side, and writes a recommendation — turning “read ten documents and build a table” into a single, defensible run.

Dynamic fan-out (Send API)

The branch count isn’t known until runtime — fan_out returns one Send per document. Three docs or forty, only the list changes.

Typed structured output

Each field is typed (text / number / boolean / list). Extraction runs against a JSON Schema and every value is coerced — a list stays a list, a bool stays a bool.

Synthesis + comparison table

A join node runs once, after every branch, and reduces the records into a documents × fields table plus a focus-driven narrative.

Retrieve within long docs

Over-budget documents aren’t blindly truncated — each branch retrieves the most relevant passages of its own document (TF-IDF cosine), or falls back to truncation.

Parallel, but capped

Branches run concurrently on a thread pool, so N docs finish in about the time of the slowest — with a max_concurrency cap so forty docs drain in waves, not one stampede.

Live streaming trajectory

Nodes emit NDJSON events; the UI renders the plan, the fan-out grid filling in per document, and the synthesis streaming token by token.

Presets & custom fields

Start from a preset (proposals, contracts, papers, resumes, products) or define your own typed fields — and pick which documents to include.

Multi-format ingestion

Upload .pdf, .docx, .txt or .md, or paste text. Each document is parsed whole and kept in a persisted set.

Export the table

Copy the comparison as Markdown or download it as CSV — drop it straight into a doc, sheet or ticket.

Password-protected settings

Model and Bedrock connection live server-side in SQLite, edited behind a password — never in the browser.

Why it’s useful

The tedious “make me a comparison table” task, automated

Any time the answer is a matrix of “document × attribute,” this is the shape of the work. It’s faster than reading each document in turn, more consistent than doing it by hand, and the structure is auditable — every cell traces to one document.

Vendor proposals & RFPs

Line up price, timeline, deliverables, strengths and risks across bids and get a value recommendation.

Contracts & agreements

Surface term length, termination, liability caps and governing law — including clauses buried deep in long PDFs.

Research papers

Compare research questions, methods, datasets and findings to scan a literature set at a glance.

Candidate resumes

Extract years of experience, skills, achievements and gaps to shortlist against a role consistently.

Product / feature comparisons

Position competing products on pricing, features, integrations and limitations to recommend the right fit.

Tech stack

What it’s built on

A small, dependency-light Python backend and a vanilla-JS frontend with no build step. The only heavyweight is the model itself.

Orchestration
LangGraph 1.xSend APIStateGraphoperator.add reducerstream_mode="custom"
Model / inference
Amazon BedrockAnthropic MantleClaude (tool use)Qwen (OpenAI-compat)
Backend
FastAPIUvicornPydanticNDJSON streaming
Ingestion & retrieval
pypdfpython-docxPure-Python TF-IDFno embeddings
Storage
SQLiteJSON doc setPBKDF2 auth
Frontend
Vanilla JSNo build stepStreaming fetch readerManrope · JetBrains Mono
How it works

Map → reduce, in one graph

Three nodes: plan fans out, extract_document runs per document in parallel, and synthesize joins them once.

START
  │
  ▼
plan ──(dynamic fan-out: one Send per document)──▶ extract_document ✕ N   (MAP · parallel)
                                                    │
                                                    ▼
                                               synthesize ──▶ END        (REDUCE · runs once)

Plan & fan out with the Send API

The number of parallel branches equals len(documents) — unknown at build time. A conditional edge returns a list of Send("extract_document", payload), one per document. LangGraph schedules them in a single superstep.

Extract a typed record — in parallel

Each branch builds a JSON Schema from the requested fields and calls the model for structured output: native tool-use for anthropic.*, a strict-JSON prompt for others. Values are coerced to their declared types. Sync nodes run on a thread pool, so the per-document Bedrock calls genuinely overlap.

One document failing to parse never sinks the run — its column is flagged and the rest proceed.

Fit long documents — retrieve, don’t truncate

When a document is over the per-document budget, the branch chunks its own text, ranks the chunks against a query derived from the fields (TF-IDF cosine), and feeds only the most relevant passages — in document order. The “metadata filter per document” is inherent: a branch only ever sees its own chunks. Truncation remains as a fallback.

Gather with a reducer, cap the concurrency

Each branch appends its record to an operator.add channel, so parallel writes gather instead of clobbering. A max_concurrency cap bounds how many branches run at once — so forty documents don’t fire forty simultaneous calls; they drain in waves.

Join once & synthesize

extract_document has a single outgoing edge to synthesize, so it runs exactly once, after every branch joins. It sorts the out-of-order results back into document order, builds the documents × fields table, and streams a focus-driven recommendation.

Stream everything to the browser

Nodes call get_stream_writer() to emit events — plan, fanout, extract_start, extract_done, table, synthesize_start, text_delta, usage, done. The server forwards them as NDJSON; every extraction event carries the document id, so the UI routes concurrent events to the right card.

⚡ Because the fan-out is data-driven, scaling from 3 documents to 40 changes nothing but the list — that’s the whole point of Send.
Get started

Run it in two commands

Ships with a pre-seeded app_config.db, so the Bedrock region, key and model carry over — it runs out of the box.

# install & launch
pip install -r requirements.txt
python server.py                 # → http://127.0.0.1:8320

# then, in the browser:
1. Documents → upload ≥ 2 files (.pdf / .docx / .txt / .md)
2. Compare   → pick a preset, tune typed fields & focus, Run comparison
3. Settings  → model & Bedrock connection (password: bala@143)