Architecture¶
The two load-bearing decisions¶
1. Vector search finds the entry point; the graph delivers the relationships¶
Embeddings are good at "this email sounds like a fire-rated office partition" and bad at "this board must be fixed with these screws at this spacing." So:
- Hybrid search (embeddings + BM25) over denormalized system cards returns the top 5–10 candidate systems — the entry point.
- Graph expansion (plain SQL joins over the relation tables) returns the exact BOM and procedures for those candidates — the relationships.
A wrong embedding costs a re-ranked candidate; it never fabricates a bill of
materials, because the BOM is read from system_product_edge, not the model.
2. Thin model, thick scaffold¶
The LLM is used in exactly two places, and never to know domain facts:
| Task | LLM? | Why |
|---|---|---|
| Email → observable facts | yes (hard) | messy natural language |
| Facts → requirements | no | finite, auditable rules table |
| Requirements → candidate systems | no | SQL + scoring |
| BOM expansion & quantities | no | arithmetic over graph edges |
| Draft prose | yes (quality) | tone, phrasing |
Everything in the "no" rows is deterministic code in this repo. That is the "thick scaffold": it is testable, auditable, and swappable independent of which model is plugged in.
The catalog graph (Postgres)¶
Nodes and edges, all in relation tables (db/migrations/0001_init.sql):
product ──< system_product_edge >── system (BOM, quantity_per_unit on edge)
product ──< product_requires_edge >── product (board requires screw)
product ──< product_compatible_edge >── product (symmetric compatibility)
system ──< system_procedure_edge >── procedure (application guides)
systemis a first-class entity. A quote is a system + resolved BOM, not a loose bag of SKUs. This mirrors how the domain actually sells ("W112 EI60 partition"), and it is where fire/acoustic/height capabilities live for the matcher to filter on.- Quantities live on edges (
quantity_per_unit,unit,waste_factor), so BOM resolution for a given area is pure arithmetic (catalog/bom.py). - Attributes are
jsonbso domain experts extend products/systems without migrations; hot filter keys can be promoted to real columns later.
Postgres is the default (per the spec). Switch to Neo4j only if traversals grow deep enough that recursive SQL becomes the bottleneck — the domain model (nodes + typed edges + edge quantities) ports directly.
The extraction contract¶
extraction/schema.py is a Pydantic model whose every field is a closed enum
drawn from the catalog vocabulary, and every enum includes unknown.
model_json_schema() is handed to a constrained-decoding backend (vLLM
guided_json / Ollama structured outputs) so the model can only emit legal
values. Two reliability techniques from the spec are both supported:
- One constrained extraction into
ExtractedFacts. - Micro-classification decomposition — small yes/no/unclear questions with
cited evidence, merged back into
ExtractedFacts. Smaller questions are markedly more reliable on self-hosted models than one mega-extraction.
Crucially, extraction reports observable facts only ("bathroom", "escape route"). It never infers standards ("needs EI90") — that is the rules engine.
The rules engine¶
rules/rules.yaml is the finite, auditable map from observable facts to
standards-adhering requirements (e.g. escape route + office → EI90). The
engine (rules/engine.py):
- fires every matching rule and merges requirements, keeping the strictest value on ordinal keys (fire rating, Rw);
- attaches provenance (rule id + rationale) to every requirement, so a salesperson can audit why a requirement was raised;
- emits clarifying questions when a needed fact is
unknown.
Domain experts extend the table without touching prompts or code.
Validation¶
validation/checks.py compares the proposed system against the derived
requirements and stated facts (fire class met, moisture met, height within
system limit, area positive). Errors block the quote and become questions;
warnings are surfaced to the salesperson. A wrong quote must never reach the
customer silently.
Draft assembly¶
draft/builder.py assembles a fully-resolved DraftContext (chosen system,
computed BOM, questions, warnings) deterministically, then hands it to the
draft LLM with a prompt that forbids inventing products, quantities, or
standards. The reply is written to Gmail as a draft in-thread — never
auto-sent.