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>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user