docs(quote-assistant): runbook accuracy, MCP tool listed in API docs

- Runbook: pre-migration settings don't persist (not "saves fail"),
  eligible statuses, 15 lines per quote, audit attribution, clone/template
  behaviour, supplier kept, doubts dot, confidence guidance (Medium is the
  default; raise to High if reps often change AI prices).
- quotes.assistant_fill added to docs/api-mcp-layer.md,
  docs/audit/api-mcp-coverage.md and the Giellie skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-16 23:42:44 +02:00
parent 6604e8b837
commit 3091ac94de
4 changed files with 37 additions and 18 deletions

View file

@ -219,6 +219,7 @@ The `letterhead` body field accepts only `"CNL"` or `"ISHERWOOD"`. Anything else
| `quotes.clone` | Clone a quote + items. |
| `quotes.save_as_template`, `quotes.create_from_template` | Template management. |
| `quotes.split`, `quotes.partial_approve`, `quotes.re_price`, `quotes.next_number`, `quotes.lookup` | Pricing workflow. **Note:** `quotes.lookup` is currently broken — see Known bugs §4. |
| `quotes.assistant_fill` | Run the AI quote assistant on one quote (`quote_id`): fills unpriced lines from POS items as purple drafts a rep must review; unmatched lines get the closest POS items noted. Only status new / follow_up / claimed, never templates. Problems come back in `error`. Never marks lines done or sends anything. |
| `quotes.items.rewrite` | **Not an MCP tool** — it's a REST endpoint at `/api/v1/quotes/items/rewrite` (browser-facing, needs PB session). |
| `quote_items.populate_insights` | **Deterministic** compute-and-write of `quote_items.ai_item_insights`. Replaces the n8n AI rerun for the per-item popover. SKU-exact → SKU-normalised → description-fuzzy (Jaccard ≥ 0.6). See "Quote-item insights backfill" below. |
@ -355,7 +356,7 @@ REST mirror at `/api/v1/quote_items` (GET list + POST create) and `/api/v1/quote
**Why this exists:** the only pre-existing path for adding to the catalogue was operator uploads via `/pricelist` → n8n ingest → `raw_price_list` → AI extraction → `price_list`. That flow is async, human-in-the-loop, and gates on n8n being healthy. This tool gives you a synchronous, agent-driven shortcut for ad-hoc updates — paste a PDF, get the catalogue updated, no async hops.
### Full tool index (75)
### Full tool index (77)
```
orders.list, orders.get, orders.create_from_quote
@ -366,7 +367,7 @@ quotes.create_from_email, quotes.append_reply, quotes.find_by_message_id,
quotes.list, quotes.get, quotes.send_email, quotes.retry_email,
quotes.clone, quotes.save_as_template, quotes.create_from_template,
quotes.split, quotes.partial_approve, quotes.re_price, quotes.next_number,
quotes.lookup
quotes.lookup, quotes.assistant_fill
quote_items.list, quote_items.get, quote_items.create, quote_items.update,
quote_items.populate_insights
attachments.upload_for_quote

View file

@ -558,6 +558,7 @@ Grouped by resource family. The full description is in the table column; the des
| `quotes.partial_approve` | Mark a subset of quote items as `approved` (or `rejected`) on a quote. Body: `{ quote_id, decisions: [{ item_id, decision }] }`. Useful when the customer accepts some lines but not others. |
| `quotes.submit` | Flip a `new` quote to `approval_pending` once every line is priced. Idempotent — re-submitting an already-`approval_pending` or `partial_approval_pending` quote is a no-op (returns `statusChanged: false`). Gated: every line on the quote ... |
| `quotes.re_price` | Re-run pricing on every quote_item under a quote. Whitelist of patchable fields is the same as `bulk_update_quote_items`. Returns the per-row result list. |
| `quotes.assistant_fill` | Run the AI quote assistant on one quote now (`{ quote_id }`): fills unpriced lines from matching POS items as purple, unreviewed drafts and notes the closest items on unmatched lines. Only quotes with status new / follow_up / claimed, never templates; at most 15 lines per run. Problems come back in `error` (not thrown). Never marks lines done, changes the quote status or sends anything. Wraps `fillQuote` (same as `POST /api/v1/quotes/[id]/assistant/run`). |
| `quotes.next_number` | Reserve the next quote number (`Q{yy}{mm}{NNN}`). Atomic counter; gaps are possible if the caller never persists the resulting draft. |
| `quotes.lookup` | Free-text lookup over quotes for the order page's quote-picker dropdown. Substring match across quote_number, quote_reference, rfq_number, customer name, company name. Excludes templates by default. |
| `quotes.list_attachments` | List all attachments on a quote (the generated PDF, original RFQ email attachments, operator-uploaded proofs, outbound attachments). Newest first. Returns up to 50 per page. |
@ -746,6 +747,6 @@ Grouped by resource family. The full description is in the table column; the des
### Alphabetical index
`ai_insights.rerun`, `ai_settings.get`, `ai_settings.update`, `attachments.upload_for_quote`, `audit_log.get`, `bulk_update_quote_items`, `buyer_tasks.generate_po`, `buyer_tasks.list`, `buyer_tasks.mark_ready`, `collection_confirm.confirm_order_items`, `collection_confirm.list`, `collection_confirm.upload_invoice`, `collection_pool.list`, `companies.create`, `companies.delete`, `companies.get`, `companies.list`, `companies.update`, `customers.create`, `customers.delete`, `customers.get`, `customers.list`, `customers.update`, `dashboard.daily_summary`, `delivery_notes.cancel`, `delivery_notes.combine`, `delivery_notes.create`, `delivery_notes.create_quote`, `delivery_notes.list`, `delivery_notes.mark_complete`, `delivery_notes.mark_invoiced`, `delivery_notes.regenerate_pdf`, `delivery_notes.set_customer_po`, `delivery_notes.set_item_quantity`, `delivery_notes.set_order`, `drivers.list_stops`, `drivers.update_stop_status`, `files.fetch`, `order_items.confirm_collected`, `order_items.create`, `order_items.get`, `order_items.list`, `order_items.update`, `order_items.update_status`, `orders.add_attachment`, `orders.add_note`, `orders.archive`, `orders.cancel`, `orders.create`, `orders.create_from_quote`, `orders.daily_summary`, `orders.find_by_email_message_id`, `orders.find_by_order_number`, `orders.find_duplicates`, `orders.get`, `orders.list`, `orders.list_attachments`, `orders.mark_delivered`, `orders.mark_invoiced`, `orders.matched_attachments`, `orders.quote_match_sweep`, `orders.review`, `orders.review_save`, `orders.set_quote`, `orders.split`, `orders.sprint_counts`, `orders.stats`, `orders.update`, `price_list.add_items`, `purchase_orders.add_attachment`, `purchase_orders.diff`, `purchase_orders.get`, `purchase_orders.list`, `purchase_orders.record_diff_report`, `purchase_orders.record_send_outcome`, `purchase_orders.retry_email`, `purchase_orders.scan`, `purchase_orders.send_email`, `purchase_orders.stale`, `quote_items.create`, `quote_items.get`, `quote_items.list`, `quote_items.populate_insights`, `quote_items.update`, `quotes.append_reply`, `quotes.clone`, `quotes.create`, `quotes.create_from_email`, `quotes.create_from_template`, `quotes.find_by_message_id`, `quotes.get`, `quotes.get_pdf`, `quotes.ingest_from_email`, `quotes.list`, `quotes.list_attachments`, `quotes.lookup`, `quotes.next_number`, `quotes.partial_approve`, `quotes.process_pending`, `quotes.re_price`, `quotes.re_run_ai`, `quotes.retry_email`, `quotes.save_as_template`, `quotes.send_email`, `quotes.split`, `quotes.submit`, `quotes.update`, `sprint.attachment_counts`, `sprint.claim`, `sprint.item_attachments`, `sprint.list`, `stock.fulfill`, `stock.surplus_book`, `stock_items.adjust`, `stock_items.create`, `stock_items.get`, `stock_items.list`, `stock_items.set_pricing`, `stock_items.update`, `stock_purchase_items.assign_driver`, `stock_purchase_items.bulk_update`, `stock_purchase_items.list`, `stock_purchase_items.mark_collected`, `stock_purchase_items.update`, `stock_purchases.add_attachment`, `stock_purchases.create`, `stock_purchases.get`, `stock_purchases.list`, `stock_purchases.match_and_receive_from_invoice`, `stock_purchases.next_number`, `stock_purchases.receive`, `stock_purchases.retry_email`, `stock_purchases.send`, `stock_purchases.send_email`, `stock_quick_draft.add_line`, `stock_quick_draft.search`, `stock_quick_draft.suppliers`, `suppliers.create`, `suppliers.delete`, `suppliers.get`, `suppliers.list`, `suppliers.update`, `tools.discovery`, `users.lookup`
`ai_insights.rerun`, `ai_settings.get`, `ai_settings.update`, `attachments.upload_for_quote`, `audit_log.get`, `bulk_update_quote_items`, `buyer_tasks.generate_po`, `buyer_tasks.list`, `buyer_tasks.mark_ready`, `collection_confirm.confirm_order_items`, `collection_confirm.list`, `collection_confirm.upload_invoice`, `collection_pool.list`, `companies.create`, `companies.delete`, `companies.get`, `companies.list`, `companies.update`, `customers.create`, `customers.delete`, `customers.get`, `customers.list`, `customers.update`, `dashboard.daily_summary`, `delivery_notes.cancel`, `delivery_notes.combine`, `delivery_notes.create`, `delivery_notes.create_quote`, `delivery_notes.list`, `delivery_notes.mark_complete`, `delivery_notes.mark_invoiced`, `delivery_notes.regenerate_pdf`, `delivery_notes.set_customer_po`, `delivery_notes.set_item_quantity`, `delivery_notes.set_order`, `drivers.list_stops`, `drivers.update_stop_status`, `files.fetch`, `order_items.confirm_collected`, `order_items.create`, `order_items.get`, `order_items.list`, `order_items.update`, `order_items.update_status`, `orders.add_attachment`, `orders.add_note`, `orders.archive`, `orders.cancel`, `orders.create`, `orders.create_from_quote`, `orders.daily_summary`, `orders.find_by_email_message_id`, `orders.find_by_order_number`, `orders.find_duplicates`, `orders.get`, `orders.list`, `orders.list_attachments`, `orders.mark_delivered`, `orders.mark_invoiced`, `orders.matched_attachments`, `orders.quote_match_sweep`, `orders.review`, `orders.review_save`, `orders.set_quote`, `orders.split`, `orders.sprint_counts`, `orders.stats`, `orders.update`, `price_list.add_items`, `purchase_orders.add_attachment`, `purchase_orders.diff`, `purchase_orders.get`, `purchase_orders.list`, `purchase_orders.record_diff_report`, `purchase_orders.record_send_outcome`, `purchase_orders.retry_email`, `purchase_orders.scan`, `purchase_orders.send_email`, `purchase_orders.stale`, `quote_items.create`, `quote_items.get`, `quote_items.list`, `quote_items.populate_insights`, `quote_items.update`, `quotes.append_reply`, `quotes.assistant_fill`, `quotes.clone`, `quotes.create`, `quotes.create_from_email`, `quotes.create_from_template`, `quotes.find_by_message_id`, `quotes.get`, `quotes.get_pdf`, `quotes.ingest_from_email`, `quotes.list`, `quotes.list_attachments`, `quotes.lookup`, `quotes.next_number`, `quotes.partial_approve`, `quotes.process_pending`, `quotes.re_price`, `quotes.re_run_ai`, `quotes.retry_email`, `quotes.save_as_template`, `quotes.send_email`, `quotes.split`, `quotes.submit`, `quotes.update`, `sprint.attachment_counts`, `sprint.claim`, `sprint.item_attachments`, `sprint.list`, `stock.fulfill`, `stock.surplus_book`, `stock_items.adjust`, `stock_items.create`, `stock_items.get`, `stock_items.list`, `stock_items.set_pricing`, `stock_items.update`, `stock_purchase_items.assign_driver`, `stock_purchase_items.bulk_update`, `stock_purchase_items.list`, `stock_purchase_items.mark_collected`, `stock_purchase_items.update`, `stock_purchases.add_attachment`, `stock_purchases.create`, `stock_purchases.get`, `stock_purchases.list`, `stock_purchases.match_and_receive_from_invoice`, `stock_purchases.next_number`, `stock_purchases.receive`, `stock_purchases.retry_email`, `stock_purchases.send`, `stock_purchases.send_email`, `stock_quick_draft.add_line`, `stock_quick_draft.search`, `stock_quick_draft.suppliers`, `suppliers.create`, `suppliers.delete`, `suppliers.get`, `suppliers.list`, `suppliers.update`, `tools.discovery`, `users.lookup`
<!-- END mcp-doc-gen -->

View file

@ -59,6 +59,7 @@ Legend: ✅ shipped · 🟡 partial · ❌ missing
| Partial approve | detail page | `POST /quotes/[id]/partial-approve` | `quotes.partial_approve` | ✅ |
| Review-approve | `/quotes/approvals` | `POST /quotes/[id]/review-approve` | ❌ | MCP gap (see §1.1) |
| Re-price items | inline | `POST /quotes/[id]/re-price` | `quotes.re_price` | ✅ |
| AI fill from POS | quote / Process page button | `POST /quotes/[id]/assistant/run` | `quotes.assistant_fill` | ✅ |
| Split quote | inline | `POST /quotes/[id]/split` | `quotes.split` | ✅ |
| Send email | email modal | `POST /quotes/[id]/send-email` | `quotes.send_email` | ✅ |
| Retry email | email modal | `POST /quotes/[id]/retry-email` | `quotes.retry_email` | ✅ |
@ -71,7 +72,7 @@ Legend: ✅ shipped · 🟡 partial · ❌ missing
| Generate next quote # | form | `GET /quotes/next-number` | `quotes.next_number` | ✅ |
| Archive quotes | `/quotes/archive` | `POST /quotes/archive` | ❌ | MCP gap (see §1.1) |
Result: 27 capabilities, 25 fully covered. **2 MCP gaps** (review-approve, archive).
Result: 28 capabilities, 26 fully covered. **2 MCP gaps** (review-approve, archive).
### Quote items

View file

@ -12,24 +12,33 @@ draft and saves as usual.
- 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.
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 page has a "Let AI fill this quote" button for one quote now.
MCP tool: `quotes.assistant_fill`.
- 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 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`). Deploying before this makes saves of the AI settings
fail.
`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).
@ -102,7 +111,7 @@ 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 AI call failed; retried after 1 hour or by the manual button) |
| `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) |
| `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 |
@ -113,13 +122,20 @@ Read it in the PB admin, or through the MCP `quote_items.get`.
| `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 rep changed the supplier |
| `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".
audit log under the actor "AI quote assistant", or "AI quote assistant (run
by <name>)" 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,
@ -137,11 +153,11 @@ 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: if most reviewed fills are unchanged and the unchanged rate
stays steady for the high-confidence group, consider lowering the minimum
confidence to Medium or widening scope. If reps keep changing prices,
raise the minimum confidence to High or edit the prompt (Settings, AI,
Quote assistant prompt; "Reset to default" restores the built-in one).
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