Adds a token-denominated guardrail in front of every server-side AI call so a misconfigured port can't run up an unbounded bill. Soft caps surface a banner; hard caps refuse new requests until the period rolls over. Usage flows into a feature-typed ledger so future AI surfaces (summary, embeddings, reply-draft) can drop in without schema changes. - New table ai_usage_ledger (port, user, feature, provider, model, input/output/total tokens, request id) with two indexes for rollup - New service ai-budget.service.ts: getAiBudget/setAiBudget, checkBudget (pre-flight gate), recordAiUsage, currentPeriodTokens, periodBreakdown — all token-based, period boundaries in UTC - runOcr now returns provider usage so the route can record the actual spend instead of estimating - Scan-receipt route gates on checkBudget before invoking AI; returns source: manual / reason: budget-exceeded when blocked, surfaces softCapWarning on the success path - Admin UI: new AiBudgetCard on the OCR settings page — shows current spend, per-feature breakdown, soft/hard cap inputs, period selector - Permission: admin.manage_settings on both routes Tests: 766/766 vitest (was 756) — +10 budget tests covering enforce/ disabled/cap-exceed/estimate-exceed/soft-warn/period boundaries/ cross-port isolation/silent ledger failure. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
197 lines
5.5 KiB
TypeScript
197 lines
5.5 KiB
TypeScript
/**
|
||
* Receipt OCR provider adapters. Each adapter takes raw image bytes
|
||
* and returns a normalized `ParsedReceipt` shape; callers don't care
|
||
* which provider produced it.
|
||
*/
|
||
|
||
import OpenAI from 'openai';
|
||
|
||
import { logger } from '@/lib/logger';
|
||
|
||
export interface ParsedReceiptLineItem {
|
||
description: string;
|
||
amount: number;
|
||
}
|
||
|
||
export interface ParsedReceipt {
|
||
establishment: string | null;
|
||
/** ISO YYYY-MM-DD. */
|
||
date: string | null;
|
||
amount: number | null;
|
||
currency: string | null;
|
||
lineItems: ParsedReceiptLineItem[];
|
||
/** 0..1; below 0.6 surfaces "verify mode" UI. */
|
||
confidence: number;
|
||
}
|
||
|
||
export interface OcrUsage {
|
||
inputTokens: number;
|
||
outputTokens: number;
|
||
requestId: string | null;
|
||
}
|
||
|
||
export interface OcrRunResult {
|
||
parsed: ParsedReceipt;
|
||
usage: OcrUsage;
|
||
}
|
||
|
||
const EMPTY_RESULT: ParsedReceipt = {
|
||
establishment: null,
|
||
date: null,
|
||
amount: null,
|
||
currency: null,
|
||
lineItems: [],
|
||
confidence: 0,
|
||
};
|
||
|
||
const SYSTEM_PROMPT =
|
||
'You extract structured data from a marina-business receipt image. Return ONLY a JSON object with these keys: establishment (string), date (ISO YYYY-MM-DD), amount (number, total), currency (3-letter ISO code), lineItems (array of {description, amount}), confidence (number 0-1). If a field cannot be read, return null for that field. Set confidence near 0 if the image is unreadable, near 1 if every field was confidently extracted.';
|
||
|
||
interface RunArgs {
|
||
imageBuffer: Buffer;
|
||
mimeType: string;
|
||
apiKey: string;
|
||
model: string;
|
||
}
|
||
|
||
function safeParse(content: string): ParsedReceipt {
|
||
const cleaned = content.replace(/```json\n?|\n?```/g, '').trim();
|
||
try {
|
||
const obj = JSON.parse(cleaned) as Partial<ParsedReceipt>;
|
||
return {
|
||
establishment: obj.establishment ?? null,
|
||
date: obj.date ?? null,
|
||
amount: typeof obj.amount === 'number' ? obj.amount : null,
|
||
currency: obj.currency ?? null,
|
||
lineItems: Array.isArray(obj.lineItems) ? obj.lineItems : [],
|
||
confidence: typeof obj.confidence === 'number' ? obj.confidence : 0,
|
||
};
|
||
} catch (err) {
|
||
logger.warn({ err, contentLen: cleaned.length }, 'OCR provider returned non-JSON');
|
||
return EMPTY_RESULT;
|
||
}
|
||
}
|
||
|
||
async function runOpenAi({ imageBuffer, mimeType, apiKey, model }: RunArgs): Promise<OcrRunResult> {
|
||
const client = new OpenAI({ apiKey });
|
||
const base64 = imageBuffer.toString('base64');
|
||
const response = await client.chat.completions.create({
|
||
model,
|
||
messages: [
|
||
{ role: 'system', content: SYSTEM_PROMPT },
|
||
{
|
||
role: 'user',
|
||
content: [
|
||
{ type: 'text', text: 'Extract the receipt as JSON.' },
|
||
{
|
||
type: 'image_url',
|
||
image_url: { url: `data:${mimeType};base64,${base64}` },
|
||
},
|
||
],
|
||
},
|
||
],
|
||
max_tokens: 1024,
|
||
response_format: { type: 'json_object' },
|
||
});
|
||
const parsed = safeParse(response.choices[0]?.message?.content ?? '{}');
|
||
return {
|
||
parsed,
|
||
usage: {
|
||
inputTokens: response.usage?.prompt_tokens ?? 0,
|
||
outputTokens: response.usage?.completion_tokens ?? 0,
|
||
requestId: response.id ?? null,
|
||
},
|
||
};
|
||
}
|
||
|
||
async function runClaude({ imageBuffer, mimeType, apiKey, model }: RunArgs): Promise<OcrRunResult> {
|
||
const base64 = imageBuffer.toString('base64');
|
||
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'x-api-key': apiKey,
|
||
'anthropic-version': '2023-06-01',
|
||
},
|
||
body: JSON.stringify({
|
||
model,
|
||
max_tokens: 1024,
|
||
system: SYSTEM_PROMPT,
|
||
messages: [
|
||
{
|
||
role: 'user',
|
||
content: [
|
||
{
|
||
type: 'image',
|
||
source: { type: 'base64', media_type: mimeType, data: base64 },
|
||
},
|
||
{ type: 'text', text: 'Extract the receipt as JSON.' },
|
||
],
|
||
},
|
||
],
|
||
}),
|
||
});
|
||
if (!res.ok) {
|
||
const detail = await res.text().catch(() => '');
|
||
throw new Error(`Claude API ${res.status}: ${detail.slice(0, 200)}`);
|
||
}
|
||
const body = (await res.json()) as {
|
||
id?: string;
|
||
content?: Array<{ type: string; text?: string }>;
|
||
usage?: { input_tokens?: number; output_tokens?: number };
|
||
};
|
||
const text = body.content?.find((c) => c.type === 'text')?.text ?? '{}';
|
||
const parsed = safeParse(text);
|
||
return {
|
||
parsed,
|
||
usage: {
|
||
inputTokens: body.usage?.input_tokens ?? 0,
|
||
outputTokens: body.usage?.output_tokens ?? 0,
|
||
requestId: body.id ?? null,
|
||
},
|
||
};
|
||
}
|
||
|
||
export async function runOcr(args: {
|
||
provider: 'openai' | 'claude';
|
||
imageBuffer: Buffer;
|
||
mimeType: string;
|
||
apiKey: string;
|
||
model: string;
|
||
}): Promise<OcrRunResult> {
|
||
if (args.provider === 'openai') return runOpenAi(args);
|
||
return runClaude(args);
|
||
}
|
||
|
||
/**
|
||
* Tiny dummy-image probe used by the admin "Test connection" button.
|
||
* Returns the raw HTTP status so callers can render plain-English errors.
|
||
*/
|
||
export async function testProvider(
|
||
provider: 'openai' | 'claude',
|
||
apiKey: string,
|
||
model: string,
|
||
): Promise<{ ok: true } | { ok: false; reason: string }> {
|
||
// 1×1 transparent PNG.
|
||
const pixelPng = Buffer.from(
|
||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||
'base64',
|
||
);
|
||
try {
|
||
await runOcr({
|
||
provider,
|
||
imageBuffer: pixelPng,
|
||
mimeType: 'image/png',
|
||
apiKey,
|
||
model,
|
||
});
|
||
return { ok: true };
|
||
} catch (err) {
|
||
const reason = err instanceof Error ? err.message : 'Unknown error';
|
||
return { ok: false, reason };
|
||
}
|
||
}
|
||
|
||
export const OCR_FEATURE = 'ocr_receipt';
|
||
export const OCR_ESTIMATED_TOKENS = 2048;
|