@accounter/server
Version:
Accounter GraphQL server
276 lines (251 loc) • 9.75 kB
text/typescript
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);
}
({
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,
};
}
}