WFDASH-WEBAPP/docs/quote-assistant.md
Dave 33813b4264 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>
2026-10-24 23:09:53 +02:00

9.3 KiB

AI quote assistant runbook

Design: docs/superpowers/specs/2026-10-16-ai-quote-assistant-design.md.

What it does

On a schedule, the assistant looks at quote lines nobody has priced yet, shortlists matching items from the POS catalogue (stock_items), asks the 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 doubts to check.

  • Lines it could not match stay unpriced and get a muted "AI: no match" badge listing the closest POS items.

  • The Quotes board shows "AI x/y" (unreviewed AI fills / total lines).

  • The quote and Process pages have a "Let AI fill this quote" button for one quote now. MCP tool: quotes.assistant_fill. On the Process page, lines you have typed in but not saved are left as you typed them.

Only quotes with status new, follow_up or claimed are filled, never templates. Up to 15 lines are sent per quote per run (one AI call); the rest follow on the next run.

It never sets is_processed, never changes quote status, never sends email, and never overwrites a line that already has a price, was edited by a person, or is processed. Prices come only from the POS item. A supplier already on the line is kept; the POS item's supplier is only written when the line has none.

Going live (manual steps, in this order)

  1. Migrate PocketBase. Run against the live PB: npm run pb:migrate:quote-assistant (adds quote_items.ai_fill and the quote_assistant_* fields on ai_settings). If the app is deployed before this, the Quote assistant settings don't persist (the switch, interval, confidence and prompt come back as defaults after Save), so the assistant stays off.
  2. Deploy the app. Pushing to main does not auto-deploy; it is a manual deploy that takes 5 to 10 minutes.
  3. Activate the n8n clock (below).
  4. Turn it on in Settings, AI: the global "AI enabled" switch must be on, then switch on "Quote assistant enabled" in the Quote assistant section and click Save. Defaults: every 10 minutes, minimum confidence Medium, prompt empty (= built-in default).

The tick does nothing until both switches are on, so steps 3 and 4 are safe in either order.

n8n workflow: "WFDASH Quote assistant tick"

n8n only acts as a clock. The app decides whether a run is due (AI and the assistant on, and at least "Run every" minutes since the last run), so the setting in the app controls the real frequency.

Spec:

Node Setting
Name WFDASH Quote assistant tick
Trigger Schedule Trigger, every 5 minutes
Action HTTP Request
Method / URL POST https://wfdash.apphub.co.za/api/v1/quotes/assistant/tick
Authentication Generic Credential Type, Header Auth, the same credential the existing quotes process-pending workflow uses (an Authorization: Bearer <AGENT_API_KEY> header). Do not create a new secret.
Body none
Settings tab Retry On Fail: off; Timeout: 120000 ms (120 s)
Status inactive until the app is deployed and migrated

Click-by-click:

  1. n8n, Workflows, Create workflow. Name it WFDASH Quote assistant tick.
  2. Add node, Schedule Trigger. Trigger Interval: Minutes, Minutes Between Triggers: 5.
  3. Add node, HTTP Request, connect it after the trigger.
    • Method: POST.
    • URL: https://wfdash.apphub.co.za/api/v1/quotes/assistant/tick.
    • Authentication: Generic Credential Type, Generic Auth Type: Header Auth. Pick the credential used by the quotes process-pending workflow (open that workflow and read its HTTP Request node to see which one).
    • Options, Add option, Timeout: 120000.
    • Settings tab on the node: turn Retry On Fail off.
  4. Save. Leave the workflow inactive until steps 1 and 2 above are done.
  5. After deploy, click Execute workflow once. Expected response while the assistant is off: {"ran":false,"reason":"disabled"}. When on and not yet due: {"ran":false,"reason":"not_due"}. While an earlier run is still going: {"ran":false,"reason":"running"}. When it ran: {"ran":true,"summary":{...}}.
  6. Toggle the workflow Active.

A 401 means the credential is wrong (it must send the app's AGENT_API_KEY as a Bearer token). A tick that takes long is normal when there is work. Each run is bounded: at most 20 quotes and 60 lines, newest first, 15 lines per quote (one AI call per quote). It also stops starting new quotes once 90 seconds have passed, so a run normally answers inside the 120 s timeout; leftovers carry to the next run.

Runs never overlap. A run stamps its start time before it does any work and writes its summary when it ends. While a start is newer than the last summary and less than 10 minutes old, other ticks answer {"ran":false,"reason":"running"}. If a run dies without writing its summary, the next tick after those 10 minutes takes over.

Reading ai_fill

quote_items.ai_fill (JSON) is the record of what the AI did to a line. Read it in the PB admin, or through the MCP quote_items.get.

Field Meaning
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
accepted_unchanged On review: cost and selling price still equal what the AI wrote
supplier_changed On review: the saved supplier differs from the POS item's supplier (the rep changed it, or the line already had another supplier)

A line is pending review (purple) while ai_fill.status = "filled", no reviewed_at, and pricing_source = "ai_pos". The rep's save sets reviewed_at; the line keeps pricing_source = "ai_pos" and a grey logo. Every AI write is in the audit log under the actor "AI quote assistant", or "AI quote assistant (run by )" for the button (and "(run by MCP)" for the MCP tool). Those writes are never credited to the person who clicked, even though the click carries their login.

Clone and Save as template copy an unreviewed AI line without its cost, selling price, markup or supplier, so the copy can't carry an unchecked price.

The last run is shown in Settings, AI, Quote assistant, and stored in ai_settings.quote_assistant_last_run_summary (quotes seen, lines filled, no match, skipped, errors).

Judging accuracy

Among reviewed AI fills (ai_fill.status = "filled" and reviewed_at set), the share with accepted_unchanged = true is the headline number: how often a rep saved the AI's price as it was. Also watch supplier_changed (supplier was wrong even when the price was right).

To tally: fetch quote_items where pricing_source = "ai_pos" (for example with quote_items.list or a short script), keep the ones whose ai_fill.reviewed_at is set, and compare accepted_unchanged true versus false.

Rough guide: the default minimum confidence is Medium. If reps often change AI prices, raise it to High (only the AI's surest matches are written) or edit the prompt (Settings, AI, Quote assistant prompt; "Reset to default" restores the built-in one). If most reviewed fills are unchanged at High, Medium is worth trying again.

Stopping it

Switch off "Quote assistant enabled" in Settings, AI and Save, or deactivate the n8n workflow. Existing purple lines stay until reviewed.