Quote requests don't arrive on a form. They arrive as "need five of the 100mm drain pipe for the site plus ten 25A elbows, and probably some heat tape too." Someone reads that, identifies the products, digs through the price list, and builds a quote.
So the usual first move is to build a request form. Then customers don't use it. They phone, or they message. Don't try to structure the input — automate handling unstructured input instead.
The governing principle — the model never sees your price list
The first worry anyone raises about AI-generated quotes is "what if it makes up a price?" That's a fair worry, and it should be prevented structurally rather than by instruction.
- The model is never given the price list. Its only job is pulling item names and quantities out of prose. Prices appear nowhere in the prompt and nowhere in its response format. It cannot invent what it cannot see.
- SQL attaches the prices. Extracted names are matched against the real catalogue table to fetch unit prices. Every number printed on the quote came out of the database.
- No price is attached when the match isn't certain. Ambiguous items stay off the quote and go to a person with the reason.
Matching items — the strings never match exactly
"Stainless elbow 25A" and the catalogue's "Stainless steel elbow 90° 25A" are the same product with different strings. So matching is done by similarity, not equality. PostgreSQL's pg_trgm extension does this.
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE TABLE IF NOT EXISTS catalog (
sku text PRIMARY KEY,
name text NOT NULL,
unit text,
unit_price numeric NOT NULL
);
-- For a large catalogue, index for similarity search.
CREATE INDEX IF NOT EXISTS catalog_name_trgm_idx
ON catalog USING gin (name gin_trgm_ops);Measured similarity values from a real run. It works on Korean product names too, which is not a given for trigram matching.
request string catalogue product similarity
"PVC 배수관 100mm" → PVC 배수관 100mm 1.000
"배수관 100mm" → PVC 배수관 100mm 0.714
"열선 10m" → 동파방지 열선 10m 0.583
"스텐 엘보 25A" → 스테인리스 엘보 90도 25A 0.421
"없는품목 XYZ" → (nothing scored above 0.2)The dangerous case isn't a miss — it's a confident wrong hit
This is the most important part of the case. Items genuinely absent from the catalogue score low and filter out easily. The real problem is when several similar products exist.
request: "배수관" (no diameter given)
PVC 배수관 150mm similarity 0.286
PVC 배수관 100mm similarity 0.286 ← an exact tie
Taking the top hit selects the 150mm. But that was decided by sort order,
not by the request. Half the time you quote the wrong diameter.- So there are two thresholds.
MATCH_MIN(default 0.35) asks whether the best match is similar enough;MIN_GAP(default 0.15) asks whether it clearly beat the runner-up. Both must pass before a price is attached. - The second condition is the one people skip. A first pass at similarity matching almost always ships with a single threshold — which silently picks arbitrarily in the tie above.
- A tie is not a match; it's a question. The right response is "did you mean the 100mm or the 150mm?" The workflow hands that judgement to a person, and names which two products were confused.
- Both values must be re-tuned per catalogue. The more your product names resemble each other — many products differing only by size — the higher
MIN_GAPneeds to be.
What reaches the quote, and what gets held
A line is priced only when all three hold:
top similarity ≥ MATCH_MIN (0.35)
top − runner-up ≥ MIN_GAP (0.15)
quantity is stated as a positive number
Hold reasons, distinguished:
tie / near-tie → "ambiguous — A vs B (similarity 0.29 / 0.29)"
low similarity → "not in the catalogue"
no quantity → "quantity not stated"
Any held line at all sets status = needs_review
→ the quote PDF is not sent automaticallynull is deliberate too. The prompt explicitly says "never assume 1 when no quantity is stated." Reading "probably some heat tape too" as one roll makes the quote quietly wrong. Better to record that you don't know.What you need
- n8n — requests arrive from outside, so a publicly addressable URL is required.
- PostgreSQL with the `pg_trgm` extension — bundled with most distributions and enabled with one
CREATE EXTENSION. Managed providers generally permit it. - Catalogue data — SKU, product name, unit, unit price. This is the prerequisite for the whole case. If it lives in a spreadsheet, move it to a table first.
- An LLM API key — for extraction. One short call per request, so cost is small.
- A PDF generation service — anything that turns HTML into a PDF. Request formats differ per service, so that node is left blank.
- A Slack Bot User OAuth Token — for results and held items.
pg_trgm 1.6) and Node.js against a five-item request — an exact match (1.000), a request missing the brand prefix (0.714), an abbreviated request resolving "스텐 엘보 25A" to "스테인리스 엘보 90도 25A" (0.421), a request with no quantity, and an item absent from the catalogue. The tie case was reproduced for real — "배수관" scores 0.286 against both the 100mm and 150mm products, and the gate was confirmed to hold it unpriced while naming both candidates. Line totals were checked by hand (4,500×10 = 45,000 and 12,000×5 = 60,000). Node types and versions match our already-published workflows. Not verified: actual LLM extraction accuracy, live PDF service integration, live Slack posting. The similarity figures above are specific to this sample catalogue — a different naming scheme produces different values, so re-tune both thresholds against your own data. Last verified: 2026-08-15.Setup (40 minutes)
- Prepare the catalogue — run
CREATE EXTENSION IF NOT EXISTS pg_trgm;, create thecatalogtable above, and load your price list. Use the names people actually say — match quality is decided here. - Get a feel for the scores — take 10 typical request phrasings and run
SELECT name, similarity(name, 'phrase') FROM catalog ORDER BY 2 DESC LIMIT 3;. Look at your own score distribution before choosing thresholds. - Register the Postgres and Slack credentials — Test each, and
/invite @your-botin the channel. - Import the workflow — n8n → Workflows →
...→ Import from File. - Set model and API key — in
Build Extraction PromptandAI Extract Items. - Tune the thresholds — set
MATCH_MINandMIN_GAPinPrice or Flagfrom what you saw in step 2. With many size variants, push `MIN_GAP` to 0.2 or higher. - Connect the PDF service — endpoint, auth, and body in
Generate Quote PDF. - Test with a deliberately ambiguous request — send a product name with the size omitted. It must come back held, not priced. If a price appears,
MIN_GAPis too low. - Activate and run a few real request messages through it.
What happens in edge cases
- An item not in the catalogue — no price attached, reported as "not in the catalogue." If it's genuinely new, adding it to the catalogue fixes every later request.
- A request missing the size — caught as a tie and held, with both candidate products named so the follow-up question is easy to ask.
- An item with no quantity — held rather than assumed as 1. Phrases like "probably some too" really do arrive.
- Any held line at all —
statusbecomesneeds_reviewand no quote is sent automatically. The subtotal of confirmed lines is still shown, so a person only fills the gaps. - A model response that isn't JSON — treated as an empty item list, so no quote is produced. Safer than quietly issuing a wrong one.
- A price change — quotes always use the catalogue's current price. Reproducing a historical quote requires storing the price as of that quote. The base structure keeps no price history.
- The same request arriving twice — there's no duplicate defence, so two quotes are produced. Use
request_idas an idempotency key withON CONFLICTif that matters.
Match products to exact supplier catalogue references, and flag missing products instead of inventing prices.— An actual paid brief posted to the n8n community