* 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>
476 lines
22 KiB
TypeScript
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')
|
|
},
|
|
})
|