Files
accounted/lib/agent/categorize/underlag.ts
T
Jakob Wennberg 72c81c21e7 feat(categorize): feed the selector the underlag, not just the bank line (#1785)
The highest-leverage quality lever for real users. A prod read showed the
majority are cold-start (365 companies, 32.7k unbooked transactions, median 0
counterparty templates), so the LLM selector carries them — and it was only
seeing the bank line (merchant + amount), never the receipt.

- lib/agent/categorize/underlag.ts: gathers the matched receipt/invoice text
  for a transaction (receipts.matched_transaction_id + invoice_inbox_items
  .matched_transaction_id + the transaction's own attached document) and renders
  it as bounded Swedish text — supplier, date, total, moms, line items. Same
  sources the categorization intent reads, as a string not a tool loop. Core
  queries the tables directly (no @/extensions import). Best-effort: '' on any
  failure.
- POST /api/agent/categorize gathers it server-side when the caller didn't
  supply `underlag`, so the model reasons over the actual supplier + line items.

Server-side only, no client change. 31 categorize tests green; lint + guards +
scoped typecheck clean.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 16:00:43 +02:00

123 lines
4.8 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import { roundOre } from '@/lib/money'
/**
* Gather the underlag (receipt / invoice text) matched to a transaction and
* render it as compact text for the account selector.
*
* This is the highest-leverage input for the cold-start majority: without it
* the model sees only the bank line (merchant + amount); with it, it sees the
* supplier name, line items and VAT off the actual receipt. Same sources the
* transaction.categorization intent reads — receipts.matched_transaction_id,
* invoice_inbox_items.matched_transaction_id, and the transaction's own
* attached document — but produced as a bounded string, not a tool loop.
*
* Company-scoped and best-effort: any failing query is skipped, never fatal.
* Returns '' when there is no underlag (the selector then works off the bank
* line + candidates as before).
*/
const MAX_UNDERLAG_CHARS = 2500
const MAX_LINE_ITEMS = 8
function fmt(amount: number | null | undefined, currency: string | null | undefined): string | null {
if (amount === null || amount === undefined) return null
return `${roundOre(Number(amount))} ${currency ?? 'SEK'}`
}
function lineItemDescriptions(ex: Record<string, unknown>): string[] {
const items = ex.lineItems
if (!Array.isArray(items)) return []
const out: string[] = []
for (const it of items.slice(0, MAX_LINE_ITEMS)) {
const d = (it as { description?: unknown }).description
if (typeof d === 'string' && d.trim()) out.push(d.trim())
}
return out
}
export async function gatherUnderlag(
supabase: SupabaseClient,
companyId: string,
transactionId: string,
documentId?: string | null,
): Promise<string> {
const [receiptsRes, inboxRes, docRes] = await Promise.all([
supabase
.from('receipts')
.select('merchant_name, receipt_date, total_amount, vat_amount, currency, is_restaurant, is_systembolaget')
.eq('company_id', companyId)
.eq('matched_transaction_id', transactionId),
supabase
.from('invoice_inbox_items')
.select('extracted_data')
.eq('company_id', companyId)
.eq('matched_transaction_id', transactionId),
documentId
? supabase
.from('document_attachments')
.select('extracted_data')
.eq('id', documentId)
.eq('company_id', companyId)
.maybeSingle()
: Promise.resolve({ data: null }),
]).catch(() => [{ data: null }, { data: null }, { data: null }] as const)
const lines: string[] = []
// Receipts (receipt-scan extracted fields).
for (const r of ((receiptsRes as { data: unknown }).data ?? []) as {
merchant_name: string | null
receipt_date: string | null
total_amount: number | null
vat_amount: number | null
currency: string | null
is_restaurant: boolean | null
is_systembolaget: boolean | null
}[]) {
const parts: string[] = []
if (r.merchant_name) parts.push(r.merchant_name)
if (r.receipt_date) parts.push(r.receipt_date)
const total = fmt(r.total_amount, r.currency)
if (total) parts.push(`totalt ${total}`)
const vat = fmt(r.vat_amount, r.currency)
if (vat) parts.push(`moms ${vat}`)
if (r.is_restaurant) parts.push('restaurang/representation')
if (r.is_systembolaget) parts.push('Systembolaget')
if (parts.length) lines.push(`Kvitto: ${parts.join(', ')}.`)
}
// Invoice inbox items (structured extraction of an invoice/receipt).
for (const it of ((inboxRes as { data: unknown }).data ?? []) as {
extracted_data: Record<string, unknown> | null
}[]) {
const ex = it.extracted_data
if (!ex) continue
lines.push(renderExtraction(ex, 'Faktura/kvitto (inkorg)'))
}
// The transaction's own attached document.
const doc = (docRes as { data: { extracted_data?: Record<string, unknown> | null } | null }).data
if (doc?.extracted_data) lines.push(renderExtraction(doc.extracted_data, 'Bifogat underlag'))
return lines.filter(Boolean).join('\n').slice(0, MAX_UNDERLAG_CHARS).trim()
}
function renderExtraction(ex: Record<string, unknown>, label: string): string {
const supplier = (ex.supplier as { name?: string | null } | undefined) ?? null
const invoice = (ex.invoice as { invoiceDate?: string | null; currency?: string | null } | undefined) ?? null
const totals = (ex.totals as { total?: number | null; vatAmount?: number | null } | undefined) ?? null
const parts: string[] = []
if (supplier?.name) parts.push(`leverantör ${supplier.name}`)
if (invoice?.invoiceDate) parts.push(invoice.invoiceDate)
const total = fmt(totals?.total, invoice?.currency)
if (total) parts.push(`totalt ${total}`)
const vat = fmt(totals?.vatAmount, invoice?.currency)
if (vat) parts.push(`moms ${vat}`)
const items = lineItemDescriptions(ex)
const head = `${label}: ${parts.join(', ') || 'utläst underlag'}.`
return items.length ? `${head} Rader: ${items.map((d) => `"${d}"`).join('; ')}.` : head
}