From 144971779fe5c11bb33d5175cd141d0e56435ffb Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 16 Oct 2026 20:09:38 +0200 Subject: [PATCH] feat(quote-assistant): settings section, n8n tick, runbook Quote assistant card in Settings, AI (on/off, interval, min confidence, editable prompt with reset, last-run line); parseSettingsForm handles the qa_* fields. docs/quote-assistant.md has the runbook and the n8n tick workflow spec; AGENTS.md gets a short section. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 12 ++ docs/quote-assistant.md | 140 +++++++++++++++ .../(app)/settings/ai/ai-settings-form.tsx | 163 ++++++++++++++++-- src/app/(app)/settings/ai/page.tsx | 5 + .../settings/ai/settings-form-helpers.test.ts | 99 +++++++++++ .../settings/ai/settings-form-helpers.ts | 30 ++++ 6 files changed, 439 insertions(+), 10 deletions(-) create mode 100644 docs/quote-assistant.md create mode 100644 src/app/(app)/settings/ai/settings-form-helpers.test.ts diff --git a/AGENTS.md b/AGENTS.md index 10c1067..7a2d7f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -171,3 +171,15 @@ in-process — a new MCP tool is automatically available to Giellie. stricter than the original spec: users may list/view their own chats only; create/update/delete are null (all writes go through the admin client in `giellie-chat-store.ts`). + +# AI quote assistant — fills unpriced quote lines from the POS + +Runbook: `docs/quote-assistant.md`. Files: `src/lib/server/quote-assistant-{core,shortlist,ai,prompt,service}.ts` +(logic, POS shortlist, MiniMax call, default prompt, orchestration) and `src/lib/quote-assistant-{review,save}.ts` +(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. + +- A line is **pending** (purple) only when `ai_fill.status === "filled" && !ai_fill.reviewed_at && pricing_source === "ai_pos"` (`isAiFillPending`). +- 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 new file mode 100644 index 0000000..acea8b2 --- /dev/null +++ b/docs/quote-assistant.md @@ -0,0 +1,140 @@ +# 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. + +- 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. +- 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`. + +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. + +## 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. +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 ` 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"}`. 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, 30 lines per quote), and leftovers carry to the next run. + +## 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`, `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) | +| `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) | +| `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 rep changed the 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". + +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: 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). + +## Stopping it + +Switch off "Quote assistant enabled" in Settings, AI and Save, or +deactivate the n8n workflow. Existing purple lines stay until reviewed. diff --git a/src/app/(app)/settings/ai/ai-settings-form.tsx b/src/app/(app)/settings/ai/ai-settings-form.tsx index 5652bd9..eb66f0b 100644 --- a/src/app/(app)/settings/ai/ai-settings-form.tsx +++ b/src/app/(app)/settings/ai/ai-settings-form.tsx @@ -18,13 +18,16 @@ import { } from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; import { toast } from "sonner"; -import { Brain, Save, Wifi, RotateCw, AlertTriangle, RotateCcw, Eye } from "lucide-react"; +import { Brain, Save, Wifi, RotateCw, AlertTriangle, RotateCcw, Eye, Sparkles } from "lucide-react"; import { saveAiSettingsAction, testAiConnectionAction, type ActionResult, } from "./actions"; import { DEFAULT_QUOTES_AI_SYSTEM_PROMPT } from "@/lib/server/ai-system-prompt"; +// Plain string export, no server-only imports, so it is safe in a client component. +import { DEFAULT_QUOTE_ASSISTANT_PROMPT } from "@/lib/server/quote-assistant-prompt"; +import type { QuoteAssistantRunSummary } from "@/lib/types"; export interface AiSettingsFormInitial { provider: string; @@ -40,6 +43,13 @@ export interface AiSettingsFormInitial { * prefills with this value so the operator can inspect + tweak. */ system_prompt: string; + /** Quote assistant (AI price drafting on the Quotes board). */ + quote_assistant_enabled: boolean; + quote_assistant_interval_minutes: number; + quote_assistant_min_confidence: "high" | "medium"; + /** Empty string = use the built-in default prompt. */ + quote_assistant_prompt: string; + quote_assistant_last_run_summary: QuoteAssistantRunSummary | null; } interface Props { @@ -82,6 +92,11 @@ export function AiSettingsForm({ initial }: Props) { // can see the default by clicking "Reset to default"). const [systemPrompt, setSystemPrompt] = useState(initial.system_prompt); const [showDefaultPreview, setShowDefaultPreview] = useState(false); + const [qaEnabled, setQaEnabled] = useState(initial.quote_assistant_enabled); + const [qaInterval, setQaInterval] = useState(String(initial.quote_assistant_interval_minutes)); + const [qaConfidence, setQaConfidence] = useState(initial.quote_assistant_min_confidence); + const [qaPrompt, setQaPrompt] = useState(initial.quote_assistant_prompt); + const [showQaDefault, setShowQaDefault] = useState(false); const [saveState, saveAction] = useActionState( saveAiSettingsAction, null, @@ -132,6 +147,7 @@ export function AiSettingsForm({ initial }: Props) { }); return ( +
@@ -147,12 +163,7 @@ export function AiSettingsForm({ initial }: Props) { Anthropic-compatible MiniMax endpoint. - - + {/* Provider */}
@@ -317,7 +328,133 @@ export function AiSettingsForm({ initial }: Props) { onCheckedChange={setEnabled} />
+
+ + + +
+ + Quote assistant +
+ + Drafts costs and selling prices for unpriced quote lines from our own + POS catalogue. Drafts show up purple on the quote and process pages + for a person to review; it never sends or finalises anything. + +
+ +
+
+ +

+ Needs the AI switch above to be on too. n8n pings every 5 minutes; + this setting decides how often it actually runs. +

+
+ +
+ +
+
+ + setQaInterval(e.target.value)} + /> +

Between 5 and 1440.

+
+ +
+ + + +

+ Matches below this are left for a person to price. +

+
+
+ +
+
+ +
+ + +
+
+