Architecture
seed-data is organized as a set of pipeline stages wired into
Strands Agents graphs, with a thin typed Python API
(Generator) on top. This page explains how the pieces fit together.
The big picture
graph TD
API["Generator (api.py)"] --> PIPE["pipeline.build_pipeline_graph(ctx)"]
API --> BATCH["batch.generate_batch(...)"]
BATCH --> PIPE
PIPE --> DATA["stages/data"]
PIPE --> DOC["stages/document"]
PIPE --> AUG["stages/augment_stage"]
DATA --> BASE["stages/base"]
DOC --> BASE
AUG --> BASE
Generator(api.py) — the public front door. Holds configuration once; its verbs (generate,generate_batch,generate_packet) build and run graphs.stages/— one module per generate+critique pair, plus a shared foundation.stages/base.py— the typed primitives every stage uses.
Stages
The document pipeline is a chain of stages. Each stage pairs a generator with a critic in one module, because they evolve together and share context:
| Stage module | Generator | Critic |
|---|---|---|
stages/data.py |
invents schema-conforming JSON | JSON-Schema validation (code) + LLM domain/arithmetic judgment |
stages/document.py |
writes HTML/ReportLab and renders a PDF | render guard (code) + vision-model layout judgment; owns the render loop |
stages/augment_stage.py |
applies augraphy aging effects | file relocation (code) + legibility judgment |
Each critic is a deterministic guard followed by an LLM judgment. The guard is plain Python (exact, free, fast — e.g. JSON-Schema validation catches structural errors before spending a single token); the LLM only judges what code cannot (realism, layout, legibility).
The shared foundation (stages/base.py)
Four primitives hold everything together:
StageContext
A pydantic object carrying everything a stage needs for one document: the
schema, steering text, sample PDFs, the resolved output paths (output_path,
data_json_path, script_path), and the run configuration (models, threshold,
renderer). It is bound into each node at build time — see
Self-contained graphs.
ModelConfig
Which model each agent uses (data, doc, critic, aug, batch). Per-field
defaults mean ModelConfig(doc="gpt-oss") overrides only the doc model.
Verdict
The structured result every critic returns — accepted, score, summary,
feedback, issues. It replaces stringly-typed control flow: edge conditions
call accepted("node") / rejected("node"), which parse the Verdict out of the
node's output rather than grepping free text. A critic serializes its verdict with
as_node_text() (a verdict:accepted|rejected marker line plus JSON), and
verdict_of(state, node) reads it back via the canonical
NodeResult.get_agent_results() accessor (works for both live GraphState and the
final GraphResult).
FunctionNode
A custom Strands graph node that runs a deterministic Python function — the
sanctioned pattern for hybrid AI + business-logic workflows. The critics are
FunctionNodes: they do code work (validation, file moves), then call an LLM, then
return a Verdict. The context is bound at construction: FunctionNode(func, name,
ctx=ctx).
Self-contained graphs
The most important design decision: a pipeline graph carries no external state.
Every node — generator agents and FunctionNode critics alike — has its
StageContext baked in when it is built. A stage builder takes ctx:
data.build_generator(ctx) # agent configured from ctx
data.build_critic(ctx) # FunctionNode with ctx bound
document.build_loop(ctx) # nested render-loop graph, all nodes hold ctx
pipeline.build_pipeline_graph(ctx) assembles these into a complete
data → data_critic → doc_loop → (augment) graph. Because the graph needs no
invocation_state injected at run time, it can be invoked directly or
composed as a node inside a larger graph — which is exactly what batch does.
This is why there are no environment-variable side-channels, no closures capturing orchestrator locals, and no module-global state: configuration flows as a typed object bound into the graph structure itself.
Single-document flow
graph LR
DG[data_generator] --> DC[data_critic]
DC -->|reject| DG
DC -->|accept| DL[doc_loop]
DL --> AUG["augment (optional)"]
pipeline.generate(...) builds the context, builds the self-contained graph,
invokes it, and returns a typed GeneratedDoc (pdf_path, lazy .data label,
verdict, score, token_usage).
Batch flow — concurrent fan-out
Batch is just a graph. A planner turns one brief into N scenarios; each scenario
gets its own StageContext (own paths, own doc_id) and its own self-contained
pipeline graph, added as a sibling node under a coordinator:
graph TD
C[coordinator] --> W0["worker_0 (pipeline graph)"]
C --> W1["worker_1 (pipeline graph)"]
C --> WN["worker_N (pipeline graph)"]
Strands runs the siblings concurrently, natively — no threads, no manual pool, no shared mutable state to collide on (each worker reads and writes only its own paths). This is possible precisely because each pipeline graph is self-contained.
The public API (api.py)
Generator is a thin, typed facade — it holds configuration and delegates to the
stage graphs:
from seed_data import Generator, ModelConfig, Schema
gen = Generator(models=ModelConfig(doc="gpt-oss", critic="haiku"), threshold=5)
gen.generate("invoice", scenario="Midwest distributors") # -> GeneratedDoc
gen.generate_batch("invoice", count=10, scenario="...") # -> BatchResult
generate() accepts a bundled schema name, a directory path, or an in-code
Schema.
Defining a document type in code
A Schema is a document type defined without files on disk — a JSON-Schema dict
or a pydantic model (rendered to JSON Schema), plus generation guidance and
optional sample PDFs:
from pydantic import BaseModel
from seed_data import Schema
class Invoice(BaseModel):
invoice_number: str
total: float
schema = Schema(name="invoice", model=Invoice,
generation_guidance="Totals must equal the sum of line items...")
gen.generate(schema)
One pipeline, three entry points
The staged pipeline (stages/) is the single architecture behind all three
capabilities. Single-document and batch generation call it directly; packet
generation (packet.py) is a thin coordinator that also drives each sub-document
through the same seed_data.stages.pipeline.generate.