diff --git a/.agents/skills/wfdash-giellie/SKILL.md b/.agents/skills/wfdash-giellie/SKILL.md index 127166a..9c2ced3 100644 --- a/.agents/skills/wfdash-giellie/SKILL.md +++ b/.agents/skills/wfdash-giellie/SKILL.md @@ -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 diff --git a/docs/api-mcp-layer.md b/docs/api-mcp-layer.md index 1ba5e65..48374f8 100644 --- a/docs/api-mcp-layer.md +++ b/docs/api-mcp-layer.md @@ -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` \ No newline at end of file diff --git a/docs/audit/api-mcp-coverage.md b/docs/audit/api-mcp-coverage.md index 87761ea..51ee25c 100644 --- a/docs/audit/api-mcp-coverage.md +++ b/docs/audit/api-mcp-coverage.md @@ -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 diff --git a/docs/quote-assistant.md b/docs/quote-assistant.md index 69dc36f..1bfff2e 100644 --- a/docs/quote-assistant.md +++ b/docs/quote-assistant.md @@ -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 )" 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