173 lines
4.8 KiB
TypeScript
173 lines
4.8 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;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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<ParsedReceipt> {
|
|||
|
|
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' },
|
|||
|
|
});
|
|||
|
|
return safeParse(response.choices[0]?.message?.content ?? '{}');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function runClaude({
|
|||
|
|
imageBuffer,
|
|||
|
|
mimeType,
|
|||
|
|
apiKey,
|
|||
|
|
model,
|
|||
|
|
}: RunArgs): Promise<ParsedReceipt> {
|
|||
|
|
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 { content?: Array<{ type: string; text?: string }> };
|
|||
|
|
const text = body.content?.find((c) => c.type === 'text')?.text ?? '{}';
|
|||
|
|
return safeParse(text);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export async function runOcr(args: {
|
|||
|
|
provider: 'openai' | 'claude';
|
|||
|
|
imageBuffer: Buffer;
|
|||
|
|
mimeType: string;
|
|||
|
|
apiKey: string;
|
|||
|
|
model: string;
|
|||
|
|
}): Promise<ParsedReceipt> {
|
|||
|
|
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 };
|
|||
|
|
}
|
|||
|
|
}
|