From supplier documents to versioned quotes
An estimator was manually transferring supplier rates from PDFs and spreadsheets, with every quote exposed to stale pricing.
My contribution
Worked directly with the client owner to define the quoting problem, compare product approaches and shape the estimator workflow. Designed the architecture and data model, implemented extraction and quoting, validated pricing and access controls in parallel testing, and delivered the production release with ongoing iteration.
Key decision Use AI to extract the documents. Keep quote calculations deterministic and issued rates immutable.
Outcome Tender cycle cut from about 20 hours to about two, validated in parallel testing. Auth-gated production release in under four weeks.
Open full-size image ↗
Situation
A Nordic fabrication client (metalwork, subsea and offshore adjacencies) was quoting jobs on the basis of supplier price lists that arrived as PDFs, Excel workbooks, and occasional emails. The estimator would open a PDF, read a row, re-type it into an internal spreadsheet, and hope not to miss the revision date. Every quote carried the risk that a supplier had updated a rate in the meantime and the estimator was working from a stale line.
The pressure was straightforward: the estimator was the bottleneck for winning work, and the bottleneck was mechanical.
Task
Decide whether to build a rules-based rate engine (predictable, high floor, high build cost), an LLM-first extractor (fast to ship, unpredictable at edges), or a hybrid – under a constraint that the client would keep quoting manually until we shipped, and we owned both the decision and the shipping.
Action
System workflow
Architecture
LLM-first extraction, deterministic quoting. Claude Sonnet 5 (via Vercel AI Gateway, with a direct-Anthropic fallback) reads uploaded PDFs, Excel files, images, CSVs, and DXF geometry and emits structured JSON against a Zod schema. The quoting engine downstream is pure – no LLM in the price math. This separation is the load-bearing decision: extraction can be improved model-by-model without touching the rate math, and the rate math is auditable.
Streaming structured output. The /api/extract-case route uses AI SDK's streamText with output: Output.object({ schema }). The client-side useObject hook deserializes the partial JSON as tokens arrive, so the estimator sees rows populate live rather than a spinner for 20 seconds.
Prompt caching everywhere. System prompt + schema description cached as text blocks; PDF files cached as file blocks under Anthropic's 5-minute TTL. Cold-path latency on a 51k-token payload is 10–20s; cached retries drop to 3–5s. The Vercel function has a 300s max duration (Fluid Compute) to accommodate the cold path without failing.
Versioned rate cards with a single "current" per workspace. The rate_cards table has a unique index on (workspace) WHERE is_current – exactly one active card per workspace, enforced by the database rather than the app. Concurrent saves race on the constraint and get a 409, which prevents "someone saved while I was editing" without any wall-clock timestamp drift.
Rate-key normalization with dual-standard fallback. Extraction emits material names verbatim from supplier docs ("A516 GR.70", "S420MC / H420LA"). A normalization pass strips casing, whitespace, and punctuation noise; a lookup then tries exact match first, then normalized, then dual-standard splitting for A / B composite names against cards that only list A. Without this, near-duplicates flooded the "missing rate" queue.
Alternatives considered and rejected:
- Rules-based extractor – too brittle against supplier document variance; would have required a per-supplier parser per PDF revision.
- LLM in the price math – closes off auditability. The client has to be able to say "here is the rate we charged you, here is the supplier source, here is the date."
- Client-side extraction – PDF files can be large, browser memory is not the extractor's friend, and the AI Gateway routing has to live server-side.
Implementation
- Email-driven intake. Inbound RFQ emails hit a Resend inbound address; a Svix webhook (signature-verified) writes the email + attachments to Supabase, with attachments in a private Storage bucket. The dedup key is Resend's
messageIdon a UNIQUE index – idempotent by construction. - Four-format ingestion. PDFs and images encode as base64 file blocks; spreadsheets (Excel, CSV) are flattened server-side to CSV text before hitting the model; DXF geometry parses to a structured payload for the quoting engine.
- Immutable snapshots on quote issue. The
issued_quotestable storescase_snapshotandrates_snapshotalongside the current live rate card. A quote issued in June answers the question "what did we quote you against?" three months later without archaeology. - Bilingual by construction. All UI strings are
Loc = { nb: string; en: string }objects consumed byuseLang(); inbox emails detect BCP-47 locale and render the correct envelope copy. Norwegian is the default (hydration-safe for SSR). - RLS deny-all on all four tables. No
authenticatedoranonpolicies. The service-role client behind auth middleware is the only ingress. The database enforces the boundary; the app enforces which rows a session may touch.
Verification
- Cost per extraction is logged. Every LLM call goes through
recordLlmGeneration()to Langfuse with input/output tokens and cost. Per-extraction spend is visible in the fleet dashboard. - Formula-injection guard on any XLSX export. Cells beginning with
=,+,@, or–are sanitized. Estimators paste extracted rates into other Excel workbooks; a mis-typed material name should not exfiltrate a workbook. - Deployment protection.
noindexheader, custom subdomain, Vercel deployment protection. The tool is not searchable and not linkable.
Result
- Production in under four weeks – from first commit to auth-gated live tool serving the client's estimator.
- Weekly feature velocity – auth, email intake, real legal-entity data, LLM-driven pricing sources – each shipped as an isolated feature with its own migration.
- Cold-path 51k-token extractions in 10–20s, cached retries in 3–5s. Fast enough that the estimator opens the tool while the supplier PDF is still on their desk.
- Zero direct-DB access from client sessions. Every read and write is mediated by auth middleware; RLS is a second wall behind that.
Second-order effects: the same email intake pattern (Resend → Svix → Storage → structured extraction) is now the template for the fleet's other document-heavy products. The confidence-stamping and rate-key normalization patterns will graduate into a shared package when the second product needs them.
What we would do differently: invest in a translation-audit workflow earlier. Bilingual strings live in components as Loc objects, which is fine for two languages and one authoring pair, but rot begins the moment a copy change lands in only one variant. A pre-commit check that flags asymmetric Loc edits would pay for itself.
Skills evidenced
- Structured LLM extraction at production quality – Zod-schema constrained output, streaming to the client, aggressive prompt caching for cost + latency.
- Postgres schema design for versioned data – unique partial indexes for "current" flags, immutable snapshots on quote issue, event-sourced status transitions.
- Multi-format ingestion pipeline – PDF, image, Excel, CSV, DXF, email attachments, all normalized to structured JSON before touching the price engine.
- Security-first webhook handling – Svix signature verification, private Storage buckets, RLS deny-all with service-role gating.
- Bilingual product from day one – nb/en toggle in the UI, BCP-47 detection on inbound emails, Norwegian-default rendering.
Portable lessons
- Put the LLM in the extraction path, keep it out of the money math. Auditability is a feature, and once it's gone it's expensive to restore.
- Enforce single-current invariants in the database, not the app. A unique partial index is one line and catches a whole class of race.
- Ship the paid-API cost log before the paid-API feature. Every LLM call in production is a rounding-error decision that adds up; instrument it before you can be surprised by it.
Want to discuss the decisions behind it?
Over fifteen years of consulting and growth, and hands-on AI delivery from discovery to daily operations. I can share my CV and talk through how I connect business outcomes with systems that run.
Available for a chat. In person in Stavanger, or remotely worldwide.