docs: the size-aware POS matcher and the exact/closest rule

The runbook and AGENTS.md described the old word-only shortlist and
did not mention the shared matcher. Both now say how the size is used,
that a price is written only for an exact match, and what
ai_fill.closest holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-24 23:09:53 +02:00
parent 2213bd36dd
commit 33813b4264
2 changed files with 22 additions and 1 deletions

View file

@ -189,7 +189,14 @@ Runbook: `docs/quote-assistant.md`. Files: `src/lib/server/quote-assistant-{core
(client-safe review/save helpers). Routes: `POST /api/v1/quotes/assistant/tick` (n8n, `assertAgentAuth`),
`POST /api/v1/quotes/{id}/assistant/run` (browser), MCP `quotes.assistant_fill`. Settings live in Settings → AI.
The POS matcher is **shared** and **size-aware**: `src/lib/server/pos-match.ts`
(the candidate pool, the size-aware ranking and `pickPosItems`) and
`src/lib/item-size.ts` (the size parser) are used by the quote assistant,
the order fill and the blue price check. `quote-assistant-shortlist.ts`
delegates to it.
- A line is **pending** (purple) only when `ai_fill.status === "filled" && !ai_fill.reviewed_at && pricing_source === "ai_pos"` (`isAiFillPending`).
- A price is written **only for an exact match**. A closest match writes no price and records `ai_fill.closest` (the nearest item + what differs); the badge then reads "Closest: …". The AI answers **exact**, **closest** or **none** per line.
- AI writes **never set `is_processed`**, never change quote status, never send, and never touch a line with a price, `user_edited` or `is_processed`.
- `pricing_source "ai_pos"` must survive human saves: route every save through `nextPricingSourceOnSave` / `withAiReview`, or a stale page turns the line back to `manual`.
- Manual live steps (like select values and pb_hooks): run `npm run pb:migrate:quote-assistant` against the live PB before deploying, then activate the n8n workflow "WFDASH Quote assistant tick" (every 5 min, POSTs the tick route). See the runbook.

View file

@ -10,6 +10,19 @@ AI (the provider set in Settings, AI) to pick the right one, and writes a
**draft** cost, selling price and supplier onto the line. A rep checks the
draft and saves as usual.
The shortlist is **size-aware**. The size is parsed out of the line
(`src/lib/item-size.ts`) and the candidates are ranked by it, so
"BOLT HEX M16X60 GR8.8 ZP" is offered the M16x60 bolt and not the M22x65
that merely shares the words "BOLT 8.8 ZP". The AI then answers **exact**,
**closest** or **none** per line:
- **exact** — the same product and every size attribute the line states
matches. The price is written.
- **closest** — the nearest item, but something differs. **No price is
written**; the item and what differs are recorded in `ai_fill.closest`
and the badge reads "Closest: …".
- **none** — nothing in the catalogue is close enough. No price.
- Filled lines show purple with an agent logo on the quote and Process
pages. Clicking the logo shows the POS item, price source, reason and
doubts. A small warning-coloured dot on the logo means the AI noted
@ -111,13 +124,14 @@ Read it in the PB admin, or through the MCP `quote_items.get`.
| Field | Meaning |
|---|---|
| `status` | `filled`, `no_match`, `no_candidates` (nothing in the POS shortlist, no AI call made) or `error` (the shortlist or AI call failed, the AI answer was cut off, or it skipped the line; retried after 1 hour or by the manual button) |
| `status` | `filled` (an **exact** match — the only status that writes a price), `no_match` (no exact match; `closest` may say what the nearest item was), `no_candidates` (nothing in the POS shortlist, no AI call made) or `error` (the shortlist or AI call failed, the AI answer was cut off, or it skipped the line; retried after 1 hour or by the manual button) |
| `stock_item_id`, `stock_item_name`, `stock_item_sku` | The POS item picked. The POS SKU lives here only; it is never copied onto the line's own `sku`. |
| `cost_price`, `selling_price`, `markup`, `supplier_id` | What was written to the line, taken from the POS item |
| `price_from_default_markup` | Selling price came from the default markup because the POS item had a cost but no selling price |
| `confidence` | `high`, `medium` or `low` |
| `reason`, `doubts` | The AI's plain-language reasoning and any mismatches to check (grade, size, pack size, etc.) |
| `candidates` | Closest POS items looked at (for unmatched lines) |
| `closest` | The nearest POS item when the AI could only find a close one — `stock_item_id`, `name`, `sku`, `cost_price`, `selling_price` and `differs`. The line keeps **no** price. |
| `description_snapshot` | The line description when tried. An unmatched line is only retried if the description changes. |
| `model`, `attempted_at` | Which model, when |
| `reviewed_at`, `reviewed_by` | Set when a rep saves the line |