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:
parent
00d09b3b12
commit
6fe09f7752
4 changed files with 267 additions and 1 deletions
|
|
@ -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'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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue