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>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
129664533c
commit
b5df2fb292
@@ -1,7 +1,10 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { fetchCandidateTransactions } from '@/extensions/general/inbox-smart-match/lib/fetch-candidates'
|
||||
import {
|
||||
fetchCandidateTransactions,
|
||||
getMatchAnchors,
|
||||
} from '@/extensions/general/inbox-smart-match/lib/fetch-candidates'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import type { ReceiptExtractionResult } from '@/types'
|
||||
import type { InvoiceExtractionResult, ReceiptExtractionResult } from '@/types'
|
||||
|
||||
function makeReceipt(overrides?: Partial<ReceiptExtractionResult>): ReceiptExtractionResult {
|
||||
return {
|
||||
@@ -74,6 +77,46 @@ describe('fetchCandidateTransactions', () => {
|
||||
expect(ids).toContain('t3')
|
||||
})
|
||||
|
||||
it('anchors invoices on dueDate with a ±14d window', () => {
|
||||
const invoice: InvoiceExtractionResult = {
|
||||
supplier: { name: 'Acme AB', orgNumber: null, vatNumber: null, address: null, bankgiro: null, plusgiro: null },
|
||||
invoice: { invoiceNumber: 'INV-1', invoiceDate: '2026-03-01', dueDate: '2026-03-31', paymentReference: null, currency: 'SEK' },
|
||||
lineItems: [],
|
||||
totals: { subtotal: 800, vatAmount: 200, total: 1000 },
|
||||
vatBreakdown: [],
|
||||
confidence: 0.9,
|
||||
}
|
||||
const anchors = getMatchAnchors(invoice)
|
||||
expect(anchors).not.toBeNull()
|
||||
expect(anchors!.date).toBe('2026-03-31')
|
||||
expect(anchors!.windowDaysBefore).toBe(14)
|
||||
expect(anchors!.windowDaysAfter).toBe(14)
|
||||
})
|
||||
|
||||
it('anchors invoices without dueDate on invoiceDate with a -7/+45 day window', () => {
|
||||
const invoice: InvoiceExtractionResult = {
|
||||
supplier: { name: 'Acme AB', orgNumber: null, vatNumber: null, address: null, bankgiro: null, plusgiro: null },
|
||||
invoice: { invoiceNumber: 'INV-1', invoiceDate: '2026-03-01', dueDate: null, paymentReference: null, currency: 'SEK' },
|
||||
lineItems: [],
|
||||
totals: { subtotal: 800, vatAmount: 200, total: 1000 },
|
||||
vatBreakdown: [],
|
||||
confidence: 0.9,
|
||||
}
|
||||
const anchors = getMatchAnchors(invoice)
|
||||
expect(anchors).not.toBeNull()
|
||||
expect(anchors!.date).toBe('2026-03-01')
|
||||
expect(anchors!.windowDaysBefore).toBe(7)
|
||||
expect(anchors!.windowDaysAfter).toBe(45)
|
||||
})
|
||||
|
||||
it('anchors receipts on receipt date with a ±7d window', () => {
|
||||
const anchors = getMatchAnchors(makeReceipt())
|
||||
expect(anchors).not.toBeNull()
|
||||
expect(anchors!.date).toBe('2026-04-15')
|
||||
expect(anchors!.windowDaysBefore).toBe(7)
|
||||
expect(anchors!.windowDaysAfter).toBe(7)
|
||||
})
|
||||
|
||||
it('uses amount_sek when receipt is foreign currency', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [] }) // no already-matched
|
||||
|
||||
@@ -30,8 +30,11 @@ export const inboxSmartMatchExtension: Extension = {
|
||||
{
|
||||
eventType: 'inbox_item.classified',
|
||||
handler: async (payload: EventPayload<'inbox_item.classified'>) => {
|
||||
// Only act on receipts for v1
|
||||
if (payload.documentType !== 'receipt') return
|
||||
// Match both receipts and supplier invoices — other document types
|
||||
// (government letters, unknown) have nothing to match against.
|
||||
if (payload.documentType !== 'receipt' && payload.documentType !== 'supplier_invoice') {
|
||||
return
|
||||
}
|
||||
|
||||
const supabase = getServiceSupabase()
|
||||
|
||||
@@ -64,14 +67,14 @@ export const inboxSmartMatchExtension: Extension = {
|
||||
|
||||
const supabase = getServiceSupabase()
|
||||
|
||||
// Find receipts in pending state for this company.
|
||||
// Cap at 10 per sync so one big bank import doesn't time out the
|
||||
// handler; leftover pending items pick up on the next sync.
|
||||
// Find pending receipts/invoices for this company. Cap at 10 per sync
|
||||
// so one big bank import doesn't time out the handler; leftover pending
|
||||
// items pick up on the next sync.
|
||||
const { data: pendingItems, error } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('*')
|
||||
.eq('company_id', payload.companyId)
|
||||
.eq('document_type', 'receipt')
|
||||
.in('document_type', ['receipt', 'supplier_invoice'])
|
||||
.eq('status', 'ready')
|
||||
.eq('match_method', 'pending_transaction')
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
/**
|
||||
* Candidate transaction fetcher — deterministic narrowing before the LLM call.
|
||||
*
|
||||
* Pulls unbooked expense transactions within ±7 days of the receipt date,
|
||||
* ordered by how close their amount is to the receipt total. Limits to top 5
|
||||
* Pulls unbooked expense transactions near the document's payment date,
|
||||
* ordered by how close their amount is to the document total. Limits to top 5
|
||||
* so the LLM has a focused candidate set and the token cost stays bounded.
|
||||
*
|
||||
* Anchor date selection:
|
||||
* - Receipts: receipt date ±7 days (paid on the spot)
|
||||
* - Invoices with dueDate: dueDate ±14 days (covers early and late payments)
|
||||
* - Invoices without dueDate: invoiceDate, window shifted forward to cover
|
||||
* standard 30-day terms (invoiceDate-7 .. invoiceDate+45)
|
||||
*/
|
||||
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { ReceiptExtractionResult } from '@/types'
|
||||
import type { InvoiceExtractionResult, ReceiptExtractionResult } from '@/types'
|
||||
|
||||
const DATE_WINDOW_DAYS = 7
|
||||
const MAX_CANDIDATES = 5
|
||||
|
||||
export interface CandidateTransaction {
|
||||
@@ -22,40 +27,80 @@ export interface CandidateTransaction {
|
||||
merchant_name: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the reference date and absolute amount from a classified receipt's
|
||||
* extracted data. Returns null if required fields are missing.
|
||||
*/
|
||||
function getReceiptMatchAnchors(
|
||||
extracted: ReceiptExtractionResult | null
|
||||
): { date: string; amount: number; currency: string } | null {
|
||||
if (!extracted) return null
|
||||
const date = extracted.receipt?.date ?? null
|
||||
const amount = extracted.totals?.total ?? null
|
||||
const currency = extracted.receipt?.currency ?? 'SEK'
|
||||
if (!date || amount == null || amount <= 0) return null
|
||||
return { date, amount, currency }
|
||||
export type ExtractedDocument = ReceiptExtractionResult | InvoiceExtractionResult
|
||||
|
||||
export interface MatchAnchors {
|
||||
date: string
|
||||
amount: number
|
||||
currency: string
|
||||
counterpartyName: string | null
|
||||
windowDaysBefore: number
|
||||
windowDaysAfter: number
|
||||
}
|
||||
|
||||
function isInvoiceExtraction(e: ExtractedDocument): e is InvoiceExtractionResult {
|
||||
return 'invoice' in e && typeof (e as InvoiceExtractionResult).invoice === 'object'
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch up to MAX_CANDIDATES unbooked expense transactions near the receipt's
|
||||
* Extract the reference date and absolute amount from a classified document's
|
||||
* extracted data. Returns null if required fields are missing.
|
||||
*/
|
||||
export function getMatchAnchors(extracted: ExtractedDocument | null): MatchAnchors | null {
|
||||
if (!extracted) return null
|
||||
|
||||
let date: string | null
|
||||
let currency: string
|
||||
let counterpartyName: string | null
|
||||
let windowDaysBefore: number
|
||||
let windowDaysAfter: number
|
||||
|
||||
if (isInvoiceExtraction(extracted)) {
|
||||
const dueDate = extracted.invoice?.dueDate ?? null
|
||||
const invoiceDate = extracted.invoice?.invoiceDate ?? null
|
||||
if (dueDate) {
|
||||
date = dueDate
|
||||
windowDaysBefore = 14
|
||||
windowDaysAfter = 14
|
||||
} else {
|
||||
date = invoiceDate
|
||||
windowDaysBefore = 7
|
||||
windowDaysAfter = 45
|
||||
}
|
||||
currency = extracted.invoice?.currency ?? 'SEK'
|
||||
counterpartyName = extracted.supplier?.name ?? null
|
||||
} else {
|
||||
date = extracted.receipt?.date ?? null
|
||||
currency = extracted.receipt?.currency ?? 'SEK'
|
||||
counterpartyName = extracted.merchant?.name ?? null
|
||||
windowDaysBefore = 7
|
||||
windowDaysAfter = 7
|
||||
}
|
||||
|
||||
const amount = extracted.totals?.total ?? null
|
||||
if (!date || amount == null || amount <= 0) return null
|
||||
return { date, amount, currency, counterpartyName, windowDaysBefore, windowDaysAfter }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch up to MAX_CANDIDATES unbooked expense transactions near the document's
|
||||
* date + amount. Ordering prefers exact amount matches first.
|
||||
*/
|
||||
export async function fetchCandidateTransactions(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
extracted: ReceiptExtractionResult | null
|
||||
extracted: ExtractedDocument | null
|
||||
): Promise<CandidateTransaction[]> {
|
||||
const anchors = getReceiptMatchAnchors(extracted)
|
||||
const anchors = getMatchAnchors(extracted)
|
||||
if (!anchors) return []
|
||||
|
||||
const receiptDate = new Date(anchors.date)
|
||||
if (isNaN(receiptDate.getTime())) return []
|
||||
const anchorDate = new Date(anchors.date)
|
||||
if (isNaN(anchorDate.getTime())) return []
|
||||
|
||||
const windowStart = new Date(receiptDate)
|
||||
windowStart.setUTCDate(windowStart.getUTCDate() - DATE_WINDOW_DAYS)
|
||||
const windowEnd = new Date(receiptDate)
|
||||
windowEnd.setUTCDate(windowEnd.getUTCDate() + DATE_WINDOW_DAYS)
|
||||
const windowStart = new Date(anchorDate)
|
||||
windowStart.setUTCDate(windowStart.getUTCDate() - anchors.windowDaysBefore)
|
||||
const windowEnd = new Date(anchorDate)
|
||||
windowEnd.setUTCDate(windowEnd.getUTCDate() + anchors.windowDaysAfter)
|
||||
|
||||
// Exclude transactions already claimed by any other inbox item in this
|
||||
// company. The partial unique index on (company_id, matched_transaction_id)
|
||||
@@ -99,14 +144,14 @@ export async function fetchCandidateTransactions(
|
||||
: data
|
||||
if (filtered.length === 0) return []
|
||||
|
||||
// Rank candidates by amount proximity. For SEK receipts we compare directly,
|
||||
// for other currencies we prefer amount_sek if the receipt amount has been converted.
|
||||
const receiptAbs = Math.abs(anchors.amount)
|
||||
// Rank candidates by amount proximity. For SEK documents we compare directly,
|
||||
// for other currencies we prefer amount_sek if the document amount has been converted.
|
||||
const anchorAbs = Math.abs(anchors.amount)
|
||||
const scored = filtered.map((tx) => {
|
||||
const txAmount = Math.abs(Number(tx.amount) || 0)
|
||||
const txSek = tx.amount_sek == null ? null : Math.abs(Number(tx.amount_sek))
|
||||
const primaryDiff = Math.abs(txAmount - receiptAbs)
|
||||
const sekDiff = txSek == null ? Infinity : Math.abs(txSek - receiptAbs)
|
||||
const primaryDiff = Math.abs(txAmount - anchorAbs)
|
||||
const sekDiff = txSek == null ? Infinity : Math.abs(txSek - anchorAbs)
|
||||
const bestDiff = Math.min(primaryDiff, sekDiff)
|
||||
return { tx, diff: bestDiff }
|
||||
})
|
||||
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
type Message,
|
||||
type ToolConfiguration,
|
||||
} from '@aws-sdk/client-bedrock-runtime'
|
||||
import type { ReceiptExtractionResult } from '@/types'
|
||||
import type { CandidateTransaction } from './fetch-candidates'
|
||||
import type { CandidateTransaction, ExtractedDocument } from './fetch-candidates'
|
||||
import { getMatchAnchors } from './fetch-candidates'
|
||||
|
||||
export interface ReceiptMatchResult {
|
||||
matched: boolean
|
||||
@@ -38,19 +38,18 @@ function getClient(): BedrockRuntimeClient {
|
||||
return _client
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = `Du är en expert på att matcha svenska kvitton mot banktransaktioner.
|
||||
const SYSTEM_PROMPT = `Du är en expert på att matcha svenska bokföringsdokument (kvitton, fakturor) mot banktransaktioner.
|
||||
|
||||
Du får:
|
||||
- Kvittodata (handlare, belopp, valuta, datum) från AI-extraktion
|
||||
- En lista med kandidat-banktransaktioner (id, beskrivning, belopp, valuta, datum, MCC)
|
||||
- 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 kvittot.
|
||||
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: banktransaktion bokförs ofta 0-3 dagar efter kvittot
|
||||
- Handlare: bankens beskrivning är ofta förkortad/versaler ("WILLYS SÖDERM" = "Willys Hemma Södermalm"). Matcha semantiskt, inte bokstavligt
|
||||
- MCC-koder kan bekräfta branschtyp
|
||||
- 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.
|
||||
@@ -95,7 +94,7 @@ const MATCH_TOOL: ToolConfiguration = {
|
||||
}
|
||||
|
||||
export interface MatchReceiptInput {
|
||||
extracted: ReceiptExtractionResult
|
||||
extracted: ExtractedDocument
|
||||
candidates: CandidateTransaction[]
|
||||
}
|
||||
|
||||
@@ -107,11 +106,12 @@ export interface MatchReceiptInput {
|
||||
export async function matchReceiptToCandidate(
|
||||
input: MatchReceiptInput
|
||||
): Promise<ReceiptMatchResult> {
|
||||
const anchors = getMatchAnchors(input.extracted)
|
||||
const receiptBrief = {
|
||||
merchant: input.extracted.merchant?.name ?? null,
|
||||
amount: input.extracted.totals?.total ?? null,
|
||||
currency: input.extracted.receipt?.currency ?? 'SEK',
|
||||
date: input.extracted.receipt?.date ?? null,
|
||||
merchant: anchors?.counterpartyName ?? null,
|
||||
amount: anchors?.amount ?? null,
|
||||
currency: anchors?.currency ?? 'SEK',
|
||||
date: anchors?.date ?? null,
|
||||
vat_amount: input.extracted.totals?.vatAmount ?? null,
|
||||
}
|
||||
|
||||
@@ -125,13 +125,13 @@ export async function matchReceiptToCandidate(
|
||||
merchant_name: c.merchant_name,
|
||||
}))
|
||||
|
||||
const userPrompt = `Kvitto:
|
||||
const userPrompt = `Dokument:
|
||||
${JSON.stringify(receiptBrief, null, 2)}
|
||||
|
||||
Kandidat-transaktioner:
|
||||
${JSON.stringify(candidateLines, null, 2)}
|
||||
|
||||
Vilken transaktion matchar kvittot? Om ingen matchar, returnera matched=false.`
|
||||
Vilken transaktion matchar dokumentet? Om ingen matchar, returnera matched=false.`
|
||||
|
||||
const messages: Message[] = [
|
||||
{
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
*/
|
||||
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { InvoiceInboxItem, ReceiptExtractionResult } from '@/types'
|
||||
import type { InvoiceInboxItem } from '@/types'
|
||||
import { appendProcessingHistory } from '@/lib/processing-history/append'
|
||||
import { fetchCandidateTransactions } from './fetch-candidates'
|
||||
import { fetchCandidateTransactions, type ExtractedDocument } from './fetch-candidates'
|
||||
import { matchReceiptToCandidate } from './match-receipt'
|
||||
|
||||
export interface MatchContext {
|
||||
@@ -36,8 +36,10 @@ export async function processInboxItemMatch(
|
||||
): Promise<MatchOutcome> {
|
||||
const tag = `[inbox-smart-match] item=${item.id} trigger=${ctx.triggerReason}`
|
||||
|
||||
// We only operate on receipts for v1
|
||||
if (item.document_type !== 'receipt') {
|
||||
// Match both receipts and supplier invoices — both have comparable anchors
|
||||
// (date, amount, counterparty, currency) and the downstream LLM prompt is
|
||||
// shape-agnostic.
|
||||
if (item.document_type !== 'receipt' && item.document_type !== 'supplier_invoice') {
|
||||
return { status: 'skipped', transactionId: null, confidence: 0, reasoning: '' }
|
||||
}
|
||||
if (item.status !== 'ready') {
|
||||
@@ -62,7 +64,7 @@ export async function processInboxItemMatch(
|
||||
}
|
||||
}
|
||||
|
||||
const extracted = item.extracted_data as unknown as ReceiptExtractionResult
|
||||
const extracted = item.extracted_data as unknown as ExtractedDocument
|
||||
const candidates = await fetchCandidateTransactions(ctx.supabase, ctx.companyId, extracted)
|
||||
|
||||
// Append DeterministicMatch event — records that the narrowing ran
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
getActiveInbox,
|
||||
composeInboxAddress,
|
||||
} from './lib/inbox-provisioning'
|
||||
import { toSwedishInboxError } from './lib/error-messages'
|
||||
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import { CreateSupplierInvoiceSchema } from '@/lib/api/schemas'
|
||||
import { appendProcessingHistory } from '@/lib/processing-history/append'
|
||||
@@ -97,7 +98,9 @@ async function uploadAndClassify(
|
||||
fileName: file.name,
|
||||
})
|
||||
} catch (err) {
|
||||
classificationError = err instanceof Error ? err.message : 'Classification failed'
|
||||
// Keep the technical message in the server log; present Swedish to users.
|
||||
console.error('[invoice-inbox/classify] Bedrock classify failed:', err)
|
||||
classificationError = toSwedishInboxError(err)
|
||||
}
|
||||
|
||||
// Audit: DocumentExtractionAttempted (fires whether classification succeeded or failed)
|
||||
@@ -343,6 +346,7 @@ export const invoiceInboxExtension: Extension = {
|
||||
.select(`
|
||||
id, status, document_type, confidence, source, created_at, extracted_data,
|
||||
matched_supplier_id, document_id, email_from, email_subject, error_message,
|
||||
resend_email_id,
|
||||
matched_transaction_id, match_confidence, match_method, match_reasoning,
|
||||
matched_transaction:transactions!matched_transaction_id(id, description, amount, currency, date)
|
||||
`)
|
||||
@@ -360,6 +364,44 @@ export const invoiceInboxExtension: Extension = {
|
||||
},
|
||||
},
|
||||
|
||||
// ── Get processing_history timeline for an inbox item ───
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/items/:id/history',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const url = new URL(request.url)
|
||||
const id = url.searchParams.get('_id')
|
||||
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 })
|
||||
|
||||
// Resolve correlation_id via the inbox item (also enforces company scope)
|
||||
const { data: item } = await ctx.supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('id, correlation_id, company_id')
|
||||
.eq('id', id)
|
||||
.eq('company_id', ctx.companyId)
|
||||
.maybeSingle()
|
||||
|
||||
if (!item) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
if (!item.correlation_id) {
|
||||
// Legacy rows created before the correlation_id column have no history
|
||||
return NextResponse.json({ data: { events: [] } })
|
||||
}
|
||||
|
||||
const { data: events, error } = await ctx.supabase
|
||||
.from('processing_history')
|
||||
.select('event_id, event_type, occurred_at, payload, actor, causation_id')
|
||||
.eq('company_id', ctx.companyId)
|
||||
.eq('correlation_id', item.correlation_id)
|
||||
.order('occurred_at', { ascending: true })
|
||||
.limit(100)
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
return NextResponse.json({ data: { events: events ?? [] } })
|
||||
},
|
||||
},
|
||||
|
||||
// ── Get single inbox item ───────────────────────────────
|
||||
{
|
||||
method: 'GET',
|
||||
|
||||
@@ -68,6 +68,45 @@ const MIME_TO_IMAGE_FORMAT: Record<string, string> = {
|
||||
'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.
|
||||
@@ -260,24 +299,26 @@ async function buildContentBlock(input: ClassificationInput): Promise<ContentBlo
|
||||
}
|
||||
}
|
||||
|
||||
// HEIC → convert to JPEG via sharp
|
||||
// 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).jpeg({ quality: 90 }).toBuffer()
|
||||
const jpegBuffer = await sharp(fileBuffer).rotate().jpeg({ quality: 90, mozjpeg: true }).toBuffer()
|
||||
const fitted = await fitImageForBedrock(jpegBuffer, 'image/jpeg')
|
||||
return {
|
||||
image: {
|
||||
format: 'jpeg',
|
||||
source: { bytes: new Uint8Array(jpegBuffer) },
|
||||
format: fitted.format,
|
||||
source: { bytes: new Uint8Array(fitted.buffer) },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Standard image formats
|
||||
// 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: imageFormat as 'jpeg' | 'png' | 'webp' | 'gif',
|
||||
source: { bytes: new Uint8Array(fileBuffer) },
|
||||
format: fitted.format,
|
||||
source: { bytes: new Uint8Array(fitted.buffer) },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Maps raw AWS Bedrock / infrastructure errors to Swedish user-facing sentences
|
||||
* for the invoice-inbox error_message column. We keep this local to the
|
||||
* extension rather than in lib/errors so the patterns can evolve with the
|
||||
* Bedrock SDK without churning the shared helper.
|
||||
*/
|
||||
|
||||
const PATTERNS: Array<[RegExp, (match: RegExpMatchArray) => string]> = [
|
||||
[
|
||||
/image exceeds 5 MB maximum: (\d+) bytes/i,
|
||||
(m) => {
|
||||
const mb = (Number(m[1]) / 1024 / 1024).toFixed(1)
|
||||
return `Bilden är för stor för AI-tolkning (${mb} MB, max 5 MB). Skicka ett mindre foto eller en PDF.`
|
||||
},
|
||||
],
|
||||
[
|
||||
/image exceeds .+ maximum/i,
|
||||
() => 'Bilden är för stor för AI-tolkning. Skicka ett mindre foto eller en PDF.',
|
||||
],
|
||||
[/ThrottlingException|TooManyRequestsException|Rate exceeded/i, () => 'AI-tjänsten är överbelastad just nu. Försök igen om en stund.'],
|
||||
[/AccessDeniedException/i, () => 'Åtkomst till AI-tjänsten nekades. Kontakta support.'],
|
||||
[/ValidationException.+modelId/i, () => 'AI-modellen är felkonfigurerad. Kontakta support.'],
|
||||
[/InternalServerException|ServiceUnavailable/i, () => 'AI-tjänsten är tillfälligt otillgänglig. Försök igen om en stund.'],
|
||||
[/Unsupported MIME type: (.+)/i, (m) => `Filformatet stöds inte (${m[1]}). Använd PDF, JPEG, PNG, HEIC eller WebP.`],
|
||||
[/No content in Bedrock response|No tool use result in Bedrock response/i, () => 'AI-tjänsten svarade inte med strukturerad data. Försök igen.'],
|
||||
[/Failed to fetch received email/i, () => 'Kunde inte hämta e-postmeddelandet från inkorgstjänsten. Försök igen.'],
|
||||
[/Failed to fetch attachment|Download URL returned/i, () => 'Kunde inte ladda ner bilagan från inkorgstjänsten.'],
|
||||
]
|
||||
|
||||
export function toSwedishInboxError(raw: unknown): string {
|
||||
const message = raw instanceof Error ? raw.message : typeof raw === 'string' ? raw : 'Okänt fel'
|
||||
|
||||
for (const [pattern, build] of PATTERNS) {
|
||||
const match = message.match(pattern)
|
||||
if (match) return build(match)
|
||||
}
|
||||
|
||||
// Preserve any message that's already Swedish (heuristic: contains å/ä/ö
|
||||
// or a known Swedish word). Otherwise surface a generic fallback and log
|
||||
// the technical detail through stderr rather than the user's screen.
|
||||
if (/[åäö]|bild|faktura|inkorg|leverant/i.test(message)) {
|
||||
return message
|
||||
}
|
||||
return 'Kunde inte bearbeta dokumentet. Försök igen eller kontakta support.'
|
||||
}
|
||||
Reference in New Issue
Block a user