diff --git a/backend/package-lock.json b/backend/package-lock.json index 59b06f3..2668c3b 100755 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -8,6 +8,7 @@ "name": "redefined-designs-backend", "version": "1.0.0", "dependencies": { + "@anthropic-ai/sdk": "^0.122.0", "@types/markdown-it": "^14.2.0", "bcryptjs": "^2.4.3", "cookie-parser": "^1.4.6", @@ -20,7 +21,8 @@ "node-pg-migrate": "^7.6.1", "nodemailer": "^6.9.14", "pg": "^8.12.0", - "sharp": "^0.35.4" + "sharp": "^0.35.4", + "zod": "^4.5.4" }, "devDependencies": { "@eslint/js": "^9.39.5", @@ -52,6 +54,26 @@ "node": ">=20.9.0" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.122.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.122.0.tgz", + "integrity": "sha512-GGPNftt0caaz9MDlmNQGHX8855Ojaduyy5pm9Sm1h7HalCn0cWNb5/bweadJF+4yzbal+QL6ztBa09WAAOzLmQ==", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -518,6 +540,14 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -3142,6 +3172,11 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==" + }, "node_modules/@tinqerjs/pg-promise-adapter": { "version": "0.0.27", "resolved": "https://registry.npmjs.org/@tinqerjs/pg-promise-adapter/-/pg-promise-adapter-0.0.27.tgz", @@ -6123,6 +6158,11 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==" + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -7579,6 +7619,18 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -9272,6 +9324,15 @@ "node": ">=10" } }, + "node_modules/standardwebhooks": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz", + "integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -9579,6 +9640,11 @@ "node": ">=0.6" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==" + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -10075,6 +10141,14 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz", + "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/backend/package.json b/backend/package.json index 9f2c296..04fbdd0 100755 --- a/backend/package.json +++ b/backend/package.json @@ -27,6 +27,7 @@ "migrate:create": "node-pg-migrate create --migration-file-language js" }, "dependencies": { + "@anthropic-ai/sdk": "^0.122.0", "@types/markdown-it": "^14.2.0", "bcryptjs": "^2.4.3", "cookie-parser": "^1.4.6", @@ -39,7 +40,8 @@ "node-pg-migrate": "^7.6.1", "nodemailer": "^6.9.14", "pg": "^8.12.0", - "sharp": "^0.35.4" + "sharp": "^0.35.4", + "zod": "^4.5.4" }, "devDependencies": { "@eslint/js": "^9.39.5", diff --git a/backend/src/adminSettings.ts b/backend/src/adminSettings.ts index f084e63..4dc1879 100644 --- a/backend/src/adminSettings.ts +++ b/backend/src/adminSettings.ts @@ -1,4 +1,5 @@ import { pool } from './db'; +import { DEFAULT_DRAFTING_MODEL, DRAFTING_MODELS, isDraftingModel } from './intake/models'; /** * Every admin-configurable setting, in one table. @@ -24,7 +25,16 @@ const DEFINITIONS = [ { key: 'verify_token_hours', name: 'verifyTokenHours', type: 'hours', fallback: 24 }, { key: 'password_reset_hours', name: 'passwordResetHours', type: 'hours', fallback: 1 }, { key: 'greeting_format', name: 'greetingFormat', type: 'text', fallback: 'Hi {{firstName}},' }, - { key: 'greeting_fallback', name: 'greetingFallback', type: 'text', fallback: 'Hi,' } + { key: 'greeting_fallback', name: 'greetingFallback', type: 'text', fallback: 'Hi,' }, + // A 'choice' rather than a 'text', so a mistyped model name is refused at the + // edge instead of stored. It would otherwise fail on every submission and + // show up only as drafts quietly not appearing (#223). + { + key: 'drafting_model', + name: 'draftingModel', + type: 'choice', + fallback: DEFAULT_DRAFTING_MODEL + } ] as const; type Definition = (typeof DEFINITIONS)[number]; @@ -33,8 +43,11 @@ export type SettingName = Definition['name']; export type HoursSettingName = Extract['name']; export type TextSettingName = Extract['name']; +export type ChoiceSettingName = Extract['name']; -export type AdminSettings = Record & Record; +export type AdminSettings = Record & + Record & + Record; export const HOURS_SETTINGS: readonly HoursSettingName[] = DEFINITIONS.filter( (d): d is Extract => d.type === 'hours' @@ -44,24 +57,67 @@ export const TEXT_SETTINGS: readonly TextSettingName[] = DEFINITIONS.filter( (d): d is Extract => d.type === 'text' ).map(d => d.name); +export const CHOICE_SETTINGS: readonly ChoiceSettingName[] = DEFINITIONS.filter( + (d): d is Extract => d.type === 'choice' +).map(d => d.name); + +/** + * The values each choice setting will accept, for the route to validate against + * and the admin UI to offer. Derived from the model catalogue rather than + * restated, so the dropdown cannot come to disagree with what is billable. + */ +export const CHOICE_OPTIONS: Readonly> = { + draftingModel: DRAFTING_MODELS.map(m => m.id) +}; + +export function isValidChoice(name: ChoiceSettingName, value: string): boolean { + return name === 'draftingModel' ? isDraftingModel(value) : false; +} + +// One reader per type, at module level rather than branched inline. The same +// reasoning as the definitions above: getSettings should read as "look each one +// up and resolve it", and a third type was enough to push the inline version +// past the complexity limit. + +// An empty format would render every greeting as nothing at all, which reads as +// a bug in the email rather than a setting someone cleared. +function resolveText(raw: string | undefined, fallback: string): string { + return raw !== undefined && raw.trim() !== '' ? raw : fallback; +} + +// A row that is present but unparseable falls back rather than yielding NaN, +// which would otherwise reach Date arithmetic and mint a token with an Invalid +// Date expiry that no query could ever match. +function resolveHours(raw: string | undefined, fallback: number): number { + const parsed = parseFloat(raw ?? ''); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +// A stored value that is no longer offered — a model retired since it was +// chosen — falls back rather than being handed on. Drafting with the default +// beats drafting with a model the API will refuse. +function resolveChoice( + name: ChoiceSettingName, + raw: string | undefined, + fallback: string +): string { + return raw !== undefined && isValidChoice(name, raw) ? raw : fallback; +} + export async function getSettings(): Promise { const { rows } = await pool.query(`SELECT key, value FROM admin_settings`); const stored = new Map(rows.map(r => [r.key, r.value])); const settings = {} as Record; - for (const { key, name, type, fallback } of DEFINITIONS) { - const raw = stored.get(key); - if (type === 'text') { - // An empty format would render every greeting as nothing at all, which - // reads as a bug in the email rather than a setting someone cleared. - settings[name] = raw !== undefined && raw.trim() !== '' ? raw : fallback; - continue; + for (const definition of DEFINITIONS) { + const raw = stored.get(definition.key); + if (definition.type === 'choice') { + settings[definition.name] = resolveChoice(definition.name, raw, definition.fallback); + } else if (definition.type === 'text') { + settings[definition.name] = resolveText(raw, definition.fallback); + } else { + settings[definition.name] = resolveHours(raw, definition.fallback); } - const parsed = parseFloat(raw ?? ''); - // A row that is present but unparseable falls back rather than yielding - // NaN, which would otherwise reach Date arithmetic and mint a token with an - // Invalid Date expiry that no query could ever match. - settings[name] = Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; } return settings as AdminSettings; } diff --git a/backend/src/envValidation.ts b/backend/src/envValidation.ts index 568a876..4ba3d98 100644 --- a/backend/src/envValidation.ts +++ b/backend/src/envValidation.ts @@ -162,6 +162,24 @@ function checkAdminGate(env: NodeJS.ProcessEnv): string[] { ]; } +// Optional on purpose, and unlike the two above, unset costs nothing in +// safety. A submission still arrives, keeps its photos and waits in the queue +// undrafted (#223). It is a warning rather than an error because the photos are +// often the only copy of an item no longer in the sender's hands, so losing a +// consignment to an expired key would be far worse than an item arriving +// without its description written. Silence would be the wrong answer too: an +// operator who believes drafting is on and finds every item undrafted has +// nothing to tell them why. +function checkDraftingKey(env: NodeJS.ProcessEnv): string[] { + if (isPresent(env, 'ANTHROPIC_API_KEY')) { + return []; + } + return [ + 'ANTHROPIC_API_KEY is not set — submitted items will arrive undrafted and wait in the ' + + 'review queue for someone to write them up by hand.' + ]; +} + // Optional, and the same shape as the admin gate above: unset is a working // configuration with one defence switched off, which is worth saying out loud // rather than leaving to be discovered. Set, it has to be an absolute origin — @@ -205,6 +223,6 @@ export function validateEnv(env: NodeJS.ProcessEnv): EnvValidation { ...mail.errors, ...uploads.errors ], - warnings: [...mail.warnings, ...checkAdminGate(env), ...uploads.warnings] + warnings: [...mail.warnings, ...checkAdminGate(env), ...uploads.warnings, ...checkDraftingKey(env)] }; } diff --git a/backend/src/intake/anthropicClient.ts b/backend/src/intake/anthropicClient.ts new file mode 100644 index 0000000..11cc3ce --- /dev/null +++ b/backend/src/intake/anthropicClient.ts @@ -0,0 +1,30 @@ +import Anthropic from '@anthropic-ai/sdk'; + +/** + * The client, or null when there is no key. + * + * Null rather than a throw, because an unconfigured environment is a working + * one: submissions still arrive and wait undrafted. The worker treats null + * exactly as it treats a failed call, which keeps one path rather than two. + * + * Constructed once and cached. The SDK holds a connection pool, and building + * one per submission would be wasteful on a route a stranger can trigger. + */ +let cached: Anthropic | null = null; +let resolved = false; + +export function getAnthropicClient(): Anthropic | null { + if (resolved) return cached; + + const key = process.env.ANTHROPIC_API_KEY; + cached = key !== undefined && key.trim() !== '' ? new Anthropic({ apiKey: key }) : null; + resolved = true; + + return cached; +} + +/** Exposed for tests, which need a fresh decision per case. */ +export function resetAnthropicClient(): void { + cached = null; + resolved = false; +} diff --git a/backend/src/intake/applyDraft.ts b/backend/src/intake/applyDraft.ts new file mode 100644 index 0000000..a7d07f2 --- /dev/null +++ b/backend/src/intake/applyDraft.ts @@ -0,0 +1,78 @@ +import { PoolClient } from 'pg'; +import { DraftOutcome } from './draftListing'; + +/** + * Writes a finished draft to the database. + * + * The copy goes on `item_drafts`, never on the item. The item keeps its + * placeholder name and description until a person approves them in the review + * queue (#225) — nothing a model wrote reaches the catalogue unreviewed. + * + * The price is the deliberate exception, because #220 chose to price an item on + * arrival rather than leave it unpriced. `price_source` records that the number + * came from a model rather than a person, which is what lets the review queue + * show it as unconfirmed. + */ +export async function applyDraft( + client: PoolClient, + itemId: number, + outcome: DraftOutcome +): Promise { + const { draft } = outcome; + + // Checked against the real table before it is stored. The schema constrains + // the shape of the answer but cannot enforce membership, and a category the + // shop does not have would be invisible to every storefront filter — a draft + // nobody could find, rather than an obvious error. + const categoryId = draft.category === null + ? null + : ( + await client.query<{ id: number }>( + `SELECT id FROM categories WHERE lower(name) = lower($1)`, + [draft.category] + ) + ).rows[0]?.id ?? null; + + const hasPrice = draft.suggestedPriceCents !== null; + + await client.query( + `UPDATE item_drafts + SET state = 'ready', + model = $2, + ai_name = $3, + ai_description = $4, + ai_category_id = $5, + ai_tag_names = $6, + ai_suggested_price_cents = $7, + price_source = $8, + input_tokens = $9, + output_tokens = $10, + cost_micros = $11, + ai_error = NULL, + drafted_at = now() + WHERE item_id = $1`, + [ + itemId, + outcome.model, + draft.name, + draft.description, + categoryId, + draft.tags, + draft.suggestedPriceCents, + hasPrice ? 'ai' : 'default', + outcome.inputTokens, + outcome.outputTokens, + outcome.costMicros + ] + ); + + // Only when there is one. Absent, the item keeps the migration's default and + // price_source stays 'default' — the review queue shows both the same way, as + // a number nobody has chosen yet. + if (hasPrice) { + await client.query(`UPDATE items SET price_cents = $2 WHERE id = $1`, [ + itemId, + draft.suggestedPriceCents + ]); + } +} diff --git a/backend/src/intake/draftListing.ts b/backend/src/intake/draftListing.ts new file mode 100644 index 0000000..1f01cae --- /dev/null +++ b/backend/src/intake/draftListing.ts @@ -0,0 +1,77 @@ +import type Anthropic from '@anthropic-ai/sdk'; +import { zodOutputFormat } from '@anthropic-ai/sdk/helpers/zod'; +import { getSettings } from '../adminSettings'; +import { DraftSchema, DraftResult } from './draftSchema'; +import { buildSystemPrompt, buildUserContent } from './draftPrompt'; +import { costMicros } from './models'; + +/** + * Read from Admin settings rather than the environment, so the choice can be + * changed without a redeploy. getSettings supplies the fallback, so there is no + * second default here to disagree with the one in the catalogue. + */ +async function draftingModel(): Promise { + return (await getSettings()).draftingModel; +} + +/** + * Enough for a listing and its tags, and low enough that a model which starts + * rambling is cut off rather than billed for indefinitely. + */ +const MAX_TOKENS = 2000; + +export interface DraftInput { + photos: { mediaType: string; base64: string }[]; + note: string | null; + categories: string[]; + tags: string[]; +} + +export interface DraftOutcome { + draft: DraftResult; + model: string; + inputTokens: number; + outputTokens: number; + costMicros: number; +} + +/** + * One submission, one draft. + * + * The client is a parameter rather than a module import so every test can pass + * a stub. A test that reaches the real API is a defect in the test: this runs + * on a public route and each call costs money. + */ +export async function draftListing( + client: Anthropic, + input: DraftInput +): Promise { + const model = await draftingModel(); + + const response = await client.messages.parse({ + model, + max_tokens: MAX_TOKENS, + system: buildSystemPrompt(input.categories, input.tags), + messages: [{ role: 'user', content: buildUserContent(input.photos, input.note) as never }], + output_config: { format: zodOutputFormat(DraftSchema) } + }); + + // Null when the response did not satisfy the schema. Guarded rather than + // asserted: the SDK's own examples reach for it with `?.`, and a model + // answering in prose is exactly the case worth failing cleanly on. + const draft = response.parsed_output; + if (!draft) { + throw new Error('the model did not return a draft matching the expected shape'); + } + + const inputTokens = response.usage?.input_tokens ?? 0; + const outputTokens = response.usage?.output_tokens ?? 0; + + return { + draft, + model, + inputTokens, + outputTokens, + costMicros: costMicros(model, inputTokens, outputTokens) + }; +} diff --git a/backend/src/intake/draftPrompt.ts b/backend/src/intake/draftPrompt.ts new file mode 100644 index 0000000..a145b04 --- /dev/null +++ b/backend/src/intake/draftPrompt.ts @@ -0,0 +1,76 @@ +/** + * What the model is told, and what it is shown. + * + * Pure and separately tested because this is where the correctness of every + * draft is decided. Nothing downstream can distinguish an observed detail from + * an invented one — the description arrives as prose either way — so the only + * place that distinction can be enforced is here, in the instruction. + * + * On a one-of-a-kind item an invented "1930s hand-thrown stoneware" is a false + * claim on a storefront, and it is the shop that answers for it rather than the + * model. The submitter's note is the only trustworthy source for anything a + * photograph cannot show. + */ + +/** + * Listed rather than described, so the model chooses from what exists instead + * of inventing a taxonomy the storefront filters know nothing about. + */ +function offer(values: string[]): string { + return values.length > 0 ? values.join(', ') : '(none defined yet)'; +} + +export function buildSystemPrompt(categories: string[], tags: string[]): string { + return [ + 'You write short listings for a shop that sells one-of-a-kind second-hand items.', + '', + 'You are given photographs of a single item, and sometimes a note from the person', + 'sending it in.', + '', + 'Describe only what you can see in the photographs, plus whatever the note tells you.', + 'Do not state a material, age, maker, or provenance that is neither visible nor in the', + 'note. If you do not know something, leave it out rather than guessing — a wrong detail', + 'here becomes a false claim on a public shop, and the shop answers for it rather than you.', + 'Mention visible damage plainly; a buyer finding it later is worse than reading about it now.', + '', + `Choose a category from this list, or null if none fits: ${offer(categories)}`, + `Choose tags from this list, or an empty list if none fit: ${offer(tags)}`, + 'Do not invent categories or tags that are not listed.', + '', + 'Suggest a price in cents if the photographs and note give you enough to judge one,', + 'or null if they do not. A person reviews everything before it is listed.' + ].join('\n'); +} + +interface Photo { + mediaType: string; + base64: string; +} + +/** + * The photos, then the note. + * + * Images first because the note refers to them. The note is quoted and labelled + * as coming from the sender rather than merged into the instruction: it is + * untrusted text from an unauthenticated stranger, and it should read as + * evidence to weigh rather than as something the shop is asserting. + */ +export function buildUserContent(photos: Photo[], note: string | null): unknown[] { + const blocks: unknown[] = photos.map((photo) => ({ + type: 'image', + source: { type: 'base64', media_type: photo.mediaType, data: photo.base64 } + })); + + // Whitespace counts as absent. Otherwise an accidental space arrives looking + // like something the sender meant to say. + const hasNote = note !== null && note.trim() !== ''; + + blocks.push({ + type: 'text', + text: hasNote + ? `The sender wrote this about the item:\n\n${note}` + : 'The sender left no note, so the photographs are all you have.' + }); + + return blocks; +} diff --git a/backend/src/intake/draftSchema.ts b/backend/src/intake/draftSchema.ts new file mode 100644 index 0000000..ee3fb46 --- /dev/null +++ b/backend/src/intake/draftSchema.ts @@ -0,0 +1,47 @@ +import { z } from 'zod'; + +/** + * What the model must return, enforced rather than hoped for. + * + * Constraining the shape is the difference between a bad draft and a crash: + * the SDK validates the response against this before any of it reaches the + * database, so a model that answers in prose or invents a field becomes a + * caught error rather than a row full of nonsense. + * + * Every field the model may decline to answer is nullable, because it is told + * to say nothing rather than guess. A null category means it did not recognise + * one, which is a better answer than a wrong one and is exactly what the review + * queue exists to resolve. + */ + +/** Beyond anything this shop sells, so an absurd number is caught here. */ +const MAX_SUGGESTED_PRICE_CENTS = 1_000_000; + +export const DraftSchema = z.object({ + /** A short title. Until this lands, the item is named for its submission. */ + name: z.string().min(1).max(200), + /** The storefront body, rendered with html:false like every other stored body. */ + description: z.string().min(1).max(4000), + /** + * Chosen from the categories it was given, or null. The prompt supplies the + * closed set; this cannot enforce membership, so applyDraft checks the answer + * against the real table before writing anything. + */ + category: z.string().nullable(), + /** Also from a supplied set, and also checked on the way in rather than here. */ + tags: z.array(z.string()), + /** + * Cents, or null when it will not guess. Integer and bounded at both ends, + * because a fractional, negative or absurd figure reaching the review queue + * is a number somebody has to notice is wrong — and being trustworthy at a + * glance is that queue's whole job. + */ + suggestedPriceCents: z + .number() + .int() + .min(0) + .max(MAX_SUGGESTED_PRICE_CENTS) + .nullable() +}); + +export type DraftResult = z.infer; diff --git a/backend/src/intake/draftingWorker.ts b/backend/src/intake/draftingWorker.ts new file mode 100644 index 0000000..3c2effe --- /dev/null +++ b/backend/src/intake/draftingWorker.ts @@ -0,0 +1,156 @@ +import type Anthropic from '@anthropic-ai/sdk'; +import { promises as fs } from 'fs'; +import path from 'path'; +import { pool } from '../db'; +import { typeForExtension } from '../uploadTypes'; +import { getAnthropicClient } from './anthropicClient'; +import { draftListing } from './draftListing'; +import { applyDraft } from './applyDraft'; + +/** + * Turns queued submissions into drafts. + * + * Driven from two places: a call at the end of a successful submission, so a + * draft is usually waiting by the time anybody looks, and a cron sweeper, so a + * restart mid-draft is recoverable rather than a permanently stalled row. + * + * The governing rule is that a submission is the only irreplaceable thing here. + * The photos are often the only copy of an item no longer in the sender's + * hands, so every failure below leaves the row and its images intact and merely + * undrafted. Nothing in this file deletes anything. + */ + +/** Three tries, then it waits for a person rather than burning money on a loop. */ +export const MAX_ATTEMPTS = 3; + +/** Small, because each one is an API call and the sweeper comes round again. */ +const DEFAULT_BATCH = 5; + +type Photo = { mediaType: string; base64: string }; + +interface QueuedRow { + item_id: number; + submitter_note: string | null; +} + +export interface SweepResult { + drafted: number; + failed: number; + skipped: number; +} + +async function readPhotos(itemId: number): Promise { + const { rows } = await pool.query<{ image_path: string }>( + `SELECT image_path FROM item_images WHERE item_id = $1 ORDER BY sort_order`, + [itemId] + ); + + const photos: Photo[] = []; + for (const row of rows) { + // basename only: image_path is stored as '/uploads/' and the + // directory it lives in is a server constant. Same rule as handleRow in + // backfillImageReencode, but using the shared typeForExtension rather than + // that file's private copy of the map. + const file = path.join(process.env.UPLOADS_DIR ?? '', path.basename(row.image_path)); + const mediaType = typeForExtension(path.extname(file)); + // Null for anything the app would refuse to serve. Sending it to the model + // would be paying to have it rejected. + if (mediaType === null) continue; + photos.push({ mediaType, base64: (await fs.readFile(file)).toString('base64') }); + } + return photos; +} + +async function namesOf(table: 'categories' | 'tags'): Promise { + // The table name is a closed union, never caller input — there is nothing + // here to interpolate from a request. + const { rows } = await pool.query<{ name: string }>(`SELECT name FROM ${table} ORDER BY name`); + return rows.map((row) => row.name); +} + +/** + * Records a failure without ever losing the submission. + * + * The row stays reachable either way: 'queued' while tries remain, so the + * sweeper picks it up again, and 'failed' once they are spent, so it stops + * costing money and waits for a person. The item and its photos are untouched + * in both cases. + */ +async function recordFailure(itemId: number, message: string): Promise { + await pool.query( + `UPDATE item_drafts + SET attempts = attempts + 1, + ai_error = $2, + state = CASE WHEN attempts + 1 >= $3 THEN 'failed' ELSE 'queued' END + WHERE item_id = $1`, + [itemId, message.slice(0, 500), MAX_ATTEMPTS] + ); +} + +/** Photos are passed in rather than re-read: the caller has already loaded them + * to check there is at least one, and reading every file off disk twice per + * submission is a cost for nothing. */ +async function draftOne( + client: Anthropic, + itemId: number, + note: string | null, + photos: Photo[] +): Promise { + const outcome = await draftListing(client, { + photos, + note, + categories: await namesOf('categories'), + tags: await namesOf('tags') + }); + + const db = await pool.connect(); + try { + await db.query('BEGIN'); + await applyDraft(db, itemId, outcome); + await db.query('COMMIT'); + } catch (err) { + await db.query('ROLLBACK'); + throw err; + } finally { + db.release(); + } +} + +export async function draftQueued(limit = DEFAULT_BATCH): Promise { + const { rows } = await pool.query( + `SELECT item_id, submitter_note FROM item_drafts + WHERE state = 'queued' AND attempts < $2 + ORDER BY created_at + LIMIT $1`, + [limit, MAX_ATTEMPTS] + ); + + // Unconfigured is not a failure and must not spend an attempt. A fortnight + // without a key would otherwise exhaust the retries and mark every waiting + // submission failed, with nothing wrong with any of them. + const client = getAnthropicClient(); + if (client === null) { + return { drafted: 0, failed: 0, skipped: rows.length }; + } + + let drafted = 0; + let failed = 0; + + for (const row of rows) { + try { + const photos = await readPhotos(row.item_id); + if (photos.length === 0) { + throw new Error('no readable photos'); + } + await draftOne(client, row.item_id, row.submitter_note, photos); + drafted++; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`[drafting] item ${row.item_id}: ${message}`); + await recordFailure(row.item_id, message); + failed++; + } + } + + return { drafted, failed, skipped: 0 }; +} diff --git a/backend/src/intake/models.ts b/backend/src/intake/models.ts new file mode 100644 index 0000000..f488caf --- /dev/null +++ b/backend/src/intake/models.ts @@ -0,0 +1,57 @@ +/** + * The models that may draft a listing, and what each one costs. + * + * One catalogue rather than two lists. The Admin settings dropdown needs the + * models, `costMicros` needs their rates, and the price shown beside a model in + * Admin has to be the price it is actually billed at — which it cannot be if + * the list and the rates are maintained separately. + * + * Rates are dollars per million tokens, confirmed against the pricing page on + * 2026-08-31 rather than recalled. Worth checking again when a model is added: + * an increase to $3/$15 had been scheduled for 2026-09-01 and was cancelled, + * with Sonnet's $2/$10 made permanent. + */ +export interface DraftingModel { + id: string; + /** Shown in the Admin dropdown. */ + label: string; + /** Dollars per million input tokens. */ + inputRate: number; + /** Dollars per million output tokens. */ + outputRate: number; +} + +export const DRAFTING_MODELS: readonly DraftingModel[] = [ + { id: 'claude-sonnet-5', label: 'Claude Sonnet 5', inputRate: 2, outputRate: 10 }, + { id: 'claude-opus-5', label: 'Claude Opus 5', inputRate: 5, outputRate: 25 }, + { id: 'claude-haiku-4-5', label: 'Claude Haiku 4.5', inputRate: 1, outputRate: 5 } +]; + +/** + * Sonnet, not Opus. The task is writing a description from a photograph rather + * than reasoning, and this runs once per submission on a route a stranger with + * a link can trigger. Opus costs two and a half times as much per item. + */ +export const DEFAULT_DRAFTING_MODEL = 'claude-sonnet-5'; + +export function isDraftingModel(id: string): boolean { + return DRAFTING_MODELS.some((model) => model.id === id); +} + +/** + * Deliberately not zero. An unrecognised model pricing at nothing would make a + * budget read as unspent however much was really spent, which is the one + * failure a spend guard must not have. Set to the most expensive rate here, so + * an unknown model errs towards over- rather than under-reporting. + */ +const FALLBACK_RATE = { inputRate: 5, outputRate: 25 }; + +/** + * Whole micros, so a cost never carries a floating-point fraction into the + * database. Rates are per million tokens and a micro is a millionth of a + * dollar, so the two cancel and the arithmetic is just tokens times rate. + */ +export function costMicros(model: string, inputTokens: number, outputTokens: number): number { + const rate = DRAFTING_MODELS.find((m) => m.id === model) ?? FALLBACK_RATE; + return Math.round(inputTokens * rate.inputRate + outputTokens * rate.outputRate); +} diff --git a/backend/src/routes/adminSettings.ts b/backend/src/routes/adminSettings.ts index 68c0890..d4bd8db 100644 --- a/backend/src/routes/adminSettings.ts +++ b/backend/src/routes/adminSettings.ts @@ -5,6 +5,9 @@ import { updateSettings, HOURS_SETTINGS, TEXT_SETTINGS, + CHOICE_SETTINGS, + CHOICE_OPTIONS, + isValidChoice, SettingName } from '../adminSettings'; @@ -38,6 +41,20 @@ router.put('/', asyncRoute(async (req: Request, res: Response) => { values[name] = raw; } + // Membership is checked here rather than left to the dropdown. A value + // outside the set would be stored happily and then fail on every submission, + // surfacing only as drafts quietly not appearing (#223). + for (const name of CHOICE_SETTINGS) { + const raw = req.body[name]; + if (raw === undefined) continue; + if (typeof raw !== 'string' || !isValidChoice(name, raw)) { + return res + .status(400) + .json({ error: `${name} must be one of: ${CHOICE_OPTIONS[name].join(', ')}` }); + } + values[name] = raw; + } + await updateSettings(values); res.json(await getSettings()); })); diff --git a/backend/src/routes/intake.ts b/backend/src/routes/intake.ts index 62db46c..d3542e3 100644 --- a/backend/src/routes/intake.ts +++ b/backend/src/routes/intake.ts @@ -4,6 +4,7 @@ import { asyncRoute } from '../asyncRoute'; import { hashToken } from '../uploadLinks'; import { uploadImages, verifyUploadedImages, insertItemImages } from '../imageUpload'; import { intakeViewLimiter, intakeSubmitLimiter } from '../rateLimit'; +import { draftQueued } from '../intake/draftingWorker'; const router = Router(); @@ -148,6 +149,14 @@ router.post( } await client.query('COMMIT'); + + // Deliberately not awaited, and catching for itself. A slow or failing + // model must not become a failed upload for someone who did nothing + // wrong, which is the whole reason drafting does not happen inline. The + // sweeper picks up anything this misses, so the cost of it failing here + // is a few minutes' delay rather than a lost submission. + void draftQueued(1).catch((err) => console.error('[drafting] after submission:', err)); + // No item id in the response: the sender has no business knowing about // the catalogue, and nothing they could do with it. res.status(201).json({ ok: true }); diff --git a/backend/src/server.ts b/backend/src/server.ts index af7d9cb..0c89560 100755 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -6,6 +6,7 @@ import { renderTemplate, greeting, formatDuration } from './emailTemplates'; import { getSettings } from './adminSettings'; import { loadStoredTemplate } from './routes/adminEmailTemplates'; import { validateEnv } from './envValidation'; +import { draftQueued } from './intake/draftingWorker'; // Release cart holds whose expiry has passed. async function sweepExpiredCarts(): Promise { @@ -96,6 +97,16 @@ interface ReminderRow { setInterval(() => void sweepExpiredCarts(), 5 * 60 * 1000); cron.schedule('0 9 * * *', () => void sendCartReminders()); +// Every five minutes, in the same shape as the cart sweep. This is what makes a +// restart mid-draft recoverable rather than a permanently stalled row, and what +// picks up anything the post-submission call missed. Unlike the two above, +// draftQueued does not catch at its own top level — the initial query can +// reject — so it catches here instead, for the reason the comment above gives. +setInterval( + () => void draftQueued().catch((err) => console.error('[drafting] sweep:', err)), + 5 * 60 * 1000 +); + const PORT = parseInt(process.env.PORT || '3000', 10); // Checked at boot rather than left to be discovered by the first request that diff --git a/backend/tests/integration/adminSettings.integration.test.ts b/backend/tests/integration/adminSettings.integration.test.ts index 69df181..43d41e3 100644 --- a/backend/tests/integration/adminSettings.integration.test.ts +++ b/backend/tests/integration/adminSettings.integration.test.ts @@ -29,7 +29,8 @@ describe('GET /api/admin/settings', () => { verifyTokenHours: 24, passwordResetHours: 1, greetingFormat: 'Hi {{firstName}},', - greetingFallback: 'Hi,' + greetingFallback: 'Hi,', + draftingModel: 'claude-sonnet-5' }); }); }); @@ -100,4 +101,22 @@ describe('PUT /api/admin/settings', () => { expect(res.status).toBe(400); expect((await getSettings()).cartExpiryHours).toBe(6); }); + // #223. A model name outside the offered set is refused rather than stored. + // Stored, it would be accepted here and then fail on every submission, + // surfacing only as drafts quietly not appearing. + it('refuses a drafting model it does not offer', async () => { + const res = await request(app) + .put('/api/admin/settings') + .send({ draftingModel: 'claude-sonnet-5-typo' }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain('draftingModel'); + }); + + it('accepts a drafting model it does offer', async () => { + const res = await request(app).put('/api/admin/settings').send({ draftingModel: 'claude-opus-5' }); + + expect(res.status).toBe(200); + expect((await getSettings()).draftingModel).toBe('claude-opus-5'); + }); }); diff --git a/backend/tests/integration/drafting.integration.test.ts b/backend/tests/integration/drafting.integration.test.ts new file mode 100644 index 0000000..39285f4 --- /dev/null +++ b/backend/tests/integration/drafting.integration.test.ts @@ -0,0 +1,244 @@ +import { pool } from '../../src/db'; +import { applyDraft } from '../../src/intake/applyDraft'; +import { DraftOutcome } from '../../src/intake/draftListing'; +import { resetDb, closeDb } from './setup/testDb'; +import { draftQueued, MAX_ATTEMPTS } from '../../src/intake/draftingWorker'; +import { resetAnthropicClient } from '../../src/intake/anthropicClient'; + +beforeEach(async () => { + await resetDb(); +}); + +afterAll(async () => { + await pool.end(); + await closeDb(); +}); + +/** + * An item at 'pending' with a draft row waiting, which is what a submission + * leaves behind. price_cents is not set: it takes the 8000 default from #222's + * migration, and that default is what the no-price case below asserts on. + */ +async function seedSubmission(): Promise { + const { rows } = await pool.query<{ id: number }>( + `INSERT INTO items (name, status) VALUES ('Submission placeholder', 'pending') RETURNING id` + ); + const itemId = rows[0]!.id; + await pool.query(`INSERT INTO item_drafts (item_id, submitter_note) VALUES ($1, 'a note')`, [itemId]); + return itemId; +} + +async function apply(itemId: number, outcome: DraftOutcome): Promise { + const client = await pool.connect(); + try { + await applyDraft(client, itemId, outcome); + } finally { + client.release(); + } +} + +const outcome: DraftOutcome = { + draft: { + name: 'Blue stoneware vase', + description: 'Hand-thrown, chipped base.', + category: null, + tags: ['blue'], + suggestedPriceCents: 4500 + }, + model: 'claude-sonnet-5', + inputTokens: 1000, + outputTokens: 200, + costMicros: 4000 +}; + +describe('applying a draft', () => { + it('writes the copy onto the draft row, not the item', async () => { + const itemId = await seedSubmission(); + await apply(itemId, outcome); + + const { rows } = await pool.query( + `SELECT ai_name, ai_description, state, model, ai_tag_names FROM item_drafts WHERE item_id = $1`, + [itemId] + ); + expect(rows[0]?.ai_name).toBe('Blue stoneware vase'); + expect(rows[0]?.state).toBe('ready'); + expect(rows[0]?.model).toBe('claude-sonnet-5'); + expect(rows[0]?.ai_tag_names).toEqual(['blue']); + + // The item keeps its placeholder name until a person approves the draft. + // Nothing a model wrote should reach the catalogue unreviewed. + const item = await pool.query(`SELECT name FROM items WHERE id = $1`, [itemId]); + expect(item.rows[0]?.name).toBe('Submission placeholder'); + }); + + // The one thing that does reach the item, because #220 chose pre-pricing over + // an unpriced row — with price_source recording that nobody chose it. + it('writes a suggested price onto the item and records where it came from', async () => { + const itemId = await seedSubmission(); + await apply(itemId, outcome); + + const item = await pool.query(`SELECT price_cents FROM items WHERE id = $1`, [itemId]); + expect(item.rows[0]?.price_cents).toBe(4500); + + const draft = await pool.query( + `SELECT price_source, ai_suggested_price_cents FROM item_drafts WHERE item_id = $1`, + [itemId] + ); + expect(draft.rows[0]?.price_source).toBe('ai'); + expect(draft.rows[0]?.ai_suggested_price_cents).toBe(4500); + }); + + it('leaves the default price alone when the model would not guess', async () => { + const itemId = await seedSubmission(); + await apply(itemId, { ...outcome, draft: { ...outcome.draft, suggestedPriceCents: null } }); + + const item = await pool.query(`SELECT price_cents FROM items WHERE id = $1`, [itemId]); + expect(item.rows[0]?.price_cents).toBe(8000); + + const draft = await pool.query(`SELECT price_source FROM item_drafts WHERE item_id = $1`, [itemId]); + expect(draft.rows[0]?.price_source).toBe('default'); + }); + + it('records what the call cost', async () => { + const itemId = await seedSubmission(); + await apply(itemId, outcome); + + const { rows } = await pool.query( + `SELECT input_tokens, output_tokens, cost_micros, drafted_at FROM item_drafts WHERE item_id = $1`, + [itemId] + ); + expect(rows[0]?.input_tokens).toBe(1000); + expect(rows[0]?.output_tokens).toBe(200); + expect(rows[0]?.cost_micros).toBe(4000); + expect(rows[0]?.drafted_at).not.toBeNull(); + }); + + it('stores a category the shop actually has', async () => { + const itemId = await seedSubmission(); + const { rows } = await pool.query<{ id: number }>( + `INSERT INTO categories (name) VALUES ('Ceramics') RETURNING id` + ); + + await apply(itemId, { ...outcome, draft: { ...outcome.draft, category: 'Ceramics' } }); + + const draft = await pool.query(`SELECT ai_category_id FROM item_drafts WHERE item_id = $1`, [itemId]); + expect(draft.rows[0]?.ai_category_id).toBe(rows[0]!.id); + }); + + // A category the shop does not have would be invisible to every storefront + // filter, and the schema cannot enforce membership — so it is checked here. + it('ignores a category that does not exist', async () => { + const itemId = await seedSubmission(); + await apply(itemId, { ...outcome, draft: { ...outcome.draft, category: 'Invented Category' } }); + + const { rows } = await pool.query(`SELECT ai_category_id FROM item_drafts WHERE item_id = $1`, [itemId]); + expect(rows[0]?.ai_category_id).toBeNull(); + }); + + // A retry after a failure has to clear the message, or a draft that + // eventually succeeded still reads as broken in the review queue. + it('clears a previous error when a retry succeeds', async () => { + const itemId = await seedSubmission(); + await pool.query( + `UPDATE item_drafts SET state = 'failed', ai_error = 'timed out', attempts = 1 WHERE item_id = $1`, + [itemId] + ); + + await apply(itemId, outcome); + + const { rows } = await pool.query(`SELECT state, ai_error FROM item_drafts WHERE item_id = $1`, [itemId]); + expect(rows[0]?.state).toBe('ready'); + expect(rows[0]?.ai_error).toBeNull(); + }); +}); + +describe('the drafting worker', () => { + beforeEach(() => { + delete process.env.ANTHROPIC_API_KEY; + resetAnthropicClient(); + }); + + afterAll(() => { + resetAnthropicClient(); + }); + + // The case that must never lose a submission. An unconfigured environment is + // a working one: the item keeps its photos and waits. + it('leaves submissions queued when there is no key', async () => { + const itemId = await seedSubmission(); + + const result = await draftQueued(); + + expect(result).toEqual({ drafted: 0, failed: 0, skipped: 1 }); + + const { rows } = await pool.query( + `SELECT state, attempts FROM item_drafts WHERE item_id = $1`, + [itemId] + ); + expect(rows[0]?.state).toBe('queued'); + // Skipping is not an attempt. Otherwise a fortnight without a key would + // exhaust the retries and mark everything failed with nothing wrong with it. + expect(rows[0]?.attempts).toBe(0); + }); + + it('does not touch a row that has already spent its attempts', async () => { + const itemId = await seedSubmission(); + await pool.query(`UPDATE item_drafts SET attempts = $2 WHERE item_id = $1`, [ + itemId, + MAX_ATTEMPTS + ]); + process.env.ANTHROPIC_API_KEY = 'sk-ant-not-used'; + resetAnthropicClient(); + + const result = await draftQueued(); + + expect(result.drafted).toBe(0); + expect(result.failed).toBe(0); + }); + + // A submission whose files cannot be read fails without an API call, and + // without the item or its row going anywhere. + it('fails a submission with no readable photos, keeping the item', async () => { + const itemId = await seedSubmission(); + process.env.ANTHROPIC_API_KEY = 'sk-ant-not-used'; + resetAnthropicClient(); + + const result = await draftQueued(); + + expect(result.failed).toBe(1); + + const draft = await pool.query( + `SELECT state, attempts, ai_error FROM item_drafts WHERE item_id = $1`, + [itemId] + ); + expect(draft.rows[0]?.attempts).toBe(1); + // One try spent, two left, so it stays reachable for the sweeper. + expect(draft.rows[0]?.state).toBe('queued'); + expect(draft.rows[0]?.ai_error).toContain('no readable photos'); + + const item = await pool.query(`SELECT status FROM items WHERE id = $1`, [itemId]); + expect(item.rows[0]?.status).toBe('pending'); + }); + + // Three tries, then it stops costing money and waits for a person. The item + // and its photos survive that too. + it('gives up after MAX_ATTEMPTS rather than retrying forever', async () => { + const itemId = await seedSubmission(); + process.env.ANTHROPIC_API_KEY = 'sk-ant-not-used'; + resetAnthropicClient(); + + for (let i = 0; i < MAX_ATTEMPTS; i++) { + await draftQueued(); + } + + const draft = await pool.query(`SELECT state, attempts FROM item_drafts WHERE item_id = $1`, [itemId]); + expect(draft.rows[0]?.attempts).toBe(MAX_ATTEMPTS); + expect(draft.rows[0]?.state).toBe('failed'); + + const item = await pool.query(`SELECT id, status FROM items WHERE id = $1`, [itemId]); + expect(item.rows[0]?.status).toBe('pending'); + + // And it is not picked up again, so a dead submission stops spending money. + expect(await draftQueued()).toEqual({ drafted: 0, failed: 0, skipped: 0 }); + }); +}); diff --git a/backend/tests/unit/draftCost.test.ts b/backend/tests/unit/draftCost.test.ts new file mode 100644 index 0000000..75de60b --- /dev/null +++ b/backend/tests/unit/draftCost.test.ts @@ -0,0 +1,58 @@ +import { + costMicros, + DRAFTING_MODELS, + DEFAULT_DRAFTING_MODEL, + isDraftingModel +} from '../../src/intake/models'; + +// Rates confirmed against the pricing page on 2026-08-31: Sonnet 5 is $2 per +// million input tokens and $10 per million output. +describe('costMicros', () => { + it('prices a million input tokens at two dollars', () => { + expect(costMicros('claude-sonnet-5', 1_000_000, 0)).toBe(2_000_000); + }); + + it('prices a million output tokens at ten dollars', () => { + expect(costMicros('claude-sonnet-5', 0, 1_000_000)).toBe(10_000_000); + }); + + it('adds both halves', () => { + expect(costMicros('claude-sonnet-5', 1_000_000, 1_000_000)).toBe(12_000_000); + }); + + it('rounds to whole micros rather than carrying a fraction', () => { + expect(Number.isInteger(costMicros('claude-sonnet-5', 1, 1))).toBe(true); + }); + + // An unknown model must not silently price at zero, which would make a budget + // ceiling read as unspent however much was actually used. + it('falls back to a non-zero rate for an unrecognised model', () => { + expect(costMicros('some-future-model', 1_000_000, 0)).toBeGreaterThan(0); + }); + + it('charges nothing for nothing', () => { + expect(costMicros('claude-sonnet-5', 0, 0)).toBe(0); + }); +}); + +describe('the model catalogue', () => { + it('offers the default as one of its choices', () => { + expect(DRAFTING_MODELS.map((m) => m.id)).toContain(DEFAULT_DRAFTING_MODEL); + }); + + // The settings dropdown and the price table are the same list precisely so + // they cannot drift. If a model were ever priced by the fallback, the figure + // shown next to it in Admin would not be the figure it is billed at. + it('prices every model it offers', () => { + for (const model of DRAFTING_MODELS) { + expect(model.inputRate).toBeGreaterThan(0); + expect(model.outputRate).toBeGreaterThan(0); + } + }); + + it('recognises exactly the models it offers', () => { + expect(isDraftingModel(DEFAULT_DRAFTING_MODEL)).toBe(true); + expect(isDraftingModel('claude-sonnet-5-typo')).toBe(false); + expect(isDraftingModel('')).toBe(false); + }); +}); diff --git a/backend/tests/unit/draftListing.test.ts b/backend/tests/unit/draftListing.test.ts new file mode 100644 index 0000000..872d83c --- /dev/null +++ b/backend/tests/unit/draftListing.test.ts @@ -0,0 +1,75 @@ +import type Anthropic from '@anthropic-ai/sdk'; +import { draftListing } from '../../src/intake/draftListing'; + +// getSettings reaches for the database, which a unit test has no business +// doing. The model choice is the only thing needed from it here. +jest.mock('../../src/adminSettings', () => ({ + getSettings: jest.fn(async () => ({ draftingModel: 'claude-sonnet-5' })) +})); + +const draft = { + name: 'Blue stoneware vase', + description: 'Hand-thrown, chipped base.', + category: null, + tags: [], + suggestedPriceCents: 4500 +}; + +/** + * A stub, always. Every test in this file would otherwise cost money, and a + * suite that bills the shop to run is one nobody will run. + */ +function stubClient(response: unknown): Anthropic { + return { + messages: { parse: jest.fn(async () => response) } + } as unknown as Anthropic; +} + +const input = { photos: [{ mediaType: 'image/jpeg', base64: 'AAAA' }], note: null, categories: [], tags: [] }; + +describe('draftListing', () => { + it('returns the parsed draft with what it cost', async () => { + const client = stubClient({ + parsed_output: draft, + usage: { input_tokens: 1000, output_tokens: 200 } + }); + + const outcome = await draftListing(client, input); + + expect(outcome.draft).toEqual(draft); + expect(outcome.model).toBe('claude-sonnet-5'); + // 1000 * $2 + 200 * $10 per million, in micros. + expect(outcome.costMicros).toBe(4000); + }); + + // The SDK returns null here when the answer did not satisfy the schema — a + // model replying in prose, most likely. Failing cleanly leaves the submission + // queued for a retry; asserting on it would crash the worker mid-loop. + it('fails rather than assuming a draft came back', async () => { + const client = stubClient({ parsed_output: null, usage: { input_tokens: 10, output_tokens: 0 } }); + + await expect(draftListing(client, input)).rejects.toThrow(/expected shape/); + }); + + // Usage has been absent on some responses. Treating that as free is wrong but + // survivable; letting it throw would lose a draft that actually succeeded. + it('survives a response with no usage figures', async () => { + const client = stubClient({ parsed_output: draft }); + + const outcome = await draftListing(client, input); + + expect(outcome.inputTokens).toBe(0); + expect(outcome.costMicros).toBe(0); + }); + + it('sends the photographs and the chosen model to the API', async () => { + const client = stubClient({ parsed_output: draft, usage: { input_tokens: 1, output_tokens: 1 } }); + + await draftListing(client, { ...input, categories: ['Ceramics'], note: 'a note' }); + + const params = (client.messages.parse as jest.Mock).mock.calls[0][0]; + expect(params.model).toBe('claude-sonnet-5'); + expect(params.system).toContain('Ceramics'); + expect(params.messages[0].content.filter((b: { type: string }) => b.type === 'image')).toHaveLength(1); + }); +}); diff --git a/backend/tests/unit/draftPrompt.test.ts b/backend/tests/unit/draftPrompt.test.ts new file mode 100644 index 0000000..5092ced --- /dev/null +++ b/backend/tests/unit/draftPrompt.test.ts @@ -0,0 +1,71 @@ +import { buildSystemPrompt, buildUserContent } from '../../src/intake/draftPrompt'; + +describe('buildSystemPrompt', () => { + const prompt = buildSystemPrompt(['Ceramics', 'Textiles'], ['vintage', 'blue']); + + // The whole reason this function is tested rather than inlined. On a + // one-of-a-kind item an invented age or maker is a false claim on a + // storefront, and nothing downstream can tell an invented detail from an + // observed one. + it('forbids inventing what is neither visible nor in the note', () => { + expect(prompt).toMatch(/do not (state|invent)/i); + expect(prompt).toMatch(/material/i); + expect(prompt).toMatch(/age/i); + expect(prompt).toMatch(/maker/i); + expect(prompt).toMatch(/provenance/i); + }); + + it('offers the categories it may choose from', () => { + expect(prompt).toContain('Ceramics'); + expect(prompt).toContain('Textiles'); + }); + + it('offers the tags it may choose from', () => { + expect(prompt).toContain('vintage'); + expect(prompt).toContain('blue'); + }); + + // Otherwise a model with no matching option picks the closest wrong one. + it('permits declining a category', () => { + expect(prompt).toMatch(/null/i); + }); + + it('survives a shop with no categories or tags yet', () => { + expect(() => buildSystemPrompt([], [])).not.toThrow(); + }); +}); + +describe('buildUserContent', () => { + const photo = { mediaType: 'image/jpeg', base64: 'AAAA' }; + type Block = { type: string; source?: { media_type: string; data: string } }; + + it('sends every photo as an image block', () => { + const images = (buildUserContent([photo, photo], 'a note') as Block[]).filter( + (block) => block.type === 'image' + ); + + expect(images).toHaveLength(2); + expect(images[0]?.source?.media_type).toBe('image/jpeg'); + expect(images[0]?.source?.data).toBe('AAAA'); + }); + + it('includes the note verbatim', () => { + const text = JSON.stringify(buildUserContent([photo], 'Chipped base, bought 1998')); + expect(text).toContain('Chipped base, bought 1998'); + }); + + // A submission with no note is ordinary — the field is optional — and the + // model has to be told so rather than left to read an empty string as a fact + // about the item. + it('says so when there is no note', () => { + const text = JSON.stringify(buildUserContent([photo], null)); + expect(text).toMatch(/no note/i); + }); + + // A whitespace-only note is the same thing as no note. Sending it would let + // the model treat an accidental space as something the sender meant to say. + it('treats a blank note as no note', () => { + const text = JSON.stringify(buildUserContent([photo], ' ')); + expect(text).toMatch(/no note/i); + }); +}); diff --git a/backend/tests/unit/draftSchema.test.ts b/backend/tests/unit/draftSchema.test.ts new file mode 100644 index 0000000..dff8cda --- /dev/null +++ b/backend/tests/unit/draftSchema.test.ts @@ -0,0 +1,54 @@ +import { DraftSchema } from '../../src/intake/draftSchema'; + +const valid = { + name: 'Blue stoneware vase', + description: 'A hand-thrown vase with a chipped base.', + category: 'Ceramics', + tags: ['stoneware', 'blue'], + suggestedPriceCents: 4500 +}; + +describe('DraftSchema', () => { + it('accepts a complete draft', () => { + expect(DraftSchema.parse(valid)).toEqual(valid); + }); + + // The model is told to say nothing rather than guess, so every field it may + // decline to answer has to be expressible as absent. + it('accepts a draft with no category, tags or price', () => { + const parsed = DraftSchema.parse({ + name: valid.name, + description: valid.description, + category: null, + tags: [], + suggestedPriceCents: null + }); + expect(parsed.category).toBeNull(); + expect(parsed.suggestedPriceCents).toBeNull(); + }); + + // A name and a description are the whole point. A draft without them is not + // a partial success worth storing. + it('refuses a draft with no name', () => { + expect(() => DraftSchema.parse({ ...valid, name: '' })).toThrow(); + }); + + it('refuses a draft with no description', () => { + expect(() => DraftSchema.parse({ ...valid, description: '' })).toThrow(); + }); + + // A negative or absurd price reaching the review queue would be a number + // somebody has to notice is wrong. Cheaper to refuse it here. + it('refuses a negative price', () => { + expect(() => DraftSchema.parse({ ...valid, suggestedPriceCents: -1 })).toThrow(); + }); + + it('refuses a price beyond anything this shop sells', () => { + expect(() => DraftSchema.parse({ ...valid, suggestedPriceCents: 100_000_00 })).toThrow(); + }); + + // Cents, not dollars. A float would round somewhere nobody is looking. + it('refuses a fractional price', () => { + expect(() => DraftSchema.parse({ ...valid, suggestedPriceCents: 45.5 })).toThrow(); + }); +}); diff --git a/backend/tests/unit/envValidation.test.ts b/backend/tests/unit/envValidation.test.ts index ef900ec..154c13d 100644 --- a/backend/tests/unit/envValidation.test.ts +++ b/backend/tests/unit/envValidation.test.ts @@ -215,4 +215,25 @@ describe('UPLOADS_BASE_URL', () => { const { errors } = validateEnv(withEnv({ UPLOADS_BASE_URL: '/uploads' })); expect(errors).toContainEqual(expect.stringContaining('absolute origin')); }); + // #223. MINIMAL deliberately has no ANTHROPIC_API_KEY, so it is already the + // absent case. + describe('the intake drafting key', () => { + // Absent is a working configuration, so this must never reach the errors + // list. A submission that arrives undrafted is a far better outcome than a + // container that will not boot. + it('is not required', () => { + expect(validateEnv(MINIMAL).errors).toEqual([]); + }); + + // But silence would be worse than a warning: an operator who thinks + // drafting is on and finds every item undrafted has no way to tell why. + it('warns when it is absent', () => { + expect(validateEnv(MINIMAL).warnings.join(' ')).toMatch(/ANTHROPIC_API_KEY/); + }); + + it('says nothing when it is set', () => { + const { warnings } = validateEnv(withEnv({ ANTHROPIC_API_KEY: 'sk-ant-test' })); + expect(warnings.join(' ')).not.toMatch(/ANTHROPIC_API_KEY/); + }); + }); }); diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 7c8341a..6851b87 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -90,6 +90,14 @@ # USPS_CLIENT_ID Optional. Leave unset to run without address # USPS_CLIENT_SECRET validation; the app degrades gracefully rather than # failing, so an empty value is a working configuration. +# ANTHROPIC_API_KEY Optional. Drafts a listing from a submitted photo +# (#223). Unset means submissions still arrive and wait +# undrafted, which is a working configuration for the +# same reason USPS is. The one credential here that +# spends money per call, and on a path anybody holding +# an upload link can trigger — put a spend limit on the +# key in the Anthropic console, because nothing in this +# repository can enforce one. # # The names above are what this file reads. A stack variable under any other # name is substituted nowhere and never reaches the container, so reconciling @@ -202,6 +210,21 @@ services: # this same container, and a certificate that covers it. Until then the # server warns at boot that the defence is off rather than staying silent. - UPLOADS_BASE_URL=${UPLOADS_BASE_URL:-} + + # Drafts a listing from a submitted photo (#223). + # + # Absent is a working configuration, deliberately. A submission still + # arrives, keeps its photos and waits in the queue undrafted — losing + # somebody's consignment to an expired key would be far worse than an + # item arriving without its description written, and the photos may be + # the only copy of an item no longer in the sender's hands. + # + # The one credential in this stack that spends money per call, on a path + # anybody holding an upload link can trigger. Set a spend limit on the + # key in the Anthropic console — nothing in this repository can enforce + # one, and #227 is the submission ceiling that bounds the volume rather + # than the bill. + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} volumes: # Production's own uploads directory. QA writes to # /volume1/configs/redefined-designs-qa/uploads; sharing this one would diff --git a/docker-compose.qa.yml b/docker-compose.qa.yml index 3fb87fd..161f728 100644 --- a/docker-compose.qa.yml +++ b/docker-compose.qa.yml @@ -40,6 +40,13 @@ # ADMIN_GATE_SECRET — the shared secret Nginx Proxy Manager injects as the # X-Admin-Gate header on the gated location. Both sides must # hold the same value or the admin API returns 403. See #63. +# QA_ANTHROPIC_API_KEY — optional. Drafts a listing from a submitted photo +# (#223). QA_ prefixed like the others so production's key +# cannot be pasted here by accident, and worth its own key +# rather than sharing production's: this is the only +# credential here that spends money per call, and it is +# reachable by anyone holding an upload link. Leave it unset +# and submissions still arrive, undrafted. services: redefined-designs-qa: @@ -114,6 +121,18 @@ services: # enough: stack variables are substituted into this file, not handed to # the container, so a variable with no line here never reaches the app. - ADMIN_GATE_SECRET=${ADMIN_GATE_SECRET} + + # Drafts a listing from a submitted photo (#223). Named QA_ in the stack + # for the same reason as the database and SMTP credentials: pasting + # production's variables in here must not silently work — and this is the + # one credential in the stack that costs money per call, on a path a + # stranger with a link can trigger. + # + # Absent is a working configuration. A submission still arrives, keeps + # its photos and waits in the queue undrafted, because losing somebody's + # consignment to an expired key would be far worse than an item arriving + # without its description written. + - ANTHROPIC_API_KEY=${QA_ANTHROPIC_API_KEY} volumes: # Separate uploads directory. Sharing production's would let a QA run # write into, and a QA teardown delete, real product images. diff --git a/docs/ops/production-stack-cutover.md b/docs/ops/production-stack-cutover.md index 81dedc3..90b2b92 100644 --- a/docs/ops/production-stack-cutover.md +++ b/docs/ops/production-stack-cutover.md @@ -60,7 +60,7 @@ Everything here is lost when the stack is deleted, and the rollback in step 8 is **The stack name**, exactly as Portainer shows it. If it is not `redefined-designs`, note that — the new stack must be created with that name, because the stack name becomes the compose project name and reusing QA's would make Compose reconcile the two against each other. -**Every stack environment variable, name and value.** They belong to the stack, and deleting it discards them. This is the step whose omission is felt hardest. The compose file interpolates thirteen names — `DEMO_MODE`, `DB_PASSWORD`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_FROM`, `ADMIN_GATE_SECRET`, `PAYPAL_CLIENT_ID`, `PAYPAL_CLIENT_SECRET`, `PAYPAL_WEBHOOK_ID`, `USPS_CLIENT_ID`, `USPS_CLIENT_SECRET`, `UPLOADS_BASE_URL` and `BACKUP_PASSPHRASE` — and an unset one substitutes to an empty string rather than failing. None of it is recoverable from anything in this repository. Take everything the stack holds rather than working from this list; it is here to say how much there is, and it is checked against the file rather than from memory. +**Every stack environment variable, name and value.** They belong to the stack, and deleting it discards them. This is the step whose omission is felt hardest. The compose file interpolates fourteen names — `DEMO_MODE`, `DB_PASSWORD`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_FROM`, `ADMIN_GATE_SECRET`, `PAYPAL_CLIENT_ID`, `PAYPAL_CLIENT_SECRET`, `PAYPAL_WEBHOOK_ID`, `USPS_CLIENT_ID`, `USPS_CLIENT_SECRET`, `UPLOADS_BASE_URL`, `BACKUP_PASSPHRASE` and `ANTHROPIC_API_KEY` — and an unset one substitutes to an empty string rather than failing. None of it is recoverable from anything in this repository. Take everything the stack holds rather than working from this list; it is here to say how much there is, and it is checked against the file rather than from memory. `USPS_CLIENT_ID` and `USPS_CLIENT_SECRET` deserve naming because losing them is the one failure here that is completely silent. Address validation is skipped when they are empty rather than failing, so checkout keeps working and quietly stops validating addresses. Nothing in step 7 catches it, and there is no crash loop to notice. diff --git a/docs/superpowers/plans/2026-08-31-intake-drafting-worker.md b/docs/superpowers/plans/2026-08-31-intake-drafting-worker.md new file mode 100644 index 0000000..375253b --- /dev/null +++ b/docs/superpowers/plans/2026-08-31-intake-drafting-worker.md @@ -0,0 +1,1295 @@ +# Intake Drafting Worker 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 (`- [x]`) syntax for tracking. + +**Goal:** A submitted item arrives with a drafted name, description, category, tags and suggested price, written from its photos and the sender's note. + +**Architecture:** A background worker reads `item_drafts` rows at `state='queued'`, sends the photos and note to Claude with a Zod-constrained output shape, and writes the result back. It is driven both by a call at the end of a successful submission and by a `node-cron` sweeper that picks up anything stranded. Every failure leaves the submission intact and undrafted. + +**Tech Stack:** TypeScript, `@anthropic-ai/sdk`, `zod`, `node-cron`, Jest. + +**Spec:** `docs/superpowers/specs/2026-08-29-intake-pipeline-design.md` + +**Already verified against the tree, so no need to re-check:** `item_drafts` has every column this plan writes (`ai_name`, `ai_description`, `ai_category_id`, `ai_tag_names`, `ai_suggested_price_cents`, `price_source`, `ai_error`, `input_tokens`, `output_tokens`, `cost_micros`, `drafted_at`, `state`, `attempts`, `model`). `items.price_cents` is `NOT NULL` with the 8000 default from #222's migration, and `items.status` has no CHECK constraint — so the seed below inserts name and status alone and lands on 8000. `item_images` is `(item_id, image_path, sort_order)`. `typeForExtension` in `src/uploadTypes.ts` lowercases its own input. Integration tests open the database as `import { pool } from '../../src/db'` plus `resetDb`/`closeDb` from `./setup/testDb`, and tear down with `await pool.end(); await closeDb();`. `envValidation.test.ts` provides `MINIMAL`, `withEnv()` and `without()`. + +## Global Constraints + +- **The model is `claude-sonnet-5`**, from `INTAKE_MODEL`, defaulting in code. Chosen in the design over Opus for cost; the task is description-writing, not reasoning. +- **`ANTHROPIC_API_KEY` is optional and its absence is a working configuration.** Both compose files already carry the line and neither joins `ALWAYS_REQUIRED`. A missing key is treated exactly like a failed call: the submission keeps its photos and waits undrafted. Losing a consignment to an expired key is worse than an item arriving without a description. +- **No test spends money.** The Anthropic client is injected and stubbed everywhere. A test that makes a real call is a defect in the test. +- **`response.parsed_output` is `null` when parsing fails** — guard it, never assert with `!`. The SDK reference says so explicitly. +- **The note is untrusted input from an unauthenticated stranger.** It is passed as data. Nothing the model returns is executed, interpolated into SQL, or rendered as HTML — the description goes through the same markdown-with-`html: false` treatment as every other stored body. +- **Every route handler stays wrapped in `asyncRoute`** — `tests/unit/routesAreWrapped.test.ts` enforces it. +- **Playwright and the integration suite need Node 20+**; the machine default is 18.16.1. Use `scripts/run-tests.ps1`, or put a newer Node first on `PATH` for the command only — never `nvm use`, which needs elevation and rewrites a machine-global symlink. +- **Commit style:** Conventional Commits, subject ending `(#223)`, no hard wrapping in bodies. + +## The correctness surface + +The prompt is not a style choice. On a one-of-a-kind item an invented "1930s hand-thrown stoneware" is a **false claim on the storefront**, not a cosmetic slip — and the person who would have to answer for it is the shop, not the model. + +The note is the only trustworthy source for anything not visible in a photo. So the system prompt must say, and the tests must assert, that the model describes what is visible, uses the note for what is not, and states no material, age, maker or provenance found in neither. + +## File Structure + +**Created:** +- `backend/src/intake/draftPrompt.ts` — builds the system prompt and user content. Pure. +- `backend/src/intake/draftSchema.ts` — the Zod shape the model must return. Pure. +- `backend/src/intake/anthropicClient.ts` — constructs the SDK client, or null when unconfigured. +- `backend/src/intake/draftListing.ts` — one call, one draft. Client injected. +- `backend/src/intake/applyDraft.ts` — writes a result to `items` and `item_drafts`. +- `backend/src/intake/draftingWorker.ts` — claims queued rows, applies results, counts attempts. +- `backend/tests/unit/draftPrompt.test.ts` +- `backend/tests/unit/draftSchema.test.ts` +- `backend/tests/unit/draftCost.test.ts` +- `backend/tests/integration/drafting.integration.test.ts` + +**Modified:** +- `backend/package.json` — `@anthropic-ai/sdk`, `zod` +- `backend/src/envValidation.ts` — warn when the key is absent, never fail +- `backend/src/routes/intake.ts` — kick the worker after a successful submission +- `backend/src/server.ts` — the sweeper + +A directory rather than six files loose in `src/`: they are one concern, they change together, and `src/` already has twenty-odd modules. + +--- + +### Task 1: Dependencies and configuration + +**Files:** +- Modify: `backend/package.json`, `backend/src/envValidation.ts` +- Test: `backend/tests/unit/envValidation.test.ts` (extend) + +**Interfaces:** +- Produces: `@anthropic-ai/sdk` and `zod` available; a boot warning when `ANTHROPIC_API_KEY` is absent. + +- [x] **Step 1: Install** + +```bash +cd backend +npm install @anthropic-ai/sdk zod +``` + +Then confirm the two things this plan asserts but could not check before the packages existed: + +```bash +ls node_modules/@anthropic-ai/sdk/helpers/ # expect a zod entry +grep -rn "zodOutputFormat" node_modules/@anthropic-ai/sdk/helpers/ | head -3 +``` + +Task 4 imports `zodOutputFormat` from `@anthropic-ai/sdk/helpers/zod`. If the installed SDK exports it from somewhere else, that import is the thing to change — not the approach. + +Both are production dependencies — the worker runs in the container. Confirm they landed under `"dependencies"`, not `"devDependencies"`: the final Docker stage installs with `--omit=dev`, so the wrong section produces a container that fails on the first submission and nowhere else. That is exactly how sharp went wrong in #226. + +- [x] **Step 2: Write the failing test** + +Add to `backend/tests/unit/envValidation.test.ts`: + +The file already defines `MINIMAL` (a valid environment), `withEnv(extra)` and `without(...names)`. Use those rather than adding a second fixture. `MINIMAL` does not include `ANTHROPIC_API_KEY`, so it is already the absent case. + +```ts +describe('the intake drafting key', () => { + // Absent is a working configuration, so this must never reach the errors + // list. A submission that arrives undrafted is a far better outcome than a + // container that will not boot. + it('is not required', () => { + expect(validateEnv(MINIMAL).errors).toEqual([]); + }); + + // But silence would be worse than a warning: an operator who thinks drafting + // is on and finds every item undrafted has no way to tell why. + it('warns when it is absent', () => { + expect(validateEnv(MINIMAL).warnings.join(' ')).toMatch(/ANTHROPIC_API_KEY/); + }); + + it('says nothing when it is set', () => { + const warnings = validateEnv(withEnv({ ANTHROPIC_API_KEY: 'sk-ant-test' })).warnings; + expect(warnings.join(' ')).not.toMatch(/ANTHROPIC_API_KEY/); + }); +}); +``` + +- [x] **Step 3: Run it to verify it fails** + +```bash +cd backend && npx jest -c jest.unit.config.js envValidation +``` + +Expected: FAIL on the warning assertions. + +- [x] **Step 4: Add the warning** + +In `backend/src/envValidation.ts`, alongside the other warning rules: + +```ts +// Absent is a working configuration: a submission still arrives, keeps its +// photos and waits undrafted. Warned about rather than required because losing +// somebody's consignment to an expired key would be far worse than an item +// arriving without its description written — and the photos are often the only +// copy of something no longer in the sender's hands. USPS is the precedent. +function checkDraftingKey(env: NodeJS.ProcessEnv): string[] { + if (isPresent(env, 'ANTHROPIC_API_KEY')) return []; + return [ + 'ANTHROPIC_API_KEY is not set — submitted items will arrive undrafted and wait in the review queue.' + ]; +} +``` + +Add it to the warning composition the same way the existing rules are composed; do not nest it inside `validateEnv`, which is what keeps that function's cognitive complexity down. + +- [x] **Step 5: Verify** + +```bash +cd backend && npm run test:unit && npm run lint && npm run build +``` + +Expected: PASS, lint no new warnings, build clean. + +- [x] **Step 6: Commit** + +```bash +git add backend/package.json backend/package-lock.json backend/src/envValidation.ts backend/tests/unit/envValidation.test.ts +git commit -m "build(intake): add the Anthropic SDK and warn when its key is absent (#223)" +``` + +--- + +### Task 2: The output shape + +**Files:** +- Create: `backend/src/intake/draftSchema.ts`, `backend/tests/unit/draftSchema.test.ts` + +**Interfaces:** +- Produces: `DraftSchema` (Zod), `type DraftResult = z.infer` + +- [x] **Step 1: Write the failing test** + +Create `backend/tests/unit/draftSchema.test.ts`: + +```ts +import { DraftSchema } from '../../src/intake/draftSchema'; + +const valid = { + name: 'Blue stoneware vase', + description: 'A hand-thrown vase with a chipped base.', + category: 'Ceramics', + tags: ['stoneware', 'blue'], + suggestedPriceCents: 4500 +}; + +describe('DraftSchema', () => { + it('accepts a complete draft', () => { + expect(DraftSchema.parse(valid)).toEqual(valid); + }); + + // The model is told to say nothing rather than guess, so every field it may + // decline to answer has to be expressible as absent. + it('accepts a draft with no category, tags or price', () => { + const parsed = DraftSchema.parse({ + name: valid.name, + description: valid.description, + category: null, + tags: [], + suggestedPriceCents: null + }); + expect(parsed.category).toBeNull(); + expect(parsed.suggestedPriceCents).toBeNull(); + }); + + // A name and a description are the whole point. A draft without them is not + // a partial success worth storing. + it('refuses a draft with no name', () => { + expect(() => DraftSchema.parse({ ...valid, name: '' })).toThrow(); + }); + + it('refuses a draft with no description', () => { + expect(() => DraftSchema.parse({ ...valid, description: '' })).toThrow(); + }); + + // A negative or absurd price reaching the review queue would be a number + // somebody has to notice is wrong. Cheaper to refuse it here. + it('refuses a negative price', () => { + expect(() => DraftSchema.parse({ ...valid, suggestedPriceCents: -1 })).toThrow(); + }); + + it('refuses a price beyond anything this shop sells', () => { + expect(() => DraftSchema.parse({ ...valid, suggestedPriceCents: 100_000_00 })).toThrow(); + }); +}); +``` + +- [x] **Step 2: Run it to verify it fails** + +```bash +cd backend && npx jest -c jest.unit.config.js draftSchema +``` + +Expected: FAIL — module not found. + +- [x] **Step 3: Write the schema** + +Create `backend/src/intake/draftSchema.ts`: + +```ts +import { z } from 'zod'; + +/** + * What the model must return, enforced rather than hoped for. + * + * Constraining the shape is the difference between a bad draft and a crash: + * the SDK validates the response against this before any of it reaches the + * database, so a model that answers in prose or invents a field is a caught + * error rather than a row full of nonsense. + * + * Every field the model may decline to answer is nullable, because it is told + * to say nothing rather than guess. A null category means it did not recognise + * one, which is a better answer than a wrong one and is what the review queue + * exists to resolve. + */ + +/** Beyond anything this shop sells, so an absurd number is caught here. */ +const MAX_SUGGESTED_PRICE_CENTS = 1_000_000; + +export const DraftSchema = z.object({ + /** A short title. The item arrives named for its submission timestamp. */ + name: z.string().min(1).max(200), + /** The storefront body. Markdown, rendered with html: false like every other stored body. */ + description: z.string().min(1).max(4000), + /** + * Chosen from the categories it was given, or null. The prompt supplies the + * closed set; this cannot enforce membership, so applyDraft checks it against + * the real table before writing anything. + */ + category: z.string().nullable(), + /** Also from a supplied set, and also checked on the way in rather than here. */ + tags: z.array(z.string()), + /** + * Cents, or null when it will not guess. Bounded at both ends because a + * negative or absurd figure reaching the review queue is a number somebody + * has to notice is wrong, and the queue's whole job is being trustworthy. + */ + suggestedPriceCents: z.number().int().min(0).max(MAX_SUGGESTED_PRICE_CENTS).nullable() +}); + +export type DraftResult = z.infer; +``` + +- [x] **Step 4: Run it to verify it passes** + +```bash +cd backend && npx jest -c jest.unit.config.js draftSchema +``` + +Expected: PASS, 6 tests. + +- [x] **Step 5: Commit** + +```bash +git add backend/src/intake/draftSchema.ts backend/tests/unit/draftSchema.test.ts +git commit -m "feat(intake): constrain what a draft may contain (#223)" +``` + +--- + +### Task 3: The prompt + +This is the correctness surface. Read "The correctness surface" above before writing it. + +**Files:** +- Create: `backend/src/intake/draftPrompt.ts`, `backend/tests/unit/draftPrompt.test.ts` + +**Interfaces:** +- Consumes: nothing +- Produces: + - `buildSystemPrompt(categories: string[], tags: string[]): string` + - `buildUserContent(photos: {mediaType: string; base64: string}[], note: string | null): unknown[]` + +- [x] **Step 1: Write the failing test** + +Create `backend/tests/unit/draftPrompt.test.ts`: + +```ts +import { buildSystemPrompt, buildUserContent } from '../../src/intake/draftPrompt'; + +describe('buildSystemPrompt', () => { + const prompt = buildSystemPrompt(['Ceramics', 'Textiles'], ['vintage', 'blue']); + + // The whole reason this function is tested rather than inlined. On a + // one-of-a-kind item an invented age or maker is a false claim on the + // storefront, and nothing downstream can tell an invented detail from an + // observed one. + it('forbids inventing what is neither visible nor in the note', () => { + expect(prompt).toMatch(/do not (state|invent)/i); + expect(prompt).toMatch(/material|age|maker|provenance/i); + }); + + it('offers the categories it may choose from', () => { + expect(prompt).toContain('Ceramics'); + expect(prompt).toContain('Textiles'); + }); + + it('offers the tags it may choose from', () => { + expect(prompt).toContain('vintage'); + expect(prompt).toContain('blue'); + }); + + // Otherwise a model with no matching option picks the closest wrong one. + it('permits declining a category', () => { + expect(prompt).toMatch(/null/i); + }); + + it('survives a shop with no categories or tags yet', () => { + expect(() => buildSystemPrompt([], [])).not.toThrow(); + }); +}); + +describe('buildUserContent', () => { + const photo = { mediaType: 'image/jpeg', base64: 'AAAA' }; + + it('sends every photo as an image block', () => { + const content = buildUserContent([photo, photo], 'a note') as { + type: string; + source?: { media_type: string; data: string }; + }[]; + const images = content.filter((block) => block.type === 'image'); + + expect(images).toHaveLength(2); + expect(images[0]?.source?.media_type).toBe('image/jpeg'); + expect(images[0]?.source?.data).toBe('AAAA'); + }); + + it('includes the note verbatim', () => { + const text = JSON.stringify(buildUserContent([photo], 'Chipped base, bought 1998')); + expect(text).toContain('Chipped base, bought 1998'); + }); + + // A submission with no note is ordinary — the field is optional — and the + // model has to be told that rather than left to read an empty string as a + // fact about the item. + it('says so when there is no note', () => { + const text = JSON.stringify(buildUserContent([photo], null)); + expect(text).toMatch(/no (note|description)/i); + }); +}); +``` + +- [x] **Step 2: Run it to verify it fails** + +```bash +cd backend && npx jest -c jest.unit.config.js draftPrompt +``` + +Expected: FAIL — module not found. + +- [x] **Step 3: Write the prompt builder** + +Create `backend/src/intake/draftPrompt.ts`: + +```ts +/** + * What the model is told, and what it is shown. + * + * Pure and separately tested because this is where the correctness of every + * draft is decided. Nothing downstream can distinguish an observed detail from + * an invented one — the description arrives as prose either way — so the only + * place that distinction can be enforced is here, in the instruction. + * + * On a one-of-a-kind item an invented "1930s hand-thrown stoneware" is a false + * claim on a storefront, and it is the shop that answers for it rather than the + * model. The submitter's note is the only trustworthy source for anything a + * photograph cannot show. + */ + +export function buildSystemPrompt(categories: string[], tags: string[]): string { + // Listed rather than described, so the model chooses from what exists instead + // of inventing a taxonomy the storefront filters know nothing about. + const categoryList = categories.length > 0 ? categories.join(', ') : '(none defined yet)'; + const tagList = tags.length > 0 ? tags.join(', ') : '(none defined yet)'; + + return [ + 'You write short listings for a shop that sells one-of-a-kind second-hand items.', + '', + 'You are given photographs of a single item, and sometimes a note from the person sending it in.', + '', + 'Describe only what you can see in the photographs, plus whatever the note tells you.', + 'Do not state a material, age, maker, or provenance that is neither visible nor in the note.', + 'If you do not know something, leave it out rather than guessing — a wrong detail here becomes', + 'a false claim on a public shop, and the shop answers for it rather than you.', + '', + `Choose a category from this list, or null if none fits: ${categoryList}`, + `Choose tags from this list, or an empty list if none fit: ${tagList}`, + 'Do not invent categories or tags that are not listed.', + '', + 'Suggest a price in cents if the photographs and note give you enough to judge one,', + 'or null if they do not. A person reviews it before anything is listed.' + ].join('\n'); +} + +interface Photo { + mediaType: string; + base64: string; +} + +/** + * The photos, then the note. + * + * Images first because the note refers to them. The note is quoted and labelled + * as coming from the sender rather than merged into the instruction: it is + * untrusted text from an unauthenticated stranger, and it should read as + * evidence to weigh rather than as something the shop is asserting. + */ +export function buildUserContent(photos: Photo[], note: string | null): unknown[] { + const blocks: unknown[] = photos.map((photo) => ({ + type: 'image', + source: { type: 'base64', media_type: photo.mediaType, data: photo.base64 } + })); + + blocks.push({ + type: 'text', + text: + note && note.trim() !== '' + ? `The sender wrote this about the item:\n\n${note}` + : 'The sender left no note, so the photographs are all you have.' + }); + + return blocks; +} +``` + +- [x] **Step 4: Run it to verify it passes** + +```bash +cd backend && npx jest -c jest.unit.config.js draftPrompt +``` + +Expected: PASS, 8 tests. + +- [x] **Step 5: Commit** + +```bash +git add backend/src/intake/draftPrompt.ts backend/tests/unit/draftPrompt.test.ts +git commit -m "feat(intake): tell the model to describe rather than invent (#223)" +``` + +--- + +### Task 3b: The model catalogue, and choosing it from Admin settings + +Added mid-execution, at Thom's request: the model must be settable from the Admin settings page rather than only by an environment variable and a redeploy. + +Two things fall out of that. A **dropdown, not a free-text box** — a mistyped model name fails on every submission and surfaces only as drafts quietly not appearing, so the valid set is enforced on the server rather than merely offered by the UI. And **one catalogue, not two** — the settings dropdown needs the model list, `costMicros` needs each model's rates, and those must not be two lists that drift. So the catalogue is a module both import. + +Rates confirmed against the pricing page on 2026-08-31, not recalled: Sonnet 5 $2/$10, Opus 5 $5/$25, Haiku 4.5 $1/$5 per million input/output tokens. Worth having checked — an increase to $3/$15 had been scheduled for 2026-09-01 and was cancelled, with $2/$10 made permanent. + +**Files:** +- Create: `backend/src/intake/models.ts`, `backend/tests/unit/draftCost.test.ts` +- Modify: `backend/src/adminSettings.ts` (a `choice` type), `backend/src/routes/adminSettings.ts`, `frontend/src/admin/Settings.tsx` + +**Interfaces:** +- Produces: `DRAFTING_MODELS`, `DEFAULT_DRAFTING_MODEL`, `isDraftingModel()`, `costMicros()`, and a `draftingModel` admin setting. + +- [x] **Step 1: The catalogue, test first.** `costMicros(model, input, output)` in whole micros. An unrecognised model must price above zero — a budget that reads as unspent however much was spent is the one failure a spend guard cannot have. +- [x] **Step 2: Add a `choice` type to `adminSettings.ts`.** The module's own docstring says adding a setting means adding a row and nothing else; that holds for `hours` and `text`, and a third type is what makes it hold for a constrained one. Row: `{ key: 'drafting_model', name: 'draftingModel', type: 'choice', fallback: DEFAULT_DRAFTING_MODEL, options: [...] }`. +- [x] **Step 3: Validate membership in the PUT route**, as a third loop beside the hours and text loops. A value outside the set is a 400, not a stored string that breaks drafting later. +- [x] **Step 4: The dropdown in `Settings.tsx`**, showing each model's price so the person switching can see that Opus costs 2.5x Sonnet before they pick it. +- [x] **Step 5:** `npm run lint && npm run build && npm run test:unit`, and the frontend's checks. +- [x] **Step 6: Commit.** + +--- + +### Task 4: One call, one draft + +**Files:** +- Create: `backend/src/intake/anthropicClient.ts`, `backend/src/intake/draftListing.ts` +- Test: extend `backend/tests/unit/draftCost.test.ts` + +**Interfaces:** +- Consumes: `DraftSchema`, `buildSystemPrompt`, `buildUserContent` +- Produces: + - `getAnthropicClient(): Anthropic | null` — null when unconfigured + - `costMicros(model: string, inputTokens: number, outputTokens: number): number` + - `draftListing(client, input): Promise<{ draft: DraftResult; usage: {...} }>` + +- [x] **Step 1: Write the failing cost test** + +Create `backend/tests/unit/draftCost.test.ts`: + +```ts +import { costMicros } from '../../src/intake/draftListing'; + +// Sonnet 5 is $2 per million input tokens and $10 per million output. +describe('costMicros', () => { + it('prices a million input tokens at two dollars', () => { + expect(costMicros('claude-sonnet-5', 1_000_000, 0)).toBe(2_000_000); + }); + + it('prices a million output tokens at ten dollars', () => { + expect(costMicros('claude-sonnet-5', 0, 1_000_000)).toBe(10_000_000); + }); + + it('adds both halves', () => { + expect(costMicros('claude-sonnet-5', 1_000_000, 1_000_000)).toBe(12_000_000); + }); + + it('rounds to whole micros rather than carrying a fraction', () => { + expect(Number.isInteger(costMicros('claude-sonnet-5', 1, 1))).toBe(true); + }); + + // An unknown model must not silently price at zero, which would make a + // budget ceiling read as unspent however much was actually used. + it('falls back to a non-zero rate for an unrecognised model', () => { + expect(costMicros('some-future-model', 1_000_000, 0)).toBeGreaterThan(0); + }); +}); +``` + +- [x] **Step 2: Run it to verify it fails** + +```bash +cd backend && npx jest -c jest.unit.config.js draftCost +``` + +Expected: FAIL — module not found. + +- [x] **Step 3: Write the client factory** + +Create `backend/src/intake/anthropicClient.ts`: + +```ts +import Anthropic from '@anthropic-ai/sdk'; + +/** + * The client, or null when there is no key. + * + * Null rather than a throw, because an unconfigured environment is a working + * one: submissions still arrive and wait undrafted. The worker treats null + * exactly as it treats a failed call, which keeps one path rather than two. + * + * Constructed once. The SDK holds a connection pool, and building one per + * submission would be wasteful on a route a stranger can trigger. + */ +let cached: Anthropic | null = null; +let resolved = false; + +export function getAnthropicClient(): Anthropic | null { + if (resolved) return cached; + + const key = process.env.ANTHROPIC_API_KEY; + cached = key && key.trim() !== '' ? new Anthropic({ apiKey: key }) : null; + resolved = true; + + return cached; +} + +/** Exposed for tests, which need a fresh decision per case. */ +export function resetAnthropicClient(): void { + cached = null; + resolved = false; +} +``` + +- [x] **Step 4: Write the drafting call** + +Create `backend/src/intake/draftListing.ts`: + +```ts +import type Anthropic from '@anthropic-ai/sdk'; +import { zodOutputFormat } from '@anthropic-ai/sdk/helpers/zod'; +import { DraftSchema, DraftResult } from './draftSchema'; +import { buildSystemPrompt, buildUserContent } from './draftPrompt'; + +/** + * The model, from configuration with a default in code. + * + * Sonnet rather than Opus: the task is writing a description from a photograph, + * not reasoning, and this runs once per submission on a public route. The + * variable exists so the choice can be revisited without a deploy. + */ +const DEFAULT_MODEL = 'claude-sonnet-5'; + +// Read from Admin settings (Task 3b) rather than the environment, so the choice +// can be changed without a redeploy. getSettings() supplies the fallback, so +// there is no default written twice here to disagree with the one there. +async function draftingModel(): Promise { + return (await getSettings()).draftingModel; +} + +/** + * Dollars per million tokens, as micros-per-token, so the arithmetic below is + * integer and a cost never carries a floating-point fraction into the database. + * + * The fallback is deliberately not zero. An unrecognised model pricing at + * nothing would make a budget ceiling read as unspent however much was really + * spent, which is the one failure a spend guard must not have. + */ +// Rates and costMicros live in ./models, shared with the Admin settings +// dropdown so the list of models and their prices cannot drift apart. + +export interface DraftInput { + photos: { mediaType: string; base64: string }[]; + note: string | null; + categories: string[]; + tags: string[]; +} + +export interface DraftOutcome { + draft: DraftResult; + model: string; + inputTokens: number; + outputTokens: number; + costMicros: number; +} + +/** + * One submission, one draft. + * + * The client is a parameter rather than a module import so every test can pass + * a stub. A test that reaches the real API is a defect in the test: this runs + * on a public route and each call costs money. + */ +export async function draftListing( + client: Anthropic, + input: DraftInput +): Promise { + const model = draftingModel(); + + const response = await client.messages.parse({ + model, + max_tokens: 2000, + system: buildSystemPrompt(input.categories, input.tags), + messages: [{ role: 'user', content: buildUserContent(input.photos, input.note) as never }], + output_config: { format: zodOutputFormat(DraftSchema) } + }); + + // Null when the response did not satisfy the schema. Guarded rather than + // asserted with `!`: the SDK documents this as a real outcome, and a model + // answering in prose is exactly the case worth failing cleanly on. + const draft = response.parsed_output; + if (!draft) { + throw new Error('the model did not return a draft matching the expected shape'); + } + + const inputTokens = response.usage?.input_tokens ?? 0; + const outputTokens = response.usage?.output_tokens ?? 0; + + return { + draft, + model, + inputTokens, + outputTokens, + costMicros: costMicros(model, inputTokens, outputTokens) + }; +} +``` + +- [x] **Step 5: Run the tests and build** + +```bash +cd backend && npx jest -c jest.unit.config.js draftCost && npm run build && npm run lint +``` + +Expected: PASS 5 tests, build clean, no new lint warnings. + +- [x] **Step 6: Commit** + +```bash +git add backend/src/intake/anthropicClient.ts backend/src/intake/draftListing.ts backend/tests/unit/draftCost.test.ts +git commit -m "feat(intake): draft a listing from photos and a note (#223)" +``` + +--- + +### Task 5: Writing a draft back + +**Files:** +- Create: `backend/src/intake/applyDraft.ts` +- Test: `backend/tests/integration/drafting.integration.test.ts` + +**Interfaces:** +- Consumes: `DraftResult` +- Produces: `applyDraft(client: PoolClient, itemId: number, outcome: DraftOutcome): Promise` + +- [x] **Step 1: Write the failing test** + +Create `backend/tests/integration/drafting.integration.test.ts`: + +```ts +import { pool } from '../../src/db'; +import { applyDraft } from '../../src/intake/applyDraft'; +import { resetDb, closeDb } from './setup/testDb'; + +beforeEach(async () => { + await resetDb(); +}); + +afterAll(async () => { + await pool.end(); + await closeDb(); +}); + +async function seedSubmission(): Promise { + const { rows } = await pool.query<{ id: number }>( + `INSERT INTO items (name, status) VALUES ('Submission placeholder', 'pending') RETURNING id` + ); + const itemId = rows[0]!.id; + await pool.query(`INSERT INTO item_drafts (item_id, submitter_note) VALUES ($1, 'a note')`, [ + itemId + ]); + return itemId; +} + +const outcome = { + draft: { + name: 'Blue stoneware vase', + description: 'Hand-thrown, chipped base.', + category: null, + tags: [], + suggestedPriceCents: 4500 + }, + model: 'claude-sonnet-5', + inputTokens: 1000, + outputTokens: 200, + costMicros: 4000 +}; + +describe('applying a draft', () => { + it('writes the copy onto the draft row, not the item', async () => { + const itemId = await seedSubmission(); + const client = await pool.connect(); + await applyDraft(client, itemId, outcome); + client.release(); + + const { rows } = await pool.query( + `SELECT ai_name, ai_description, state, model FROM item_drafts WHERE item_id = $1`, + [itemId] + ); + expect(rows[0]?.ai_name).toBe('Blue stoneware vase'); + expect(rows[0]?.state).toBe('ready'); + expect(rows[0]?.model).toBe('claude-sonnet-5'); + + // The item keeps its placeholder name until a person approves the draft. + // Nothing a model wrote should reach the catalogue unreviewed. + const item = await pool.query(`SELECT name FROM items WHERE id = $1`, [itemId]); + expect(item.rows[0]?.name).toBe('Submission placeholder'); + }); + + // The one thing that does reach the item, because #220 chose pre-pricing over + // an unpriced row — with price_source recording that nobody chose it. + it('writes a suggested price onto the item and records where it came from', async () => { + const itemId = await seedSubmission(); + const client = await pool.connect(); + await applyDraft(client, itemId, outcome); + client.release(); + + const item = await pool.query(`SELECT price_cents FROM items WHERE id = $1`, [itemId]); + expect(item.rows[0]?.price_cents).toBe(4500); + + const draft = await pool.query( + `SELECT price_source, ai_suggested_price_cents FROM item_drafts WHERE item_id = $1`, + [itemId] + ); + expect(draft.rows[0]?.price_source).toBe('ai'); + expect(draft.rows[0]?.ai_suggested_price_cents).toBe(4500); + }); + + it('leaves the default price alone when the model would not guess', async () => { + const itemId = await seedSubmission(); + const client = await pool.connect(); + await applyDraft(client, itemId, { ...outcome, draft: { ...outcome.draft, suggestedPriceCents: null } }); + client.release(); + + const item = await pool.query(`SELECT price_cents FROM items WHERE id = $1`, [itemId]); + expect(item.rows[0]?.price_cents).toBe(8000); + + const draft = await pool.query(`SELECT price_source FROM item_drafts WHERE item_id = $1`, [itemId]); + expect(draft.rows[0]?.price_source).toBe('default'); + }); + + it('records what the call cost', async () => { + const itemId = await seedSubmission(); + const client = await pool.connect(); + await applyDraft(client, itemId, outcome); + client.release(); + + const { rows } = await pool.query( + `SELECT input_tokens, output_tokens, cost_micros, drafted_at FROM item_drafts WHERE item_id = $1`, + [itemId] + ); + expect(rows[0]?.input_tokens).toBe(1000); + expect(rows[0]?.cost_micros).toBe(4000); + expect(rows[0]?.drafted_at).not.toBeNull(); + }); + + // A category the shop does not have would break the storefront filters, and + // the schema cannot enforce membership — so it is checked here. + it('ignores a category that does not exist', async () => { + const itemId = await seedSubmission(); + const client = await pool.connect(); + await applyDraft(client, itemId, { + ...outcome, + draft: { ...outcome.draft, category: 'Invented Category' } + }); + client.release(); + + const { rows } = await pool.query( + `SELECT ai_category_id FROM item_drafts WHERE item_id = $1`, + [itemId] + ); + expect(rows[0]?.ai_category_id).toBeNull(); + }); +}); +``` + +- [x] **Step 2: Run it to verify it fails** + +Bring up a database first (see Global Constraints), then: + +```bash +cd backend && npx jest -c jest.integration.config.js --runInBand drafting.integration +``` + +Expected: FAIL — module not found. + +- [x] **Step 3: Write it** + +Create `backend/src/intake/applyDraft.ts`: + +```ts +import { PoolClient } from 'pg'; +import { DraftOutcome } from './draftListing'; + +/** + * Writes a finished draft to the database. + * + * The copy goes on `item_drafts`, never on the item. The item keeps its + * placeholder name and description until a person approves them in the review + * queue (#225) — nothing a model wrote reaches the catalogue unreviewed. + * + * The price is the deliberate exception, because #220 chose to price an item on + * arrival rather than leave it unpriced. `price_source` records that the number + * came from a model rather than a person, which is what lets the review queue + * mark it as unconfirmed. + * + * A category is checked against the real table before it is stored. The schema + * constrains the shape of the answer but cannot enforce membership, and a + * category the shop does not have would be invisible to every storefront + * filter — a draft nobody could find rather than an obvious error. + */ +export async function applyDraft( + client: PoolClient, + itemId: number, + outcome: DraftOutcome +): Promise { + const { draft } = outcome; + + const categoryId = draft.category + ? ( + await client.query<{ id: number }>(`SELECT id FROM categories WHERE name = $1`, [ + draft.category + ]) + ).rows[0]?.id ?? null + : null; + + await client.query( + `UPDATE item_drafts + SET state = 'ready', + model = $2, + ai_name = $3, + ai_description = $4, + ai_category_id = $5, + ai_tag_names = $6, + ai_suggested_price_cents = $7, + price_source = $8, + input_tokens = $9, + output_tokens = $10, + cost_micros = $11, + ai_error = NULL, + drafted_at = now() + WHERE item_id = $1`, + [ + itemId, + outcome.model, + draft.name, + draft.description, + categoryId, + draft.tags, + draft.suggestedPriceCents, + draft.suggestedPriceCents === null ? 'default' : 'ai', + outcome.inputTokens, + outcome.outputTokens, + outcome.costMicros + ] + ); + + // Only when there is one. Absent, the item keeps the migration's default and + // price_source stays 'default' — the review queue shows both the same way, + // as a number nobody chose. + if (draft.suggestedPriceCents !== null) { + await client.query(`UPDATE items SET price_cents = $2 WHERE id = $1`, [ + itemId, + draft.suggestedPriceCents + ]); + } +} +``` + +- [x] **Step 4: Run it to verify it passes** + +```bash +cd backend && npx jest -c jest.integration.config.js --runInBand drafting.integration +``` + +Expected: PASS, 5 tests. + +- [x] **Step 5: Commit** + +```bash +git add backend/src/intake/applyDraft.ts backend/tests/integration/drafting.integration.test.ts +git commit -m "feat(intake): record a draft without publishing it (#223)" +``` + +--- + +### Task 6: The worker + +**Files:** +- Create: `backend/src/intake/draftingWorker.ts` +- Test: extend `backend/tests/integration/drafting.integration.test.ts` + +**Interfaces:** +- Consumes: everything above +- Produces: `draftQueued(limit?: number): Promise<{ drafted: number; failed: number; skipped: number }>` + +- [x] **Step 1: Write the failing tests** + +Append to `backend/tests/integration/drafting.integration.test.ts`: + +```ts +import { draftQueued, MAX_ATTEMPTS } from '../../src/intake/draftingWorker'; +import { resetAnthropicClient } from '../../src/intake/anthropicClient'; + +describe('the drafting worker', () => { + beforeEach(() => { + delete process.env.ANTHROPIC_API_KEY; + resetAnthropicClient(); + }); + + // The case that must never lose a submission. An unconfigured environment is + // a working one: the item keeps its photos and waits. + it('leaves submissions queued when there is no key', async () => { + const itemId = await seedSubmission(); + + const result = await draftQueued(); + + expect(result.skipped).toBe(1); + const { rows } = await pool.query(`SELECT state, attempts FROM item_drafts WHERE item_id = $1`, [ + itemId + ]); + expect(rows[0]?.state).toBe('queued'); + // Skipping is not an attempt. Otherwise a fortnight without a key would + // exhaust the retries and mark everything failed. + expect(rows[0]?.attempts).toBe(0); + }); + + it('gives up after MAX_ATTEMPTS and keeps the item', async () => { + const itemId = await seedSubmission(); + await pool.query(`UPDATE item_drafts SET attempts = $2 WHERE item_id = $1`, [ + itemId, + MAX_ATTEMPTS + ]); + + const result = await draftQueued(); + + expect(result.skipped).toBe(1); + const item = await pool.query(`SELECT id, status FROM items WHERE id = $1`, [itemId]); + expect(item.rows[0]?.status).toBe('pending'); + }); +}); +``` + +- [x] **Step 2: Run it to verify it fails** + +```bash +cd backend && npx jest -c jest.integration.config.js --runInBand drafting.integration +``` + +Expected: FAIL — module not found. + +- [x] **Step 3: Write the worker** + +Create `backend/src/intake/draftingWorker.ts`: + +```ts +import { promises as fs } from 'fs'; +import path from 'path'; +import { pool } from '../db'; +import { typeForExtension } from '../uploadTypes'; +import { getAnthropicClient } from './anthropicClient'; +import { draftListing } from './draftListing'; +import { applyDraft } from './applyDraft'; + +/** + * Turns queued submissions into drafts. + * + * Driven from two places: a call at the end of a successful submission, so a + * draft is usually waiting by the time anybody looks, and a cron sweeper, so a + * restart mid-draft is recoverable rather than a permanently stalled row. + * + * The governing rule is that a submission is the only irreplaceable thing here. + * The photos may be the only copy of an item no longer in the sender's hands, + * so every failure below leaves the row and its images intact and merely + * undrafted. Nothing in this file deletes anything. + */ + +/** Three tries, then it waits for a person rather than burning money on a loop. */ +export const MAX_ATTEMPTS = 3; + +const UPLOADS_DIR = process.env.UPLOADS_DIR || '/app/uploads'; + +interface QueuedRow { + item_id: number; + submitter_note: string | null; +} + +async function readPhotos(itemId: number): Promise<{ mediaType: string; base64: string }[]> { + const { rows } = await pool.query<{ image_path: string }>( + `SELECT image_path FROM item_images WHERE item_id = $1 ORDER BY sort_order`, + [itemId] + ); + + const photos = []; + for (const row of rows) { + // basename only: image_path is stored as '/uploads/' and the + // directory it lives in is a server constant. Same rule as handleRow in + // backfillImageReencode.ts — but using the shared typeForExtension rather + // than that file's private copy of the map. + const file = path.join(UPLOADS_DIR, path.basename(row.image_path)); + const mediaType = typeForExtension(path.extname(file)); + // typeForExtension returns null for anything the app would refuse to serve. + // Sending it to the model would be paying to have it refused. + if (!mediaType) continue; + photos.push({ mediaType, base64: (await fs.readFile(file)).toString('base64') }); + } + return photos; +} + +async function namesOf(table: 'categories' | 'tags'): Promise { + const { rows } = await pool.query<{ name: string }>(`SELECT name FROM ${table} ORDER BY name`); + return rows.map((row) => row.name); +} + +export async function draftQueued( + limit = 5 +): Promise<{ drafted: number; failed: number; skipped: number }> { + const client = getAnthropicClient(); + + const { rows } = await pool.query( + `SELECT item_id, submitter_note FROM item_drafts + WHERE state = 'queued' AND attempts < $2 + ORDER BY created_at + LIMIT $1`, + [limit, MAX_ATTEMPTS] + ); + + // Unconfigured is not a failure and must not spend an attempt. A fortnight + // without a key would otherwise exhaust the retries and mark every waiting + // submission failed, with nothing wrong with any of them. + if (!client) { + return { drafted: 0, failed: 0, skipped: rows.length }; + } + + let drafted = 0; + let failed = 0; + + for (const row of rows) { + const photos = await readPhotos(row.item_id); + if (photos.length === 0) { + await pool.query( + `UPDATE item_drafts SET attempts = attempts + 1, state = 'failed', + ai_error = 'no readable photos' WHERE item_id = $1`, + [row.item_id] + ); + failed++; + continue; + } + + try { + const outcome = await draftListing(client, { + photos, + note: row.submitter_note, + categories: await namesOf('categories'), + tags: await namesOf('tags') + }); + + const db = await pool.connect(); + try { + await db.query('BEGIN'); + await applyDraft(db, row.item_id, outcome); + await db.query('COMMIT'); + } catch (err) { + await db.query('ROLLBACK'); + throw err; + } finally { + db.release(); + } + drafted++; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`[drafting] item ${row.item_id}:`, message); + + // The row stays reachable either way: 'queued' under the cap so the + // sweeper retries it, 'failed' once the tries are spent so it stops + // costing money and waits for a person. The item and its photos are + // untouched in both. + await pool.query( + `UPDATE item_drafts + SET attempts = attempts + 1, + ai_error = $2, + state = CASE WHEN attempts + 1 >= $3 THEN 'failed' ELSE 'queued' END + WHERE item_id = $1`, + [row.item_id, message.slice(0, 500), MAX_ATTEMPTS] + ); + failed++; + } + } + + return { drafted, failed, skipped: 0 }; +} +``` + +- [x] **Step 4: Run it to verify it passes** + +```bash +cd backend && npx jest -c jest.integration.config.js --runInBand drafting.integration +``` + +Expected: PASS, 7 tests. + +- [x] **Step 5: Commit** + +```bash +git add backend/src/intake/draftingWorker.ts backend/tests/integration/drafting.integration.test.ts +git commit -m "feat(intake): draft queued submissions without ever losing one (#223)" +``` + +--- + +### Task 7: Wiring + +**Files:** +- Modify: `backend/src/routes/intake.ts`, `backend/src/server.ts` + +- [x] **Step 1: Kick the worker after a successful submission** + +In `backend/src/routes/intake.ts`, after `await client.query('COMMIT');` and before the response: + +```ts + // Deliberately not awaited. A slow or failing model request must not turn + // into a failed upload for someone who did nothing wrong — the whole + // reason the AI is not called inline. The sweeper below picks up anything + // this misses, so the only cost of it failing here is a short delay. + void draftQueued(1).catch((err) => console.error('[drafting] after submission:', err)); +``` + +Import it at the top: + +```ts +import { draftQueued } from '../intake/draftingWorker'; +``` + +- [x] **Step 2: Add the sweeper** + +In `backend/src/server.ts`, beside the existing schedules: + +```ts +// Every five minutes, in the same shape as the cart sweep above. This is what +// makes a restart mid-draft recoverable rather than a permanently stalled row, +// and what picks up anything the post-submission call missed. void, because an +// unhandled rejection here would take the container down with it. +setInterval(() => void draftQueued(), 5 * 60 * 1000); +``` + +Import it beside the other background work: + +```ts +import { draftQueued } from './intake/draftingWorker'; +``` + +- [x] **Step 3: Verify nothing regressed** + +```bash +cd backend +npm run lint +npm run build +npm run test:unit +npx jest -c jest.integration.config.js --runInBand +``` + +Expected: all pass. The intake suite matters most — submissions must still succeed with no key configured, which is the state the test database runs in. + +- [x] **Step 4: Commit** + +```bash +git add backend/src/routes/intake.ts backend/src/server.ts +git commit -m "feat(intake): run the drafting worker after a submission and on a sweep (#223)" +``` + +--- + + +> **Tasks 1-7 complete** as of 2026-08-31, on `feature/223-drafting-worker`. Task 8 is the only manual step and is blocked on an API key existing. + +### Task 8: One real call + +Everything above is stubbed. This is the only step that spends money, and it is the only one that proves the prompt produces something worth reading. + +- [ ] **Step 1: Configure a key** + +```powershell +$env:ANTHROPIC_API_KEY = "sk-ant-..." +``` + +Set a spend limit on the key in the Anthropic console first. Nothing in this repository can enforce one. + +- [ ] **Step 2: Submit a real photograph** + +Start the stack, create an upload link in the admin, open `/submit/`, and send **a real photograph of a real object** with a short note. A synthetic test image proves nothing about whether the writing is any good. + +- [ ] **Step 3: Read what it wrote** + +```sql +SELECT ai_name, ai_description, ai_suggested_price_cents, price_source, + input_tokens, output_tokens, cost_micros +FROM item_drafts ORDER BY id DESC LIMIT 1; +``` + +Judge three things, in order of importance: + +1. **Does it claim anything it could not know?** A material, an age, a maker, a provenance that is neither visible in the photograph nor in the note. This is the failure that matters — it is a false claim on a shop — and if it happens the prompt needs work before this ships, not after. +2. Is the description worth editing rather than rewriting? +3. Is `cost_micros` in the range the estimate suggested — a few pence per item? + +- [ ] **Step 4: Submit one with no note** + +The note is optional and its absence is ordinary. Confirm the draft degrades to describing what is visible rather than inventing the rest. + +- [ ] **Step 5: Record what it cost** + +Put the real figures on #223 as a comment. The design estimated a few pence per item; that estimate has never been checked against a real call, and the monthly budget in #227 should be set from a measured number rather than a guessed one. + +--- + +## Done when + +- A submission is drafted within a few minutes without anybody doing anything. +- With no key configured, submissions still arrive, keep their photos, and stay `queued` with `attempts` at zero. +- A failing call leaves the item `pending` with its images, and the draft `failed` after three tries. +- The item's name and description are untouched; only a suggested price reaches it, with `price_source = 'ai'`. +- A category the shop does not have is ignored rather than stored. +- `npm run lint`, `npm run build`, `npm run test:unit` and the integration suite all pass. +- A real photograph produces a draft that claims nothing it could not know. + +## Not in this plan + +The monthly spend ceiling. The design mentions it, but a budget set from a guessed cost is a number nobody trusts — Task 8 measures the real one first, and #227 already exists for the submission ceiling that bounds volume. Filing the spend ceiling separately, informed by that measurement, is better than inventing a threshold here. + +The review queue that reads these drafts is #225. Until it exists, drafts are visible only in the database — which is fine: nothing is published either way. diff --git a/frontend/src/admin/Settings.tsx b/frontend/src/admin/Settings.tsx index 42ecfc2..7571967 100644 --- a/frontend/src/admin/Settings.tsx +++ b/frontend/src/admin/Settings.tsx @@ -7,7 +7,8 @@ import message from 'antd/es/message'; import Card from 'antd/es/card'; import Space from 'antd/es/space'; import Input from 'antd/es/input'; -import { fetchAdminSettings, updateAdminSettings } from './adminSettingsApi'; +import Select from 'antd/es/select'; +import { fetchAdminSettings, updateAdminSettings, DRAFTING_MODELS } from './adminSettingsApi'; const { Title, Text } = Typography; @@ -115,6 +116,32 @@ export default function Settings() { + + Item drafting + {/* A dropdown rather than a text field, and validated again on the + server. A mistyped model name is accepted by the API only at the + point of use, so it would fail on every submission and show up + merely as drafts not appearing. */} + + Which model writes the name, description and suggested price for a submitted item. The price is per + million tokens; a typical item costs a few pence. Drafts are always reviewed before anything is + published. + + +