UNPKG

@accounter/server

Version:
276 lines (251 loc) 9.75 kB
import { generateText, ModelMessage, Output } from 'ai'; import { Injectable, Scope } from 'graphql-modules'; import stripIndent from 'strip-indent'; import { z } from 'zod'; import { anthropic } from '@ai-sdk/anthropic'; import { Currency, DocumentType } from '../../shared/enums.js'; import type { BusinessMatchData, OwnerMatchInfo } from './helpers/business-matcher.helper.js'; import { applyForeignCounterpartyVatDefault, matchBusiness, } from './helpers/business-matcher.helper.js'; // NOTE: schema is kept as simple as possible to stay under Anthropic's constrained-decoding // grammar complexity budget. Two rules: // 1. Fields use `.optional()` (not `.nullable().optional()`) — `.nullable()` emits // `anyOf: [..., {type:"null"}]` which counts against the budget. // 2. Enum fields (`type`, `currency`) use `z.string()` with valid values listed in // `.describe()` instead of `z.enum()`. Each enum value is a grammar alternative; // 18 explicit values across 2 fields pushed the schema over the limit. Values are // validated against the TypeScript enums after the LLM call. const documentDataSchema = z.object({ type: z .enum(DocumentType) .describe('The type of financial document. Return UNPROCESSED if missing.'), issuer: z .string() .describe( 'Legal name of the organization that issued the document. Return empty string if missing.', ), recipient: z .string() .describe( 'Legal name and details of the entity to whom the document is addressed. Return empty string if missing.', ), issuerVatNumber: z .string() .describe('VAT or business registration number of the issuer. Return empty string if missing.'), recipientVatNumber: z .string() .describe( 'VAT or business registration number of the recipient. Return empty string if missing.', ), fullAmount: z .number() .nullable() .describe( 'Total monetary amount including taxes and all charges. Return NULL (not empty string!) if missing.', ), currency: z .enum(Currency) .nullable() .describe('ISO 4217 currency code. Return NULL (not empty string!) if missing.'), vatAmount: z .number() .nullable() .describe( 'Value Added Tax amount if separately specified. Return NULL (not empty string!) if missing.', ), date: z .string() .regex(/^\d{4}-\d{2}-\d{2}$/) .nullable() .describe( 'Document issue date in ISO 8601 format (YYYY-MM-DD). Return NULL (not empty string!) if missing.', ), referenceCode: z .string() .describe( 'Complete document identifier including any separators (e.g., dashes, slashes). Return empty string if missing.', ), allocationNumber: z .string() .length(9) .nullable() .describe( 'Unique document 9-digit allocation number (מספר הקצאה). Sometimes last 9 digits of a longer number. Return NULL (not empty string!) if no VAT amount, if amount is < 5000 ILS, or if missing.', ), description: z .string() .describe('Additional description or remarks. Return empty string if missing.'), }); type DocumentData = z.infer<typeof documentDataSchema>; export type DocumentDataWithMatches = Omit<DocumentData, 'type' | 'currency'> & { type?: DocumentType; currency?: Currency; suggestedIssuer: string | null; suggestedRecipient: string | null; }; const businessMatchSchema = z.object({ issuerMatch: z .string() .nullable() .describe('UUID of the best-matching issuer business, or null if no confident match'), recipientMatch: z .string() .nullable() .describe('UUID of the best-matching recipient business, or null if no confident match'), }); const FINANCIAL_DOC_TYPES = new Set<string>([ DocumentType.Invoice, DocumentType.Receipt, DocumentType.InvoiceReceipt, DocumentType.CreditInvoice, DocumentType.Proforma, ]); const SUPPORTED_FILE_TYPES = [ 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', ] as const; type SupportedFileType = (typeof SUPPORTED_FILE_TYPES)[number]; function isSupportedFileType(value: string): value is SupportedFileType { return SUPPORTED_FILE_TYPES.includes(value as SupportedFileType); } @Injectable({ scope: Scope.Singleton, global: true, }) export class AnthropicProvider { private async fileToBase64(fileOrBlob: File | Blob): Promise<string> { const buffer = await fileOrBlob.arrayBuffer(); return Buffer.from(buffer).toString('base64'); } async extractInvoiceDetails( fileOrBlob: File | Blob, businesses?: BusinessMatchData[], owner?: OwnerMatchInfo, ): Promise<DocumentDataWithMatches> { const fileType = fileOrBlob.type.toLowerCase(); if (!isSupportedFileType(fileType)) { throw new Error('Unsupported file type. Please provide an image or PDF.'); } const fileData = await this.fileToBase64(fileOrBlob); const inputMessages: Array<ModelMessage> = [ { role: 'user' as const, content: [ { type: 'text' as const, text: stripIndent(`Please analyze the provided document and extract: - Document type - Issuer and recipient details (names and VAT/registration numbers) - Monetary amounts (total and VAT) - Date and reference numbers - Allocation number (if VAT exists and applicable) - Description or remarks Note that some receipts (e.g. by Stripe) carry the invoice details; pay extra attention not to misclassify them as INVOICE_RECEIPT. Return only a JSON object without any explanation. Omit any field whose value is missing or not present on the document; allocation number is optional.`), }, { type: 'file', data: fileData, mediaType: fileType }, ], }, ]; const { output, finalStep: { response }, } = await generateText({ model: anthropic('claude-sonnet-4-5'), output: Output.object({ schema: documentDataSchema }), messages: inputMessages, }).catch(err => { throw new Error(`Failed to extract document details: ${err.message}`); }); const draft = output; const businessList = businesses ?? []; let suggestedIssuer = matchBusiness(draft.issuer, draft.issuerVatNumber, businessList); let suggestedRecipient = matchBusiness(draft.recipient, draft.recipientVatNumber, businessList); // LLM fallback: for financial documents with unmatched sides, ask Claude to // pick from the businesses list using the existing conversation context (no // file re-send — the document is already in the prior turn). if ( businessList.length > 0 && draft.type != null && FINANCIAL_DOC_TYPES.has(draft.type) && (suggestedIssuer === null || suggestedRecipient === null) ) { const unmatched: string[] = []; if (suggestedIssuer === null && draft.issuer) { unmatched.push(`issuer "${draft.issuer}"`); } if (suggestedRecipient === null && draft.recipient) { unmatched.push(`recipient "${draft.recipient}"`); } if (unmatched.length > 0) { const sortedBusinesses = [...businessList].sort( (a, b) => (b.suggestion_data?.priority ?? 0) - (a.suggestion_data?.priority ?? 0), ); const businessesText = sortedBusinesses .map(b => `${b.id}|${b.name ?? b.hebrew_name ?? ''}`) .join('\n'); const followUpText = [ `The document has unmatched ${unmatched.join(' and ')}.`, `Below is a list of known businesses (format: UUID|name). Return the UUID of the closest matching business for each unmatched side, or null if no confident match. Do not guess.`, businessesText, ].join('\n\n'); try { const { output: matchOutput } = await generateText({ model: anthropic('claude-sonnet-4-5'), output: Output.object({ schema: businessMatchSchema }), messages: [ ...inputMessages, // eslint-disable-next-line @typescript-eslint/no-explicit-any ...(response.messages as any[]), { role: 'user' as const, content: followUpText }, ], }); if (matchOutput) { if ( suggestedIssuer === null && matchOutput.issuerMatch && businessList.some(b => b.id === matchOutput.issuerMatch) ) { suggestedIssuer = matchOutput.issuerMatch; } if ( suggestedRecipient === null && matchOutput.recipientMatch && businessList.some(b => b.id === matchOutput.recipientMatch) ) { suggestedRecipient = matchOutput.recipientMatch; } } } catch { // LLM fallback failure is non-fatal — proceed with server-side match only } } } const vatAmount = applyForeignCounterpartyVatDefault( draft.vatAmount, owner, suggestedIssuer, suggestedRecipient, businessList, ); const validatedType = (Object.values(DocumentType) as string[]).includes(draft.type ?? '') ? (draft.type as DocumentType) : undefined; const validatedCurrency = (Object.values(Currency) as string[]).includes(draft.currency ?? '') ? (draft.currency as Currency) : undefined; return { ...draft, vatAmount, type: validatedType, currency: validatedCurrency, suggestedIssuer, suggestedRecipient, }; } }