Skip to content

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)
  • system is 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 jsonb so 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.