feat(orders): Settings → AI order assistant section

Task 9 of the AI order assistant plan.

- new "Order assistant" card in Settings → AI: enabled switch, run
  every (minutes, 5-1440), auto-link when sure, fill lines from the
  quote, the prompt with show/reset default, and the last-run summary
- parseSettingsForm reads oa_* and validates the interval; the last-run
  fields are never written by the form
- the page passes the new settings through as the initial state

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-24 13:16:09 +02:00
parent 00d09b3b12
commit 6fe09f7752
4 changed files with 267 additions and 1 deletions

View file

@ -29,7 +29,13 @@ import { DEFAULT_QUOTES_AI_SYSTEM_PROMPT } from "@/lib/server/ai-system-prompt";
import { DEFAULT_QUOTE_ASSISTANT_PROMPT } from "@/lib/server/quote-assistant-prompt";
// Plain string export, no server-only imports, so it is safe in a client component.
import { DEFAULT_COLLECTION_AI_PROMPT } from "@/lib/server/collection-ai-prompt";
import type { CollectionAiRunSummary, QuoteAssistantRunSummary } from "@/lib/types";
// Plain string export, no server-only imports, so it is safe in a client component.
import { DEFAULT_ORDER_ASSISTANT_PROMPT } from "@/lib/server/order-assistant-prompt";
import type {
CollectionAiRunSummary,
OrderAssistantRunSummary,
QuoteAssistantRunSummary,
} from "@/lib/types";
export interface AiSettingsFormInitial {
provider: string;
@ -57,6 +63,14 @@ export interface AiSettingsFormInitial {
/** Empty string = use the built-in default prompt. */
collection_ai_prompt: string;
collection_ai_last_run_summary: CollectionAiRunSummary | null;
/** AI order assistant (matches orders to the quote they came from). */
order_assistant_enabled: boolean;
order_assistant_interval_minutes: number;
order_assistant_auto_link: boolean;
order_assistant_fill_lines: boolean;
/** Empty string = use the built-in default prompt. */
order_assistant_prompt: string;
order_assistant_last_run_summary: OrderAssistantRunSummary | null;
}
interface Props {
@ -107,6 +121,12 @@ export function AiSettingsForm({ initial }: Props) {
const [caEnabled, setCaEnabled] = useState(initial.collection_ai_enabled);
const [caPrompt, setCaPrompt] = useState(initial.collection_ai_prompt);
const [showCaDefault, setShowCaDefault] = useState(false);
const [oaEnabled, setOaEnabled] = useState(initial.order_assistant_enabled);
const [oaInterval, setOaInterval] = useState(String(initial.order_assistant_interval_minutes));
const [oaAutoLink, setOaAutoLink] = useState(initial.order_assistant_auto_link);
const [oaFillLines, setOaFillLines] = useState(initial.order_assistant_fill_lines);
const [oaPrompt, setOaPrompt] = useState(initial.order_assistant_prompt);
const [showOaDefault, setShowOaDefault] = useState(false);
const [saveState, saveAction] = useActionState<ActionResult | null, FormData>(
saveAiSettingsAction,
null,
@ -551,6 +571,150 @@ export function AiSettingsForm({ initial }: Props) {
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
<CardTitle>Order assistant</CardTitle>
</div>
<CardDescription>
Works out which quote an order came from, and links the order to it
when it is sure. It never changes a line&apos;s status, never creates a
purchase order and never sends 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="oa_enabled" className="text-base">
Order assistant enabled
</Label>
<p className="text-xs text-muted-foreground">
Needs the AI switch above to be on too. Runs on a timer and looks at
orders that have no quote yet.
</p>
</div>
<Switch
id="oa_enabled"
name="oa_enabled"
checked={oaEnabled}
onCheckedChange={setOaEnabled}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="oa_interval_minutes">Run every (minutes)</Label>
<Input
id="oa_interval_minutes"
name="oa_interval_minutes"
type="number"
min={5}
max={1440}
step={1}
value={oaInterval}
onChange={(e) => setOaInterval(e.target.value)}
/>
<p className="text-xs text-muted-foreground">Between 5 and 1440.</p>
</div>
<div className="grid gap-3">
<div className="flex items-center justify-between gap-4 rounded-lg border border-border p-3">
<div>
<Label htmlFor="oa_auto_link" className="text-base">
Auto-link when sure
</Label>
<p className="text-xs text-muted-foreground">
Links the order itself when the AI is certain. When unsure it only
suggests the quote and a person decides.
</p>
</div>
<Switch
id="oa_auto_link"
name="oa_auto_link"
checked={oaAutoLink}
onCheckedChange={setOaAutoLink}
/>
</div>
<div className="flex items-center justify-between gap-4 rounded-lg border border-border p-3">
<div>
<Label htmlFor="oa_fill_lines" className="text-base">
Fill lines from the quote
</Label>
<p className="text-xs text-muted-foreground">
Pre-fills the order lines from the matched quote. Not in use yet —
the assistant only matches orders to quotes.
</p>
</div>
<Switch
id="oa_fill_lines"
name="oa_fill_lines"
checked={oaFillLines}
onCheckedChange={setOaFillLines}
/>
</div>
</div>
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between gap-3">
<Label htmlFor="oa_prompt">Order assistant prompt</Label>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowOaDefault((s) => !s)}
title="Toggle the built-in default for comparison"
>
<Eye /> {showOaDefault ? "Hide" : "Show"} default
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setOaPrompt(DEFAULT_ORDER_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="oa_prompt"
name="oa_prompt"
value={oaPrompt}
onChange={(e) => setOaPrompt(e.target.value)}
placeholder="Leave empty to use the built-in default."
maxLength={50000}
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 an order to a quote. Empty = use the built-in default. The rules
assume the{" "}
<code className="rounded bg-muted px-1 py-0.5">submit_order_quote_match</code>{" "}
tool.
</p>
{showOaDefault && (
<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_ORDER_ASSISTANT_PROMPT}
</pre>
)}
</div>
<p className="text-xs text-muted-foreground">
Last run: {formatOrderAssistantLastRun(initial.order_assistant_last_run_summary)}
</p>
</CardContent>
</Card>
<div className="space-y-4">
{/* Save / Test */}
<div className="flex flex-wrap items-center gap-2">
@ -607,6 +771,13 @@ function formatCollectionLastRun(summary: CollectionAiRunSummary | null): string
return `${time} · ${summary.files_read} files read · ${summary.cards_checked} cards checked`;
}
function formatOrderAssistantLastRun(summary: OrderAssistantRunSummary | 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.orders_seen} orders · ${summary.linked} linked · ${summary.suggested} suggested · ${summary.no_match} no match`;
}
function SaveButton() {
const { pending } = useFormStatus();
return (

View file

@ -50,6 +50,12 @@ export default async function AiSettingsPage() {
collection_ai_enabled: settings.collection_ai_enabled,
collection_ai_prompt: settings.collection_ai_prompt,
collection_ai_last_run_summary: settings.collection_ai_last_run_summary,
order_assistant_enabled: settings.order_assistant_enabled,
order_assistant_interval_minutes: settings.order_assistant_interval_minutes,
order_assistant_auto_link: settings.order_assistant_auto_link,
order_assistant_fill_lines: settings.order_assistant_fill_lines,
order_assistant_prompt: settings.order_assistant_prompt,
order_assistant_last_run_summary: settings.order_assistant_last_run_summary,
}}
/>
</div>

View file

@ -98,6 +98,73 @@ describe("parseSettingsForm: quote assistant", () => {
});
});
describe("parseSettingsForm: order assistant section", () => {
it("maps oa_enabled=on to true and a missing value to false", () => {
expect(parseSettingsForm(form({ oa_enabled: "on" })).patch.order_assistant_enabled).toBe(true);
expect(parseSettingsForm(form({})).patch.order_assistant_enabled).toBe(false);
});
it("parses a valid interval", () => {
const { patch, errors } = parseSettingsForm(form({ oa_interval_minutes: "15" }));
expect(errors).toEqual([]);
expect(patch.order_assistant_interval_minutes).toBe(15);
});
it("leaves the interval untouched when blank", () => {
const { patch, errors } = parseSettingsForm(form({ oa_interval_minutes: " " }));
expect(errors).toEqual([]);
expect(patch.order_assistant_interval_minutes).toBeUndefined();
});
it.each(["4", "1441", "abc"])("rejects interval %s", (value) => {
const { patch, errors } = parseSettingsForm(form({ oa_interval_minutes: value }));
expect(errors).toContain("Order assistant interval must be between 5 and 1440 minutes");
expect(patch.order_assistant_interval_minutes).toBeUndefined();
});
it("accepts the interval bounds", () => {
expect(parseSettingsForm(form({ oa_interval_minutes: "5" })).errors).toEqual([]);
expect(parseSettingsForm(form({ oa_interval_minutes: "1440" })).errors).toEqual([]);
});
it("reads the auto-link and fill-lines switches", () => {
const on = parseSettingsForm(
form({ oa_auto_link: "on", oa_fill_lines: "on" }),
).patch;
expect(on.order_assistant_auto_link).toBe(true);
expect(on.order_assistant_fill_lines).toBe(true);
const off = parseSettingsForm(form({})).patch;
expect(off.order_assistant_auto_link).toBe(false);
expect(off.order_assistant_fill_lines).toBe(false);
});
it("passes the prompt through untrimmed, and leaves it untouched when absent", () => {
expect(parseSettingsForm(form({ oa_prompt: " rules\n" })).patch.order_assistant_prompt).toBe(
" rules\n",
);
expect(parseSettingsForm(form({ oa_prompt: "" })).patch.order_assistant_prompt).toBe("");
expect(parseSettingsForm(form({})).patch.order_assistant_prompt).toBeUndefined();
});
it("does not disturb the quote assistant or collection checks fields", () => {
const { patch, errors } = parseSettingsForm(
form({ qa_enabled: "on", qa_prompt: "q", ca_prompt: "c", oa_enabled: "on" }),
);
expect(errors).toEqual([]);
expect(patch.quote_assistant_enabled).toBe(true);
expect(patch.quote_assistant_prompt).toBe("q");
expect(patch.collection_ai_prompt).toBe("c");
expect(patch.order_assistant_enabled).toBe(true);
});
it("never writes the last-run fields", () => {
const patch = parseSettingsForm(form({ oa_enabled: "on" })).patch;
expect(patch).not.toHaveProperty("order_assistant_last_run_at");
expect(patch).not.toHaveProperty("order_assistant_last_run_summary");
});
});
describe("parseSettingsForm: collection checks section", () => {
it("reads ca_enabled=on as true and missing as false", () => {
expect(parseSettingsForm(form({ ca_enabled: "on" })).patch.collection_ai_enabled).toBe(true);

View file

@ -113,5 +113,27 @@ export function parseSettingsForm(formData: FormData): ParseResult {
patch.collection_ai_prompt = caPromptRaw;
}
// ── Order assistant section (same <form>: missing checkbox = off).
patch.order_assistant_enabled = formData.get("oa_enabled") === "on";
const oaIntervalRaw = String(formData.get("oa_interval_minutes") ?? "").trim();
if (oaIntervalRaw !== "") {
const n = Number(oaIntervalRaw);
if (!Number.isFinite(n) || n < 5 || n > 1440) {
errors.push("Order assistant interval must be between 5 and 1440 minutes");
} else {
patch.order_assistant_interval_minutes = Math.round(n);
}
}
patch.order_assistant_auto_link = formData.get("oa_auto_link") === "on";
patch.order_assistant_fill_lines = formData.get("oa_fill_lines") === "on";
// Plain text, untrimmed. Empty means "use the built-in default".
const oaPromptRaw = formData.get("oa_prompt");
if (typeof oaPromptRaw === "string") {
patch.order_assistant_prompt = oaPromptRaw;
}
return { patch, errors };
}