docs(collection-confirm): AI checks implementation plan

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-17 01:04:42 +02:00
parent 3624d87801
commit 3e37ae482f

View file

@ -0,0 +1,522 @@
# 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`.