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:
parent
fa854060c4
commit
144971779f
6 changed files with 439 additions and 10 deletions
12
AGENTS.md
12
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;
|
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
|
create/update/delete are null (all writes go through the admin client in
|
||||||
`giellie-chat-store.ts`).
|
`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
140
docs/quote-assistant.md
Normal 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.
|
||||||
|
|
@ -18,13 +18,16 @@ import {
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { toast } from "sonner";
|
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 {
|
import {
|
||||||
saveAiSettingsAction,
|
saveAiSettingsAction,
|
||||||
testAiConnectionAction,
|
testAiConnectionAction,
|
||||||
type ActionResult,
|
type ActionResult,
|
||||||
} from "./actions";
|
} from "./actions";
|
||||||
import { DEFAULT_QUOTES_AI_SYSTEM_PROMPT } from "@/lib/server/ai-system-prompt";
|
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 {
|
export interface AiSettingsFormInitial {
|
||||||
provider: string;
|
provider: string;
|
||||||
|
|
@ -40,6 +43,13 @@ export interface AiSettingsFormInitial {
|
||||||
* prefills with this value so the operator can inspect + tweak.
|
* prefills with this value so the operator can inspect + tweak.
|
||||||
*/
|
*/
|
||||||
system_prompt: string;
|
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 {
|
interface Props {
|
||||||
|
|
@ -82,6 +92,11 @@ export function AiSettingsForm({ initial }: Props) {
|
||||||
// can see the default by clicking "Reset to default").
|
// can see the default by clicking "Reset to default").
|
||||||
const [systemPrompt, setSystemPrompt] = useState(initial.system_prompt);
|
const [systemPrompt, setSystemPrompt] = useState(initial.system_prompt);
|
||||||
const [showDefaultPreview, setShowDefaultPreview] = useState(false);
|
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>(
|
const [saveState, saveAction] = useActionState<ActionResult | null, FormData>(
|
||||||
saveAiSettingsAction,
|
saveAiSettingsAction,
|
||||||
null,
|
null,
|
||||||
|
|
@ -132,6 +147,7 @@ export function AiSettingsForm({ initial }: Props) {
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<form ref={formRef} action={saveAction} className="space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -147,12 +163,7 @@ export function AiSettingsForm({ initial }: Props) {
|
||||||
Anthropic-compatible MiniMax endpoint.
|
Anthropic-compatible MiniMax endpoint.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="space-y-6">
|
||||||
<form
|
|
||||||
ref={formRef}
|
|
||||||
action={saveAction}
|
|
||||||
className="space-y-6"
|
|
||||||
>
|
|
||||||
{/* Provider */}
|
{/* Provider */}
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="provider">Provider</Label>
|
<Label htmlFor="provider">Provider</Label>
|
||||||
|
|
@ -317,7 +328,133 @@ export function AiSettingsForm({ initial }: Props) {
|
||||||
onCheckedChange={setEnabled}
|
onCheckedChange={setEnabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* Save / Test */}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<SaveButton />
|
<SaveButton />
|
||||||
|
|
@ -354,12 +491,18 @@ export function AiSettingsForm({ initial }: Props) {
|
||||||
Last saved: {new Date(initial.updated_at).toLocaleString()}
|
Last saved: {new Date(initial.updated_at).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</form>
|
</div>
|
||||||
</CardContent>
|
</form>
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
function SaveButton() {
|
||||||
const { pending } = useFormStatus();
|
const { pending } = useFormStatus();
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,11 @@ export default async function AiSettingsPage() {
|
||||||
api_key_set: settings.api_key_set,
|
api_key_set: settings.api_key_set,
|
||||||
updated_at: settings.updated_at,
|
updated_at: settings.updated_at,
|
||||||
system_prompt: settings.system_prompt,
|
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>
|
</div>
|
||||||
|
|
|
||||||
99
src/app/(app)/settings/ai/settings-form-helpers.test.ts
Normal file
99
src/app/(app)/settings/ai/settings-form-helpers.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -74,5 +74,35 @@ export function parseSettingsForm(formData: FormData): ParseResult {
|
||||||
patch.system_prompt = systemPromptRaw;
|
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 };
|
return { patch, errors };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue