feat(collection-ai): data model, settings, migration

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-17 01:18:10 +02:00
parent 3e37ae482f
commit e6cac6e447
7 changed files with 357 additions and 2 deletions

View file

@ -21,6 +21,7 @@
"pb:migrate:audit-log": "npx tsx scripts/migrations/2026-09-25-create-audit-log.ts",
"pb:migrate:rep-trip-legs": "npx tsx scripts/migrations/2026-09-28-create-rep-trip-legs.ts",
"pb:migrate:quote-assistant": "npx tsx scripts/migrations/2026-10-16-add-quote-assistant-fields.ts",
"pb:migrate:collection-ai": "npx tsx scripts/migrations/2026-10-17-add-collection-ai.ts",
"pb:migrate:giellie-chats": "npx tsx scripts/migrations/2026-09-30-create-giellie-chats.ts",
"pb:migrate:place-overrides": "npx tsx scripts/migrations/2026-09-28-create-place-overrides.ts",
"pb:migrate:drop-orders-status": "npx tsx scripts/migrations/2026-06-27-drop-orders-status-and-wipe.ts",

View file

@ -19,7 +19,7 @@ const IGNORED_FIELDS = {
email_tracking_id: 1, has_new_reply: 1, last_reply_at: 1, pdf_generated_at: 1,
quote_matching_attempted: 1, delivery_note_matching_attempted: 1,
duplicate_order_matching_attempted: 1, order_number_duplicate_checked: 1,
email_opened: 1, email_opened_at: 1, email_delivered: 1, email_delivered_at: 1,
email_opened: 1, email_opened_at: 1, email_delivered: 1, email_delivered_at: 1, ai_check: 1,
};
// Key fields recorded when a line is added or removed, so the trail can

View file

@ -0,0 +1,143 @@
/**
* One-shot migration: schema for the Collection Confirm AI checks.
*
* collection_doc_reads (new collection) one row per invoice photo the AI read
* order_items.ai_check json (per-line check record)
* stock_purchase_items.ai_check json (per-line check record)
* ai_settings.collection_ai_enabled bool
* ai_settings.collection_ai_prompt text (max 50000)
* ai_settings.collection_ai_last_run_at text (max 40)
* ai_settings.collection_ai_last_run_summary json
*
* Run with:
*
* npm run pb:migrate:collection-ai
*
* Idempotent — an existing collection or field is skipped. Manual step on
* the live PB (like select values and pb_hooks).
*
* NOTE: PB text fields with `max: 0` cap at 5000 chars, NOT unlimited, so
* every text field gets an explicit max.
*
* Rollback: delete the collection and the fields in the PB admin UI.
*/
import "./_env-loader";
import PocketBase from "pocketbase";
const PB_URL = process.env.NEXT_PUBLIC_POCKETBASE_URL || process.env.PB_URL || "http://127.0.0.1:8090";
const ADMIN_EMAIL = process.env.PB_ADMIN_EMAIL;
const ADMIN_PASSWORD = process.env.PB_ADMIN_PASSWORD;
const TAG = "[collection-ai]";
type FieldDef = Record<string, unknown> & { name: string };
const text = (name: string, max = 500): FieldDef => ({
name,
type: "text",
required: false,
presentable: false,
max,
});
const json = (name: string, maxSize: number): FieldDef => ({
name,
type: "json",
required: false,
presentable: false,
maxSize,
});
const DOC_READ_FIELDS: FieldDef[] = [
text("source_collection", 100),
text("source_id", 50),
text("filename", 500),
text("content_hash", 100),
text("status", 20),
text("doc_type", 30),
text("document_no", 200),
text("doc_date", 40),
text("supplier_name", 300),
text("model", 100),
text("error", 2000),
text("read_at", 40),
json("po_refs", 200000),
json("lines", 200000),
json("notes", 200000),
json("findings", 200000),
];
const AI_CHECK_FIELDS: FieldDef[] = [json("ai_check", 50000)];
const AI_SETTINGS_FIELDS: FieldDef[] = [
{ name: "collection_ai_enabled", type: "bool", required: false, presentable: false },
text("collection_ai_prompt", 50000),
text("collection_ai_last_run_at", 40),
json("collection_ai_last_run_summary", 20000),
];
async function addMissingFields(pb: PocketBase, collectionName: string, wanted: FieldDef[]) {
const collection = await pb.collections.getOne(collectionName);
const existing = new Set(collection.fields.map((f: { name: string }) => f.name));
const missing = wanted.filter((f) => !existing.has(f.name));
for (const f of wanted) {
if (existing.has(f.name)) {
console.log(`${TAG} ${collectionName}.${f.name} already exists — skipping.`);
}
}
if (missing.length === 0) return;
await pb.collections.update(collection.id, {
fields: [...collection.fields, ...missing],
});
for (const f of missing) {
console.log(`${TAG} ${collectionName}.${f.name} added.`);
}
}
async function main() {
if (!ADMIN_EMAIL || !ADMIN_PASSWORD) {
throw new Error("Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD (e.g. from .env.local).");
}
const pb = new PocketBase(PB_URL);
await pb.collection("_superusers").authWithPassword(ADMIN_EMAIL, ADMIN_PASSWORD);
console.log(`${TAG} Authenticated against ${PB_URL}`);
const all = await pb.collections.getFullList();
if (all.find((c) => c.name === "collection_doc_reads")) {
console.log(`${TAG} collection_doc_reads already exists — skipping.`);
} else {
await pb.collections.create({
name: "collection_doc_reads",
type: "base",
// Reads are open to signed-in users; every write goes through the
// server's admin client.
listRule: '@request.auth.id != ""',
viewRule: '@request.auth.id != ""',
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
...DOC_READ_FIELDS,
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: [
"CREATE UNIQUE INDEX idx_collection_doc_reads_hash ON collection_doc_reads (content_hash)",
"CREATE INDEX idx_collection_doc_reads_source ON collection_doc_reads (source_collection, source_id, filename)",
],
});
console.log(`${TAG} Created collection_doc_reads.`);
}
await addMissingFields(pb, "order_items", AI_CHECK_FIELDS);
await addMissingFields(pb, "stock_purchase_items", AI_CHECK_FIELDS);
await addMissingFields(pb, "ai_settings", AI_SETTINGS_FIELDS);
console.log(`${TAG} Done.`);
}
main().catch((err) => {
console.error(`${TAG} Migration failed:`, err);
process.exit(1);
});

View file

@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import {
clearAiSettingsCache,
getAiSettings,
recordCollectionAiRun,
recordQuoteAssistantRun,
updateAiSettings,
} from "@/lib/server/ai-settings-service";
@ -168,3 +169,75 @@ describe("recordQuoteAssistantRun", () => {
expect(pb2.update).not.toHaveBeenCalled();
});
});
describe("collection AI settings", () => {
it("defaults the fields when the row lacks them", async () => {
buildPb([BASE_ROW]);
const s = await getAiSettings();
expect(s.collection_ai_enabled).toBe(false);
expect(s.collection_ai_prompt).toBe("");
expect(s.collection_ai_last_run_at).toBe("");
expect(s.collection_ai_last_run_summary).toBeNull();
});
it("reads stored values", async () => {
const summary = {
started_at: "a",
finished_at: "b",
files_read: 2,
cards_checked: 5,
errors: [],
};
buildPb([
{
...BASE_ROW,
collection_ai_enabled: true,
collection_ai_prompt: "custom",
collection_ai_last_run_at: "2026-10-17T09:00:00.000Z",
collection_ai_last_run_summary: summary,
},
]);
const s = await getAiSettings();
expect(s.collection_ai_enabled).toBe(true);
expect(s.collection_ai_prompt).toBe("custom");
expect(s.collection_ai_last_run_at).toBe("2026-10-17T09:00:00.000Z");
expect(s.collection_ai_last_run_summary).toEqual(summary);
});
it("caps the prompt at 50 000 characters", async () => {
const pb = buildPb([BASE_ROW]);
await updateAiSettings({ collection_ai_prompt: "x".repeat(60_000) });
const sent = pb.update.mock.calls[0][1].collection_ai_prompt as string;
expect(sent).toHaveLength(50_000);
});
it("writes the enabled flag", async () => {
const pb = buildPb([BASE_ROW]);
await updateAiSettings({ collection_ai_enabled: true });
expect(pb.update.mock.calls[0][1].collection_ai_enabled).toBe(true);
});
});
describe("recordCollectionAiRun", () => {
it("writes only the last-run fields and refreshes the cache", async () => {
const pb = buildPb([BASE_ROW]);
await recordCollectionAiRun({ last_run_at: "2026-10-17T10:00:00.000Z" });
expect(pb.update).toHaveBeenCalledTimes(1);
expect(pb.update).toHaveBeenCalledWith("s1", {
collection_ai_last_run_at: "2026-10-17T10:00:00.000Z",
});
pb.getFullList.mockClear();
const s = await getAiSettings();
expect(s.collection_ai_last_run_at).toBe("2026-10-17T10:00:00.000Z");
expect(pb.getFullList).not.toHaveBeenCalled();
});
it("does nothing when there is no settings row or no fields", async () => {
const pb = buildPb([]);
await recordCollectionAiRun({ last_run_at: "x" });
expect(pb.update).not.toHaveBeenCalled();
const pb2 = buildPb([BASE_ROW]);
await recordCollectionAiRun({});
expect(pb2.update).not.toHaveBeenCalled();
});
});

View file

@ -30,7 +30,7 @@
* over encryption-at-rest on 2026-08-09. Don't add encryption here
* without re-checking with them.
*/
import type { QuoteAssistantRunSummary } from "@/lib/types";
import type { CollectionAiRunSummary, QuoteAssistantRunSummary } from "@/lib/types";
import { getAdminPocketBase } from "./pocketbase-admin";
export type AiProvider = "anthropic" | "minimax";
@ -67,6 +67,13 @@ export interface AiSettings {
/** ISO timestamp of the last tick; "" = never. */
quote_assistant_last_run_at: string;
quote_assistant_last_run_summary: QuoteAssistantRunSummary | null;
/** Collection Confirm AI checks: background tick on/off (default off). */
collection_ai_enabled: boolean;
/** Operator override of the collection-check prompt; "" = use the default. */
collection_ai_prompt: string;
/** ISO timestamp of the last tick; "" = never. */
collection_ai_last_run_at: string;
collection_ai_last_run_summary: CollectionAiRunSummary | null;
updated_at: string;
}
@ -97,6 +104,9 @@ export interface AiSettingsUpdate {
quote_assistant_min_confidence?: QuoteAssistantMinConfidence;
/** `""` clears the override. Capped at 50 000 chars. */
quote_assistant_prompt?: string;
collection_ai_enabled?: boolean;
/** `""` clears the override. Capped at 50 000 chars. */
collection_ai_prompt?: string;
}
const COLLECTION = "ai_settings";
@ -119,6 +129,10 @@ const DEFAULT_SETTINGS: AiSettings = {
quote_assistant_prompt: "",
quote_assistant_last_run_at: "",
quote_assistant_last_run_summary: null,
collection_ai_enabled: false,
collection_ai_prompt: "",
collection_ai_last_run_at: "",
collection_ai_last_run_summary: null,
updated_at: "",
};
@ -149,6 +163,10 @@ interface RawSettingsRow {
quote_assistant_prompt?: string;
quote_assistant_last_run_at?: string;
quote_assistant_last_run_summary?: QuoteAssistantRunSummary | null;
collection_ai_enabled?: boolean;
collection_ai_prompt?: string;
collection_ai_last_run_at?: string;
collection_ai_last_run_summary?: CollectionAiRunSummary | null;
updated_at?: string;
}
@ -165,6 +183,13 @@ function normaliseRunSummary(value: unknown): QuoteAssistantRunSummary | null {
return null;
}
function normaliseCollectionRunSummary(value: unknown): CollectionAiRunSummary | null {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as CollectionAiRunSummary;
}
return null;
}
function rowToSettings(row: RawSettingsRow): AiSettings {
return {
id: row.id,
@ -189,6 +214,12 @@ function rowToSettings(row: RawSettingsRow): AiSettings {
quote_assistant_last_run_summary: normaliseRunSummary(
row.quote_assistant_last_run_summary,
),
collection_ai_enabled: !!row.collection_ai_enabled,
collection_ai_prompt: row.collection_ai_prompt ?? "",
collection_ai_last_run_at: row.collection_ai_last_run_at ?? "",
collection_ai_last_run_summary: normaliseCollectionRunSummary(
row.collection_ai_last_run_summary,
),
updated_at: row.updated_at ?? "",
};
}
@ -331,6 +362,13 @@ export async function updateAiSettings(
// Same defensive cap as system_prompt (PB field max is 50 000).
dbPatch.quote_assistant_prompt = patch.quote_assistant_prompt.slice(0, 50_000);
}
if (patch.collection_ai_enabled !== undefined) {
dbPatch.collection_ai_enabled = !!patch.collection_ai_enabled;
}
if (patch.collection_ai_prompt !== undefined) {
// Same defensive cap as system_prompt (PB field max is 50 000).
dbPatch.collection_ai_prompt = patch.collection_ai_prompt.slice(0, 50_000);
}
dbPatch.updated_at = new Date().toISOString();
@ -386,6 +424,32 @@ export async function recordQuoteAssistantRun(fields: {
cacheSet(rowToSettings(saved));
}
/**
* Persist the outcome of a Collection Confirm AI tick. Mirrors
* `recordQuoteAssistantRun`: writes ONLY the last-run fields (never
* `updated_at`), refreshes the cache, no-op without a row or fields.
*/
export async function recordCollectionAiRun(fields: {
last_run_at?: string;
summary?: CollectionAiRunSummary;
}): Promise<void> {
const dbPatch: Record<string, unknown> = {};
if (fields.last_run_at !== undefined) {
dbPatch.collection_ai_last_run_at = fields.last_run_at;
}
if (fields.summary !== undefined) {
dbPatch.collection_ai_last_run_summary = fields.summary;
}
if (Object.keys(dbPatch).length === 0) return;
const pb = await getAdminPocketBase();
const rows = await pb.collection(COLLECTION).getFullList({});
if (rows.length === 0) return;
const id = (rows[0] as RawSettingsRow).id;
const saved = (await pb.collection(COLLECTION).update(id, dbPatch)) as RawSettingsRow;
cacheSet(rowToSettings(saved));
}
/**
* Convenience for callers that just want the SDK knobs (no record
* scaffolding). Equivalent to `getAiSettings({ includeSecret: true })`

View file

@ -1,6 +1,7 @@
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { getOrderItemsCollectionName } from "@/lib/server/orders-service";
import { compressFile } from "@/lib/server/attachment-compressor";
import type { CollectionAiCheck, CollectionDocType } from "@/lib/types";
import {
appendCostNote,
@ -109,6 +110,20 @@ export type ConfirmRow = {
supplier_sku?: string;
supplier?: string;
poNumber?: string;
/** AI check of this line against the driver's invoice photo. */
ai_check?: CollectionAiCheck | null;
/** Document-level summary of the invoice the AI read for this card. */
ai_doc?: {
read_id: string;
doc_type: CollectionDocType;
document_no: string;
doc_date: string;
supplier_name: string;
findings: string[];
} | null;
/** True when the line's supplier code matches a stock item (POS). */
in_pos?: boolean | null;
pos_stock_item_id?: string | null;
/**
* Lightweight summary of the most recent `price_diff_report` entry
* the `po-agent` wrote to this PO's `notes` field. Populated by

View file

@ -553,6 +553,65 @@ export interface QuoteAssistantRunSummary {
errors: string[];
}
export type CollectionDocType = "tax_invoice" | "delivery_note" | "other";
export interface CollectionDocLine {
line_no: number;
code: string;
description: string;
quantity: number | null;
unit: string;
/** Per single unit, already normalised from "per 100" etc. */
unit_price: number | null;
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[];
}
export interface QuoteItem extends BaseRecord {
quote: string;
description: string;