import Anthropic from '@anthropic-ai/sdk'; import { zodOutputFormat } from '@anthropic-ai/sdk/helpers/zod'; import { z } from 'zod'; import type { Env } from '../../lib/env'; /** * RULES.md section 9. The model returns this and nothing else: structured outputs * constrain the response to the schema, so there is no prose to strip and no JSON to * repair. Every field is nullable, because "unreadable" is a real answer and guessing a * number onto a tax document is the one thing this must never do. */ export const OcrExtraction = z.object({ emitter_ruc: z.string().nullable(), emitter_dv: z.string().nullable(), emitter_name: z.string().nullable(), receiver_doc: z.string().nullable(), doc_number: z.string().nullable(), issue_date: z.string().nullable(), total: z.number().int().nullable(), amount_iva10: z.number().int().nullable(), amount_iva5: z.number().int().nullable(), amount_exenta: z.number().int().nullable(), iva10: z.number().int().nullable(), iva5: z.number().int().nullable(), confidence: z.record(z.string(), z.number()), }); export type OcrExtraction = z.infer; export interface OcrProvider { extract(args: { data: Uint8Array; mime: string }): Promise; } const SYSTEM_PROMPT = `Sos un extractor de datos de comprobantes fiscales paraguayos (facturas, autofacturas, notas de credito y debito). Leé la imagen y devolvé unicamente los campos del esquema. Reglas: - Las facturas paraguayas imprimen las columnas de IVA como "10%", "5%" y "Exentas". amount_iva10, amount_iva5 y amount_exenta son las bases gravadas de cada columna; iva10 e iva5 son los impuestos liquidados de cada una. - Los importes se imprimen con punto como separador de miles y no llevan decimales. Devolvelos como enteros sin separadores: "1.234.567" es 1234567. - issue_date en formato YYYY-MM-DD. - emitter_ruc sin el digito verificador; emitter_dv es ese digito. - Si un campo no se lee con claridad, devolvé null. Nunca adivines un numero. - confidence lleva una entrada por campo que sí leiste, de 0 a 1.`; /** Guaranies have no cents, so a component sum may legitimately differ by rounding. */ const TOTAL_TOLERANCE_GS = 1; /** RULES.md section 9: money fields drop to this when the components do not add up. */ const MISMATCH_CONFIDENCE = 0.5; /** * Returns null when no key is configured. Every caller treats that as "OCR is off" and * falls back to manual entry rather than failing the scan (SPEC.md section 8). */ export function createOcrProvider(env: Env): OcrProvider | null { if (!env.ANTHROPIC_API_KEY) return null; const client = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY }); return { async extract({ data, mime }) { const message = await client.messages.parse({ model: env.OCR_MODEL, max_tokens: 4096, system: SYSTEM_PROMPT, output_config: { format: zodOutputFormat(OcrExtraction) }, messages: [ { role: 'user', content: [ { type: 'image', source: { type: 'base64', media_type: imageMediaType(mime), data: Buffer.from(data).toString('base64'), }, }, { type: 'text', text: 'Extraé los datos de este comprobante.' }, ], }, ], }); const parsed = message.parsed_output; if (!parsed) throw new Error('OCR returned no parseable output'); return lowerConfidenceOnMismatch(OcrExtraction.parse(parsed)); }, }; } /** * RULES.md section 9: when the total and the components are both present and disagree, * the money fields are not trusted, whatever the model said about them. */ export function lowerConfidenceOnMismatch(extraction: OcrExtraction): OcrExtraction { const { total, amount_iva10, amount_iva5, amount_exenta, iva10, iva5 } = extraction; const components = [amount_iva10, amount_iva5, amount_exenta, iva10, iva5]; if (total === null || components.some((value) => value === null)) return extraction; const derived = (amount_iva10 ?? 0) + (amount_iva5 ?? 0) + (amount_exenta ?? 0) + (iva10 ?? 0) + (iva5 ?? 0); if (Math.abs(total - derived) <= TOTAL_TOLERANCE_GS) return extraction; const confidence = { ...extraction.confidence }; for (const field of ['total', 'amount_iva10', 'amount_iva5', 'amount_exenta', 'iva10', 'iva5']) { if (field in confidence) confidence[field] = Math.min(confidence[field] ?? 1, MISMATCH_CONFIDENCE); else confidence[field] = MISMATCH_CONFIDENCE; } return { ...extraction, confidence }; } type ImageMediaType = 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp'; function imageMediaType(mime: string): ImageMediaType { switch (mime) { case 'image/png': case 'image/gif': case 'image/webp': return mime; default: return 'image/jpeg'; } }