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
Showing only changes of commit 2a30a69653 - Show all commits
@@ -472,6 +472,30 @@ git commit -m "feat(intake): tell the model to describe rather than invent (#223
---
### Task 3b: The model catalogue, and choosing it from Admin settings
Added mid-execution, at Thom's request: the model must be settable from the Admin settings page rather than only by an environment variable and a redeploy.
Two things fall out of that. A **dropdown, not a free-text box** — a mistyped model name fails on every submission and surfaces only as drafts quietly not appearing, so the valid set is enforced on the server rather than merely offered by the UI. And **one catalogue, not two** — the settings dropdown needs the model list, `costMicros` needs each model's rates, and those must not be two lists that drift. So the catalogue is a module both import.
Rates confirmed against the pricing page on 2026-08-31, not recalled: Sonnet 5 $2/$10, Opus 5 $5/$25, Haiku 4.5 $1/$5 per million input/output tokens. Worth having checked — an increase to $3/$15 had been scheduled for 2026-09-01 and was cancelled, with $2/$10 made permanent.
**Files:**
- Create: `backend/src/intake/models.ts`, `backend/tests/unit/draftCost.test.ts`
- Modify: `backend/src/adminSettings.ts` (a `choice` type), `backend/src/routes/adminSettings.ts`, `frontend/src/admin/Settings.tsx`
**Interfaces:**
- Produces: `DRAFTING_MODELS`, `DEFAULT_DRAFTING_MODEL`, `isDraftingModel()`, `costMicros()`, and a `draftingModel` admin setting.
- [ ] **Step 1: The catalogue, test first.** `costMicros(model, input, output)` in whole micros. An unrecognised model must price above zero — a budget that reads as unspent however much was spent is the one failure a spend guard cannot have.
- [ ] **Step 2: Add a `choice` type to `adminSettings.ts`.** The module's own docstring says adding a setting means adding a row and nothing else; that holds for `hours` and `text`, and a third type is what makes it hold for a constrained one. Row: `{ key: 'drafting_model', name: 'draftingModel', type: 'choice', fallback: DEFAULT_DRAFTING_MODEL, options: [...] }`.
- [ ] **Step 3: Validate membership in the PUT route**, as a third loop beside the hours and text loops. A value outside the set is a 400, not a stored string that breaks drafting later.
- [ ] **Step 4: The dropdown in `Settings.tsx`**, showing each model's price so the person switching can see that Opus costs 2.5x Sonnet before they pick it.
- [ ] **Step 5:** `npm run lint && npm run build && npm run test:unit`, and the frontend's checks.
- [ ] **Step 6: Commit.**
---
### Task 4: One call, one draft
**Files:**
@@ -582,8 +606,11 @@ import { buildSystemPrompt, buildUserContent } from './draftPrompt';
*/
const DEFAULT_MODEL = 'claude-sonnet-5';
export function draftingModel(): string {
return process.env.INTAKE_MODEL || DEFAULT_MODEL;
// Read from Admin settings (Task 3b) rather than the environment, so the choice
// can be changed without a redeploy. getSettings() supplies the fallback, so
// there is no default written twice here to disagree with the one there.
async function draftingModel(): Promise<string> {
return (await getSettings()).draftingModel;
}
/**
@@ -594,22 +621,8 @@ export function draftingModel(): string {
* nothing would make a budget ceiling read as unspent however much was really
* spent, which is the one failure a spend guard must not have.
*/
// CONFIRM THESE against the current pricing page before committing. They are
// the one set of numbers in this plan taken from memory rather than read out of
// the repository, and the test above pins them exactly. If a rate differs,
// change the table and the test together — a cost column that is quietly wrong
// by half is worse than no cost column, because #227 sets a budget from it.
const RATES: Record<string, { input: number; output: number }> = {
'claude-sonnet-5': { input: 2, output: 10 },
'claude-opus-5': { input: 5, output: 25 },
'claude-haiku-4-5': { input: 1, output: 5 }
};
const FALLBACK_RATE = { input: 5, output: 25 };
export function costMicros(model: string, inputTokens: number, outputTokens: number): number {
const rate = RATES[model] ?? FALLBACK_RATE;
return Math.round(inputTokens * rate.input + outputTokens * rate.output);
}
// Rates and costMicros live in ./models, shared with the Admin settings
// dropdown so the list of models and their prices cannot drift apart.
export interface DraftInput {
photos: { mediaType: string; base64: string }[];