- templates/web/pricing.py: pricing table with source and as-of date; cost_for_call
(input rate + (output + thinking) at output rate, long-context above threshold;
unknown model raises naming the known models).
- Cost tab: coverage pill, total/this-month/tokens tiles, month-by-month table,
per-run tokens and cost ("not priced", never $0), by-hand panel with editable
assumptions labelled as such.
- Contract: RoiRun and GET /roi; optional GET /models and POST /runs/{id}/retry.
- SKILL.md: cost page wiring, model picker and retry-on-other-model as optional sections.
- Tests: test_pricing.py self-check, page_check_ai/test_ai_cost.py.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
72 lines
2.9 KiB
TypeScript
72 lines
2.9 KiB
TypeScript
/** The AI contract additions (see contract.md): runs, review rows, the approve gate. */
|
||
export type Band = 'green' | 'amber' | 'red' | 'blank' | 'unscored'
|
||
export type RunStatus = 'pending' | 'queued' | 'running' | 'done' | 'failed'
|
||
export type Run = {
|
||
job_id: string
|
||
document: string
|
||
status: RunStatus
|
||
model: string
|
||
pages_done: number
|
||
pages_total: number
|
||
summary: Record<Band, number> | null // null until the run is done
|
||
approved: boolean
|
||
error: string | null
|
||
}
|
||
export type ReviewRow = {
|
||
row_id: number
|
||
field: string
|
||
value: string | null
|
||
band: Band
|
||
score: number | null
|
||
reason: string | null
|
||
verified: boolean
|
||
corrected_value: string | null
|
||
}
|
||
|
||
/** One run on the cost page. total_cost is null when the run recorded no token usage (unpriced). */
|
||
export type RoiRun = {
|
||
job_id: string
|
||
document: string
|
||
created_at: string // ISO, UTC
|
||
model: string
|
||
input_tokens: number | null
|
||
output_tokens: number | null
|
||
thinking_tokens: number | null
|
||
total_cost: number | null // USD
|
||
}
|
||
export type Pricing = { source: string; as_of: string }
|
||
|
||
export const BUSY: RunStatus[] = ['queued', 'running'] // polled every 1.5s while in one of these
|
||
|
||
/** Legend order, label, and the colour token. Band colours are semantic: never on buttons or headings. */
|
||
export const BANDS: { band: Band; label: string; colour: string }[] = [
|
||
{ band: 'green', label: 'Clean ≥0.95', colour: 'var(--clean)' },
|
||
{ band: 'amber', label: 'Check 0.90–0.95', colour: 'var(--verify)' },
|
||
{ band: 'red', label: 'Review <0.90', colour: 'var(--review)' },
|
||
{ band: 'blank', label: 'Missing (required)', colour: 'var(--absent)' },
|
||
{ band: 'unscored', label: 'Unscored', colour: 'var(--absent)' },
|
||
]
|
||
export const CAVEAT = 'Colour reflects print clarity, not correctness. A cleanly printed value can still be misread.'
|
||
|
||
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||
const res = await fetch(API_BASE + path, { ...init, headers: { 'Content-Type': 'application/json' } })
|
||
if (!res.ok) {
|
||
// The server says why it refused (the approve gate answers 409 with the count).
|
||
const body = await res.json().catch(() => null)
|
||
throw new Error(body?.detail ?? `${res.status} ${res.statusText}`)
|
||
}
|
||
return res.json()
|
||
}
|
||
|
||
export const ai = {
|
||
runs: () => req<{ runs: Run[] }>('/runs'),
|
||
run: (id: string) => req<Run>(`/runs/${id}`),
|
||
rows: (id: string) => req<{ rows: ReviewRow[] }>(`/runs/${id}/review`),
|
||
patchRow: (rowId: number, body: { corrected_value?: string | null; verified: boolean }) =>
|
||
req<ReviewRow>(`/review/${rowId}`, { method: 'PATCH', body: JSON.stringify(body) }),
|
||
verifyRemaining: (id: string) => req<{ verified: number }>(`/runs/${id}/verify-remaining`, { method: 'POST' }),
|
||
approve: (id: string) => req<{ ok: boolean }>(`/runs/${id}/approve`, { method: 'POST' }),
|
||
roi: () => req<{ runs: RoiRun[]; pricing: Pricing }>('/roi'),
|
||
documentUrl: (id: string) => `${API_BASE}/runs/${id}/document`,
|
||
}
|