From eae2c15f1ad33ec82522fe2a3632159cb714d431 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Mon, 31 Aug 2026 19:44:59 -0500 Subject: [PATCH] feat(intake): draft a listing from photos and a note (#223) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client is a parameter rather than a module import, so every test passes a stub. A test that reaches the real API is a defect in the test: this runs on a route a stranger with a link can trigger, and each call costs money. getAnthropicClient returns null rather than throwing when there is no key. An unconfigured environment is a working one, and the worker treats null exactly as it treats a failed call — one path rather than two. parsed_output is guarded, not asserted. The SDK returns null there when the answer did not satisfy the schema, which is what a model replying in prose looks like; failing cleanly leaves the submission queued for a retry, where asserting would crash the worker mid-loop. Absent usage figures are treated as zero for the same reason: undercounting a cost is survivable, throwing away a draft that actually succeeded is not. Co-Authored-By: Claude Opus 5 --- backend/src/intake/anthropicClient.ts | 30 ++++++++++ backend/src/intake/draftListing.ts | 77 +++++++++++++++++++++++++ backend/tests/unit/draftListing.test.ts | 75 ++++++++++++++++++++++++ 3 files changed, 182 insertions(+) create mode 100644 backend/src/intake/anthropicClient.ts create mode 100644 backend/src/intake/draftListing.ts create mode 100644 backend/tests/unit/draftListing.test.ts diff --git a/backend/src/intake/anthropicClient.ts b/backend/src/intake/anthropicClient.ts new file mode 100644 index 0000000..11cc3ce --- /dev/null +++ b/backend/src/intake/anthropicClient.ts @@ -0,0 +1,30 @@ +import Anthropic from '@anthropic-ai/sdk'; + +/** + * The client, or null when there is no key. + * + * Null rather than a throw, because an unconfigured environment is a working + * one: submissions still arrive and wait undrafted. The worker treats null + * exactly as it treats a failed call, which keeps one path rather than two. + * + * Constructed once and cached. The SDK holds a connection pool, and building + * one per submission would be wasteful on a route a stranger can trigger. + */ +let cached: Anthropic | null = null; +let resolved = false; + +export function getAnthropicClient(): Anthropic | null { + if (resolved) return cached; + + const key = process.env.ANTHROPIC_API_KEY; + cached = key !== undefined && key.trim() !== '' ? new Anthropic({ apiKey: key }) : null; + resolved = true; + + return cached; +} + +/** Exposed for tests, which need a fresh decision per case. */ +export function resetAnthropicClient(): void { + cached = null; + resolved = false; +} diff --git a/backend/src/intake/draftListing.ts b/backend/src/intake/draftListing.ts new file mode 100644 index 0000000..1f01cae --- /dev/null +++ b/backend/src/intake/draftListing.ts @@ -0,0 +1,77 @@ +import type Anthropic from '@anthropic-ai/sdk'; +import { zodOutputFormat } from '@anthropic-ai/sdk/helpers/zod'; +import { getSettings } from '../adminSettings'; +import { DraftSchema, DraftResult } from './draftSchema'; +import { buildSystemPrompt, buildUserContent } from './draftPrompt'; +import { costMicros } from './models'; + +/** + * Read from Admin settings rather than the environment, so the choice can be + * changed without a redeploy. getSettings supplies the fallback, so there is no + * second default here to disagree with the one in the catalogue. + */ +async function draftingModel(): Promise { + return (await getSettings()).draftingModel; +} + +/** + * Enough for a listing and its tags, and low enough that a model which starts + * rambling is cut off rather than billed for indefinitely. + */ +const MAX_TOKENS = 2000; + +export interface DraftInput { + photos: { mediaType: string; base64: string }[]; + note: string | null; + categories: string[]; + tags: string[]; +} + +export interface DraftOutcome { + draft: DraftResult; + model: string; + inputTokens: number; + outputTokens: number; + costMicros: number; +} + +/** + * One submission, one draft. + * + * The client is a parameter rather than a module import so every test can pass + * a stub. A test that reaches the real API is a defect in the test: this runs + * on a public route and each call costs money. + */ +export async function draftListing( + client: Anthropic, + input: DraftInput +): Promise { + const model = await draftingModel(); + + const response = await client.messages.parse({ + model, + max_tokens: MAX_TOKENS, + system: buildSystemPrompt(input.categories, input.tags), + messages: [{ role: 'user', content: buildUserContent(input.photos, input.note) as never }], + output_config: { format: zodOutputFormat(DraftSchema) } + }); + + // Null when the response did not satisfy the schema. Guarded rather than + // asserted: the SDK's own examples reach for it with `?.`, and a model + // answering in prose is exactly the case worth failing cleanly on. + const draft = response.parsed_output; + if (!draft) { + throw new Error('the model did not return a draft matching the expected shape'); + } + + const inputTokens = response.usage?.input_tokens ?? 0; + const outputTokens = response.usage?.output_tokens ?? 0; + + return { + draft, + model, + inputTokens, + outputTokens, + costMicros: costMicros(model, inputTokens, outputTokens) + }; +} diff --git a/backend/tests/unit/draftListing.test.ts b/backend/tests/unit/draftListing.test.ts new file mode 100644 index 0000000..872d83c --- /dev/null +++ b/backend/tests/unit/draftListing.test.ts @@ -0,0 +1,75 @@ +import type Anthropic from '@anthropic-ai/sdk'; +import { draftListing } from '../../src/intake/draftListing'; + +// getSettings reaches for the database, which a unit test has no business +// doing. The model choice is the only thing needed from it here. +jest.mock('../../src/adminSettings', () => ({ + getSettings: jest.fn(async () => ({ draftingModel: 'claude-sonnet-5' })) +})); + +const draft = { + name: 'Blue stoneware vase', + description: 'Hand-thrown, chipped base.', + category: null, + tags: [], + suggestedPriceCents: 4500 +}; + +/** + * A stub, always. Every test in this file would otherwise cost money, and a + * suite that bills the shop to run is one nobody will run. + */ +function stubClient(response: unknown): Anthropic { + return { + messages: { parse: jest.fn(async () => response) } + } as unknown as Anthropic; +} + +const input = { photos: [{ mediaType: 'image/jpeg', base64: 'AAAA' }], note: null, categories: [], tags: [] }; + +describe('draftListing', () => { + it('returns the parsed draft with what it cost', async () => { + const client = stubClient({ + parsed_output: draft, + usage: { input_tokens: 1000, output_tokens: 200 } + }); + + const outcome = await draftListing(client, input); + + expect(outcome.draft).toEqual(draft); + expect(outcome.model).toBe('claude-sonnet-5'); + // 1000 * $2 + 200 * $10 per million, in micros. + expect(outcome.costMicros).toBe(4000); + }); + + // The SDK returns null here when the answer did not satisfy the schema — a + // model replying in prose, most likely. Failing cleanly leaves the submission + // queued for a retry; asserting on it would crash the worker mid-loop. + it('fails rather than assuming a draft came back', async () => { + const client = stubClient({ parsed_output: null, usage: { input_tokens: 10, output_tokens: 0 } }); + + await expect(draftListing(client, input)).rejects.toThrow(/expected shape/); + }); + + // Usage has been absent on some responses. Treating that as free is wrong but + // survivable; letting it throw would lose a draft that actually succeeded. + it('survives a response with no usage figures', async () => { + const client = stubClient({ parsed_output: draft }); + + const outcome = await draftListing(client, input); + + expect(outcome.inputTokens).toBe(0); + expect(outcome.costMicros).toBe(0); + }); + + it('sends the photographs and the chosen model to the API', async () => { + const client = stubClient({ parsed_output: draft, usage: { input_tokens: 1, output_tokens: 1 } }); + + await draftListing(client, { ...input, categories: ['Ceramics'], note: 'a note' }); + + const params = (client.messages.parse as jest.Mock).mock.calls[0][0]; + expect(params.model).toBe('claude-sonnet-5'); + expect(params.system).toContain('Ceramics'); + expect(params.messages[0].content.filter((b: { type: string }) => b.type === 'image')).toHaveLength(1); + }); +});