Two mechanical sweeps closing the audit's HIGH §16 + MED §11 findings: * 38 client components / 56 toast.error sites converted to toastError(err) so the new admin error inspector becomes usable from user-reported issues — every failed inline-edit, save, send, archive, upload, etc. now carries the request-id + error-code (Copy ID action). * 26 service files / 62 bare-Error throws converted to CodedError or the existing AppError subclasses. Adds new error codes: DOCUMENSO_UPSTREAM_ERROR (502), DOCUMENSO_AUTH_FAILURE (502), DOCUMENSO_TIMEOUT (504), OCR_UPSTREAM_ERROR (502), IMAP_UPSTREAM_ERROR (502), UMAMI_UPSTREAM_ERROR (502), UMAMI_NOT_CONFIGURED (409), and INSERT_RETURNING_EMPTY (500) for post-insert returning-empty guards. * Five vitest assertions updated to match the new user-facing wording (client-merge "already been merged", expense/interest "couldn't find that …", documenso "signing service didn't respond"). Test status: 1168/1168 vitest, tsc clean. Refs: docs/audit-comprehensive-2026-05-05.md HIGH §16 (auditor-H Issue 1) + MED §11 (auditor-G Issue 1). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
207 lines
6.0 KiB
TypeScript
207 lines
6.0 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 { CodedError } from '@/lib/errors';
|
||
import { logger } from '@/lib/logger';
|
||
import { fetchWithTimeout } from '@/lib/fetch-with-timeout';
|
||
|
||
const OCR_TIMEOUT_MS = 30_000;
|
||
|
||
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> {
|
||
// Default OpenAI client has no timeout — a hung request would hold a Bull
|
||
// documents-worker concurrency slot until the OS reset it (~15 min). The
|
||
// 30s cap matches the cap on the (newer) email-draft worker fetch.
|
||
const client = new OpenAI({ apiKey, timeout: OCR_TIMEOUT_MS });
|
||
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 fetchWithTimeout('https://api.anthropic.com/v1/messages', {
|
||
timeoutMs: OCR_TIMEOUT_MS,
|
||
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 CodedError('OCR_UPSTREAM_ERROR', {
|
||
internalMessage: `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;
|