Feature/223 drafting worker #251
@@ -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)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user