Feature/223 drafting worker #251

Merged
bermudalamb merged 13 commits from feature/223-drafting-worker into main 2026-09-01 08:32:44 -05:00
7 changed files with 264 additions and 16 deletions
Showing only changes of commit c8fb610c70 - Show all commits
+70 -14
View File
@@ -1,4 +1,5 @@
import { pool } from './db';
import { DEFAULT_DRAFTING_MODEL, DRAFTING_MODELS, isDraftingModel } from './intake/models';
/**
* Every admin-configurable setting, in one table.
@@ -24,7 +25,16 @@ const DEFINITIONS = [
{ key: 'verify_token_hours', name: 'verifyTokenHours', type: 'hours', fallback: 24 },
{ key: 'password_reset_hours', name: 'passwordResetHours', type: 'hours', fallback: 1 },
{ key: 'greeting_format', name: 'greetingFormat', type: 'text', fallback: 'Hi {{firstName}},' },
{ key: 'greeting_fallback', name: 'greetingFallback', type: 'text', fallback: 'Hi,' }
{ key: 'greeting_fallback', name: 'greetingFallback', type: 'text', fallback: 'Hi,' },
// A 'choice' rather than a 'text', so a mistyped model name is refused at the
// edge instead of stored. It would otherwise fail on every submission and
// show up only as drafts quietly not appearing (#223).
{
key: 'drafting_model',
name: 'draftingModel',
type: 'choice',
fallback: DEFAULT_DRAFTING_MODEL
}
] as const;
type Definition = (typeof DEFINITIONS)[number];
@@ -33,8 +43,11 @@ export type SettingName = Definition['name'];
export type HoursSettingName = Extract<Definition, { type: 'hours' }>['name'];
export type TextSettingName = Extract<Definition, { type: 'text' }>['name'];
export type ChoiceSettingName = Extract<Definition, { type: 'choice' }>['name'];
export type AdminSettings = Record<HoursSettingName, number> & Record<TextSettingName, string>;
export type AdminSettings = Record<HoursSettingName, number> &
Record<TextSettingName, string> &
Record<ChoiceSettingName, string>;
export const HOURS_SETTINGS: readonly HoursSettingName[] = DEFINITIONS.filter(
(d): d is Extract<Definition, { type: 'hours' }> => d.type === 'hours'
@@ -44,24 +57,67 @@ export const TEXT_SETTINGS: readonly TextSettingName[] = DEFINITIONS.filter(
(d): d is Extract<Definition, { type: 'text' }> => d.type === 'text'
).map(d => d.name);
export const CHOICE_SETTINGS: readonly ChoiceSettingName[] = DEFINITIONS.filter(
(d): d is Extract<Definition, { type: 'choice' }> => d.type === 'choice'
).map(d => d.name);
/**
* The values each choice setting will accept, for the route to validate against
* and the admin UI to offer. Derived from the model catalogue rather than
* restated, so the dropdown cannot come to disagree with what is billable.
*/
export const CHOICE_OPTIONS: Readonly<Record<ChoiceSettingName, readonly string[]>> = {
draftingModel: DRAFTING_MODELS.map(m => m.id)
};
export function isValidChoice(name: ChoiceSettingName, value: string): boolean {
return name === 'draftingModel' ? isDraftingModel(value) : false;
}
// One reader per type, at module level rather than branched inline. The same
// reasoning as the definitions above: getSettings should read as "look each one
// up and resolve it", and a third type was enough to push the inline version
// past the complexity limit.
// An empty format would render every greeting as nothing at all, which reads as
// a bug in the email rather than a setting someone cleared.
function resolveText(raw: string | undefined, fallback: string): string {
return raw !== undefined && raw.trim() !== '' ? raw : fallback;
}
// A row that is present but unparseable falls back rather than yielding NaN,
// which would otherwise reach Date arithmetic and mint a token with an Invalid
// Date expiry that no query could ever match.
function resolveHours(raw: string | undefined, fallback: number): number {
const parsed = parseFloat(raw ?? '');
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
// A stored value that is no longer offered — a model retired since it was
// chosen — falls back rather than being handed on. Drafting with the default
// beats drafting with a model the API will refuse.
function resolveChoice(
name: ChoiceSettingName,
raw: string | undefined,
fallback: string
): string {
return raw !== undefined && isValidChoice(name, raw) ? raw : fallback;
}
export async function getSettings(): Promise<AdminSettings> {
const { rows } = await pool.query<SettingRow>(`SELECT key, value FROM admin_settings`);
const stored = new Map<string, string>(rows.map(r => [r.key, r.value]));
const settings = {} as Record<SettingName, number | string>;
for (const { key, name, type, fallback } of DEFINITIONS) {
const raw = stored.get(key);
if (type === 'text') {
// An empty format would render every greeting as nothing at all, which
// reads as a bug in the email rather than a setting someone cleared.
settings[name] = raw !== undefined && raw.trim() !== '' ? raw : fallback;
continue;
for (const definition of DEFINITIONS) {
const raw = stored.get(definition.key);
if (definition.type === 'choice') {
settings[definition.name] = resolveChoice(definition.name, raw, definition.fallback);
} else if (definition.type === 'text') {
settings[definition.name] = resolveText(raw, definition.fallback);
} else {
settings[definition.name] = resolveHours(raw, definition.fallback);
}
const parsed = parseFloat(raw ?? '');
// A row that is present but unparseable falls back rather than yielding
// NaN, which would otherwise reach Date arithmetic and mint a token with an
// Invalid Date expiry that no query could ever match.
settings[name] = Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
return settings as AdminSettings;
}
+57
View File
@@ -0,0 +1,57 @@
/**
* The models that may draft a listing, and what each one costs.
*
* One catalogue rather than two lists. The Admin settings dropdown needs the
* models, `costMicros` needs their rates, and the price shown beside a model in
* Admin has to be the price it is actually billed at — which it cannot be if
* the list and the rates are maintained separately.
*
* Rates are dollars per million tokens, confirmed against the pricing page on
* 2026-08-31 rather than recalled. Worth checking again when a model is added:
* an increase to $3/$15 had been scheduled for 2026-09-01 and was cancelled,
* with Sonnet's $2/$10 made permanent.
*/
export interface DraftingModel {
id: string;
/** Shown in the Admin dropdown. */
label: string;
/** Dollars per million input tokens. */
inputRate: number;
/** Dollars per million output tokens. */
outputRate: number;
}
export const DRAFTING_MODELS: readonly DraftingModel[] = [
{ id: 'claude-sonnet-5', label: 'Claude Sonnet 5', inputRate: 2, outputRate: 10 },
{ id: 'claude-opus-5', label: 'Claude Opus 5', inputRate: 5, outputRate: 25 },
{ id: 'claude-haiku-4-5', label: 'Claude Haiku 4.5', inputRate: 1, outputRate: 5 }
];
/**
* Sonnet, not Opus. The task is writing a description from a photograph rather
* than reasoning, and this runs once per submission on a route a stranger with
* a link can trigger. Opus costs two and a half times as much per item.
*/
export const DEFAULT_DRAFTING_MODEL = 'claude-sonnet-5';
export function isDraftingModel(id: string): boolean {
return DRAFTING_MODELS.some((model) => model.id === id);
}
/**
* Deliberately not zero. An unrecognised model pricing at nothing would make a
* budget read as unspent however much was really spent, which is the one
* failure a spend guard must not have. Set to the most expensive rate here, so
* an unknown model errs towards over- rather than under-reporting.
*/
const FALLBACK_RATE = { inputRate: 5, outputRate: 25 };
/**
* Whole micros, so a cost never carries a floating-point fraction into the
* database. Rates are per million tokens and a micro is a millionth of a
* dollar, so the two cancel and the arithmetic is just tokens times rate.
*/
export function costMicros(model: string, inputTokens: number, outputTokens: number): number {
const rate = DRAFTING_MODELS.find((m) => m.id === model) ?? FALLBACK_RATE;
return Math.round(inputTokens * rate.inputRate + outputTokens * rate.outputRate);
}
+17
View File
@@ -5,6 +5,9 @@ import {
updateSettings,
HOURS_SETTINGS,
TEXT_SETTINGS,
CHOICE_SETTINGS,
CHOICE_OPTIONS,
isValidChoice,
SettingName
} from '../adminSettings';
@@ -38,6 +41,20 @@ router.put('/', asyncRoute(async (req: Request, res: Response) => {
values[name] = raw;
}
// Membership is checked here rather than left to the dropdown. A value
// outside the set would be stored happily and then fail on every submission,
// surfacing only as drafts quietly not appearing (#223).
for (const name of CHOICE_SETTINGS) {
const raw = req.body[name];
if (raw === undefined) continue;
if (typeof raw !== 'string' || !isValidChoice(name, raw)) {
return res
.status(400)
.json({ error: `${name} must be one of: ${CHOICE_OPTIONS[name].join(', ')}` });
}
values[name] = raw;
}
await updateSettings(values);
res.json(await getSettings());
}));
@@ -29,7 +29,8 @@ describe('GET /api/admin/settings', () => {
verifyTokenHours: 24,
passwordResetHours: 1,
greetingFormat: 'Hi {{firstName}},',
greetingFallback: 'Hi,'
greetingFallback: 'Hi,',
draftingModel: 'claude-sonnet-5'
});
});
});
@@ -100,4 +101,22 @@ describe('PUT /api/admin/settings', () => {
expect(res.status).toBe(400);
expect((await getSettings()).cartExpiryHours).toBe(6);
});
// #223. A model name outside the offered set is refused rather than stored.
// Stored, it would be accepted here and then fail on every submission,
// surfacing only as drafts quietly not appearing.
it('refuses a drafting model it does not offer', async () => {
const res = await request(app)
.put('/api/admin/settings')
.send({ draftingModel: 'claude-sonnet-5-typo' });
expect(res.status).toBe(400);
expect(res.body.error).toContain('draftingModel');
});
it('accepts a drafting model it does offer', async () => {
const res = await request(app).put('/api/admin/settings').send({ draftingModel: 'claude-opus-5' });
expect(res.status).toBe(200);
expect((await getSettings()).draftingModel).toBe('claude-opus-5');
});
});
+58
View File
@@ -0,0 +1,58 @@
import {
costMicros,
DRAFTING_MODELS,
DEFAULT_DRAFTING_MODEL,
isDraftingModel
} from '../../src/intake/models';
// Rates confirmed against the pricing page on 2026-08-31: 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);
});
it('charges nothing for nothing', () => {
expect(costMicros('claude-sonnet-5', 0, 0)).toBe(0);
});
});
describe('the model catalogue', () => {
it('offers the default as one of its choices', () => {
expect(DRAFTING_MODELS.map((m) => m.id)).toContain(DEFAULT_DRAFTING_MODEL);
});
// The settings dropdown and the price table are the same list precisely so
// they cannot drift. If a model were ever priced by the fallback, the figure
// shown next to it in Admin would not be the figure it is billed at.
it('prices every model it offers', () => {
for (const model of DRAFTING_MODELS) {
expect(model.inputRate).toBeGreaterThan(0);
expect(model.outputRate).toBeGreaterThan(0);
}
});
it('recognises exactly the models it offers', () => {
expect(isDraftingModel(DEFAULT_DRAFTING_MODEL)).toBe(true);
expect(isDraftingModel('claude-sonnet-5-typo')).toBe(false);
expect(isDraftingModel('')).toBe(false);
});
});
+28 -1
View File
@@ -7,7 +7,8 @@ import message from 'antd/es/message';
import Card from 'antd/es/card';
import Space from 'antd/es/space';
import Input from 'antd/es/input';
import { fetchAdminSettings, updateAdminSettings } from './adminSettingsApi';
import Select from 'antd/es/select';
import { fetchAdminSettings, updateAdminSettings, DRAFTING_MODELS } from './adminSettingsApi';
const { Title, Text } = Typography;
@@ -115,6 +116,32 @@ export default function Settings() {
</Form.Item>
</Card>
<Card>
<Title level={4}>Item drafting</Title>
{/* A dropdown rather than a text field, and validated again on the
server. A mistyped model name is accepted by the API only at the
point of use, so it would fail on every submission and show up
merely as drafts not appearing. */}
<Text type="secondary">
Which model writes the name, description and suggested price for a submitted item. The price is per
million tokens; a typical item costs a few pence. Drafts are always reviewed before anything is
published.
</Text>
<Form.Item
name="draftingModel"
label="Drafting model"
rules={[{ required: true }]}
style={{ marginTop: 16, marginBottom: 0 }}
>
<Select
options={DRAFTING_MODELS.map(m => ({
value: m.id,
label: `${m.label}${m.price}`
}))}
/>
</Form.Item>
</Card>
<Button type="primary" onClick={handleSave} loading={loading || saving}>Save</Button>
</Space>
</Form>
+14
View File
@@ -4,8 +4,22 @@ export interface AdminSettings {
passwordResetHours: number;
greetingFormat: string;
greetingFallback: string;
draftingModel: string;
}
/**
* The models the drafting worker will accept, with what each costs per million
* input/output tokens. Mirrors the backend catalogue in src/intake/models.ts,
* which is the authority — the server refuses anything outside its own list, so
* a stale entry here fails loudly on save rather than silently drafting with
* the wrong model.
*/
export const DRAFTING_MODELS = [
{ id: 'claude-sonnet-5', label: 'Claude Sonnet 5', price: '$2 / $10 per Mtok' },
{ id: 'claude-opus-5', label: 'Claude Opus 5', price: '$5 / $25 per Mtok' },
{ id: 'claude-haiku-4-5', label: 'Claude Haiku 4.5', price: '$1 / $5 per Mtok' }
];
export async function fetchAdminSettings(): Promise<AdminSettings> {
const res = await fetch('/api/admin/settings');
if (!res.ok) throw new Error('Could not load settings');