Only the manual verification against a real photograph is left, and it needs an API key that does not exist yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1296 lines
48 KiB
Markdown
1296 lines
48 KiB
Markdown
# 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<typeof DraftSchema>`
|
|
|
|
- [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<typeof DraftSchema>;
|
|
```
|
|
|
|
- [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<string> {
|
|
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<DraftOutcome> {
|
|
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<void>`
|
|
|
|
- [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<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;
|
|
}
|
|
|
|
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<void> {
|
|
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/<name>' 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<string[]> {
|
|
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<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.
|
|
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/<token>`, 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.
|