The problem: solicitation packages are long and scattered
A single grant or contract opportunity can come with a solicitation document, several attachments, standard forms, spreadsheets and sometimes a ZIP of everything, including scanned PDFs. Somewhere in that pile are the things a bid team must not miss: each file to upload, each certification, each narrative question and its deadline.
Our client, a government-contracting software platform, wanted that list produced automatically, then compared with each bidder's existing profile so the platform could ask only for what was missing. Client and data details are withheld under NDA.
The architecture at a glance
- Jobs arrive on queues. Each job type (new opportunity, requirements gathering, gap analysis, proposal generation, document upload) has its own AWS SQS queue, with retries and a dead-letter queue. A router sends each message to the right processor.
- Download and extract. Linked documents are downloaded and converted to text: PDFs with PyMuPDF, plus Word, Excel, HTML and ZIP archives. When a PDF has no text layer, Tesseract OCR takes over.
- Chunk. Long documents are split on sentence boundaries into chunks of up to about 50,000 characters.
- Extract (one agent per chunk). An extraction agent pulls out two kinds of requirement:
uploadable(files, forms, certifications, every "Attachment N" or "Form X") andtext(narrative answers), each with a deadline and notes. - Compose. A composer agent merges the per-chunk lists, removes duplicates and splits the result into upload requirements and writing guidelines.
- Find gaps. A gap-analysis agent compares the requirements with the bidder's profile (answers, uploaded files, certifications) and lists what's missing, with the kind of input needed: a file, text, a checkbox or a certification.
- Draft. A proposal agent drafts sections from the profile and the requirements.
Results are stored in MongoDB and files in S3. The service is Python with FastAPI.
The workflow graph
LangGraph holds the steps together. Each node is a function that reads the shared state and returns the fields it changed; edges fix the order. A simplified version of the requirements-gathering graph:
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
class RFPState(TypedDict):
resource_links: list[str]
chunks: list[str]
extracted: list[list[dict]] # one list of requirements per chunk
requirements: list[dict] # merged and de-duplicated
errors: list[str]
graph = StateGraph(RFPState)
graph.add_node("download_and_extract", download_and_extract)
graph.add_node("extract_requirements", extract_requirements)
graph.add_node("compose", compose)
graph.add_edge(START, "download_and_extract")
graph.add_edge("download_and_extract", "extract_requirements")
graph.add_edge("extract_requirements", "compose")
graph.add_edge("compose", END)
app = graph.compile()Seven design decisions that made it reliable
- Map, then reduce. Extract from each chunk separately, then compose once. Every extraction call sees a manageable amount of text, and the composer sees only structured requirements, not raw documents.
- Hand the model its IDs. We pre-generate requirement IDs and pass them in instead of letting the model invent them. Later steps, such as gap analysis, refer to requirements by ID, so the IDs must be stable.
- A schema at every boundary. Each agent's output is validated against a Pydantic model, so a malformed result is caught at the step that produced it, not three steps later.
- Errors are state, not exceptions. Each node appends problems to an
errorslist and carries on. One unreadable attachment doesn't lose the other twenty. - A small model for narrow work. Extraction is repetitive and well defined, so it runs on a small, inexpensive model (gpt-4o-mini).
- Queues for long-running jobs. Processing a package can take minutes. Queues decouple it from the web request, retry transient failures and park repeated failures in a dead-letter queue for inspection.
- OCR only as a fallback. OCR is slow and less accurate, so it runs only when a PDF has no text layer.
What we'd change in the next version
- Structured outputs from the SDK. Declaring an
output_typeon each agent lets the SDK return a validated object, instead of parsing JSON out of free text. - Concurrent chunk processing. Chunks are independent, so they can run in parallel with a concurrency limit.
- A persistent checkpointer. In-memory checkpoints don't survive a restart; a database-backed one lets a crashed run resume where it stopped.
- An evaluation set. A handful of real solicitation packages with hand-labelled requirements, scored on every prompt or model change, turns "seems to work" into a recall number.