2026-06-30 09:09:27 +00:00
# Pricelist extraction — schema + prompt
Companion doc for the AI-powered step that converts a raw supplier
price-list PDF into structured `price_list` rows.
## Two-collection flow
WFDASH has two related collections, both already in PB:
- **`raw_price_list`** — one row per uploaded PDF. Holds the file, the
status (Pending → Completed / Failed), and a log summary.
Schema (live): `id` , `supplier_file` , `supplier_name` (relation →
suppliers), `status` (select), `log_summary` , `created` , `updated` .
- **`price_list`** — one row per line item. The agent-readable
catalogue the quote-filler looks up against. Schema (live, this is
what gets filled):
| Field | Type | Source |
|---|---|---|
| `sku` | text | AI: normalized product code |
| `description` | text | AI: full product name |
| `category` | text | AI: product family (snake_case) |
| `subcategory` | text | AI: sub-family if PDF has it |
| `brand` | text | AI: manufacturer |
| `supplier` | text | denormalized from `raw_price_list.supplier_name` |
| `cost_price` | number | AI: supplier's price |
| `unit` | text | AI: each / pack / metre / kg / set |
| `markup` | number | workflow default (25%) — operator override later |
| `selling_price` | number | workflow computes: `cost_price × (1 + markup/100)` |
| `stock_qty` | number | workflow default (0) — inventory updates later |
feat(pricelist): Catalogue search hits every field (incl. synonyms)
The Catalogue tab's search box used to OR across just sku,
description, and brand. Now it hits:
- sku, description, brand, supplier, category, subcategory
(PB's case-insensitive `~` operator, each indexed)
- search_text (a denormalised text blob)
The denormalised column is the key part: PB can't substring-
match a JSON field, so without this, synonyms from
`specifications.synonyms` ("slugger cutter", "turbo tough",
etc.) would never match in the search box. The n8n workflow's
Code node now flattens every searchable bit — including the
synonyms array — into search_text on every POST. The 46 pre-
existing rows were backfilled once with the same formula.
The `price_list.supplier` field also stored the PB id on the
46 pre-existing rows (n8n was setting the resolved name, but
the original LLM tests that seeded the table didn't). Fixed
that in the same backfill — supplier now reads 'JEISA -
WESTSCOTT GROUP' instead of 'q6hpwbxsicyoiuu', which makes
the supplier filter dropdown work AND makes
'supplier ~ "jeisa"' match.
Migration `2026-06-30-add-price-list-search-text`:
- Adds `search_text` (text) column
- Adds `idx_pl_search_text` non-unique index
- Idempotent — re-runs are safe and self-heal the index
Updates:
- `getPriceLists` helper ORs across all 6 columns + search_text
- `PriceList` TS type gains `search_text: string`
- `pricelist-extraction.md` Code node snippet shows how the
n8n workflow should populate `search_text` on every POST
- Search input placeholder reads 'Search anything — SKU,
description, brand, supplier, synonyms…'
Tests: 229/230 (same pre-existing failure, unrelated).
TypeScript clean.
2026-06-30 10:10:27 +00:00
| `specifications` | json | AI: rich JSON (attributes + synonyms) |
| `source_file` | text | workflow: `raw_price_list.id` this row was extracted from |
2026-06-30 19:20:53 +00:00
| `search_text` | text | workflow: denormalised "search everything" blob — every text column (sku, description, brand, supplier, category, subcategory, unit, source_file), the resolved supplier name, the four number columns tokenised (cost_price, selling_price, markup, stock_qty), AND the full `specifications` JSON (every key, every string value, every number value, every array value, and one level of nested-object keys). PB can't substring-match JSON, so the Catalogue tab ORs against this column directly. |
feat(pricelist): Catalogue search hits every field (incl. synonyms)
The Catalogue tab's search box used to OR across just sku,
description, and brand. Now it hits:
- sku, description, brand, supplier, category, subcategory
(PB's case-insensitive `~` operator, each indexed)
- search_text (a denormalised text blob)
The denormalised column is the key part: PB can't substring-
match a JSON field, so without this, synonyms from
`specifications.synonyms` ("slugger cutter", "turbo tough",
etc.) would never match in the search box. The n8n workflow's
Code node now flattens every searchable bit — including the
synonyms array — into search_text on every POST. The 46 pre-
existing rows were backfilled once with the same formula.
The `price_list.supplier` field also stored the PB id on the
46 pre-existing rows (n8n was setting the resolved name, but
the original LLM tests that seeded the table didn't). Fixed
that in the same backfill — supplier now reads 'JEISA -
WESTSCOTT GROUP' instead of 'q6hpwbxsicyoiuu', which makes
the supplier filter dropdown work AND makes
'supplier ~ "jeisa"' match.
Migration `2026-06-30-add-price-list-search-text`:
- Adds `search_text` (text) column
- Adds `idx_pl_search_text` non-unique index
- Idempotent — re-runs are safe and self-heal the index
Updates:
- `getPriceLists` helper ORs across all 6 columns + search_text
- `PriceList` TS type gains `search_text: string`
- `pricelist-extraction.md` Code node snippet shows how the
n8n workflow should populate `search_text` on every POST
- Search input placeholder reads 'Search anything — SKU,
description, brand, supplier, synonyms…'
Tests: 229/230 (same pre-existing failure, unrelated).
TypeScript clean.
2026-06-30 10:10:27 +00:00
| `created` / `updated` | autodate | — |
2026-06-30 09:09:27 +00:00
Indexed on `sku` , `category` , `subcategory` , `brand` , `supplier` — so
the agent's "find me all HSS cutters 12mm from JEISA" becomes a
single PB filter, not a full-table scan.
## Per-item JSON shape (what the AI outputs)
One price list → one record → N line items. The LLM returns ONE outer
object with an `items` array; the workflow splits it and POSTs each
item as a `price_list` row.
```jsonc
{
"items": [
{
"sku": "TS-DRL-12-55",
"description": "HSS Twist Drill 12× 55mm",
"category": "twist_drill",
"subcategory": "jobber",
"brand": "Dormer",
"cost_price": 24.50,
"currency": "ZAR",
"unit": "each",
"specifications": {
"material": "HSS",
"diameter_mm": 12,
"length_mm": 55,
"shank_mm": 12,
"point_angle_deg": 118,
"coating": "bright",
"synonyms": [
"drill bit",
"12mm drill",
"HSS drill",
"twist drill"
]
}
}
],
"extracted_at": "2026-06-30T10:00:00Z",
"extraction_model": "gpt-4o-2024-08-06",
"extraction_confidence": "high",
"page_count": 4,
"raw_text_excerpt": "first 500 chars of OCR'd text, for debugging"
}
```
### `specifications.attributes` per category
`attributes` is the rich, flexible part. LLM fills whatever the PDF
gives it. Common keys by category:
| Category | Typical attributes |
|---|---|
| twist_drill | material, diameter_mm, length_mm, shank_mm, point_angle_deg, coating |
| step_drill | material, min_diameter_mm, max_diameter_mm, steps, coating |
| hss_cutter | material, diameter_mm, length_mm, shank_mm, type |
| countersink | material, diameter_mm, angle_deg, shank_mm |
| reamer | material, diameter_mm, flutes, shank_mm |
| burr | material, shape, diameter_mm, shank_mm |
| tct_cutter | material, diameter_mm, teeth, shank_mm |
| hole_saw | material, diameter_mm, shank_mm, depth_mm |
| rail_cutter | material, diameter_mm, shank_mm |
For categories not in the table, the LLM invents attribute names
from the PDF content. Snake_case, units in the key.
### `specifications.synonyms` — the matching bridge
Always include inside `specifications` :
- The category name (`hss_cutter`, `twist_drill` )
- The material + category combo (`HSS cutter`, `HSS slugger` )
- 1– 3 colloquial variants (`slugger`, `TCT cutter` , `annular cutter` )
- The full description with units stripped (`HSS Twist Drill 12× 55`)
Don't include:
- The supplier's brand (the buyer doesn't know it)
- The supplier's SKU
- Made-up words not in the PDF
### Defaults the workflow applies AFTER extraction
| Field | Default | How to apply |
|---|---|---|
| `supplier` | (denormalize from raw) | Code node reads `raw_price_list.expand.supplier_name.suppliername` |
| `markup` | 25 (or operator override from `notes` ) | Regex `/markup\s*[:=]?\s*(\d+)\s*%?/i` on the `notes` field; falls back to 25 if not found |
| `selling_price` | cost_price × (1 + markup/100) | Code node: `Math.round(cost_price * (1 + markup/100) * 100) / 100` |
| `stock_qty` | 0 | Hardcoded; inventory system updates later |
## The `notes` field (operator → AI)
`raw_price_list.notes` is a free-text field the operator fills in at
upload time on the Pricelist page. It's the bridge between "what the
operator knows about this price list" and "what the AI should pay
attention to." Common uses:
- **Brand override** — `Brand is JEI` (when the PDF doesn't print the brand in every row)
- **Markup override** — `Apply 30% markup` (Code node regexes this out for selling_price)
- **Currency** — `All prices in ZAR excl. VAT` (or `incl. VAT` — affects the workflow)
- **Quirks** — `Last 3 items are TCT, not HSS` / `Page 4 has discount codes` / `Header logo in row 1 is the supplier name`
- **Default behavior** — `Use defaults` (no overrides; the workflow uses its 25% markup, ZAR, no brand)
The n8n workflow reads `notes` and **injects it into the LLM user
message** as context. The LLM treats the notes as a per-upload override
of the system prompt's defaults.
### LLM user message shape
```json
{
"messages": [
{
"role": "system",
"content": "[the system prompt from this doc]"
},
{
"role": "user",
"content": [
{
"type": "text",
"text": "Supplier: JEISA - WESTSCOTT GROUP.\nOperator notes: {{ $json.notes }}\n\nExtract all line items from the attached PDF and return them in the JSON shape from your instructions."
},
{
"type": "image_url",
"image_url": { "url": "data:application/pdf;base64,..." }
}
]
}
]
}
```
If `notes` is empty, the LLM uses the system prompt defaults. If the
operator wrote "Brand is JEI", the LLM stamps every item with
`brand: "JEI"` . If the operator wrote "Apply 30% markup", the system
prompt's hardcoded "25%" gets overridden in the LLM's mind AND the
Code node regexes the same number out for `selling_price` .
### Markup regex in the Code node
```js
const notes = $('Search raw_price_list').first().json.notes ?? '';
const markupMatch = notes.match(/markup\s*[:=]?\s*(\d+)\s*%?/i);
const DEFAULT_MARKUP = 25;
const markup = markupMatch ? Number(markupMatch[1]) : DEFAULT_MARKUP;
```
So `notes = "Apply 30% markup"` → `markup = 30` , `notes = "Markup: 35%"` → `markup = 35` , `notes = "use defaults"` → `markup = 25` . The regex is intentionally loose — matches `30%` , `30 %` , `30` , `markup 30` , `Markup: 30` , `markup=30` , etc.
field. The "user" message is the PDF as base64.
```
You are a price-list parser for Westcott Fasteners, a South African
industrial fastener and tool supplier. You receive a single supplier
price list as a PDF (image or text) and must return ONE strict JSON
object — no prose, no markdown fences.
The JSON must match this shape exactly:
{
"items": [
{
"sku": "string", // normalized SKU, uppercase, dash-separated
"description": "string", // full product name as printed
"category": "string", // e.g. "twist_drill", "hss_cutter"
"subcategory": "string" | null,
"brand": "string" | null,
"cost_price": number, // supplier's price (no VAT)
"currency": "ZAR" | "USD" | "EUR" | "GBP",
"unit": "each" | "pack" | "metre" | "kg" | "set" | "box",
"specifications": {
// Flexible per-category. Use snake_case, units in the key.
// Common keys:
// twist_drill: material, diameter_mm, length_mm, shank_mm,
// point_angle_deg, coating
// step_drill: material, min_diameter_mm, max_diameter_mm,
// steps, coating
// hss_cutter: material, diameter_mm, length_mm, shank_mm, type
// countersink: material, diameter_mm, angle_deg, shank_mm
// reamer: material, diameter_mm, flutes, shank_mm
// burr: material, shape, diameter_mm, shank_mm
// tct_cutter: material, diameter_mm, teeth, shank_mm
// hole_saw: material, diameter_mm, shank_mm, depth_mm
// rail_cutter: material, diameter_mm, shank_mm
// For anything else, invent the keys from the PDF.
"material": "string",
"diameter_mm": number | null,
"synonyms": [ "string", ... ] // see synonyms rules below
}
}
],
"extracted_at": "ISO8601 timestamp",
"extraction_model": "your model name + version",
"extraction_confidence": "low" | "medium" | "high",
"page_count": number,
"raw_text_excerpt": "string" // first 500 chars of OCR'd text
}
═══════════════════════════════════════════════════════════════════
HARD RULES
═══════════════════════════════════════════════════════════════════
1. NEVER invent data. If a value isn't in the PDF, set it to null.
"Don't know" beats "guess wrong" — a wrong price on a quote is
much worse than a missing price the operator fills in by hand.
2. PRICES — read them as printed. R245.50 → 245.50. R 1,245 → 1245
(strip thousands separators). $12.99 → 12.99. If the currency
isn't stated and the supplier is South African, default to ZAR.
cost_price is the supplier's pre-VAT number unless the PDF
explicitly shows VAT-inclusive.
3. SKUs — normalize to UPPERCASE, dash-separated, no spaces, no
supplier prefixes. "ACME TS-12x55" → "TS-12X55". If the PDF
doesn't show a SKU, fabricate one from the category + key
attributes: e.g. "TS-DRL-12-55".
4. CURRENCY — the price_list collection only stores the numeric
cost_price. The currency is captured in this JSON for the
operator's reference; the workflow drops it on insert.
5. UNITS — normalize to the enum: "ea" / "pcs" / "PC" → "each",
"m" → "metre", "KG" → "kg", "BOX" / "CTN" → "box", "SET" → "set".
If unclear, default to "each".
6. ATTRIBUTES — fill whatever the PDF gives. Use snake_case, units
in the key (e.g. `diameter_mm` , `point_angle_deg` ). Leave missing
ones out of the JSON entirely rather than setting to null.
7. SYNONYMS — generate 3-6 buyer-friendly aliases per item. Include:
the category, the material + category combo, 1-3 colloquial
variants, and the description with units stripped. Example for
an HSS annular cutter:
["hss_cutter", "HSS cutter", "HSS slugger", "slugger cutter",
"slugger", "annular cutter", "HSS Annular Cutter 12× 55"]
Never include: supplier brand, supplier SKU, made-up words.
8. CATEGORIES — snake_case, lowercase, from the table above when
applicable. New categories are fine if the product family is
genuinely new (don't force-fit).
9. CONFIDENCE — "high" if every line has a clear price and OCR is
clean. "medium" if some values required inference. "low" if the
PDF is scanned, blurry, or only partially readable — in that
case leave `items: []` and put whatever you CAN see in
`raw_text_excerpt` . The operator will re-upload or fix manually.
10. OUTPUT — return only the JSON object. No prose, no markdown
fences, no leading "Here is the JSON:". Just the object.
═══════════════════════════════════════════════════════════════════
WHAT TO DO WHEN THINGS GO WRONG
═══════════════════════════════════════════════════════════════════
- PDF is a SCAN, not text: still try. If unreadable, set
extraction_confidence: "low" and items: [].
- PDF is a PHOTO of a paper list: same as scan.
- PDF has multiple categories mixed: each item gets its own
`category` per the table above. Don't try to guess a single
category for the whole PDF.
- Price column shows "POA" / "TBA" / "On Request": set
cost_price: null.
- Same product appears multiple times with different pack sizes
(e.g. "each" and "pack of 10"): emit TWO items, one per
pack size, with the `unit` field distinguishing them.
- PDF is a multi-page catalog with photos and prose: only emit
items where you found an explicit price. Skip the marketing
pages.
═══════════════════════════════════════════════════════════════════
```
## Workflow (n8n)
```
Schedule Trigger (every 5 min)
↓
HTTP Request — GET /api/collections/raw_price_list/records
?filter=status="Pending"& perPage=1
↓
Limit (1)
↓
HTTP Request — GET /api/files/{collectionId}/{id}/{filename}
(response format = file)
↓
OpenAI / Anthropic — vision call, system prompt above
↓
Code — parse JSON, validate shape, build N POST payloads
↓
SplitInBatches (one per item)
↓
HTTP Request — POST /api/collections/price_list/records
↓
(loop until SplitInBatches done)
↓
HTTP Request — PATCH /api/collections/raw_price_list/records/{id}
{ status: "Completed",
log_summary: "Extracted N items via gpt-4o, confidence: high" }
```
### Code node (between LLM and SplitInBatches)
2026-06-30 19:20:53 +00:00
Parses the LLM response and applies the per-row defaults. The
`buildSearchText()` helper is a vanilla-JS port of the shared
TypeScript function in `src/lib/price-list-search.ts` — keep both
in sync if you change one. Pseudocode:
2026-06-30 09:09:27 +00:00
```js
2026-06-30 19:20:53 +00:00
// Vanilla-JS port of src/lib/price-list-search.ts#buildPriceListSearchText.
// Pushes every searchable token into a Set (deduped), then joins
// with spaces. See the TS source for the canonical reference.
function pushIfPresent(out, value) {
if (value == null || value === '') return;
if (typeof value === 'string') {
const t = value.toLowerCase().trim();
if (t) out.add(t);
return;
}
if (typeof value === 'number' & & !Number.isNaN(value)) {
out.add(String(value).toLowerCase());
const asInt = Math.trunc(value);
if (asInt !== value) out.add(String(asInt));
return;
}
}
function pushSpecifications(out, spec) {
if (!spec || typeof spec !== 'object' || Array.isArray(spec)) return;
for (const [key, val] of Object.entries(spec)) {
out.add(key.toLowerCase());
if (val == null) continue;
if (Array.isArray(val)) { for (const item of val) pushIfPresent(out, item); continue; }
if (typeof val === 'object') {
for (const [k2, v2] of Object.entries(val)) {
out.add(k2.toLowerCase());
pushIfPresent(out, v2);
}
continue;
}
pushIfPresent(out, val);
}
}
function buildSearchText(row, supplierName) {
const tokens = new Set();
for (const k of ['sku','description','brand','category','subcategory','unit','source_file']) {
pushIfPresent(tokens, row[k]);
}
pushIfPresent(tokens, supplierName ?? row.supplier);
for (const k of ['cost_price','selling_price','markup','stock_qty']) {
pushIfPresent(tokens, row[k]);
}
pushSpecifications(tokens, row.specifications);
return [...tokens].join(' ');
}
2026-06-30 09:09:27 +00:00
const parsed = JSON.parse($input.first().json.message.content);
const rawRecord = $('Search raw_price_list').first().json;
const supplier = rawRecord.expand?.supplier_name?.suppliername || '';
2026-06-30 19:20:53 +00:00
const rawId = rawRecord.id;
2026-06-30 09:09:27 +00:00
const DEFAULT_MARKUP = 25;
const rows = parsed.items
.filter(it => it.cost_price != null) // skip POA / TBA
feat(pricelist): Catalogue search hits every field (incl. synonyms)
The Catalogue tab's search box used to OR across just sku,
description, and brand. Now it hits:
- sku, description, brand, supplier, category, subcategory
(PB's case-insensitive `~` operator, each indexed)
- search_text (a denormalised text blob)
The denormalised column is the key part: PB can't substring-
match a JSON field, so without this, synonyms from
`specifications.synonyms` ("slugger cutter", "turbo tough",
etc.) would never match in the search box. The n8n workflow's
Code node now flattens every searchable bit — including the
synonyms array — into search_text on every POST. The 46 pre-
existing rows were backfilled once with the same formula.
The `price_list.supplier` field also stored the PB id on the
46 pre-existing rows (n8n was setting the resolved name, but
the original LLM tests that seeded the table didn't). Fixed
that in the same backfill — supplier now reads 'JEISA -
WESTSCOTT GROUP' instead of 'q6hpwbxsicyoiuu', which makes
the supplier filter dropdown work AND makes
'supplier ~ "jeisa"' match.
Migration `2026-06-30-add-price-list-search-text`:
- Adds `search_text` (text) column
- Adds `idx_pl_search_text` non-unique index
- Idempotent — re-runs are safe and self-heal the index
Updates:
- `getPriceLists` helper ORs across all 6 columns + search_text
- `PriceList` TS type gains `search_text: string`
- `pricelist-extraction.md` Code node snippet shows how the
n8n workflow should populate `search_text` on every POST
- Search input placeholder reads 'Search anything — SKU,
description, brand, supplier, synonyms…'
Tests: 229/230 (same pre-existing failure, unrelated).
TypeScript clean.
2026-06-30 10:10:27 +00:00
.map(it => {
2026-06-30 19:20:53 +00:00
// search_text: denormalised "search everything" blob. Includes
// every text column on the row, the full specifications JSON
// (keys + values, including synonyms and any ad-hoc attributes
// the LLM invented), the four number columns tokenised, and the
// resolved supplier name. PB can't substring-match JSON, so the
// Catalogue tab's search box ORs against this column directly.
const searchText = buildSearchText(it, supplier);
feat(pricelist): Catalogue search hits every field (incl. synonyms)
The Catalogue tab's search box used to OR across just sku,
description, and brand. Now it hits:
- sku, description, brand, supplier, category, subcategory
(PB's case-insensitive `~` operator, each indexed)
- search_text (a denormalised text blob)
The denormalised column is the key part: PB can't substring-
match a JSON field, so without this, synonyms from
`specifications.synonyms` ("slugger cutter", "turbo tough",
etc.) would never match in the search box. The n8n workflow's
Code node now flattens every searchable bit — including the
synonyms array — into search_text on every POST. The 46 pre-
existing rows were backfilled once with the same formula.
The `price_list.supplier` field also stored the PB id on the
46 pre-existing rows (n8n was setting the resolved name, but
the original LLM tests that seeded the table didn't). Fixed
that in the same backfill — supplier now reads 'JEISA -
WESTSCOTT GROUP' instead of 'q6hpwbxsicyoiuu', which makes
the supplier filter dropdown work AND makes
'supplier ~ "jeisa"' match.
Migration `2026-06-30-add-price-list-search-text`:
- Adds `search_text` (text) column
- Adds `idx_pl_search_text` non-unique index
- Idempotent — re-runs are safe and self-heal the index
Updates:
- `getPriceLists` helper ORs across all 6 columns + search_text
- `PriceList` TS type gains `search_text: string`
- `pricelist-extraction.md` Code node snippet shows how the
n8n workflow should populate `search_text` on every POST
- Search input placeholder reads 'Search anything — SKU,
description, brand, supplier, synonyms…'
Tests: 229/230 (same pre-existing failure, unrelated).
TypeScript clean.
2026-06-30 10:10:27 +00:00
return {
sku: it.sku,
description: it.description,
category: it.category,
subcategory: it.subcategory || null,
brand: it.brand || null,
supplier, // denormalized from raw
cost_price: it.cost_price,
markup: DEFAULT_MARKUP,
selling_price: Math.round(it.cost_price * (1 + DEFAULT_MARKUP / 100) * 100) / 100,
unit: it.unit || 'each',
stock_qty: 0,
specifications: it.specifications,
source_file: rawId, // trace-back + verify key
2026-06-30 19:20:53 +00:00
search_text: searchText, // search index
feat(pricelist): Catalogue search hits every field (incl. synonyms)
The Catalogue tab's search box used to OR across just sku,
description, and brand. Now it hits:
- sku, description, brand, supplier, category, subcategory
(PB's case-insensitive `~` operator, each indexed)
- search_text (a denormalised text blob)
The denormalised column is the key part: PB can't substring-
match a JSON field, so without this, synonyms from
`specifications.synonyms` ("slugger cutter", "turbo tough",
etc.) would never match in the search box. The n8n workflow's
Code node now flattens every searchable bit — including the
synonyms array — into search_text on every POST. The 46 pre-
existing rows were backfilled once with the same formula.
The `price_list.supplier` field also stored the PB id on the
46 pre-existing rows (n8n was setting the resolved name, but
the original LLM tests that seeded the table didn't). Fixed
that in the same backfill — supplier now reads 'JEISA -
WESTSCOTT GROUP' instead of 'q6hpwbxsicyoiuu', which makes
the supplier filter dropdown work AND makes
'supplier ~ "jeisa"' match.
Migration `2026-06-30-add-price-list-search-text`:
- Adds `search_text` (text) column
- Adds `idx_pl_search_text` non-unique index
- Idempotent — re-runs are safe and self-heal the index
Updates:
- `getPriceLists` helper ORs across all 6 columns + search_text
- `PriceList` TS type gains `search_text: string`
- `pricelist-extraction.md` Code node snippet shows how the
n8n workflow should populate `search_text` on every POST
- Search input placeholder reads 'Search anything — SKU,
description, brand, supplier, synonyms…'
Tests: 229/230 (same pre-existing failure, unrelated).
TypeScript clean.
2026-06-30 10:10:27 +00:00
};
});
2026-06-30 09:09:27 +00:00
return rows.map(r => ({ json: r }));
```
Returns N items, one per `price_list` row. SplitInBatches loops the
HTTP POST over them. After the batch, the PATCH updates the
`raw_price_list` status.
### Error / confidence paths
- **LLM returns invalid JSON** → Code node catches, PATCH
`raw_price_list` with `{status: "Failed", log_summary: <error>}` .
- **`extraction_confidence: "low"` with empty `items` ** → PATCH
`raw_price_list` with `{status: "Failed", log_summary: "AI could
not read PDF — re-upload or fix manually"}`. Operator sees red
badge in the Pricelist UI.
- **Token limit hit on a long PDF** → for >25-page lists, split the
PDF in half via a Code node (using `pdf-lib` if available, or just
two `extractFromFile` calls) and merge the `items` arrays in the
Code node before POSTing.
## What the agent does at quote time
When the operator types a quote line, the agent:
```js
// Pseudocode for the agent's lookup step
const match = await pb.collection('price_list').getList(1, 5, {
filter: `category = "hss_cutter"
& & supplier = "JEISA - WESTSCOTT GROUP"
& & specifications.diameter_mm = 12`,
sort: '-updated',
});
// OR for fuzzy:
const fuzzy = await pb.collection('price_list').getList(1, 5, {
filter: `specifications.synonyms ~ "slugger"` ,
});
```
The agent returns the matches to the LLM with full context
(price, brand, dimensions, supplier SKU), and the LLM picks the
best fit or asks the operator to disambiguate.