feat(collection-ai): settings section, runbook
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
137b458a06
commit
8c3cfd45fc
6 changed files with 265 additions and 1 deletions
12
AGENTS.md
12
AGENTS.md
|
|
@ -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.
|
||||
|
|
|
|||
112
docs/collection-ai-checks.md
Normal file
112
docs/collection-ai-checks.md
Normal 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.
|
||||
|
|
@ -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'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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue