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 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-16 20:09:38 +02:00
parent fa854060c4
commit 144971779f
6 changed files with 439 additions and 10 deletions

View file

@ -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.

140
docs/quote-assistant.md Normal file
View file

@ -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 <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"}`. 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.

View file

@ -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<string>(initial.quote_assistant_min_confidence);
const [qaPrompt, setQaPrompt] = useState(initial.quote_assistant_prompt);
const [showQaDefault, setShowQaDefault] = useState(false);
const [saveState, saveAction] = useActionState<ActionResult | null, FormData>(
saveAiSettingsAction,
null,
@ -132,6 +147,7 @@ export function AiSettingsForm({ initial }: Props) {
});
return (
<form ref={formRef} action={saveAction} className="space-y-6">
<Card>
<CardHeader>
<div className="flex items-center gap-2">
@ -147,12 +163,7 @@ export function AiSettingsForm({ initial }: Props) {
Anthropic-compatible MiniMax endpoint.
</CardDescription>
</CardHeader>
<CardContent>
<form
ref={formRef}
action={saveAction}
className="space-y-6"
>
<CardContent className="space-y-6">
{/* Provider */}
<div className="grid gap-2">
<Label htmlFor="provider">Provider</Label>
@ -317,7 +328,133 @@ export function AiSettingsForm({ initial }: Props) {
onCheckedChange={setEnabled}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
<CardTitle>Quote assistant</CardTitle>
</div>
<CardDescription>
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.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between gap-4 rounded-lg border border-border p-3">
<div>
<Label htmlFor="qa_enabled" className="text-base">
Quote assistant enabled
</Label>
<p className="text-xs text-muted-foreground">
Needs the AI switch above to be on too. n8n pings every 5 minutes;
this setting decides how often it actually runs.
</p>
</div>
<Switch
id="qa_enabled"
name="qa_enabled"
checked={qaEnabled}
onCheckedChange={setQaEnabled}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="qa_interval_minutes">Run every (minutes)</Label>
<Input
id="qa_interval_minutes"
name="qa_interval_minutes"
type="number"
min={5}
max={1440}
step={1}
value={qaInterval}
onChange={(e) => setQaInterval(e.target.value)}
/>
<p className="text-xs text-muted-foreground">Between 5 and 1440.</p>
</div>
<div className="grid gap-2">
<Label htmlFor="qa_min_confidence">Minimum confidence to fill</Label>
<Select
value={qaConfidence}
onValueChange={(v) => setQaConfidence(v ?? "medium")}
>
<SelectTrigger id="qa_min_confidence" className="w-full">
<SelectValue placeholder="Select confidence" />
</SelectTrigger>
<SelectContent>
<SelectItem value="high">High</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
</SelectContent>
</Select>
<input type="hidden" name="qa_min_confidence" value={qaConfidence} />
<p className="text-xs text-muted-foreground">
Matches below this are left for a person to price.
</p>
</div>
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between gap-3">
<Label htmlFor="qa_prompt">Quote assistant prompt</Label>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowQaDefault((s) => !s)}
title="Toggle the built-in default for comparison"
>
<Eye /> {showQaDefault ? "Hide" : "Show"} default
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setQaPrompt(DEFAULT_QUOTE_ASSISTANT_PROMPT);
toast.success("Loaded the default prompt. Click Save to apply.");
}}
title="Copy the built-in default into the textarea"
>
<RotateCcw /> Reset to default
</Button>
</div>
</div>
<Textarea
id="qa_prompt"
name="qa_prompt"
value={qaPrompt}
onChange={(e) => setQaPrompt(e.target.value)}
placeholder="Leave empty to use the built-in default."
rows={10}
className="font-mono text-xs"
spellCheck={false}
/>
<p className="text-xs text-muted-foreground">
Sent as <code className="rounded bg-muted px-1 py-0.5">system:</code> when
matching quote lines. Empty = use the built-in default. The rules assume the{" "}
<code className="rounded bg-muted px-1 py-0.5">submit_line_matches</code> tool.
</p>
{showQaDefault && (
<pre className="rounded-md border border-border bg-muted/40 p-3 text-xs font-mono whitespace-pre-wrap max-h-64 overflow-auto">
{DEFAULT_QUOTE_ASSISTANT_PROMPT}
</pre>
)}
</div>
<p className="text-xs text-muted-foreground">
Last run: {formatLastRun(initial.quote_assistant_last_run_summary)}
</p>
</CardContent>
</Card>
<div className="space-y-4">
{/* Save / Test */}
<div className="flex flex-wrap items-center gap-2">
<SaveButton />
@ -354,12 +491,18 @@ export function AiSettingsForm({ initial }: Props) {
Last saved: {new Date(initial.updated_at).toLocaleString()}
</p>
)}
</form>
</CardContent>
</Card>
</div>
</form>
);
}
function formatLastRun(summary: QuoteAssistantRunSummary | null): string {
if (!summary) return "Never";
const when = new Date(summary.finished_at || summary.started_at);
const time = Number.isNaN(when.getTime()) ? "Unknown time" : when.toLocaleString();
return `${time} · ${summary.quotes_seen} quotes · ${summary.lines_filled} filled · ${summary.lines_unmatched} no match`;
}
function SaveButton() {
const { pending } = useFormStatus();
return (

View file

@ -42,6 +42,11 @@ export default async function AiSettingsPage() {
api_key_set: settings.api_key_set,
updated_at: settings.updated_at,
system_prompt: settings.system_prompt,
quote_assistant_enabled: settings.quote_assistant_enabled,
quote_assistant_interval_minutes: settings.quote_assistant_interval_minutes,
quote_assistant_min_confidence: settings.quote_assistant_min_confidence,
quote_assistant_prompt: settings.quote_assistant_prompt,
quote_assistant_last_run_summary: settings.quote_assistant_last_run_summary,
}}
/>
</div>

View file

@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import { parseSettingsForm } from "./settings-form-helpers";
function form(entries: Record<string, string>): FormData {
const fd = new FormData();
for (const [k, v] of Object.entries(entries)) fd.set(k, v);
return fd;
}
describe("parseSettingsForm: existing fields", () => {
it("parses provider, model, max_tokens, enabled and system prompt as before", () => {
const { patch, errors } = parseSettingsForm(
form({
provider: "minimax",
base_url: " https://api.minimax.io/anthropic ",
model_id: "MiniMax-M3",
max_tokens: "4096.4",
enabled: "on",
system_prompt: " keep me ",
}),
);
expect(errors).toEqual([]);
expect(patch).toMatchObject({
provider: "minimax",
base_url: "https://api.minimax.io/anthropic",
model_id: "MiniMax-M3",
max_tokens: 4096,
enabled: true,
system_prompt: " keep me ",
});
});
it("rejects an unknown provider and an out-of-range max_tokens", () => {
const { errors } = parseSettingsForm(form({ provider: "openai", max_tokens: "10" }));
expect(errors).toEqual([
'Unknown provider "openai"',
"max_tokens must be between 256 and 8192",
]);
});
it("only sets api_key when given or explicitly cleared", () => {
expect(parseSettingsForm(form({})).patch.api_key).toBeUndefined();
expect(parseSettingsForm(form({ api_key: "sk-1" })).patch.api_key).toBe("sk-1");
expect(parseSettingsForm(form({ api_key_clear: "on" })).patch.api_key).toBe("");
});
});
describe("parseSettingsForm: quote assistant", () => {
it("maps qa_enabled=on to true and a missing value to false", () => {
expect(parseSettingsForm(form({ qa_enabled: "on" })).patch.quote_assistant_enabled).toBe(true);
expect(parseSettingsForm(form({})).patch.quote_assistant_enabled).toBe(false);
});
it("parses a valid interval", () => {
const { patch, errors } = parseSettingsForm(form({ qa_interval_minutes: "15" }));
expect(errors).toEqual([]);
expect(patch.quote_assistant_interval_minutes).toBe(15);
});
it("leaves the interval untouched when blank", () => {
const { patch, errors } = parseSettingsForm(form({ qa_interval_minutes: " " }));
expect(errors).toEqual([]);
expect(patch.quote_assistant_interval_minutes).toBeUndefined();
});
it.each(["4", "1441", "abc"])("rejects interval %s", (value) => {
const { patch, errors } = parseSettingsForm(form({ qa_interval_minutes: value }));
expect(errors).toContain("Quote assistant interval must be between 5 and 1440 minutes");
expect(patch.quote_assistant_interval_minutes).toBeUndefined();
});
it("accepts the interval bounds", () => {
expect(parseSettingsForm(form({ qa_interval_minutes: "5" })).errors).toEqual([]);
expect(parseSettingsForm(form({ qa_interval_minutes: "1440" })).errors).toEqual([]);
});
it("passes high and medium confidence through", () => {
expect(
parseSettingsForm(form({ qa_min_confidence: "high" })).patch.quote_assistant_min_confidence,
).toBe("high");
expect(
parseSettingsForm(form({ qa_min_confidence: "medium" })).patch.quote_assistant_min_confidence,
).toBe("medium");
});
it("rejects any other confidence", () => {
const { patch, errors } = parseSettingsForm(form({ qa_min_confidence: "low" }));
expect(errors).toContain('Quote assistant minimum confidence must be "high" or "medium"');
expect(patch.quote_assistant_min_confidence).toBeUndefined();
});
it("passes the prompt through untrimmed, and leaves it untouched when absent", () => {
expect(parseSettingsForm(form({ qa_prompt: " rules\n" })).patch.quote_assistant_prompt).toBe(
" rules\n",
);
expect(parseSettingsForm(form({ qa_prompt: "" })).patch.quote_assistant_prompt).toBe("");
expect(parseSettingsForm(form({})).patch.quote_assistant_prompt).toBeUndefined();
});
});

View file

@ -74,5 +74,35 @@ export function parseSettingsForm(formData: FormData): ParseResult {
patch.system_prompt = systemPromptRaw;
}
// ── Quote assistant section (same <form>, so a missing checkbox = off,
// exactly like `enabled` above). Numeric/select fields are only patched
// when present so a partial post never resets them.
patch.quote_assistant_enabled = formData.get("qa_enabled") === "on";
const qaIntervalRaw = String(formData.get("qa_interval_minutes") ?? "").trim();
if (qaIntervalRaw !== "") {
const n = Number(qaIntervalRaw);
if (!Number.isFinite(n) || n < 5 || n > 1440) {
errors.push("Quote assistant interval must be between 5 and 1440 minutes");
} else {
patch.quote_assistant_interval_minutes = Math.round(n);
}
}
const qaConfidenceRaw = formData.get("qa_min_confidence");
if (typeof qaConfidenceRaw === "string" && qaConfidenceRaw !== "") {
if (qaConfidenceRaw === "high" || qaConfidenceRaw === "medium") {
patch.quote_assistant_min_confidence = qaConfidenceRaw;
} else {
errors.push('Quote assistant minimum confidence must be "high" or "medium"');
}
}
// Plain text, untrimmed. Empty means "use the built-in default".
const qaPromptRaw = formData.get("qa_prompt");
if (typeof qaPromptRaw === "string") {
patch.quote_assistant_prompt = qaPromptRaw;
}
return { patch, errors };
}