Files
accounted/lib/agent/intents/supplier-invoice-review.ts
T
Jakob WennbergandClaude Opus 5 2d543ac999 feat(agent): move every model call to Sonnet 5 (#1218)
* feat(agent): move every model call to Sonnet 5

Sonnet 5 is verified enabled on our Bedrock account already: a live probe of
eu.anthropic.claude-sonnet-5 in eu-north-1 answered normally, so no model-access
request was needed. The bare anthropic.claude-sonnet-5 is rejected (on-demand
throughput needs the cross-region inference profile), so the eu. prefix we
already use stays.

This is not a model-string swap. Sonnet 5 REJECTS the fixed thinking budget
outright: thinking {type:'enabled', budget_tokens} returns 400 "not supported
for this model. Use thinking.type.adaptive and output_config.effort". Every
chat intent set a budget, so the assistant would have failed on the first turn
after a bare ID change. Reasoning depth is now an effort level (STANDARD high,
DEEP xhigh), and max_tokens is explicit per tier rather than derived from a
budget that no longer exists.

display:'summarized' is load-bearing, not cosmetic. The default is 'omitted',
which still emits thinking blocks but with empty text. Measured on our own
account at xhigh effort: summarized returned ~1k characters of reasoning, the
default returned none. Without it the collapsible "Tänker ..." block in the
chat would have gone silently empty, which no mocked test would have caught.

Ceilings are raised (16k standard, 24k deep) because Sonnet 5's tokenizer
produces roughly 30% more tokens for the same text and max_tokens now caps
thinking and the visible reply together.

Also resolves the Opus 4.7 landmine recorded in the readiness doc: the composer
comment told ops to flip BEDROCK_OPUS_MODEL_ID to Opus 4.7, which would have
400d every thinking intent against the legacy budget shape. Both model
constants now point at Sonnet 5 and the stale instruction is gone.

Checked but deliberately unchanged: forced tool_choice in atom-selection. The
Sonnet 5 docs require thinking:{type:'disabled'} alongside a forced tool_choice
on Bedrock; probed against our account, the forced call succeeds without it, so
no change was made rather than adding a guard we cannot show is needed.

Other call sites moved too: invoice-inbox extraction, document extraction, the
compliance config, and the CI/CD workflows (pr-agent MODEL and MODEL_WEAK,
swedish-compliance-review, compliance-swarm).

Verified: 11315 tests pass, lint and tsc clean on every touched file, guards
pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(agent): review triage: keep the no-thinking output ceiling, finish the model sweep

max_tokens now caps thinking and the visible reply together, so collapsing the
two tiers into one made every non-thinking intent inherit a 16000 ceiling where
it used to have 4096. Give it its own MAX_TOKENS_NO_THINKING instead, set to the
old 4096 scaled ~30% for Sonnet 5's tokenizer so the effective reply length is
unchanged rather than quietly cut.

scripts/swedish-compliance-review.mjs still fell back to Sonnet 4.6 when
REVIEW_MODEL was unset, so a manual run silently used the old model. The initial
sweep only covered .ts and .yml.

pr-agent's FALLBACK_MODELS listed the primary model as its own fallback, which is
not a fallback; dropped it and rewrote the surrounding comments, which still
described Opus 4.8 and a 200k window.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:16:40 +02:00

476 lines
22 KiB
TypeScript

import { defineAgentIntent } from './types'
import { OPUS_MODEL, EFFORT_DEEP } from '@/lib/agent/composer/client'
import { renderAgentGroundRules } from './shared-rules'
// supplier_invoice.review: "Fråga din assistent" from a supplier invoice
// detail page. Helps the user verify a supplier invoice before attestering
// it: BAS account, VAT treatment (reverse charge byggtjänster?), anomaly
// detection vs. prior invoices from the same supplier, and missing-field
// checks against ML 17 kap 24§.
//
// Declarative atom mode: loads VAT + invoice compliance + accounting
// compliance upfront, plus the company's vertical + modifier atoms. AP
// flows benefit most from these: invoices from EU suppliers trigger
// reverse charge logic, bygg suppliers trigger omvänd skattskyldighet.
//
// Default model: Opus (per plan §8 V1 #5: heavy intent). The reasoning
// chain is non-trivial:
// 1. Read inbox-extracted fields (supplier, total, VAT, line items)
// 2. Compare to supplier history: anomalies?
// 3. Detect reverse charge cases (EU, bygg)
// 4. Verify ML 17 kap 24§ mandatory fields are present
// 5. Propose BAS account + VAT code
//
// Plan ref: dev_docs/specialized-agent-plan.md §8 (V1 intent #5).
interface SupplierInvoiceReviewArgs {
supplier_invoice_id: string
}
interface CapturedSupplierInvoiceReview {
invoice: {
id: string
arrival_number: number | null
supplier_invoice_number: string | null
invoice_date: string | null
due_date: string | null
status: string | null
currency: string | null
subtotal: number | null
vat_amount: number | null
total: number | null
// The invoice's magnitudes travel WITH their own currency, and the SEK
// legs are separate fields that stay null when no rate was ever resolved.
// Never collapse the two: a EUR amount printed without a unit reads to the
// agent as kronor, and it attests the booking on that number.
vat_amount_sek: number | null
total_sek: number | null
exchange_rate: number | null
vat_treatment: string | null
reverse_charge: boolean | null
payment_reference: string | null
is_credit_note: boolean | null
document_id: string | null
} | null
supplier: {
id: string
name: string | null
org_number: string | null
vat_number: string | null
country: string | null
} | null
items: {
description: string | null
quantity: number | null
unit_price: number | null
line_total: number | null
vat_rate: number | null
account_number: string | null
}[]
recent_invoices_from_supplier: {
invoice_number: string | null
invoice_date: string | null
total: number | null
currency: string | null
// Anomaly detection compares these against each other. Without a common
// unit that comparison is meaningless, so the SEK leg travels along.
total_sek: number | null
exchange_rate: number | null
status: string | null
}[]
// Linked inbox / document extraction so the agent doesn't re-ask for what
// the AI has already extracted.
inbox_extraction: Record<string, unknown> | null
document_extraction: Record<string, unknown> | null
}
// ISO code as stored, uppercased, or null when nothing usable is there. A blank
// or absent code is NOT silently promoted to SEK: an amount whose unit we do
// not know must be rendered as unknown, never as kronor.
function normalizeCurrency(value: string | null | undefined): string | null {
if (typeof value !== 'string') return null
const code = value.trim().toUpperCase()
return code.length === 0 ? null : code
}
// "1 200 EUR", or "1 200 (valuta okänd)" when no currency code is attached.
function formatMoney(amount: number | null | undefined, currency: string | null | undefined): string {
if (amount == null || !Number.isFinite(Number(amount))) return '?'
const code = normalizeCurrency(currency)
const value = Number(amount).toLocaleString('sv-SE')
return code ? `${value} ${code}` : `${value} (valuta okänd)`
}
// SEK magnitude of an amount, or null when there genuinely is none. Mirrors
// resolveSekAmount()'s priority (stored *_sek column, then exchange_rate) but
// deliberately drops its legacy "assume SEK" fallback: for a reader the honest
// answer is "no known SEK value", not the face value relabelled as kronor.
function sekEquivalent(
amount: number | null | undefined,
amountSek: number | null | undefined,
currency: string | null | undefined,
exchangeRate: number | null | undefined,
): number | null {
if (amount == null || !Number.isFinite(Number(amount))) return null
if (normalizeCurrency(currency) === 'SEK') return Number(amount)
if (amountSek != null && Number.isFinite(Number(amountSek))) return Number(amountSek)
const rate = exchangeRate == null ? null : Number(exchangeRate)
if (rate != null && Number.isFinite(rate) && rate > 0) {
return Math.round(Number(amount) * rate * 100) / 100
}
return null
}
// "1 200 EUR (13 800 SEK)" when a rate exists, plain "1 200 SEK" for a domestic
// amount, and the bare foreign amount when no SEK value is known.
function formatMoneyWithSek(
amount: number | null | undefined,
amountSek: number | null | undefined,
currency: string | null | undefined,
exchangeRate: number | null | undefined,
): string {
const own = formatMoney(amount, currency)
if (amount == null) return own
if (normalizeCurrency(currency) === 'SEK') return own
const sek = sekEquivalent(amount, amountSek, currency, exchangeRate)
return sek == null ? own : `${own} (${sek.toLocaleString('sv-SE')} SEK)`
}
export const supplierInvoiceReview = defineAgentIntent<
SupplierInvoiceReviewArgs,
CapturedSupplierInvoiceReview
>({
id: 'supplier_invoice.review',
buttonLabel: 'Granska med assistent',
sheetTitle: 'Granska leverantörsfaktura',
atoms: {
mode: 'declarative',
horizontal: ['swedish-vat', 'swedish-invoice-compliance', 'swedish-accounting-compliance'],
includeCompanyVertical: true,
includeCompanyModifiers: true,
},
tools: [
'gnubok_get_supplier_ledger',
'gnubok_query_journal',
'gnubok_get_document_content',
'gnubok_approve_supplier_invoice',
'gnubok_credit_supplier_invoice',
'gnubok_load_skill',
'gnubok_search_tools',
'gnubok_remember_fact',
'gnubok_forget_fact',
],
// Opus per plan §8: anomaly detection + multi-source synthesis benefits
// from deeper reasoning than Sonnet's strength on selection tasks.
model: OPUS_MODEL,
// Reason about underlag, VAT treatment and anomalies in the thinking channel
// so the visible reply is a single conclusion after the booking is staged:
// not a pre-tool analysis echoed again post-tool. Matches the always-on
// prompt's promise that reasoning happens in the (separately shown) tankekanal.
thinking: { effort: EFFORT_DEEP },
capture: async ({ supplier_invoice_id }, { supabase, companyId }) => {
const { data: invoice } = await supabase
.from('supplier_invoices')
.select(
'id, supplier_id, arrival_number, supplier_invoice_number, invoice_date, due_date, status, currency, exchange_rate, subtotal, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, reverse_charge, payment_reference, is_credit_note, document_id',
)
.eq('id', supplier_invoice_id)
.eq('company_id', companyId)
.maybeSingle()
if (!invoice) {
return {
invoice: null,
supplier: null,
items: [],
recent_invoices_from_supplier: [],
inbox_extraction: null,
document_extraction: null,
}
}
const supplierId = (invoice as { supplier_id: string }).supplier_id
const documentId = (invoice as { document_id: string | null }).document_id
const [
{ data: supplier },
{ data: items },
{ data: recent },
{ data: inboxRow },
{ data: docRow },
] = await Promise.all([
supplierId
? supabase
.from('suppliers')
.select('id, name, org_number, vat_number, country')
.eq('id', supplierId)
.eq('company_id', companyId)
.maybeSingle()
: Promise.resolve({ data: null }),
supabase
.from('supplier_invoice_items')
.select('description, quantity, unit_price, line_total, vat_rate, account_number')
.eq('supplier_invoice_id', supplier_invoice_id),
supplierId
? supabase
.from('supplier_invoices')
.select('supplier_invoice_number, invoice_date, total, total_sek, currency, exchange_rate, status')
.eq('supplier_id', supplierId)
.eq('company_id', companyId)
.neq('id', supplier_invoice_id)
.order('invoice_date', { ascending: false })
.limit(5)
: Promise.resolve({ data: [] }),
documentId
? supabase
.from('invoice_inbox_items')
.select('extracted_data')
.eq('document_id', documentId)
.eq('company_id', companyId)
.maybeSingle()
: Promise.resolve({ data: null }),
documentId
? supabase
.from('document_attachments')
.select('extracted_data')
.eq('id', documentId)
.eq('company_id', companyId)
.maybeSingle()
: Promise.resolve({ data: null }),
])
return {
invoice: {
id: (invoice as { id: string }).id,
arrival_number: ((invoice as { arrival_number?: number }).arrival_number) ?? null,
supplier_invoice_number:
((invoice as { supplier_invoice_number?: string | null }).supplier_invoice_number) ?? null,
invoice_date: ((invoice as { invoice_date?: string | null }).invoice_date) ?? null,
due_date: ((invoice as { due_date?: string | null }).due_date) ?? null,
status: ((invoice as { status?: string | null }).status) ?? null,
currency: ((invoice as { currency?: string | null }).currency) ?? null,
subtotal: ((invoice as { subtotal?: number | null }).subtotal) ?? null,
vat_amount: ((invoice as { vat_amount?: number | null }).vat_amount) ?? null,
total: ((invoice as { total?: number | null }).total) ?? null,
vat_amount_sek: ((invoice as { vat_amount_sek?: number | null }).vat_amount_sek) ?? null,
total_sek: ((invoice as { total_sek?: number | null }).total_sek) ?? null,
exchange_rate: ((invoice as { exchange_rate?: number | null }).exchange_rate) ?? null,
vat_treatment: ((invoice as { vat_treatment?: string | null }).vat_treatment) ?? null,
reverse_charge: ((invoice as { reverse_charge?: boolean | null }).reverse_charge) ?? null,
payment_reference:
((invoice as { payment_reference?: string | null }).payment_reference) ?? null,
is_credit_note: ((invoice as { is_credit_note?: boolean | null }).is_credit_note) ?? null,
document_id: documentId,
},
supplier: supplier
? {
id: (supplier as { id: string }).id,
name: ((supplier as { name?: string | null }).name) ?? null,
org_number: ((supplier as { org_number?: string | null }).org_number) ?? null,
vat_number: ((supplier as { vat_number?: string | null }).vat_number) ?? null,
country: ((supplier as { country?: string | null }).country) ?? null,
}
: null,
items: ((items ?? []) as {
description: string | null
quantity: number | null
unit_price: number | null
line_total: number | null
vat_rate: number | null
account_number: string | null
}[]).map((i) => ({
description: i.description,
quantity: i.quantity,
unit_price: i.unit_price,
line_total: i.line_total,
vat_rate: i.vat_rate,
account_number: i.account_number,
})),
recent_invoices_from_supplier: ((recent ?? []) as {
supplier_invoice_number: string | null
invoice_date: string | null
total: number | null
total_sek: number | null
currency: string | null
exchange_rate: number | null
status: string | null
}[]).map((r) => ({
invoice_number: r.supplier_invoice_number,
invoice_date: r.invoice_date,
total: r.total,
total_sek: r.total_sek ?? null,
currency: r.currency,
exchange_rate: r.exchange_rate ?? null,
status: r.status,
})),
inbox_extraction: (inboxRow as { extracted_data?: Record<string, unknown> | null } | null)?.extracted_data ?? null,
document_extraction: (docRow as { extracted_data?: Record<string, unknown> | null } | null)?.extracted_data ?? null,
}
},
promptTemplate: ({ captured, profileSummary }) => {
if (!captured.invoice) {
return [
'Användaren öppnade hjälpfönstret från en leverantörsfaktura, men fakturan kunde inte hittas.',
'Be om mer information och försök hjälpa på generell nivå.',
].join(' ')
}
const inv = captured.invoice
const lines: string[] = []
if (profileSummary) lines.push(`Företagets profil: ${profileSummary}`, '')
lines.push(renderAgentGroundRules())
lines.push('')
lines.push('Granska denna leverantörsfaktura innan attestering:')
lines.push(`- Ankomst #${inv.arrival_number ?? '?'} / Fakturanummer ${inv.supplier_invoice_number ?? '?'}`)
if (captured.supplier) {
const s = captured.supplier
const supplierLine: string[] = []
if (s.name) supplierLine.push(`Leverantör: ${s.name}`)
if (s.country && s.country !== 'SE') supplierLine.push(`(${s.country})`)
if (s.org_number) supplierLine.push(`org.nr ${s.org_number}`)
if (s.vat_number) supplierLine.push(`VAT ${s.vat_number}`)
lines.push(`- ${supplierLine.join(' ')}`)
}
lines.push(`- Status: ${inv.status ?? '?'} | Datum: ${inv.invoice_date ?? '?'} | Förfaller: ${inv.due_date ?? '?'}`)
const invCurrency = normalizeCurrency(inv.currency)
lines.push(
`- Belopp: ${formatMoney(inv.total, inv.currency)} (moms ${formatMoney(inv.vat_amount, inv.currency)})`,
)
// A foreign invoice is booked in SEK, so the SEK leg is the number that
// actually lands in the verifikat. Print it next to the face value when it
// exists, and say plainly that it is missing when it does not: the one
// thing this block must never do is let a EUR figure pass for kronor.
if (invCurrency !== 'SEK' && inv.total != null) {
const totalSek = sekEquivalent(inv.total, inv.total_sek, inv.currency, inv.exchange_rate)
const vatSek = sekEquivalent(inv.vat_amount, inv.vat_amount_sek, inv.currency, inv.exchange_rate)
if (totalSek != null) {
lines.push(
`- I SEK (bokföringen sker i SEK): ${totalSek.toLocaleString('sv-SE')} SEK (moms ${vatSek != null ? `${vatSek.toLocaleString('sv-SE')} SEK` : '?'})${inv.exchange_rate != null ? `, växelkurs ${inv.exchange_rate}` : ''}`,
)
} else {
lines.push(
`- SEK-BELOPP SAKNAS: ingen växelkurs är lagrad på fakturan. ${formatMoney(inv.total, inv.currency)} är INTE ${inv.total.toLocaleString('sv-SE')} SEK. Attestera inte på det nominella beloppet: växelkursen måste fyllas i först.`,
)
}
}
lines.push(
`- Momskod: ${inv.vat_treatment ?? '?'}${inv.reverse_charge ? ' (omvänd skattskyldighet flaggad)' : ''}`,
)
if (inv.payment_reference) lines.push(`- OCR / referens: ${inv.payment_reference}`)
if (inv.is_credit_note) lines.push('- DETTA ÄR EN KREDITFAKTURA')
lines.push('')
if (captured.items.length > 0) {
lines.push('Rader:')
for (const it of captured.items.slice(0, 20)) {
// Line amounts are stored in the invoice's currency, so they carry the
// invoice's currency code: never a bare number that reads as kronor.
const total = formatMoney(it.line_total, inv.currency)
const vat = it.vat_rate != null ? `${it.vat_rate}%` : '?'
const acc = it.account_number ? ` → ${it.account_number}` : ' → (ingen kontering)'
lines.push(` • ${it.description ?? '(beskrivning saknas)'}: ${total} (${vat})${acc}`)
}
lines.push('')
}
if (captured.recent_invoices_from_supplier.length > 0) {
lines.push('Tidigare fakturor från samma leverantör (för anomalikontroll):')
for (const r of captured.recent_invoices_from_supplier) {
const amt = formatMoneyWithSek(r.total, r.total_sek, r.currency, r.exchange_rate)
lines.push(` • ${r.invoice_number ?? '?'} (${r.invoice_date ?? '?'}, ${r.status ?? '?'}): ${amt}`)
}
lines.push('')
}
const ex = captured.inbox_extraction ?? captured.document_extraction
if (ex) {
const supplier = (ex.supplier as { name?: string | null; orgNumber?: string | null; vatNumber?: string | null } | undefined) ?? null
const exInvoice = (ex.invoice as { currency?: string | null } | undefined) ?? null
const totals = (ex.totals as { total?: number | null; vatAmount?: number | null } | undefined) ?? null
const breakdown = (ex.vatBreakdown as { rate: number; base: number; amount: number }[] | undefined) ?? []
const exCurrency = normalizeCurrency(exInvoice?.currency)
const hasExtractedAmounts =
totals?.total != null || totals?.vatAmount != null || breakdown.length > 0
// An extracted amount is a KNOWN FACT only when we know its unit AND that
// unit is the one the invoice is registered in. Anything else is half a
// fact, and half a fact under a "fråga INTE om dessa" header reads as
// confirmation of the registered amount: that is exactly how a 1 200 EUR
// invoice gets attested as 1 200 SEK. Those values move to their own
// block below, where the agent is told to settle them first.
const amountsConfirmRegistered =
hasExtractedAmounts && exCurrency != null && invCurrency != null && exCurrency === invCurrency
const renderAmountLines = (): string[] => {
const out: string[] = []
if (totals?.total != null) out.push(`- Total (PDF): ${formatMoney(totals.total, exCurrency)}`)
if (totals?.vatAmount != null) out.push(`- Moms (PDF): ${formatMoney(totals.vatAmount, exCurrency)}`)
if (breakdown.length > 0) {
out.push(
`- Momsuppdelning: ${breakdown.map((b) => `${b.rate}%: ${formatMoney(b.amount, exCurrency)}`).join('; ')}`,
)
}
return out
}
const known: string[] = []
if (supplier?.name) known.push(`- Leverantör (PDF): ${supplier.name}`)
if (supplier?.orgNumber) known.push(`- Org.nr (PDF): ${supplier.orgNumber}`)
if (supplier?.vatNumber) known.push(`- VAT-nr (PDF): ${supplier.vatNumber}`)
if (amountsConfirmRegistered) known.push(...renderAmountLines())
if (known.length > 0) {
lines.push('KÄNDA FAKTA från AI-extraktion av underlaget: fråga INTE om dessa:')
lines.push(...known)
lines.push('')
}
if (hasExtractedAmounts && !amountsConfirmRegistered) {
lines.push(
'EJ BEKRÄFTADE BELOPP från AI-extraktionen: dessa är INTE kända fakta och bekräftar INTE det registrerade beloppet ovan:',
)
lines.push(...renderAmountLines())
if (exCurrency == null) {
lines.push(
`- Extraktionen anger ingen valuta. Fakturan är registrerad i ${invCurrency ?? 'okänd valuta'}: ett belopp utan valuta kan varken bekräfta eller motsäga det registrerade beloppet. Behandla det ALDRIG som kronor.`,
)
} else {
lines.push(
`- ⚠ VALUTAKONFLIKT: fakturan är registrerad i ${invCurrency ?? 'okänd valuta'} men underlaget anger ${exCurrency}. Attestera inte förrän det är utrett vilken valuta som gäller: ett av beloppen är fel registrerat.`,
)
}
lines.push(
`- Fastställ valuta och belopp INNAN du attesterar: läs underlaget med gnubok_get_document_content(document_id=${inv.document_id ?? '…'}), eller fråga användaren.`,
)
lines.push('')
}
} else if (inv.document_id) {
lines.push('Underlag är bifogat men inte AI-extraherat. Använd gnubok_get_document_content för att läsa PDF/bilden om du behöver fler signaler.')
lines.push('')
} else {
lines.push('Inget underlag bifogat. Be användaren ladda upp fakturan om något är otydligt.')
lines.push('')
}
lines.push('Arbetssätt: granska, peka på risker, föreslå.')
lines.push('1. Kontrollera momsbehandling: passar den med leverantörens land + VAT-status?')
lines.push(' - SE-leverantör: 25/12/6 % beroende på vara/tjänst.')
lines.push(' - EU näringsidkare: omvänd skattskyldighet (2614/2645).')
lines.push(' - Bygg i Sverige: omvänd skattskyldighet enligt ML 1 kap 2 § 1 st 4b.')
lines.push(' - Tredje land: import-moms via Tullverket eller deklareras via momsdeklaration ruta 60-62.')
lines.push('2. Valuta: bokföringen sker i SEK. Är fakturan i utländsk valuta är det SEK-beloppet ovan som bokförs, aldrig det nominella beloppet i främmande valuta. Saknas SEK-beloppet (ingen växelkurs lagrad): attestera inte, be användaren fylla i växelkursen först. Ett belopp utan angiven valuta är inte ett SEK-belopp: reda ut valutan innan du räknar på det.')
lines.push('3. Avvikelse mot tidigare fakturor från samma leverantör? Beloppen i ungefär samma härad? Jämför i SEK när valutorna skiljer sig åt: en jämförelse mellan olika valutor säger ingenting.')
lines.push('4. Saknas obligatoriska fält (ML 17 kap 24§): fakturanummer, datum, org.nr, moms-belopp, VAT-id vid reverse charge?')
lines.push('5. Föreslå rätt BAS-konto för varje rad (eller för hela fakturan om bara en summarad finns). Följ leverantörens historik: gnubok_query_journal({ text: "<leverantörens namn>", source_type: "supplier_invoice", limit: 5 }): när du väljer konto.')
lines.push('6. Om du är säker, staga attestering via gnubok_approve_supplier_invoice. Annars: peka på vad som ska klargöras innan attestering.')
lines.push('')
lines.push('Svara på svenska, var direkt och konkret. Ditt första svar är det första användaren ser.')
return lines.join('\n')
},
})