Feature/223 drafting worker #251

Merged
bermudalamb merged 13 commits from feature/223-drafting-worker into main 2026-09-01 08:32:44 -05:00
27 changed files with 2648 additions and 20 deletions
+75 -1
View File
@@ -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"
}
}
}
}
+3 -1
View File
@@ -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",
+70 -14
View File
@@ -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<Definition, { type: 'hours' }>['name'];
export type TextSettingName = Extract<Definition, { type: 'text' }>['name'];
export type ChoiceSettingName = Extract<Definition, { type: 'choice' }>['name'];
export type AdminSettings = Record<HoursSettingName, number> & Record<TextSettingName, string>;
export type AdminSettings = Record<HoursSettingName, number> &
Record<TextSettingName, string> &
Record<ChoiceSettingName, string>;
export const HOURS_SETTINGS: readonly HoursSettingName[] = DEFINITIONS.filter(
(d): d is Extract<Definition, { type: 'hours' }> => d.type === 'hours'
@@ -44,24 +57,67 @@ export const TEXT_SETTINGS: readonly TextSettingName[] = DEFINITIONS.filter(
(d): d is Extract<Definition, { type: 'text' }> => d.type === 'text'
).map(d => d.name);
export const CHOICE_SETTINGS: readonly ChoiceSettingName[] = DEFINITIONS.filter(
(d): d is Extract<Definition, { type: 'choice' }> => 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<Record<ChoiceSettingName, readonly string[]>> = {
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<AdminSettings> {
const { rows } = await pool.query<SettingRow>(`SELECT key, value FROM admin_settings`);
const stored = new Map<string, string>(rows.map(r => [r.key, r.value]));
const settings = {} as Record<SettingName, number | string>;
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;
}
+19 -1
View File
@@ -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)]
};
}
+30
View File
@@ -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;
}
+78
View File
@@ -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<void> {
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
]);
}
}
+77
View File
@@ -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<string> {
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<DraftOutcome> {
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)
};
}
+76
View File
@@ -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;
}
+47
View File
@@ -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<typeof DraftSchema>;
+156
View File
@@ -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<Photo[]> {
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/<name>' 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<string[]> {
// 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<void> {
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<void> {
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<SweepResult> {
const { rows } = await pool.query<QueuedRow>(
`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 };
}
+57
View File
@@ -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);
}
+17
View File
@@ -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());
}));
+9
View File
@@ -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 });
+11
View File
@@ -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<void> {
@@ -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
@@ -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');
});
});
@@ -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<number> {
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<void> {
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 });
});
});
+58
View File
@@ -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);
});
});
+75
View File
@@ -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);
});
});
+71
View File
@@ -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);
});
});
+54
View File
@@ -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();
});
});
+21
View File
@@ -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/);
});
});
});
+23
View File
@@ -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
+19
View File
@@ -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.
+1 -1
View File
@@ -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.
File diff suppressed because it is too large Load Diff
+28 -1
View File
@@ -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() {
</Form.Item>
</Card>
<Card>
<Title level={4}>Item drafting</Title>
{/* 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. */}
<Text type="secondary">
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.
</Text>
<Form.Item
name="draftingModel"
label="Drafting model"
rules={[{ required: true }]}
style={{ marginTop: 16, marginBottom: 0 }}
>
<Select
options={DRAFTING_MODELS.map(m => ({
value: m.id,
label: `${m.label}${m.price}`
}))}
/>
</Form.Item>
</Card>
<Button type="primary" onClick={handleSave} loading={loading || saving}>Save</Button>
</Space>
</Form>
+14
View File
@@ -4,8 +4,22 @@ export interface AdminSettings {
passwordResetHours: number;
greetingFormat: string;
greetingFallback: string;
draftingModel: string;
}
/**
* The models the drafting worker will accept, with what each costs per million
* input/output tokens. Mirrors the backend catalogue in src/intake/models.ts,
* which is the authority the server refuses anything outside its own list, so
* a stale entry here fails loudly on save rather than silently drafting with
* the wrong model.
*/
export const DRAFTING_MODELS = [
{ id: 'claude-sonnet-5', label: 'Claude Sonnet 5', price: '$2 / $10 per Mtok' },
{ id: 'claude-opus-5', label: 'Claude Opus 5', price: '$5 / $25 per Mtok' },
{ id: 'claude-haiku-4-5', label: 'Claude Haiku 4.5', price: '$1 / $5 per Mtok' }
];
export async function fetchAdminSettings(): Promise<AdminSettings> {
const res = await fetch('/api/admin/settings');
if (!res.ok) throw new Error('Could not load settings');