feat(collection-ai): settings section, runbook

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-17 09:55:44 +02:00
parent 137b458a06
commit 8c3cfd45fc
6 changed files with 265 additions and 1 deletions

View file

@ -183,3 +183,15 @@ Runbook: `docs/quote-assistant.md`. Files: `src/lib/server/quote-assistant-{core
- 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.
# AI Collection Confirm checks — reads driver photos, ticks lines, never confirms
Runbook: `docs/collection-ai-checks.md`. Files: `src/lib/server/collection-ai-{read,pair,findings,service,prompt}.ts`
(vision read, pairing call, document-level findings, orchestration, default prompt) and `src/lib/collection-ai-ticks.ts`
(pure, client-safe tick logic). Routes: `POST /api/v1/collection-confirm/ai/tick` (n8n, agent auth),
`.../ai/check` and `.../add-to-pos` (browser session), MCP `collection_confirm.ai_check`. Settings live in Settings → AI → Collection checks.
- **Manual first:** the Add to POS button and the Check with AI button are manual; the tick only reads and checks, nothing else.
- The AI writes **only** `ai_check`, `collection_doc_reads` rows, and an **empty** SKU when it is sure. It never overwrites a person's SKU, confirms a collection, or changes status.
- The service refuses to run until `ai_check` exists (PB drops unknown fields silently); `ai_check` is in `IGNORED_FIELDS` of the audit hook.
- Manual live steps (like select values and pb_hooks): run `npm run pb:migrate:collection-ai` against the live PB, redeploy `pocketbase/pb_hooks/audit_log_utils.js`, deploy, activate the n8n workflow "WFDASH Collection check tick" (every 5 min), then switch Collection checks on in Settings → AI. See the runbook.

View file

@ -0,0 +1,112 @@
# Collection Confirm AI checks runbook
Design: `docs/superpowers/specs/2026-10-17-collection-confirm-ai-checks-design.md`.
## What it does
On Collection Confirm, drivers upload photos of the supplier's tax invoice
or delivery note. The AI (the provider set in Settings, AI) reads each
photo, pairs the document lines with our order lines, and shows four ticks
per line (SKU, quantity, description, price), plus a POS column and an
"AI findings" box when something at document level looks wrong.
- It runs automatically (n8n clock, every 5 minutes) on files not yet read,
and on demand with the **Check with AI** button on a card.
- **Check with AI** needs only the global AI switch. The scheduled tick
needs both the global AI switch and "Collection checks enabled".
- A file is read once (by content hash, stored in `collection_doc_reads`).
A failed photo read is retried only by the **Check with AI** button, not
by the tick.
- Lines with no AI check look exactly as before, with no ticks.
- **Add to POS** is a manual feature (per line, or "Confirm and add to
POS" on the card). It creates a `stock_items` row with zero stock.
What the AI writes: only `ai_check` on the order/stock line, the document
reads, and an **empty SKU**, and only when it is sure. It never overwrites
a SKU a person set, never confirms a collection, and never changes status.
The SKU fill is audited as an ordinary `sku` change.
## Going live (manual steps, in this order)
1. **Migrate PocketBase.** Run against the live PB:
`npm run pb:migrate:collection-ai`
(creates `collection_doc_reads`, adds `ai_check` to `order_items` and
`stock_purchase_items`, and the `collection_ai_*` fields on
`ai_settings`).
2. **Redeploy the audit hook** `pocketbase/pb_hooks/audit_log_utils.js` to
the PB server's `pb_hooks/`. It now ignores `ai_check`; until then the
only cost is noisy history (a large JSON diff on every re-run).
3. **Deploy** the app. Pushing to main does not auto-deploy; it is a
manual deploy that takes 5 to 10 minutes.
4. **Activate the n8n workflow** (below). Create it inactive, activate it
after steps 1 to 3.
5. **Turn it on** in Settings, AI: the global "AI enabled" switch must be
on, then switch on "Collection checks enabled" in the Collection checks
section and click Save.
The tick does nothing until both switches are on, so steps 4 and 5 are
safe in either order.
## n8n workflow: "WFDASH Collection check tick"
n8n only acts as a clock; the app decides whether anything is due.
| Node | Setting |
|---|---|
| Name | `WFDASH Collection check tick` |
| Trigger | Schedule Trigger, every 5 minutes |
| Action | HTTP Request |
| Method / URL | `POST` `https://wfdash.apphub.co.za/api/v1/collection-confirm/ai/tick` |
| Authentication | The `WFDASH Agent API Key` credential (`Authorization: Bearer <AGENT_API_KEY>`). Do not create a new secret. |
| Body | none |
| Options | Timeout **120000 ms** (120 s); Never Error (neverError) **on** |
| Status | **inactive** until the app is deployed and migrated |
After deploy, Execute workflow once. Expected responses:
`{"ran":false,"reason":"disabled"}` while off, `{"ran":false,"reason":"running"}`
while an earlier run is still going, `{"ran":true,"summary":{...}}` when it
ran. A 401 means the credential is wrong.
Each run is bounded: at most 10 files, and it stops starting new files
after 90 seconds, so it normally answers inside the 120 s timeout;
leftovers carry to the next run. Runs never overlap: a run holds a lease
for up to 10 minutes, and if a run dies without writing its summary the
next tick after that takes over.
The last run (files read, cards checked) is shown in Settings, AI,
Collection checks, and stored in
`ai_settings.collection_ai_last_run_summary`.
## Reading the ticks
| Colour | Meaning |
|---|---|
| Green | The document agrees with our line (SKU matches after normalising, quantity equals what was collected and what was ordered, AI is sure it is the same item, unit price within 1 cent) |
| Amber | Close but worth a look: SKU differs from the document code; collected quantity matches the document but the ordered quantity differs; the AI thinks it is the same item but is only "likely" sure |
| Red | Mismatch or missing: no SKU on our line, quantity differs from the document, price differs or we have no cost, or the AI could not pair the line (all four red) |
| Grey | Not applicable: the document shows no code or quantity, or no price (delivery notes never have a price tick) |
Hover a tick for the values compared (ours vs the document). A robot icon
marks a SKU the AI filled. Click the document chip on a card to see the
extracted lines next to the photo.
## Errors
- **"Collection checks are not set up yet: run `npm run pb:migrate:collection-ai`
against PocketBase first"** is the guard. The service refuses to run
until `ai_check` exists on the live PB, because PB silently drops
unknown fields and results would vanish. Run step 1 above.
- **"AI is disabled in settings"** from **Check with AI**: switch on the
global AI switch in Settings, AI.
## Changing the prompt
Settings, AI, Collection checks prompt. "Reset to default" loads the
built-in prompt; empty means the built-in default. Keep it to the
`submit_document_read` tool's fields.
## Stopping it
Switch off "Collection checks enabled" in Settings, AI and Save, or
deactivate the n8n workflow. Existing ticks stay until the card is
re-checked or the line is cleared.

View file

@ -27,7 +27,9 @@ import {
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";
// 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";
export interface AiSettingsFormInitial {
provider: string;
@ -50,6 +52,11 @@ export interface AiSettingsFormInitial {
/** Empty string = use the built-in default prompt. */
quote_assistant_prompt: string;
quote_assistant_last_run_summary: QuoteAssistantRunSummary | null;
/** Collection Confirm AI checks. */
collection_ai_enabled: boolean;
/** Empty string = use the built-in default prompt. */
collection_ai_prompt: string;
collection_ai_last_run_summary: CollectionAiRunSummary | null;
}
interface Props {
@ -97,6 +104,9 @@ export function AiSettingsForm({ initial }: Props) {
const [qaConfidence, setQaConfidence] = useState<string>(initial.quote_assistant_min_confidence);
const [qaPrompt, setQaPrompt] = useState(initial.quote_assistant_prompt);
const [showQaDefault, setShowQaDefault] = useState(false);
const [caEnabled, setCaEnabled] = useState(initial.collection_ai_enabled);
const [caPrompt, setCaPrompt] = useState(initial.collection_ai_prompt);
const [showCaDefault, setShowCaDefault] = useState(false);
const [saveState, saveAction] = useActionState<ActionResult | null, FormData>(
saveAiSettingsAction,
null,
@ -455,6 +465,92 @@ 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>Collection checks</CardTitle>
</div>
<CardDescription>
Reads the driver&apos;s photos of supplier invoices and delivery notes
on Collection Confirm and ticks each line against the order.
</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="ca_enabled" className="text-base">
Collection checks enabled
</Label>
<p className="text-xs text-muted-foreground">
Needs the AI switch above to be on too. Reads driver photos on
Collection Confirm and ticks each line; it never confirms anything.
</p>
</div>
<Switch
id="ca_enabled"
name="ca_enabled"
checked={caEnabled}
onCheckedChange={setCaEnabled}
/>
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between gap-3">
<Label htmlFor="ca_prompt">Collection checks prompt</Label>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowCaDefault((s) => !s)}
title="Toggle the built-in default for comparison"
>
<Eye /> {showCaDefault ? "Hide" : "Show"} default
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setCaPrompt(DEFAULT_COLLECTION_AI_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="ca_prompt"
name="ca_prompt"
value={caPrompt}
onChange={(e) => setCaPrompt(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
reading a document photo. Empty = use the built-in default. The rules assume
the <code className="rounded bg-muted px-1 py-0.5">submit_document_read</code> tool.
</p>
{showCaDefault && (
<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_COLLECTION_AI_PROMPT}
</pre>
)}
</div>
<p className="text-xs text-muted-foreground">
Last run: {formatCollectionLastRun(initial.collection_ai_last_run_summary)}
</p>
</CardContent>
</Card>
<div className="space-y-4">
{/* Save / Test */}
<div className="flex flex-wrap items-center gap-2">
@ -504,6 +600,13 @@ function formatLastRun(summary: QuoteAssistantRunSummary | null): string {
return `${time} · ${summary.quotes_seen} quotes · ${summary.lines_filled} filled · ${summary.lines_unmatched} no match`;
}
function formatCollectionLastRun(summary: CollectionAiRunSummary | 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.files_read} files read · ${summary.cards_checked} cards checked`;
}
function SaveButton() {
const { pending } = useFormStatus();
return (

View file

@ -47,6 +47,9 @@ export default async function AiSettingsPage() {
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,
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,
}}
/>
</div>

View file

@ -97,3 +97,28 @@ describe("parseSettingsForm: quote assistant", () => {
expect(parseSettingsForm(form({})).patch.quote_assistant_prompt).toBeUndefined();
});
});
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);
expect(parseSettingsForm(form({})).patch.collection_ai_enabled).toBe(false);
});
it("passes ca_prompt through untrimmed, and leaves it untouched when absent", () => {
expect(parseSettingsForm(form({ ca_prompt: " rules\n" })).patch.collection_ai_prompt).toBe(
" rules\n",
);
expect(parseSettingsForm(form({ ca_prompt: "" })).patch.collection_ai_prompt).toBe("");
expect(parseSettingsForm(form({})).patch.collection_ai_prompt).toBeUndefined();
});
it("does not disturb the quote assistant fields", () => {
const { patch, errors } = parseSettingsForm(
form({ qa_enabled: "on", qa_prompt: "q", ca_prompt: "c" }),
);
expect(errors).toEqual([]);
expect(patch.quote_assistant_enabled).toBe(true);
expect(patch.quote_assistant_prompt).toBe("q");
expect(patch.collection_ai_prompt).toBe("c");
});
});

View file

@ -104,5 +104,14 @@ export function parseSettingsForm(formData: FormData): ParseResult {
patch.quote_assistant_prompt = qaPromptRaw;
}
// ── Collection checks section (same <form>: missing checkbox = off).
patch.collection_ai_enabled = formData.get("ca_enabled") === "on";
// Plain text, untrimmed. Empty means "use the built-in default".
const caPromptRaw = formData.get("ca_prompt");
if (typeof caPromptRaw === "string") {
patch.collection_ai_prompt = caPromptRaw;
}
return { patch, errors };
}