The pipeline
GL categorization
Add a pipeline step that suggests a general ledger account from a fixed ten-account catalog using structured output.
By the end of this stage the pipeline produces its final piece of metadata, a suggested general ledger account for every document, picked from a fixed catalog, with a confidence and a reason Maya can read.
daveebbelaar/invoice-review1:30:23Why a separate step
GL categorization could have been folded into the classification prompt. Dave builds it as its own step on purpose. Each step keeps one responsibility, the step could later run on a different model than the classifier, and, because the pipeline is a chain, adding it costs one line in build_default_pipeline. This is the payoff of the design-pattern conversation from the previous stage. The runner just loops over steps in order, so a new step is configuration, not surgery.
The catalog
The brief mentions GL coding but names no accounts, so the build invents ten plausible ones for a facilities company. They live as a typed catalog in backend/app/accounting/catalog.py, an enum of codes plus a description of when each applies:
| Code | Account | Used for |
|---|---|---|
| 6100 | Cleaning services | Janitorial, window cleaning, waste disposal |
| 6110 | Building maintenance | General repairs, handyman work, routine upkeep |
| 6120 | Electrical services | Electrician work, lighting, electrical repairs |
| 6130 | Plumbing and HVAC | Plumbing, heating, ventilation, air conditioning |
| 6140 | Equipment and tools | Tool rental, small equipment, safety gear |
| 6150 | Office supplies | Stationery, printer supplies, consumables |
| 6160 | Professional fees | Legal, accounting, consulting |
| 6170 | Travel and transport | Fuel, parking, mileage, public transport |
| 6180 | Utilities | Electricity, water, gas |
| 6190 | Miscellaneous operating expenses | Everything the accounts above do not cover |
The descriptions are not documentation garnish. They are formatted into the agent's instructions at import time, so the catalog is simultaneously the source of truth for the code and for the prompt.
The enum is the guardrail
The suggestion model reuses the structured-output pattern from classification, with one important twist:
class GlSuggestion(BaseModel):
account_code: GlAccountCode
confidence: float = Field(ge=0.0, le=1.0)
reasoning: straccount_code is typed as the GlAccountCode enum, not a string. The model literally cannot return an account that does not exist; anything outside the ten codes fails validation and Pydantic AI retries. The constraint lives in the schema, not in prompt wording that a model might drift past.
The step also reads only normalized fields, never the file bytes:
class GlCategorizationStep:
name = "gl_categorization"
def run(self, ctx: PipelineContext) -> PipelineContext:
source = ctx.review_data if ctx.review_data is not None else ctx.extraction
suggestion = self._categorizer.run(source)
return ctx.model_copy(update={"gl_suggestion": suggestion})By this point in the chain the document has already been read twice. Vendor name, line items, and totals are enough to pick an expense account; sending the PDF a third time would only add cost.
Run it
Rerun the pipeline playground script and the payload gains a gl_suggestion block. The interesting test is an ambiguous document. In the video, a generic sample lands on 6190, miscellaneous operating expenses, and Dave judges that correct behavior. When nothing fits well, the catalog's catch-all is the right answer, and the reasoning field says why. The fuel receipt, by contrast, lands cleanly on 6170.
That completes the intelligence layer of the requirements. The system can classify, extract, validate, and code every document. What is missing is everything Maya touches.
Checkpoint
- Every processed sample receives a valid catalog code with confidence and reasoning
- You can explain why the enum, not the prompt, prevents invented accounts
- You can say what data this step reads and what it deliberately does not