Files
redefined-designs/backend/src/intake/draftListing.ts
T
bermudalambandClaude Opus 5 0d559244d0 feat(intake): draft a listing from photos and a note (#223)
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 <noreply@anthropic.com>
2026-08-31 19:44:59 -05:00

78 lines
2.4 KiB
TypeScript

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)
};
}