Case studies
CASE STUDY — AI

From a free-text request to a quote PDF — flagging missing items instead of inventing prices

Deployed at Plumbing / interiors / industrial supply9 days to build
n8nOpenAIPostgres (pg_trgm)PDF generationSlack

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.

quote-generator.workflowLIVE
TXTFree-text requestAIExtract itemsMTMatch cataloguePDFGenerate quote

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.
The ordering matters. The common failing design pastes the whole price list into the prompt and asks for a quote. Then when the model substitutes a similar-but-different product, nobody notices, because the numbers look plausible. Separating extraction from pricing makes that failure structurally impossible.

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_GAP needs 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 automatically
Leaving quantity as null 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.
⬇︎ Download the workflow (quote-generator.json)
One Postgres credential, one Slack credential, plus your LLM key and model gets extraction, matching, and gating running. Supply your own catalogue table and PDF service.
Being precise about what was verified. The matching query and gate logic were executed on PostgreSQL 17 (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)

  1. Prepare the catalogue — run CREATE EXTENSION IF NOT EXISTS pg_trgm;, create the catalog table above, and load your price list. Use the names people actually say — match quality is decided here.
  2. 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.
  3. Register the Postgres and Slack credentialsTest each, and /invite @your-bot in the channel.
  4. Import the workflow — n8n → Workflows → ...Import from File.
  5. Set model and API key — in Build Extraction Prompt and AI Extract Items.
  6. Tune the thresholds — set MATCH_MIN and MIN_GAP in Price or Flag from what you saw in step 2. With many size variants, push `MIN_GAP` to 0.2 or higher.
  7. Connect the PDF service — endpoint, auth, and body in Generate Quote PDF.
  8. 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_GAP is too low.
  9. 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 allstatus becomes needs_review and 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_id as an idempotency key with ON CONFLICT if that matters.
Zero
invented prices
Two-stage
matching gate
Tie detection
prevents size mix-ups
40 min
setup time
Match products to exact supplier catalogue references, and flag missing products instead of inventing prices.An actual paid brief posted to the n8n community

Your operation belongs
in here, too.

Tell us the most repetitive task you have. We'll map an automation scenario for it.