* 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>
201 lines
6.5 KiB
TypeScript
201 lines
6.5 KiB
TypeScript
/**
|
|
* Text-only LLM matcher — decides which candidate bank transaction (if any)
|
|
* corresponds to a classified receipt. Uses Bedrock Converse with structured
|
|
* tool output. No image input: the receipt is already represented by the
|
|
* extracted data, and candidates are pure text.
|
|
*/
|
|
|
|
import {
|
|
BedrockRuntimeClient,
|
|
ConverseCommand,
|
|
type ContentBlock,
|
|
type Message,
|
|
type ToolConfiguration,
|
|
} from '@aws-sdk/client-bedrock-runtime'
|
|
import type { CandidateTransaction, ExtractedDocument } from './fetch-candidates'
|
|
import { getMatchAnchors } from './fetch-candidates'
|
|
|
|
export interface ReceiptMatchResult {
|
|
matched: boolean
|
|
transactionId: string | null
|
|
confidence: number // 0..1
|
|
reasoning: string
|
|
usage: { inputTokens: number; outputTokens: number }
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
const SYSTEM_PROMPT = `Du är en expert på att matcha svenska bokföringsdokument (kvitton, fakturor) mot banktransaktioner.
|
|
|
|
Du får:
|
|
- Dokumentdata (handlare/leverantör, belopp, valuta, datum) från AI-extraktion
|
|
- En lista med kandidat-banktransaktioner (id, beskrivning, belopp, valuta, datum)
|
|
|
|
Uppgift: identifiera vilken (om någon) banktransaktion som motsvarar dokumentet.
|
|
|
|
Resonera utifrån:
|
|
- Belopp: bör vara identiskt eller mycket nära (ta hänsyn till valutaväxling om olika valutor)
|
|
- Datum: för kvitton bokförs banktransaktionen ofta 0-3 dagar efter köpet; för leverantörsfakturor kan betalningen ske flera dagar till veckor efter fakturadatum
|
|
- Handlare/leverantör: bankens beskrivning är ofta förkortad/versaler ("WILLYS SÖDERM" = "Willys Hemma Södermalm"). Matcha semantiskt, inte bokstavligt
|
|
|
|
Om inget förslag är trovärdigt — returnera matched=false.
|
|
Anropa ALLTID verktyget match_receipt med resultatet.
|
|
Motivering ska vara kort, på svenska och förklara varför transaktionen valdes.`
|
|
|
|
const MATCH_TOOL: ToolConfiguration = {
|
|
tools: [
|
|
{
|
|
toolSpec: {
|
|
name: 'match_receipt',
|
|
description: 'Returnera vilken kandidat-transaktion som matchar kvittot',
|
|
inputSchema: {
|
|
json: {
|
|
type: 'object',
|
|
required: ['matched', 'confidence', 'reasoning'],
|
|
properties: {
|
|
matched: {
|
|
type: 'boolean',
|
|
description: 'true om en kandidat matchar, false annars',
|
|
},
|
|
transaction_id: {
|
|
type: ['string', 'null'],
|
|
description: 'id för den matchande kandidaten (null om matched=false)',
|
|
},
|
|
confidence: {
|
|
type: 'integer',
|
|
minimum: 0,
|
|
maximum: 100,
|
|
description: 'Säkerhet 0-100. Sätt lågt när matched=false.',
|
|
},
|
|
reasoning: {
|
|
type: 'string',
|
|
description: '1-2 meningar på svenska som förklarar beslutet.',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
toolChoice: { any: {} },
|
|
}
|
|
|
|
export interface MatchReceiptInput {
|
|
extracted: ExtractedDocument
|
|
candidates: CandidateTransaction[]
|
|
}
|
|
|
|
/**
|
|
* Call Bedrock to choose the best matching transaction.
|
|
* Returns a neutral result (matched=false) if the model doesn't find a fit or
|
|
* the tool schema is missing from the response.
|
|
*/
|
|
export async function matchReceiptToCandidate(
|
|
input: MatchReceiptInput
|
|
): Promise<ReceiptMatchResult> {
|
|
const anchors = getMatchAnchors(input.extracted)
|
|
const receiptBrief = {
|
|
merchant: anchors?.counterpartyName ?? null,
|
|
amount: anchors?.amount ?? null,
|
|
currency: anchors?.currency ?? 'SEK',
|
|
date: anchors?.date ?? null,
|
|
vat_amount: input.extracted.totals?.vatAmount ?? null,
|
|
}
|
|
|
|
const candidateLines = input.candidates.map((c) => ({
|
|
id: c.id,
|
|
date: c.date,
|
|
description: c.description,
|
|
amount: c.amount,
|
|
amount_sek: c.amount_sek,
|
|
currency: c.currency,
|
|
merchant_name: c.merchant_name,
|
|
}))
|
|
|
|
const userPrompt = `Dokument:
|
|
${JSON.stringify(receiptBrief, null, 2)}
|
|
|
|
Kandidat-transaktioner:
|
|
${JSON.stringify(candidateLines, null, 2)}
|
|
|
|
Vilken transaktion matchar dokumentet? Om ingen matchar, returnera matched=false.`
|
|
|
|
const messages: Message[] = [
|
|
{
|
|
role: 'user',
|
|
content: [{ text: userPrompt }],
|
|
},
|
|
]
|
|
|
|
const modelId = process.env.BEDROCK_MODEL_ID || 'eu.anthropic.claude-sonnet-4-6'
|
|
const maxTokens = parseInt(process.env.BEDROCK_MAX_TOKENS || '1024', 10)
|
|
|
|
const command = new ConverseCommand({
|
|
modelId,
|
|
messages,
|
|
system: [{ text: SYSTEM_PROMPT }],
|
|
toolConfig: MATCH_TOOL,
|
|
inferenceConfig: { maxTokens, temperature: 0 },
|
|
})
|
|
|
|
const response = await getClient().send(command)
|
|
|
|
const usage = {
|
|
inputTokens: response.usage?.inputTokens ?? 0,
|
|
outputTokens: response.usage?.outputTokens ?? 0,
|
|
}
|
|
|
|
const outputMessage = response.output?.message
|
|
if (!outputMessage?.content) {
|
|
return { matched: false, transactionId: null, confidence: 0, reasoning: 'Inget LLM-svar', usage }
|
|
}
|
|
|
|
const toolUseBlock = outputMessage.content.find(
|
|
(block): block is ContentBlock.ToolUseMember => 'toolUse' in block && block.toolUse !== undefined
|
|
)
|
|
|
|
if (!toolUseBlock?.toolUse?.input) {
|
|
return { matched: false, transactionId: null, confidence: 0, reasoning: 'Inget verktygsanrop', usage }
|
|
}
|
|
|
|
const raw = toolUseBlock.toolUse.input as Record<string, unknown>
|
|
const matched = Boolean(raw.matched)
|
|
const rawId = typeof raw.transaction_id === 'string' ? raw.transaction_id : null
|
|
const transactionId = matched
|
|
? (input.candidates.find((c) => c.id === rawId)?.id ?? null)
|
|
: null
|
|
const confidenceRaw = Number(raw.confidence)
|
|
const confidence =
|
|
isFinite(confidenceRaw)
|
|
? Math.min(1, Math.max(0, confidenceRaw / 100))
|
|
: 0
|
|
const reasoning = typeof raw.reasoning === 'string' ? raw.reasoning.trim() : ''
|
|
|
|
// If LLM said matched but we can't resolve the transaction_id to a candidate,
|
|
// degrade gracefully to unmatched so downstream isn't left dangling.
|
|
if (matched && !transactionId) {
|
|
return {
|
|
matched: false,
|
|
transactionId: null,
|
|
confidence: 0,
|
|
reasoning: reasoning || 'LLM angav ogiltigt transaction_id',
|
|
usage,
|
|
}
|
|
}
|
|
|
|
return { matched, transactionId, confidence, reasoning, usage }
|
|
}
|