WFDASH-WEBAPP/docs/superpowers/plans/2026-10-17-collection-confirm-ai-checks.md
Dave 3e37ae482f docs(collection-confirm): AI checks implementation plan
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 01:04:42 +02:00

522 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Collection Confirm AI Checks Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** The built-in AI reads the driver's invoice or delivery-note photo for each Collection Confirm card. It pairs the document lines with our PO lines, fills empty SKUs, and shows per-line SKU/Qty/Desc/Price ticks, a findings box, a pre-filled confirm box and POS buttons. All of this sits on top of today's fully manual page.
**Architecture:**
- **Two AI calls per document**, through the same Anthropic-SDK/MiniMax pattern as `quotes-ai-processor.ts`: a vision read (cached per file, de-duplicated by content hash, stored in a new `collection_doc_reads` collection), then a text-only pairing call.
- **Code, not the AI, decides the ticks.** A pure, client-safe module computes them from the stored pairing plus live row values, so manual edits update the ticks instantly.
- **Triggers:** a service orchestrates per-card checks and an n8n-driven tick. "Add to POS" is a manual feature built on an extracted `createStockItem` service.
**Tech Stack:** Next.js (read `node_modules/next/dist/docs/` before writing routes or pages), PocketBase JS SDK, `@anthropic-ai/sdk`, vitest + Testing Library, Tailwind tokens, lucide-react.
**Spec:** `docs/superpowers/specs/2026-10-17-collection-confirm-ai-checks-design.md`
## Global Constraints
- **Manual first:** with the AI off, not run, failed or ignored, Collection Confirm behaves exactly as today: inline SKU/cost/qty edits, the confirm dialog, Confirm, Book to stock, Clear. The AI never blocks a confirm.
- **The AI writes only:**
- `ai_check` on lines;
- `collection_doc_reads` rows;
- an empty line SKU, through the existing services (`updateOrderItemSku` / `updateStockPurchaseItem({ sku })`).
- **The AI never writes** `cost_price`, `qty_collected` / `quantity_received`, `item_status` / `status`, or invoice details.
- **A human-set SKU is never overwritten.** A re-run only refreshes `ai_check` and the document findings.
- **"SKU" means the supplier's code everywhere** (SKU tick, AI fill, In POS, Add to POS). Codes are compared normalised: uppercase, `A-Z0-9` only.
- **"Ordered"** = `order_qty`, falling back to `quantity` when `order_qty` is 0.
- **Price tick:** green when |document per-unit price − `cost_price`| ≤ 0.01. Grey for a delivery note or a null price.
- **Images only** (jpeg/png/webp/gif). PDFs are stored as `status: "skipped"` with note `"PDF not read yet"`.
- **Audit actor:** all writes go inside `runWithAuditActor({ name: "AI collection check", source: "agent", ignoreRequestUser: true }, …)`.
- **Guard:** refuse to run (error `COLLECTION_AI_NOT_SET_UP_ERROR`) until `order_items` has an `ai_check` field. PB drops unknown fields silently.
- **Settings:** `collection_ai_enabled` is off by default, and the global AI switch must also be on. `collection_ai_prompt` text max is 50000, never `max: 0`.
- **Tick limits:** at most 10 files read per run, a 90 s time budget, and a 10-minute in-progress lease (same pattern as `quote-assistant-service.ts`).
- **Data layer:** new code lives in `src/lib/server/*` and `/api/v1/*`. Don't extend `pb-helpers.ts`, and don't migrate existing page calls.
- **Auth:** the tick uses `assertAgentAuth`. Browser routes use `getBrowserSessionUser(req)`, with the client sending `Authorization: Bearer ${pb.authStore.token}`.
- **Never test against live PB or through MCP write tools.** Tests stub PB and the AI.
- **Commits** end with `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
## Review Focus
1. **Same photo uploaded several times (today: 7 copies).** It is read once. A file already read is never re-read on later ticks. Tested in Task 6.
2. **A rep sets a SKU, then the AI runs again.** The rep's SKU stays. The robot icon shows only while the SKU equals `sku_filled_value`. Tested in Tasks 2 and 6.
3. **Document price missing, or "per 100" already normalised.** A null price gives a grey price tick (never red), and R0.20 vs 0.2 is green. Tested in Task 2.
4. **The AI returns an unknown PO line id, or pairs two document lines to one PO line.** Unknown ids are ignored, the first pairing wins, and a line with no valid pairing is never green. Tested in Task 5.
5. **AI off, not set up, or the read failed.** Rows without `ai_check` render exactly as today: no tick columns' content, no findings box, no pre-fill. Tested in Task 8.
---
### Task 1: Data model, settings fields, migration, audit ignore
**Files:**
- Modify: `src/lib/types.ts` (new types)
- Modify: `src/lib/server/collection-confirm-service.ts` (`ConfirmRow` fields only)
- Modify: `src/lib/server/ai-settings-service.ts` (+ `ai-settings-service.test.ts`)
- Create: `scripts/migrations/2026-10-17-add-collection-ai.ts`
- Modify: `package.json` (`"pb:migrate:collection-ai"`)
- Modify: `pocketbase/pb_hooks/audit_log_utils.js` (`IGNORED_FIELDS` += `ai_check`)
**Interfaces — Produces in `src/lib/types.ts`:**
```ts
export type CollectionDocType = "tax_invoice" | "delivery_note" | "other";
export interface CollectionDocLine {
line_no: number; code: string; description: string;
quantity: number | null; unit: string;
unit_price: number | null; // per single unit, already normalised from "per 100" etc.
line_total: number | null; to_follow: number | null; price_note: string;
}
export interface CollectionDocRead {
id: string; source_collection: string; source_id: string; filename: string; content_hash: string;
status: "read" | "failed" | "skipped"; doc_type: CollectionDocType | "";
document_no: string; doc_date: string; supplier_name: string;
po_refs: string[]; lines: CollectionDocLine[]; notes: string[]; findings: string[];
model: string; error: string; read_at: string;
}
export type SameItem = "sure" | "likely" | "different";
export interface CollectionAiCheck {
read_id: string; document_no: string; doc_date: string; doc_type: CollectionDocType;
doc_line: CollectionDocLine | null; same_item: SameItem | null; reason: string;
sku_filled_value: string; findings: string[]; checked_at: string; model: string;
}
export interface CollectionAiRunSummary {
started_at: string; finished_at: string; files_read: number; cards_checked: number; errors: string[];
}
```
**Interfaces — Produces on `ConfirmRow`:** `ai_check?: CollectionAiCheck | null`, `ai_doc?: { read_id: string; doc_type: CollectionDocType; document_no: string; doc_date: string; supplier_name: string; findings: string[] } | null`, `in_pos?: boolean | null`, `pos_stock_item_id?: string | null`.
**Interfaces — Produces on `AiSettings` / `AiSettingsUpdate`:** `collection_ai_enabled: boolean` (default false), `collection_ai_prompt: string` (default "", sliced to 50000), `collection_ai_last_run_at: string`, `collection_ai_last_run_summary: CollectionAiRunSummary | null`. Also `export async function recordCollectionAiRun(fields: { last_run_at?: string; summary?: CollectionAiRunSummary }): Promise<void>`, mirroring `recordQuoteAssistantRun`.
- [ ] **Step 1: Write failing tests** in `ai-settings-service.test.ts`.
- A row without the new fields reads back `collection_ai_enabled: false`, `collection_ai_prompt: ""`, `collection_ai_last_run_summary: null`.
- `updateAiSettings({ collection_ai_prompt: "x".repeat(60_000) })` sends 50000 characters.
- `recordCollectionAiRun({ last_run_at: "2026-10-17T10:00:00.000Z" })` sends exactly that one key and refreshes the cache.
- [ ] **Step 2: Run the tests and confirm they fail.**
Run: `npx vitest run src/lib/server/ai-settings-service.test.ts`. Expected: FAIL.
- [ ] **Step 3: Implement the types, settings, migration script and hook edit.**
- Model the migration on `scripts/migrations/2026-10-16-add-quote-assistant-fields.ts`: idempotent, `import "./_env-loader"`.
- It creates the collection `collection_doc_reads` with:
- text fields `source_collection`, `source_id`, `filename`, `content_hash`, `status`, `doc_type`, `document_no`, `doc_date`, `supplier_name`, `model`, `error`, `read_at`;
- text field `error` with max 2000;
- json fields `po_refs`, `lines`, `notes`, `findings`, each with maxSize 200000;
- a unique index on `content_hash`;
- a non-unique index on (`source_collection`, `source_id`, `filename`);
- list/view rules `@request.auth.id != ""` and create/update/delete `null`.
- It adds json `ai_check` (maxSize 50000) to `order_items` and `stock_purchase_items`.
- It adds the four `ai_settings` fields, with `collection_ai_prompt` text max 50000.
- Add `"ai_check"` to `IGNORED_FIELDS` in the hook utils.
- [ ] **Step 4: Run the tests and the type check.**
Run: `npx vitest run src/lib/server/ai-settings-service.test.ts && npx tsc --noEmit`. Expected: PASS.
- [ ] **Step 5: Commit** with message `feat(collection-ai): data model, settings, migration`.
---
### Task 2: Tick logic (pure, client-safe)
**Files:**
- Create: `src/lib/collection-ai-ticks.ts`
- Create: `src/lib/collection-ai-ticks.test.ts`
**Interfaces:**
- Consumes: `CollectionAiCheck` (Task 1).
- Produces:
```ts
export type TickState = "green" | "amber" | "red" | "grey";
export interface LineTicks { sku: TickState; qty: TickState; desc: TickState; price: TickState }
export type TickRow = { sku?: string; qty_collected?: number; cost_price?: number | null; order_qty?: number; quantity?: number };
export function normaliseCode(code: string | null | undefined): string; // uppercase, A-Z0-9 only
export function computeLineTicks(row: TickRow, check: CollectionAiCheck): LineTicks;
export function allGreen(t: LineTicks): boolean;
export function showsAiFilledSku(row: TickRow, check: CollectionAiCheck | null | undefined): boolean;
```
- The file imports only types from `@/lib/types`.
- [ ] **Step 1: Write failing tests** with these exact cases.
- **No pairing:** `doc_line: null` gives all four `"red"`.
- **SKU tick:**
- green when `normaliseCode(row.sku) === normaliseCode(doc_line.code)` (e.g. `"sm293306016"` vs `"SM293306016"`);
- green when `row.sku === check.sku_filled_value` and that value is non-empty;
- amber when the row SKU is non-empty and different from the code;
- grey when `doc_line.code` is empty;
- red when the row SKU is empty and the code is non-empty (the AI couldn't fill it).
- **Qty tick:**
- green when doc qty 40, collected 40 and ordered 40;
- amber when doc qty 5, collected 5, `order_qty` 8;
- red when doc qty 4, collected 3;
- grey when doc qty is null;
- "ordered" uses `quantity` when `order_qty` is 0.
- **Desc tick:** `same_item` `"sure"` gives green, `"likely"` amber, `"different"` red, and null red.
- **Price tick:**
- doc `unit_price` 0.2 vs `cost_price` 0.20 gives green;
- 0.33 vs 0.345 gives red;
- 14.51 vs 14.515 gives green (within 0.01);
- `doc_type` `"delivery_note"` gives grey;
- a null `unit_price` gives grey;
- a null `cost_price` gives red.
- **`allGreen`:** true only when all four are green.
- **`showsAiFilledSku`:** false after a rep changes the SKU away from `sku_filled_value`.
- [ ] **Step 2: Run and confirm they fail.**
Run: `npx vitest run src/lib/collection-ai-ticks.test.ts`.
- [ ] **Step 3: Implement the module.**
- [ ] **Step 4: Run the tests.** Expected: PASS.
- [ ] **Step 5: Commit** with message `feat(collection-ai): line tick rules`.
---
### Task 3: Document findings (pure)
**Files:**
- Create: `src/lib/server/collection-ai-findings.ts`
- Create: `src/lib/server/collection-ai-findings.test.ts`
**Interfaces:**
- Consumes: `CollectionDocRead`, `CollectionDocLine`; `supplierNamesMatch` from `stock-purchase-invoice-matching-service.ts`.
- Produces:
```ts
export type PoLineLite = {
id: string; po_number: string; description: string; collected: boolean;
order_qty: number; quantity: number; qty_collected: number;
};
export type LinePair = { doc_line_no: number; po_line_id: string | null };
export function buildDocFindings(input: {
read: Pick<CollectionDocRead, "document_no" | "supplier_name" | "lines">;
resolvedPos: Array<{ po_number: string; supplier_name: string }>;
fellBackToPo: string | null; // set when no printed PO ref resolved
poLines: PoLineLite[]; // all lines on resolvedPos, any status
pairs: LinePair[];
}): string[];
```
- [ ] **Step 1: Write failing tests.** Each case asserts the exact string. Model them on the NSS fixture (document `5028256`; POs `PO202610003CEM` and `PO202610009CAS`).
- Two resolved POs: `"Document 5028256 covers 2 POs: PO202610003CEM, PO202610009CAS."`
- A paired PO line with `collected: false` (M8x25, document qty 40): `"M8x25 stainless steel × 40 is on the document but not marked collected."`
- A document line with no PO line: `"Document line \"<description>\" doesn't match any PO line."`
- A collected PO line not paired: `"<description> was marked collected but isn't on the document."`
- Supplier differs (`supplierNamesMatch` is false): `"Supplier on the document (<doc>) differs from the PO supplier (<po>)."`
- A document line with `to_follow: 0` where the PO line's ordered is greater than collected: `"<description>: supplier shows nothing to follow, but <ordered − collected> are still outstanding."`
- `order_qty` 4 and `quantity` 40 on a paired line: `"<description>: order line says 40 but the PO quantity is 4."`
- `fellBackToPo` set: `"No PO number on the document matched; checked against <po>."`
- Findings come out in the order listed above.
- [ ] **Step 2: Run and confirm they fail.**
Run: `npx vitest run src/lib/server/collection-ai-findings.test.ts`.
- [ ] **Step 3: Implement `buildDocFindings`.**
- [ ] **Step 4: Run the tests.** Expected: PASS.
- [ ] **Step 5: Commit** with message `feat(collection-ai): document findings`.
---
### Task 4: Vision read of one document
**Files:**
- Create: `src/lib/server/collection-ai-prompt.ts` (`DEFAULT_COLLECTION_AI_PROMPT`)
- Create: `src/lib/server/collection-ai-read.ts`
- Create: `src/lib/server/collection-ai-read.test.ts`
**Interfaces:**
- Consumes: `getActiveAiConfig`, `getAiSettings` (`collection_ai_prompt`).
- Produces:
```ts
export const SUBMIT_DOCUMENT_READ_TOOL: { name: "submit_document_read"; description: string; input_schema: object };
export type DocumentReadResult = Pick<CollectionDocRead,
"doc_type" | "document_no" | "doc_date" | "supplier_name" | "po_refs" | "lines" | "notes"> & { model: string };
export function parseDocumentRead(raw: unknown): Omit<DocumentReadResult, "model">;
export async function readDocumentImage(
image: { base64: string; contentType: string },
deps?: { client?: Pick<Anthropic, "messages"> },
): Promise<DocumentReadResult>;
```
- [ ] **Step 1: Write failing tests.** Inject a fake client, as `quote-assistant-ai.test.ts` does.
- **Request shape:** the request sends one image block plus a text instruction, with `tool_choice: { type: "tool", name: "submit_document_read" }`. The system prompt is `DEFAULT_COLLECTION_AI_PROMPT` unless `collection_ai_prompt` is non-blank.
- **`parseDocumentRead` on the NSS fixture:** a transcribed tool input with 4 lines, `unit_price` 0.95 / 0.33 / 0.2 / 14.51 and `po_refs` `["PO202610003CEM", "PO202610009CAS"]`. It returns them unchanged. `line_no` is assigned 1..n when missing.
- **Defensive parsing:**
- a bad `doc_type` becomes `"other"`;
- non-numeric quantity or price becomes null;
- a missing `lines` becomes `[]`;
- strings are trimmed;
- `doc_date` that isn't ISO `YYYY-MM-DD` becomes `""`.
- **Errors:**
- `stop_reason === "max_tokens"` throws `"AI response was cut off (max_tokens)"`;
- no tool block throws `"AI returned no submit_document_read tool call"`.
- [ ] **Step 2: Run and confirm they fail.**
Run: `npx vitest run src/lib/server/collection-ai-read.test.ts`.
- [ ] **Step 3: Implement.**
- Build the client like `quote-assistant-ai.ts` (`newAnthropic`).
- Set `max_tokens` to `Math.max(aiConfig.maxTokens, 4096)`.
- The default prompt is plain text, with this exact content:
```
You read photos of South African supplier documents (tax invoices and delivery notes) that a driver took when collecting goods. Return exactly what is printed via the submit_document_read tool.
Rules:
1. doc_type: "tax_invoice", "delivery_note", or "other" (a photo of goods, not paperwork).
2. document_no: the invoice or delivery-note number printed as the document's own number (not account or VAT numbers). date: the document date as YYYY-MM-DD.
3. po_refs: every customer order / PO number printed (often under "Customer PO", "Customer Order No" or "Your Ref"). Split values like "PO202610003CEM/PO202610009CAS" into separate entries.
4. lines: one entry per item line. code = the supplier's stock code; description as printed; quantity = quantity supplied; unit as printed.
5. unit_price must be the price for ONE unit. If the document prices per 100 (or per 1000, per box of N), divide accordingly and say so in price_note (e.g. "printed 33.00 per 100").
6. to_follow: the "to follow" / back-order quantity if printed, else null.
7. Never guess a number you cannot read clearly: use null. The photo may be rotated or partly covered.
```
- [ ] **Step 4: Run the tests.** Expected: PASS.
- [ ] **Step 5: Commit** with message `feat(collection-ai): vision read of driver documents`.
---
### Task 5: Pairing document lines with PO lines
**Files:**
- Create: `src/lib/server/collection-ai-pair.ts`
- Create: `src/lib/server/collection-ai-pair.test.ts`
**Interfaces:**
- Consumes: `CollectionDocLine`, `SameItem`, `PoLineLite` (Task 3; extend locally with `sku`, `po_description`, `cost_price`, `status`).
- Produces:
```ts
export type PairPoLine = PoLineLite & { sku: string; po_description: string; cost_price: number | null; status: string };
export type PairDecision = { doc_line_no: number; po_line_id: string | null; same_item: SameItem | null; reason: string };
export function parsePairs(raw: unknown, docLineNos: ReadonlySet<number>, poLineIds: ReadonlySet<string>): Map<number, PairDecision>;
export async function pairDocumentLines(
docLines: CollectionDocLine[], poLines: PairPoLine[],
deps?: { client?: Pick<Anthropic, "messages"> },
): Promise<{ pairs: Map<number, PairDecision>; model: string }>;
```
- [ ] **Step 1: Write failing tests.**
- **Unknown ids:** a `po_line_id` not in `poLineIds` becomes `po_line_id: null, same_item: null` (Review Focus #4).
- **Unknown line numbers:** a `doc_line_no` not in `docLineNos` is dropped.
- **Duplicate PO line:** a PO line paired to two document lines keeps the first; the second gets `po_line_id: null`.
- **Bad values:** an invalid `same_item` becomes null.
- **No PO lines:** with an empty `poLines`, `pairDocumentLines` makes no AI call and returns every document line unpaired.
- **Request shape:** the request text lists each PO line's id, description, PO description, SKU, ordered, collected and cost. It uses tool `submit_line_pairs`, and its system prompt says "pair only when it is the same product; wording differs between supplier and customer — judge the item, not the words".
- [ ] **Step 2: Run and confirm they fail.**
Run: `npx vitest run src/lib/server/collection-ai-pair.test.ts`.
- [ ] **Step 3: Implement.** Text-only, `max_tokens` 4096. Throw on `max_tokens` cut-off as in Task 4.
- [ ] **Step 4: Run the tests.** Expected: PASS.
- [ ] **Step 5: Commit** with message `feat(collection-ai): pair document lines with PO lines`.
---
### Task 6: Collection AI service (card check, tick, list passthrough)
**Files:**
- Create: `src/lib/server/collection-ai-service.ts`
- Create: `src/lib/server/collection-ai-service.test.ts`
- Modify: `src/lib/server/collection-confirm-service.ts` (`listCollectedItems` attaches `ai_check`, `ai_doc`, `in_pos`, `pos_stock_item_id`)
- Modify: `src/lib/server/collection-confirm-service.test.ts`
**Interfaces:**
- Consumes: Tasks 1–5; `updateOrderItemSku`, `updateStockPurchaseItem`; `normalizePoReference`; `runWithAuditActor`; `getAiSettings`, `clearAiSettingsCache`, `recordCollectionAiRun`, `AiDisabledError`.
- Produces:
```ts
export const COLLECTION_AI_NOT_SET_UP_ERROR = "Collection checks are not set up yet: run `npm run pb:migrate:collection-ai` against PocketBase first";
export const COLLECTION_AI_DISABLED_ERROR = "AI is disabled in settings";
export const TICK_MAX_FILES = 10; export const TICK_TIME_BUDGET_MS = 90_000; export const TICK_LEASE_MS = 10 * 60_000;
export interface CollectionAiDeps {
readDocument?: typeof readDocumentImage; pairLines?: typeof pairDocumentLines;
fetchFile?: (rec: { collection: string; id: string }, filename: string) => Promise<{ base64: string; contentType: string; bytes: Buffer }>;
now?: () => Date;
}
export interface CardCheckResult { po_number: string; files_read: number; lines_checked: number; skus_filled: number; error: string }
export async function checkCollectionCard(poNumber: string, opts?: { actorName?: string; maxFiles?: number; deps?: CollectionAiDeps }): Promise<CardCheckResult>;
export async function runCollectionAiTick(deps?: CollectionAiDeps): Promise<{ ran: boolean; reason?: "disabled" | "running"; summary?: CollectionAiRunSummary }>;
```
- [ ] **Step 1: Write failing tests.** Use an in-memory PB stub (as `quote-assistant-service.test.ts` does) with `order_items`, `stock_purchase_items`, `stock_purchases`, `purchase_orders`, `collection_doc_reads`, `stock_items` and `collections.getOne`.
`checkCollectionCard`:
- **Not set up:** returns `COLLECTION_AI_NOT_SET_UP_ERROR` with no writes when `order_items` lacks `ai_check`.
- **AI off:** `AiDisabledError` from `readDocument` returns `COLLECTION_AI_DISABLED_ERROR` and writes nothing.
- **Reads are cached (Review Focus #1):**
- A file with an existing `collection_doc_reads` row (same source and filename) is not fetched or read again.
- Three identical files (same bytes) produce one read. The other two get rows pointing to the same content via `content_hash`; no second AI call.
- **PDFs:** a `.pdf` attachment gets a `status: "skipped"` row with note `"PDF not read yet"`, and no AI call.
- **NSS fixture (two POs, one photo on PO…003CEM's line):**
- PO references resolve to both POs.
- Every collected line on both POs gets `ai_check`.
- The read's `findings` include the "covers 2 POs" and "M8x25 … not marked collected" strings from Task 3.
- **SKU fill:**
- An empty SKU with `same_item` "sure" is filled via `updateOrderItemSku`, and `sku_filled_value` is recorded.
- An existing SKU is never changed (Review Focus #2).
- `same_item` "different" never fills.
- **Stock rows** fill through `updateStockPurchaseItem` with `{ sku }`.
- **Fallback:** no PO ref resolves, so it falls back to the card's PO, and the finding says so.
- **Audit:** every write runs inside `runWithAuditActor` with `name` `"AI collection check"` (or `"AI collection check (run by <actorName>)"`), `source: "agent"` and `ignoreRequestUser: true`.
`runCollectionAiTick`:
- Returns `disabled` unless both `enabled` and `collection_ai_enabled` are on.
- Applies the lease check `running` as in the quote assistant.
- Records `last_run_at` before work.
- Picks only collected lines whose image attachments lack reads or whose rows lack `ai_check`, grouped by PO number, newest first.
- Stops at `TICK_MAX_FILES` or the time budget.
- Isolates per-card errors into the bounded summary (20 entries of up to 300 characters each).
`listCollectedItems`:
- Attaches `ai_doc` from the read referenced by `ai_check.read_id`, including `findings`.
- Sets `in_pos: true` and `pos_stock_item_id` when a `stock_items` SKU normalises to the row's SKU.
- Stock rows always get `in_pos: true`.
- Rows without `ai_check` get `ai_check: null` and `ai_doc: null`.
- [ ] **Step 2: Run and confirm they fail.**
Run: `npx vitest run src/lib/server/collection-ai-service.test.ts src/lib/server/collection-confirm-service.test.ts`.
- [ ] **Step 3: Implement.**
- **Default file fetch:** the admin PB `files.getToken()`, then fetch `files.getURL(record, filename, { token, thumb: "1600x0" })`. Hash the full bytes with sha256 (`node:crypto`).
- **Choosing the read:** when several reads cover one line, the newest `read_at` with a pairing wins.
- **`in_pos` lookup:** chunk distinct SKUs (≤ 50) into OR'd `sku ~ "<code>"` filters on `stock_items`, then compare `normaliseCode` in the app.
- [ ] **Step 4: Run the tests and the full suite.**
Run: `npx vitest run` and `npx tsc --noEmit`. Expected: PASS.
- [ ] **Step 5: Commit** with message `feat(collection-ai): card check, tick and list passthrough`.
---
### Task 7: Add to POS service and routes, plus the AI routes and MCP tool
**Files:**
- Modify: `src/lib/server/stock-items-service.ts` (add `createStockItem`, + test)
- Modify: `src/app/api/v1/stock/items/create/route.ts` (use the service; same request contract)
- Create: `src/app/api/v1/collection-confirm/add-to-pos/route.ts` (+ `route.test.ts`)
- Create: `src/app/api/v1/collection-confirm/ai/tick/route.ts` (+ test)
- Create: `src/app/api/v1/collection-confirm/ai/check/route.ts` (+ test)
- Modify: `src/app/api/mcp/route.ts` (+ list test), `src/lib/server/collection-confirm-service.ts` (`addRowsToPos`)
**Interfaces:**
- Produces:
```ts
// stock-items-service.ts
export type CreateStockItemInput = { name: string; sku?: string; description?: string; unit?: string; location?: string;
current_qty?: number; min_qty?: number; supplier?: string; notes?: string; category?: string;
cost_price?: number; selling_price?: number; markup?: number };
export async function createStockItem(input: CreateStockItemInput): Promise<StockItem>;
// collection-confirm-service.ts
export async function addRowsToPos(rowIds: string[]): Promise<Array<{ row_id: string; status: "created" | "already_in_pos" | "not_found"; stock_item_id: string | null }>>;
```
- **HTTP:**
- `POST /api/v1/collection-confirm/add-to-pos`: browser session, body `{ row_ids: string[] }` (1–100 ids), returns 200 `{ results }`.
- `POST /api/v1/collection-confirm/ai/tick`: agent auth.
- `POST /api/v1/collection-confirm/ai/check`: browser session, body `{ po_number }`. Returns 409 for `COLLECTION_AI_NOT_SET_UP_ERROR` / `COLLECTION_AI_DISABLED_ERROR`, otherwise 200 with `CardCheckResult`.
- **MCP:** `collection_confirm.ai_check({ po_number })` calls `checkCollectionCard(po, { actorName: "MCP" })`. It is not in `READ_TOOLS`.
- [ ] **Step 1: Write failing tests.**
- `createStockItem({ name: "M6 nylock", sku: "SM2NUT9B506", cost_price: 0.2 })` applies `applyDefaultMarkupToPatch` (selling 0.36, markup 80) and sets `current_qty` 0 and `is_active` true.
- The existing create route's test (or a new one) still accepts `costPrice` / `sellingPrice` / `markup` in camelCase.
- `addRowsToPos` for a customer row:
- creates the item with `name` = description, `description` = `po_description` or description, `sku`, `supplier`, `cost_price`, and `unit` from `ai_check.doc_line.unit` when present;
- returns `already_in_pos` when a normalised SKU match exists;
- returns `not_found` for an unknown id.
- Route tests cover 401, 400 (missing or empty `row_ids`, missing `po_number`), the 409 mapping, and the MCP tool classifying as "write".
- [ ] **Step 2: Run and confirm they fail.**
Run: `npx vitest run src/lib/server/stock-items-service.test.ts src/app/api/v1/collection-confirm src/app/api/v1/stock src/app/api/mcp src/lib/server/giellie-tool-policy.catalogue.test.ts`.
- [ ] **Step 3: Implement the service, routes and MCP entry.**
- [ ] **Step 4: Run the tests.** Expected: PASS.
- [ ] **Step 5: Commit** with message `feat(collection-ai): add to POS, AI routes and MCP tool`.
---
### Task 8: Collection Confirm page UI
**Files:**
- Modify: `src/components/collection-confirm/items-table.tsx` (+ test)
- Modify: `src/components/collection-confirm/po-card.tsx` (+ test)
- Modify: `src/components/collection-confirm/confirm-invoice-dialog.tsx` (pre-fill only if needed)
- Modify: `src/app/(app)/collection-confirm/page.tsx` (handlers)
- Create: `src/components/collection-confirm/ai-findings.tsx` (+ test)
**Interfaces:**
- Consumes: `computeLineTicks`, `showsAiFilledSku` (Task 2); `ConfirmRow.ai_check` / `ai_doc` / `in_pos` (Tasks 1 and 6); the routes from Task 7.
- Produces:
- `ItemsTable` gains optional props `onAddToPos?: (rowId: string) => void` and `showAi?: boolean`.
- `PoCard` gains optional `onCheckWithAi?: () => Promise<void>` and `onConfirmAndAddToPos?: () => void`.
- `AiFindings({ findings }: { findings: string[] })` renders null for an empty list.
- [ ] **Step 1: Write failing component tests.**
- **No AI data (Review Focus #5):** a row with `ai_check: null` renders no tick icons and no robot icon. The card with no `ai_doc` renders no findings box and no document chip. The table renders its existing columns unchanged; a snapshot of the header cells is equal to before plus the new "AI" and "POS" headers only when some row has `ai_check` or `in_pos`.
- **Ticks:** a row with `ai_check` renders four tick icons whose `aria-label`s come from `computeLineTicks` (`"SKU: matches"`, `"Qty: have a look"`, `"Desc: doesn't match"`, `"Price: can't check"`). Each tooltip shows "ours vs document" values. The Desc tooltip shows both descriptions.
- **Robot icon:** shows next to the SKU only when `showsAiFilledSku` is true.
- **POS column:** "In POS" (with a link to `/stock/<pos_stock_item_id>`) when `in_pos`. Otherwise an "Add to POS" button calls `onAddToPos(row.id)`.
- **Document chip:** `"Tax invoice 5028256 · 16 Oct 2026 · read from driver photo"`. A delivery note uses `"Delivery note"`. Clicking the chip opens a panel listing the extracted document lines (code, description, qty, per-unit price, price note) beside the existing photo viewer (`invoice-viewer.tsx`).
- **Check with AI** calls `onCheckWithAi` and shows a spinner while pending.
- **Confirm buttons:** "Confirm and add to POS" renders only when at least one customer row is not in POS.
- **Pre-fill:** opening the confirm dialog from a card whose rows have `ai_check.document_no` "5028256" and `doc_date` "2026-10-16" shows those values pre-filled. With no `ai_check`, the dialog shows today's defaults (date today, number blank).
- [ ] **Step 2: Run and confirm they fail.**
Run: `npx vitest run src/components/collection-confirm`.
- [ ] **Step 3: Implement.**
- **Page handlers:**
- `handleCheckWithAi(poNumber)` POSTs to `/ai/check`, toasts the result or the 409 error, then reloads the feed.
- `handleAddToPos(rowIds)` POSTs to `/add-to-pos`, toasts `"Added N items to the POS"`, then reloads.
- "Confirm and add to POS" runs the existing confirm flow, then calls `handleAddToPos` with the card's not-in-POS customer rows only after the confirm succeeds.
- **Pre-fill:** the confirm dialog takes its initial values from the first row with `ai_check.document_no`.
- **Styling:** ticks use the `success` / `warn` / `destructive` / `muted` tokens with `CheckCircle2` / `AlertTriangle` / `XCircle` / `MinusCircle`. The robot uses lucide `Bot` with `text-ai`. Surgical edits only.
- [ ] **Step 4: Run the tests and checks.**
Run: `npx vitest run src/components/collection-confirm && npx tsc --noEmit`, plus eslint on the changed files. Expected: PASS, with no new lint errors.
- [ ] **Step 5: Commit** with message `feat(collection-ai): ticks, findings and POS buttons on Collection Confirm`.
---
### Task 9: Settings section, runbook, AGENTS.md
**Files:**
- Modify: `src/app/(app)/settings/ai/settings-form-helpers.ts` (+ test), `ai-settings-form.tsx`, `page.tsx`
- Create: `docs/collection-ai-checks.md`
- Modify: `AGENTS.md`
**Interfaces:**
- Form fields: `ca_enabled` (checkbox `"on"`), `ca_prompt` → `collection_ai_enabled`, `collection_ai_prompt`.
- [ ] **Step 1: Write failing parser tests.**
- `ca_enabled=on` gives true, and missing gives false.
- `ca_prompt` passes through untrimmed.
- The existing and `qa_*` fields are unchanged.
- [ ] **Step 2: Run, confirm they fail, then implement the parser.**
Run: `npx vitest run "src/app/(app)/settings/ai/settings-form-helpers.test.ts"`. Expected: FAIL, then PASS.
- [ ] **Step 3: Build the "Collection checks" card in the same form as "Quote assistant".**
- On/off switch.
- Prompt textarea with `maxLength={50000}`, "Reset to default" (`DEFAULT_COLLECTION_AI_PROMPT`) and a read-only preview.
- "Last run" line: `"<time> · <files_read> files read · <cards_checked> cards checked"`, or `"Never"`.
- Helper text: `"Needs the AI switch above to be on too. Reads driver photos on Collection Confirm and ticks each line; it never confirms anything."`
- [ ] **Step 4: Write the runbook and the AGENTS.md section.**
- `docs/collection-ai-checks.md` covers:
- what the feature does;
- the manual live steps: (1) `npm run pb:migrate:collection-ai`, (2) redeploy `pocketbase/pb_hooks/audit_log_utils.js` (`ai_check` ignored), (3) deploy, (4) activate the n8n "WFDASH Collection check tick", (5) Settings → AI → Collection checks on;
- what each tick colour means;
- the guard error.
- `AGENTS.md` gets a section of 6–10 lines with:
- the file map;
- the "manual first" rule;
- "AI writes only `ai_check`, doc reads, and empty SKUs";
- the manual live steps.
- [ ] **Step 5: Run final verification.**
Run: `npx vitest run && npx tsc --noEmit && npm run build`, plus eslint on the changed files. Expected: all PASS.
- [ ] **Step 6: Commit** with message `feat(collection-ai): settings section, runbook`.