Files
accounted/extensions/general/invoice-inbox/lib/classify-document.ts
T
Jakob WennbergandClaude Opus 4.7 b5df2fb292 feat: invoice-inbox polish + SIE source voucher traceability (#299)
* fix: consolidate commit_journal_entry to single 4-arg signature

Replaces the phantom-overload drop migration with an idempotent consolidation
that leaves only the 4-arg-with-defaults signature, callable with either 2 or
4 named args. Fixes the "Could not choose the best candidate function"
ambiguity caused when the commit-metadata migration CREATE OR REPLACE'd a
4-arg version alongside the existing 2-arg one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: preserve SIE source voucher identity on journal entries

Adds source_voucher_series / source_voucher_number columns to journal_entries
so per-verifikat traceability survives the importer's skip-empty-voucher
logic. The SIE importer populates the original series/number even when
skipped vouchers cause gnubok's target numbering to drift from the source
file's sequence. Required for BFNAR 2013:2 kap 8 behandlingshistorik.

- Migration adds columns + partial index + extends immutability trigger
- importVouchers() records rawSeries/rawNumber per voucher
- JournalEntry type + test fixtures gain the new fields
- Bookkeeping detail page surfaces "Ursprungligt verifikat" when present

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: polish invoice-inbox workspace for production use

- Bedrock image fit: shrink images > 5 MB via sharp before Bedrock upload
  so HEIC/high-res phone photos don't fail with the 5 MB cap
- Swedish error mapping: toSwedishInboxError translates Bedrock /
  infrastructure errors to Swedish sentences stored in error_message
- History timeline endpoint (GET /items/:id/history) returns the
  processing_history events correlated to the inbox item
- Workspace UI: inline diagnostic timeline inside the convert dialog,
  same-email row grouping ("+N dokument" chip), inferred-VAT affordance
  with "needs review" signalling, Riksbanken exchange-rate prefill for
  foreign-currency invoices so the supplier-invoice create path populates
  *_sek audit columns

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: extend inbox-smart-match to supplier invoices

Both receipts and supplier invoices expose structurally identical match
anchors (date, amount, currency, counterparty name) so the matcher can
reuse the same narrowing + LLM prompt. Adds getMatchAnchors() as a shared
extractor across ReceiptExtractionResult / InvoiceExtractionResult, and
updates the event handlers to process supplier_invoice items alongside
receipts. LLM prompt re-phrased as "dokument" rather than "kvitto" and
loosened the date-window heuristic since invoice payments can lag behind
the invoice date by weeks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: drop unused category selector from TransactionForm

The manual "Lägg till transaktion" dialog predates the current categorization
flow (SwipeCategorizationView, BatchCategorySelector, AI suggestions). The
category dropdown here never drove journal-entry creation — onSubmit fanned
it out to CreateTransactionInput.category, which is optional. Removes the
dropdown, the unused watch() hook, and the categories lookup table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(migrations): restore drop-phantom file and rebump timestamps

Supabase branch DB failed with PK violation on schema_migrations because
my two migrations collided with timestamps already on main:
  20260421120000 → journal_entries_with_related_rpc (PR #298)
  20260421130000 → drop_legacy_supplier_invoice_user_id_uniqueness (PR #296)

Rebumped to 20260421140000 and 20260421150000 so each migration has a
unique version (Supabase uses only the 14-digit prefix as the PK).

Also restored the 20260420130000_drop_phantom_commit_journal_entry_overload
migration I had deleted — CLAUDE.md rule #5 forbids modifying existing
migrations. My consolidate migration is still compatible: drop_phantom
drops the 4-arg overload (no-op where absent), then consolidate recreates
it with defaults.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(inbox-smart-match): anchor invoices on dueDate with wider window

The original ±7d window around invoiceDate filtered out all real payments
for invoices with standard 30–60 day terms — the matcher would see zero
candidates before the LLM was called, making the supplier-invoice matcher
effectively dead.

New anchor selection:
- Receipts: receipt date ±7 days (unchanged; paid on the spot)
- Invoices with dueDate: dueDate ±14 days (covers early/late payments)
- Invoices without dueDate: invoiceDate -7/+45 days (covers 30-day terms)

MatchAnchors now carries windowDaysBefore/After so the window can vary per
document shape. Added three getMatchAnchors tests asserting window sizes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 14:33:38 +02:00

638 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Document Classification Pipeline
*
* Pure function: file buffer + mime type → structured classification result.
* No database, no side effects. Uses AWS Bedrock (Claude Sonnet) for vision-based
* extraction of Swedish financial documents.
*/
import {
BedrockRuntimeClient,
ConverseCommand,
type ContentBlock,
type ToolConfiguration,
type Message,
} from '@aws-sdk/client-bedrock-runtime'
import sharp from 'sharp'
import type {
InvoiceExtractionResult,
ReceiptExtractionResult,
ExtractedLineItem,
ExtractedInvoiceLineItem,
VatBreakdownItem,
} from '@/types'
// ── Types ────────────────────────────────────────────────────
export type DocumentClassificationType = 'supplier_invoice' | 'receipt' | 'government_letter' | 'unknown'
export interface ClassificationInput {
fileBuffer: Buffer
mimeType: string
fileName: string
}
export interface ClassificationResult {
documentType: DocumentClassificationType
extractedData: InvoiceExtractionResult | ReceiptExtractionResult | null
confidence: number
rawResponse: Record<string, unknown>
usage: { inputTokens: number; outputTokens: number }
}
// ── Bedrock client (lazy singleton) ──────────────────────────
let _client: BedrockRuntimeClient | null = null
function getClient(): BedrockRuntimeClient {
if (!_client) {
_client = new BedrockRuntimeClient({
region: process.env.AWS_REGION || 'eu-north-1',
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
})
}
return _client
}
// ── Constants ────────────────────────────────────────────────
const VALID_VAT_RATES = [0, 6, 12, 25]
const MIME_TO_IMAGE_FORMAT: Record<string, string> = {
'image/jpeg': 'jpeg',
'image/png': 'png',
'image/webp': 'webp',
'image/gif': 'gif',
}
// Bedrock rejects image bytes > 5 MB. Keep headroom under that ceiling.
const BEDROCK_IMAGE_BYTE_LIMIT = 4_500_000
// Shrink an image until it fits Bedrock's 5 MB cap. Steps down the longest edge
// and JPEG quality in sequence — preserves legibility of receipt text while
// guaranteeing we stay under the limit (or throwing if a photo is so dense it
// can't be compressed enough, which in practice never happens below 500px).
async function fitImageForBedrock(
buffer: Buffer,
mimeType: string
): Promise<{ buffer: Buffer; format: 'jpeg' | 'png' | 'webp' | 'gif' }> {
const originalFormat = MIME_TO_IMAGE_FORMAT[mimeType] as 'jpeg' | 'png' | 'webp' | 'gif'
if (buffer.byteLength <= BEDROCK_IMAGE_BYTE_LIMIT) {
return { buffer, format: originalFormat }
}
// Re-encode to JPEG while shrinking. PNG at receipt-scale is usually 3-5×
// larger than an equivalent JPEG, so JPEG is the right target format even
// for PNG input.
const dimensionSteps = [2400, 1800, 1400, 1000, 800]
const qualitySteps = [85, 75, 60]
for (const maxDim of dimensionSteps) {
for (const quality of qualitySteps) {
const candidate = await sharp(buffer)
.rotate() // respect EXIF orientation
.resize({ width: maxDim, height: maxDim, fit: 'inside', withoutEnlargement: true })
.jpeg({ quality, mozjpeg: true })
.toBuffer()
if (candidate.byteLength <= BEDROCK_IMAGE_BYTE_LIMIT) {
return { buffer: candidate, format: 'jpeg' }
}
}
}
throw new Error('Bilden kunde inte komprimeras tillräckligt för AI-tolkning.')
}
// ── System prompt ────────────────────────────────────────────
const SYSTEM_PROMPT = `Du är en svensk bokföringsdokumentklassificerare och dataextraktor.
Du analyserar bilder och PDF:er av finansiella dokument (leverantörsfakturor, kvitton, skattedokument) och extraherar strukturerad data.
Kontext:
- Svensk bokföring enligt Bokföringslagen (BFL) och BFNAR
- Giltiga momssatser: 0%, 6%, 12%, 25%
- Organisationsnummer: 10 siffror, format XXXXXX-XXXX
- Vanliga betalningsmetoder: bankgiro, plusgiro, Swish, banköverföring, kort
- Valutor: SEK är standard, men EUR, USD, GBP etc förekommer
Instruktioner:
- Klassificera dokumenttypen
- Extrahera alla synliga fält — returnera null för fält som inte kan utläsas
- Belopp med max 2 decimaler. Totaler (amount_excl_vat, amount_incl_vat, vat_amount) är alltid positiva. line_items.amount är normalt positiva men KAN vara negativa för rabattrader.
- Datum i ISO-format (YYYY-MM-DD)
- Momssats som heltal (0, 6, 12, eller 25)
- Ge en confidence-poäng 0-100 för hur säker du är på klassificeringen och extraktionen
KRITISKT — Totaler och rabatter:
- amount_incl_vat MÅSTE motsvara fakturans slutbelopp ("Amount due", "Att betala", "Totalt", "Subtotal" när moms saknas). Summera ALDRIG delrader om fakturan anger ett explicit totalbelopp — använd det.
- Om en rad har en rabatt/discount under sig (t.ex. "Discount (-$10.00)", "Rabatt -100 kr"), extrahera radens NETTO-belopp (brutto − rabatt), INTE bruttobeloppet. Exempel: en rad "Compute Hours $50.68" följd av "Discount -$10.00" → line_items.amount = 40.68, inte 50.68.
- Summan av line_items.amount + moms MÅSTE bli lika med amount_incl_vat. Om det inte stämmer har du antingen missat en rabatt eller dubbelräknat en rad — kontrollera och justera.
- Om du är osäker på hur rabatter ska fördelas, lägg en enskild negativ "Rabatt"-rad i line_items så summan blir rätt.
Anropa ALLTID verktyget classify_document med resultatet.`
// ── Tool schema for structured output ────────────────────────
const CLASSIFICATION_TOOL: ToolConfiguration = {
tools: [
{
toolSpec: {
name: 'classify_document',
description: 'Klassificera och extrahera data från ett svenskt finansiellt dokument',
inputSchema: {
json: {
type: 'object',
required: ['document_type', 'confidence'],
properties: {
document_type: {
type: 'string',
enum: ['supplier_invoice', 'receipt', 'government_letter', 'unknown'],
description: 'Typ av dokument',
},
confidence: {
type: 'integer',
minimum: 0,
maximum: 100,
description: 'Säkerhet i klassificeringen (0-100)',
},
// Supplier invoice fields
supplier_name: { type: ['string', 'null'] },
supplier_org_number: { type: ['string', 'null'] },
supplier_vat_number: { type: ['string', 'null'] },
supplier_address: { type: ['string', 'null'] },
supplier_bankgiro: { type: ['string', 'null'] },
supplier_plusgiro: { type: ['string', 'null'] },
invoice_number: { type: ['string', 'null'] },
invoice_date: { type: ['string', 'null'], description: 'YYYY-MM-DD' },
due_date: { type: ['string', 'null'], description: 'YYYY-MM-DD' },
payment_reference: { type: ['string', 'null'], description: 'OCR-nummer eller betalningsreferens' },
currency: { type: ['string', 'null'], description: 'ISO 4217 (t.ex. SEK, EUR)' },
// Receipt fields
merchant_name: { type: ['string', 'null'] },
merchant_org_number: { type: ['string', 'null'] },
merchant_vat_number: { type: ['string', 'null'] },
merchant_is_foreign: { type: ['boolean', 'null'] },
receipt_date: { type: ['string', 'null'], description: 'YYYY-MM-DD' },
receipt_time: { type: ['string', 'null'], description: 'HH:MM' },
is_restaurant: { type: ['boolean', 'null'] },
is_systembolaget: { type: ['boolean', 'null'] },
// Shared amount fields
amount_excl_vat: { type: ['number', 'null'] },
amount_incl_vat: { type: ['number', 'null'] },
vat_amount: { type: ['number', 'null'] },
// Line items
line_items: {
type: 'array',
items: {
type: 'object',
properties: {
description: { type: 'string' },
quantity: { type: ['number', 'null'] },
unit_price: { type: ['number', 'null'] },
amount: { type: ['number', 'null'] },
vat_rate: { type: ['integer', 'null'], description: '0, 6, 12, or 25' },
},
required: ['description'],
},
},
// VAT breakdown (for invoices)
vat_breakdown: {
type: 'array',
items: {
type: 'object',
properties: {
rate: { type: 'integer' },
base: { type: 'number' },
amount: { type: 'number' },
},
required: ['rate', 'base', 'amount'],
},
},
},
},
},
},
},
],
toolChoice: { any: {} },
}
// ── Core classification function ─────────────────────────────
export async function classifyDocument(input: ClassificationInput): Promise<ClassificationResult> {
const contentBlock = await buildContentBlock(input)
const messages: Message[] = [
{
role: 'user',
content: [
contentBlock,
{ text: 'Analysera detta dokument. Klassificera typ och extrahera all strukturerad data.' },
],
},
]
const modelId = process.env.BEDROCK_MODEL_ID || 'eu.anthropic.claude-sonnet-4-6'
const maxTokens = parseInt(process.env.BEDROCK_MAX_TOKENS || '8192', 10)
const command = new ConverseCommand({
modelId,
messages,
system: [{ text: SYSTEM_PROMPT }],
toolConfig: CLASSIFICATION_TOOL,
inferenceConfig: { maxTokens },
})
const response = await getClient().send(command)
// Extract tool use result from response
const outputMessage = response.output?.message
if (!outputMessage?.content) {
throw new Error('No content in Bedrock response')
}
const toolUseBlock = outputMessage.content.find(
(block): block is ContentBlock.ToolUseMember => 'toolUse' in block && block.toolUse !== undefined
)
if (!toolUseBlock?.toolUse?.input) {
throw new Error('No tool use result in Bedrock response')
}
const rawData = toolUseBlock.toolUse.input as Record<string, unknown>
const usage = {
inputTokens: response.usage?.inputTokens ?? 0,
outputTokens: response.usage?.outputTokens ?? 0,
}
// Validate and map to typed result
const result = mapToClassificationResult(rawData, usage)
// If validation found issues, retry once with correction
if (!result) {
return retryWithCorrection(input, rawData, usage)
}
return result
}
// ── Content block builder ────────────────────────────────────
async function buildContentBlock(input: ClassificationInput): Promise<ContentBlock> {
const { fileBuffer, mimeType } = input
// PDF → document block
if (mimeType === 'application/pdf') {
return {
document: {
format: 'pdf',
name: sanitizeDocName(input.fileName),
source: {
bytes: new Uint8Array(fileBuffer),
},
},
}
}
// HEIC → convert to JPEG via sharp, then fit to Bedrock's byte limit
if (mimeType === 'image/heic' || mimeType === 'image/heif') {
const jpegBuffer = await sharp(fileBuffer).rotate().jpeg({ quality: 90, mozjpeg: true }).toBuffer()
const fitted = await fitImageForBedrock(jpegBuffer, 'image/jpeg')
return {
image: {
format: fitted.format,
source: { bytes: new Uint8Array(fitted.buffer) },
},
}
}
// Standard image formats — downscale if the buffer exceeds Bedrock's 5 MB cap
const imageFormat = MIME_TO_IMAGE_FORMAT[mimeType]
if (imageFormat) {
const fitted = await fitImageForBedrock(fileBuffer, mimeType)
return {
image: {
format: fitted.format,
source: { bytes: new Uint8Array(fitted.buffer) },
},
}
}
throw new Error(`Unsupported MIME type: ${mimeType}`)
}
/** Sanitize filename for Bedrock document name (alphanumeric, spaces, hyphens, brackets only) */
function sanitizeDocName(fileName: string): string {
const name = fileName.replace(/\.[^.]+$/, '') // strip extension
return name.replace(/[^a-zA-Z0-9\s\-\[\]\(\)åäöÅÄÖ]/g, '').trim() || 'document'
}
// ── Response mapping + validation ────────────────────────────
function mapToClassificationResult(
raw: Record<string, unknown>,
usage: { inputTokens: number; outputTokens: number }
): ClassificationResult | null {
const documentType = raw.document_type as DocumentClassificationType
const confidence = Math.min(100, Math.max(0, Number(raw.confidence) || 0))
if (!['supplier_invoice', 'receipt', 'government_letter', 'unknown'].includes(documentType)) {
return null
}
if (documentType === 'government_letter' || documentType === 'unknown') {
return {
documentType,
extractedData: null,
confidence,
rawResponse: raw,
usage,
}
}
if (documentType === 'supplier_invoice') {
const extractedData = mapToInvoiceExtraction(raw)
if (!extractedData) return null
// mapToInvoiceExtraction caps its own confidence to 50% when line items
// don't reconcile with amount_incl_vat. Propagate that cap to the outer
// confidence so invoice_inbox_items.confidence (used by the UI badge)
// also reflects the reconciliation failure.
const effectiveConfidence = Math.min(confidence, Math.round(extractedData.confidence * 100))
return { documentType, extractedData, confidence: effectiveConfidence, rawResponse: raw, usage }
}
if (documentType === 'receipt') {
const extractedData = mapToReceiptExtraction(raw)
if (!extractedData) return null
return { documentType, extractedData, confidence, rawResponse: raw, usage }
}
return null
}
// Sum of line items must approximately match the extracted subtotal.
// Allows 0.02 * max(|subtotal|, 1) tolerance for rounding. Returns true if the extraction
// is internally consistent; false signals the model skipped a discount or double-counted.
function invoiceTotalsAreConsistent(raw: Record<string, unknown>): boolean {
const subtotal = Number(raw.amount_excl_vat)
const total = Number(raw.amount_incl_vat)
const vat = Number(raw.vat_amount) || 0
const items = Array.isArray(raw.line_items) ? raw.line_items : []
if (!items.length) return true // nothing to compare against
if (!isFinite(subtotal) && !isFinite(total)) return true
const sumOfLines = items.reduce((acc, item) => {
if (typeof item !== 'object' || item === null) return acc
const amount = Number((item as Record<string, unknown>).amount)
return acc + (isFinite(amount) ? amount : 0)
}, 0)
const anchor = isFinite(subtotal) ? subtotal : total - vat
const tolerance = Math.max(0.02, Math.abs(anchor) * 0.02)
return Math.abs(sumOfLines - anchor) <= tolerance
}
function mapToInvoiceExtraction(raw: Record<string, unknown>): InvoiceExtractionResult | null {
const lineItems = mapInvoiceLineItems(raw.line_items)
const vatBreakdown = mapVatBreakdown(raw.vat_breakdown)
const totalsConsistent = invoiceTotalsAreConsistent(raw)
const result: InvoiceExtractionResult = {
supplier: {
name: strOrNull(raw.supplier_name),
orgNumber: strOrNull(raw.supplier_org_number),
vatNumber: strOrNull(raw.supplier_vat_number),
address: strOrNull(raw.supplier_address),
bankgiro: strOrNull(raw.supplier_bankgiro),
plusgiro: strOrNull(raw.supplier_plusgiro),
},
invoice: {
invoiceNumber: strOrNull(raw.invoice_number),
invoiceDate: dateOrNull(raw.invoice_date),
dueDate: dateOrNull(raw.due_date),
paymentReference: strOrNull(raw.payment_reference),
currency: strOrNull(raw.currency) || 'SEK',
},
lineItems,
totals: {
subtotal: roundAmount(raw.amount_excl_vat),
vatAmount: roundAmount(raw.vat_amount),
total: roundAmount(raw.amount_incl_vat),
},
vatBreakdown,
// If totals don't reconcile with line items, cap confidence at 50% so the UI flags it
confidence: (() => {
const raw_conf = Math.min(1, Math.max(0, Number(raw.confidence) / 100 || 0))
return totalsConsistent ? raw_conf : Math.min(raw_conf, 0.5)
})(),
}
return result
}
function mapToReceiptExtraction(raw: Record<string, unknown>): ReceiptExtractionResult | null {
const lineItems = mapReceiptLineItems(raw.line_items)
const result: ReceiptExtractionResult = {
merchant: {
name: strOrNull(raw.merchant_name),
orgNumber: strOrNull(raw.merchant_org_number),
vatNumber: strOrNull(raw.merchant_vat_number),
isForeign: Boolean(raw.merchant_is_foreign),
},
receipt: {
date: dateOrNull(raw.receipt_date),
time: strOrNull(raw.receipt_time),
currency: strOrNull(raw.currency) || 'SEK',
},
lineItems,
totals: {
subtotal: roundAmount(raw.amount_excl_vat),
vatAmount: roundAmount(raw.vat_amount),
total: roundAmount(raw.amount_incl_vat),
},
flags: {
isRestaurant: Boolean(raw.is_restaurant),
isSystembolaget: Boolean(raw.is_systembolaget),
isForeignMerchant: Boolean(raw.merchant_is_foreign),
},
confidence: Math.min(1, Math.max(0, Number(raw.confidence) / 100 || 0)),
}
return result
}
// ── Line item mappers ────────────────────────────────────────
function mapInvoiceLineItems(items: unknown): ExtractedInvoiceLineItem[] {
if (!Array.isArray(items)) return []
return items
.filter((item): item is Record<string, unknown> => typeof item === 'object' && item !== null)
.map((item) => ({
description: String(item.description || ''),
quantity: typeof item.quantity === 'number' ? item.quantity : 1,
unitPrice: roundAmount(item.unit_price),
lineTotal: roundAmount(item.amount) ?? 0,
vatRate: validateVatRate(item.vat_rate),
accountSuggestion: null,
}))
}
function mapReceiptLineItems(items: unknown): ExtractedLineItem[] {
if (!Array.isArray(items)) return []
return items
.filter((item): item is Record<string, unknown> => typeof item === 'object' && item !== null)
.map((item) => ({
description: String(item.description || ''),
quantity: typeof item.quantity === 'number' ? item.quantity : 1,
unitPrice: roundAmount(item.unit_price),
lineTotal: roundAmount(item.amount) ?? 0,
vatRate: validateVatRate(item.vat_rate),
suggestedCategory: null,
}))
}
function mapVatBreakdown(items: unknown): VatBreakdownItem[] {
if (!Array.isArray(items)) return []
return items
.filter((item): item is Record<string, unknown> => typeof item === 'object' && item !== null)
.filter((item) => VALID_VAT_RATES.includes(Number(item.rate)))
.map((item) => ({
rate: Number(item.rate),
base: Math.round(Number(item.base || 0) * 100) / 100,
amount: Math.round(Number(item.amount || 0) * 100) / 100,
}))
}
// ── Retry with correction ────────────────────────────────────
async function retryWithCorrection(
input: ClassificationInput,
previousResult: Record<string, unknown>,
previousUsage: { inputTokens: number; outputTokens: number }
): Promise<ClassificationResult> {
const contentBlock = await buildContentBlock(input)
const messages: Message[] = [
{
role: 'user',
content: [
contentBlock,
{ text: 'Analysera detta dokument. Klassificera typ och extrahera all strukturerad data.' },
],
},
{
role: 'assistant',
content: [
{
toolUse: {
toolUseId: 'retry_1',
name: 'classify_document',
input: previousResult as Record<string, unknown>,
} as ContentBlock.ToolUseMember['toolUse'],
},
],
},
{
role: 'user',
content: [
{
toolResult: {
toolUseId: 'retry_1',
status: 'error',
content: [
{
text: `Valideringen misslyckades. Kontrollera:
- document_type måste vara ett av: supplier_invoice, receipt, government_letter, unknown
- Datum i format YYYY-MM-DD
- Momssatser måste vara 0, 6, 12, eller 25
- Totaler: amount_incl_vat ska vara fakturans slutbelopp. Summa av line_items.amount + vat_amount MÅSTE bli lika med amount_incl_vat. Om rader har rabatt under sig, använd NETTO-beloppet per rad, eller lägg till en separat negativ rabattrad så summan stämmer.
Försök igen med korrigerad data.`,
},
],
},
},
],
},
]
const modelId = process.env.BEDROCK_MODEL_ID || 'eu.anthropic.claude-sonnet-4-6'
const maxTokens = parseInt(process.env.BEDROCK_MAX_TOKENS || '8192', 10)
try {
const command = new ConverseCommand({
modelId,
messages,
system: [{ text: SYSTEM_PROMPT }],
toolConfig: CLASSIFICATION_TOOL,
inferenceConfig: { maxTokens },
})
const response = await getClient().send(command)
const outputMessage = response.output?.message
const toolUseBlock = outputMessage?.content?.find(
(block): block is ContentBlock.ToolUseMember => 'toolUse' in block && block.toolUse !== undefined
)
if (!toolUseBlock?.toolUse?.input) {
throw new Error('No tool use in retry response')
}
const rawData = toolUseBlock.toolUse.input as Record<string, unknown>
const retryUsage = {
inputTokens: previousUsage.inputTokens + (response.usage?.inputTokens ?? 0),
outputTokens: previousUsage.outputTokens + (response.usage?.outputTokens ?? 0),
}
const result = mapToClassificationResult(rawData, retryUsage)
if (result) return result
} catch {
// Retry failed — fall through to error return
}
// Both attempts failed — return error result with raw data
return {
documentType: 'unknown',
extractedData: null,
confidence: 0,
rawResponse: previousResult,
usage: previousUsage,
}
}
// ── Utility helpers ──────────────────────────────────────────
function strOrNull(val: unknown): string | null {
if (typeof val === 'string' && val.trim().length > 0) return val.trim()
return null
}
function dateOrNull(val: unknown): string | null {
if (typeof val !== 'string') return null
// Validate ISO date format YYYY-MM-DD
const match = val.match(/^\d{4}-\d{2}-\d{2}$/)
if (!match) return null
const d = new Date(val)
if (isNaN(d.getTime())) return null
return val
}
function roundAmount(val: unknown): number | null {
if (val === null || val === undefined) return null
const n = Number(val)
if (isNaN(n)) return null
return Math.round(n * 100) / 100
}
function validateVatRate(val: unknown): number | null {
if (val === null || val === undefined) return null
const n = Number(val)
if (VALID_VAT_RATES.includes(n)) return n
return null
}