Files
accounted/lib/agent/intents/invoice-draft.ts
T
Jakob Wennberg 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

249 lines
9.9 KiB
TypeScript

import { defineAgentIntent } from './types'
import { SONNET_MODEL, EFFORT_STANDARD } from '@/lib/agent/composer/client'
import { renderAgentGroundRules } from './shared-rules'
// invoice.draft: "Fråga om denna faktura" from the invoice form.
//
// Declarative atom mode: loads VAT + invoice compliance + e-invoicing
// upfront, plus the company's vertical + modifier atoms. The agent helps
// with VAT treatment (25/12/6 % or reverse charge), payment terms,
// kreditfaktura mechanics, OCR/Bankgiro on the invoice, and EU-customer
// edge cases.
//
// The user does the actual drafting in the form; this intent advises.
// gnubok_create_invoice / send_invoice are NOT in the tool list because
// the form already submits to those endpoints: the agent shouldn't race
// the form.
//
// Plan ref: dev_docs/specialized-agent-plan.md §8 (V1 intent #2).
interface InvoiceDraftArgs {
// null when the user opened the agent before picking a customer.
customer_id?: string | null
// Set when editing an existing draft (route /invoices/[id]). null for new.
invoice_id?: string | null
}
interface CapturedInvoiceDraft {
customer: {
id: string
name: string | null
customer_type: string | null
country: string | null
vat_number: string | null
vat_number_validated: boolean | null
org_number: string | null
} | null
recent_invoices: {
invoice_number: string | null
invoice_date: string | null
status: string | null
total: number | null
currency: string | null
}[]
invoice: {
id: string
invoice_number: string | null
status: string | null
total: number | null
currency: string | null
} | null
// Compact subset of company_settings relevant to invoice drafting.
company_invoice_context: {
moms_period: string | null
vat_registered: boolean | null
accounting_method: string | null
invoice_default_days: number | null
} | null
}
export const invoiceDraft = defineAgentIntent<InvoiceDraftArgs, CapturedInvoiceDraft>({
id: 'invoice.draft',
buttonLabel: 'Fråga om denna faktura',
sheetTitle: 'Hjälp med faktura',
atoms: {
mode: 'declarative',
horizontal: ['swedish-vat', 'swedish-invoice-compliance', 'swedish-e-invoicing'],
includeCompanyVertical: true,
includeCompanyModifiers: true,
},
tools: [
'gnubok_list_customers',
'gnubok_create_customer',
'gnubok_load_skill',
'gnubok_search_tools',
'gnubok_remember_fact',
'gnubok_forget_fact',
],
model: SONNET_MODEL,
// Draft the invoice lines + VAT in the thinking channel, so the visible reply
// is one short confirmation after staging rather than a play-by-play that
// repeats once before the tool call and once after it.
thinking: { effort: EFFORT_STANDARD },
capture: async ({ customer_id, invoice_id }, { supabase, companyId }) => {
// Resolve the effective customer_id. When the FAB lands here from
// /invoices/[id] it only knows invoice_id: read customer_id off the
// invoice row so the customer section of the prompt isn't empty.
type InvoiceRow = {
id: string
invoice_number?: string | null
status?: string | null
total?: number | null
currency?: string | null
customer_id?: string | null
}
let invoice: InvoiceRow | null = null
if (invoice_id) {
const { data } = await supabase
.from('invoices')
.select('id, invoice_number, status, total, currency, customer_id')
.eq('id', invoice_id)
.eq('company_id', companyId)
.maybeSingle()
invoice = (data as InvoiceRow | null) ?? null
}
const effectiveCustomerId = customer_id ?? invoice?.customer_id ?? null
const [{ data: customer }, { data: recent }, { data: settings }] = await Promise.all([
effectiveCustomerId
? supabase
.from('customers')
.select('id, name, customer_type, country, vat_number, vat_number_validated, org_number')
.eq('id', effectiveCustomerId)
.eq('company_id', companyId)
.maybeSingle()
: Promise.resolve({ data: null }),
effectiveCustomerId
? supabase
.from('invoices')
.select('invoice_number, invoice_date, status, total, currency')
.eq('customer_id', effectiveCustomerId)
.eq('company_id', companyId)
.order('invoice_date', { ascending: false })
.limit(5)
: Promise.resolve({ data: [] }),
supabase
.from('company_settings')
.select('moms_period, vat_registered, accounting_method, invoice_default_days')
.eq('company_id', companyId)
.maybeSingle(),
])
return {
customer: customer
? {
id: (customer as { id: string }).id,
name: ((customer as { name?: string | null }).name) ?? null,
customer_type: ((customer as { customer_type?: string | null }).customer_type) ?? null,
country: ((customer as { country?: string | null }).country) ?? null,
vat_number: ((customer as { vat_number?: string | null }).vat_number) ?? null,
vat_number_validated:
((customer as { vat_number_validated?: boolean | null }).vat_number_validated) ?? null,
org_number: ((customer as { org_number?: string | null }).org_number) ?? null,
}
: null,
recent_invoices: ((recent ?? []) as {
invoice_number: string | null
invoice_date: string | null
status: string | null
total: number | null
currency: string | null
}[]).map((r) => ({
invoice_number: r.invoice_number,
invoice_date: r.invoice_date,
status: r.status,
total: r.total,
currency: r.currency,
})),
invoice: invoice
? {
id: (invoice as { id: string }).id,
invoice_number: ((invoice as { invoice_number?: string | null }).invoice_number) ?? null,
status: ((invoice as { status?: string | null }).status) ?? null,
total: ((invoice as { total?: number | null }).total) ?? null,
currency: ((invoice as { currency?: string | null }).currency) ?? null,
}
: null,
company_invoice_context: settings
? {
moms_period: (settings as { moms_period?: string | null }).moms_period ?? null,
vat_registered: (settings as { vat_registered?: boolean | null }).vat_registered ?? null,
accounting_method:
(settings as { accounting_method?: string | null }).accounting_method ?? null,
invoice_default_days:
(settings as { invoice_default_days?: number | null }).invoice_default_days ?? null,
}
: null,
}
},
promptTemplate: ({ captured, profileSummary }) => {
const lines: string[] = []
if (profileSummary) lines.push(`Företagets profil: ${profileSummary}`, '')
lines.push('Användaren håller på att skriva en faktura. Hjälp dem komma rätt.')
lines.push('')
lines.push(renderAgentGroundRules())
lines.push('')
if (captured.customer) {
const c = captured.customer
lines.push('KUND (vald):')
lines.push(`- Namn: ${c.name ?? '(saknas)'}`)
lines.push(`- Typ: ${c.customer_type ?? '(saknas)'}`)
lines.push(`- Land: ${c.country ?? 'SE'}`)
if (c.vat_number) {
lines.push(
`- VAT-nummer: ${c.vat_number}${c.vat_number_validated ? ' (validerat via VIES)' : ' (ej validerat)'}`,
)
}
if (c.org_number) lines.push(`- Org.nr: ${c.org_number}`)
lines.push('')
if (captured.recent_invoices.length > 0) {
lines.push('Senaste fakturor till denna kund:')
for (const r of captured.recent_invoices) {
const amt = r.total != null ? `${r.total.toLocaleString('sv-SE')} ${r.currency ?? 'SEK'}` : '?'
lines.push(` • ${r.invoice_number ?? '?'} (${r.invoice_date ?? '?'}, ${r.status ?? '?'}): ${amt}`)
}
lines.push('')
}
} else {
lines.push('Ingen kund vald ännu. Be användaren välja eller skapa en kund först om de behöver hjälp med momsbehandling: momskod beror på kundens land och typ.')
lines.push('')
}
if (captured.company_invoice_context) {
const s = captured.company_invoice_context
const known: string[] = []
if (s.moms_period) known.push(`Momsperiod: ${s.moms_period}`)
if (s.vat_registered != null) known.push(`Momsregistrerad: ${s.vat_registered ? 'ja' : 'nej'}`)
if (s.accounting_method) known.push(`Bokföringsmetod: ${s.accounting_method}`)
if (s.invoice_default_days != null) known.push(`Standardbetalningsvillkor: ${s.invoice_default_days} dagar`)
if (known.length > 0) {
lines.push('KÄNDA FAKTA (fråga inte om dessa):')
for (const k of known) lines.push(`- ${k}`)
lines.push('')
}
}
lines.push('Arbetssätt: hämta information via verktygsanrop FÖRST (tyst: statusraderna visar att du söker, och ditt resonemang sker i tankekanalen), föreslå sedan. Skriv din förklaring EN gång efteråt, inte i flera block runt anropen.')
lines.push('- Hjälp användaren välja rätt momsbehandling baserat på kundens land + typ + VAT-validering:')
lines.push(' · SE-kund: 25/12/6 % beroende på vara/tjänst.')
lines.push(' · EU näringsidkare med validerat VAT-nr: omvänd skattskyldighet (reverse charge) på tjänster.')
lines.push(' · EU privatperson: SE-moms (eller OSS-tröskel om varor).')
lines.push(' · Utanför EU: export, 0 %.')
lines.push('- Föreslå betalningsvillkor, OCR/Bankgiro-uppgifter, eventuell ROT/RUT, EU-text på fakturan vid reverse charge.')
lines.push('- Du SKAPAR INTE fakturan. Användaren gör det själv i formuläret. Du rådger.')
lines.push('- Om kunden saknar VAT-nummer men är EU-näringsidkare, säg till: VIES-validering krävs för reverse charge.')
lines.push('')
lines.push('Svara på svenska och var direkt: ditt första svar är det första användaren ser.')
return lines.join('\n')
},
})