diff --git a/AGENTS.md b/AGENTS.md index a4f93fe..af573e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/docs/quote-assistant.md b/docs/quote-assistant.md index 1bfff2e..cf549e3 100644 --- a/docs/quote-assistant.md +++ b/docs/quote-assistant.md @@ -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 |