Em dashes (—) and en dashes (–) had spread across comments, docs, tests, and a few UI strings, reading as AI-generated boilerplate rather than house style. Replaced each with punctuation matching its context: colon for explanatory clauses, comma for asides, plain hyphen for numeric/legal ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for paired-dash asides. messages/en.json and messages/sv.json were fixed by hand together to keep sv/en in sync. Left untouched where the dash is the functional subject rather than decorative punctuation: date-range-parser.ts's separator regex, charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the agent system-prompt files that already instruct against em dashes, and a golden iXBRL test fixture compared byte-for-byte. Also fixes two bugs surfaced along the way: an off-by-one in ApiKeysPanel's scope-label split (a leftover from an earlier partial pass), and a charset-repair test that had lost the literal en-dash it exists to verify. Regenerated the agent atom seed migration (skills:generate) since 27 SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes, with an explicit carve-out for the functional-dash cases above. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
273 lines
12 KiB
TypeScript
273 lines
12 KiB
TypeScript
import { defineAgentIntent } from './types'
|
|
import { SONNET_MODEL, THINKING_BUDGET_STANDARD } from '@/lib/agent/composer/client'
|
|
import { renderAgentGroundRules } from './shared-rules'
|
|
|
|
// verifikation.draft: "Fråga om denna verifikation" on the journal entry
|
|
// creation/draft surfaces (Bokföring → "Skapa med assistent", the Ny
|
|
// verifikat-dialog, and a draft verifikat's own page).
|
|
//
|
|
// Helps the user construct a balanced verifikation end to end: read the
|
|
// underlag (kvitto/faktura) the user often can't see themselves and pull the
|
|
// figures from it, pick the right BAS accounts, handle VAT splits, and detect
|
|
// when a transaction should instead be matched to an invoice or supplier
|
|
// invoice (rather than booked from scratch). Reads any in-progress draft state
|
|
// + linked underlag passed via intent_args.
|
|
|
|
interface VerifikationDraftArgs {
|
|
// Optional id when the user is editing an existing draft. null for /new.
|
|
journal_entry_id?: string | null
|
|
// Optional starter description from the form, so the agent can suggest
|
|
// counterparty templates without round-tripping.
|
|
description?: string | null
|
|
}
|
|
|
|
interface CapturedVerifikationDraft {
|
|
entry: {
|
|
id: string
|
|
entry_date: string | null
|
|
description: string | null
|
|
status: string | null
|
|
} | null
|
|
current_lines: {
|
|
account_number: string | null
|
|
debit_amount: number | null
|
|
credit_amount: number | null
|
|
description: string | null
|
|
}[]
|
|
period_status: {
|
|
period_id: string | null
|
|
status: string | null
|
|
lock_date: string | null
|
|
} | null
|
|
description_hint: string | null
|
|
// Underlag already linked to the entry (when editing a draft). Flattened
|
|
// from document_attachments.extracted_data the same way
|
|
// transaction.categorization does, so the agent can read the figures
|
|
// without a round-trip. Empty for a brand-new verifikation: there the
|
|
// agent discovers underlag via gnubok_list_inbox_items.
|
|
underlag: {
|
|
document_id: string | null
|
|
file_name: string | null
|
|
merchant_name: string | null
|
|
receipt_date: string | null
|
|
total_amount: number | null
|
|
vat_amount: number | null
|
|
currency: string | null
|
|
raw_extraction: Record<string, unknown> | null
|
|
}[]
|
|
}
|
|
|
|
export const verifikationDraft = defineAgentIntent<
|
|
VerifikationDraftArgs,
|
|
CapturedVerifikationDraft
|
|
>({
|
|
id: 'verifikation.draft',
|
|
buttonLabel: 'Fråga om denna verifikation',
|
|
sheetTitle: 'Hjälp med verifikation',
|
|
|
|
atoms: {
|
|
mode: 'declarative',
|
|
horizontal: ['swedish-accounting-compliance', 'swedish-vat'],
|
|
includeCompanyVertical: true,
|
|
includeCompanyModifiers: true,
|
|
},
|
|
|
|
tools: [
|
|
'gnubok_get_trial_balance',
|
|
'gnubok_query_journal',
|
|
'gnubok_create_voucher',
|
|
// Underlag reading: the ground rules (shared-rules.ts) already instruct
|
|
// the agent to look in the inbox and read the underlag before proposing a
|
|
// booking; these are the tools that make those instructions callable.
|
|
'gnubok_get_document_content',
|
|
'gnubok_list_inbox_items',
|
|
'gnubok_list_unmatched_documents',
|
|
'gnubok_get_inbox_item',
|
|
'gnubok_load_skill',
|
|
'gnubok_search_tools',
|
|
'gnubok_remember_fact',
|
|
'gnubok_forget_fact',
|
|
],
|
|
|
|
model: SONNET_MODEL,
|
|
|
|
// Work out the entry (accounts, VAT, balance) in the thinking channel, so the
|
|
// visible reply lands once: after the voucher is staged: instead of an
|
|
// analysis before the tool call and a near-identical answer after it. The
|
|
// always-on prompt promises "resonemang sker i tankekanalen"; without this
|
|
// that channel doesn't exist and the reasoning spills into the visible reply.
|
|
thinking: { budgetTokens: THINKING_BUDGET_STANDARD },
|
|
|
|
capture: async ({ journal_entry_id, description }, { supabase, companyId }) => {
|
|
let entry: CapturedVerifikationDraft['entry'] = null
|
|
let lines: CapturedVerifikationDraft['current_lines'] = []
|
|
let periodStatus: CapturedVerifikationDraft['period_status'] = null
|
|
const underlag: CapturedVerifikationDraft['underlag'] = []
|
|
|
|
if (journal_entry_id) {
|
|
const { data: e } = await supabase
|
|
.from('journal_entries')
|
|
.select('id, entry_date, description, status')
|
|
.eq('id', journal_entry_id)
|
|
.eq('company_id', companyId)
|
|
.maybeSingle()
|
|
if (e) {
|
|
entry = {
|
|
id: (e as { id: string }).id,
|
|
entry_date: ((e as { entry_date?: string | null }).entry_date) ?? null,
|
|
description: ((e as { description?: string | null }).description) ?? null,
|
|
status: ((e as { status?: string | null }).status) ?? null,
|
|
}
|
|
const { data: rows } = await supabase
|
|
.from('journal_entry_lines')
|
|
.select('account_number, debit_amount, credit_amount, description')
|
|
.eq('journal_entry_id', journal_entry_id)
|
|
.order('id', { ascending: true })
|
|
lines = (rows ?? []) as CapturedVerifikationDraft['current_lines']
|
|
const entryDate = entry?.entry_date ?? null
|
|
if (entryDate) {
|
|
const { data: period } = await supabase
|
|
.from('fiscal_periods')
|
|
.select('id, status, locked_through')
|
|
.eq('company_id', companyId)
|
|
.lte('period_start', entryDate)
|
|
.gte('period_end', entryDate)
|
|
.maybeSingle()
|
|
if (period) {
|
|
periodStatus = {
|
|
period_id: (period as { id: string }).id,
|
|
status: ((period as { status?: string | null }).status) ?? null,
|
|
lock_date: ((period as { locked_through?: string | null }).locked_through) ?? null,
|
|
}
|
|
}
|
|
}
|
|
|
|
// Underlag already linked to this draft: surface the extracted fields
|
|
// so the agent suggests accounts from what's on the kvitto without
|
|
// re-asking. Mirrors transaction.categorization's document_attachments
|
|
// read (same table, same extracted_data shape).
|
|
const { data: docs } = await supabase
|
|
.from('document_attachments')
|
|
.select('id, file_name, extracted_data')
|
|
.eq('journal_entry_id', journal_entry_id)
|
|
.eq('company_id', companyId)
|
|
.eq('is_current_version', true)
|
|
for (const d of (docs ?? []) as {
|
|
id: string
|
|
file_name: string | null
|
|
extracted_data: Record<string, unknown> | null
|
|
}[]) {
|
|
const ex = d.extracted_data ?? null
|
|
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
|
|
underlag.push({
|
|
document_id: d.id,
|
|
file_name: d.file_name,
|
|
merchant_name: supplier?.name ?? null,
|
|
receipt_date: invoice?.invoiceDate ?? null,
|
|
total_amount: totals?.total ?? null,
|
|
vat_amount: totals?.vatAmount ?? null,
|
|
currency: invoice?.currency ?? null,
|
|
raw_extraction: ex,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
entry,
|
|
current_lines: lines,
|
|
period_status: periodStatus,
|
|
description_hint: description ?? null,
|
|
underlag,
|
|
}
|
|
},
|
|
|
|
promptTemplate: ({ captured, profileSummary }) => {
|
|
const lines: string[] = []
|
|
if (profileSummary) lines.push(`Företagets profil: ${profileSummary}`, '')
|
|
|
|
lines.push('Användaren skapar eller redigerar en verifikation.')
|
|
if (captured.entry) {
|
|
lines.push(
|
|
`Verifikation: ${captured.entry.id} (${captured.entry.entry_date ?? '?'}, status ${captured.entry.status ?? '?'})`,
|
|
)
|
|
if (captured.entry.description) lines.push(`Beskrivning: ${captured.entry.description}`)
|
|
} else if (captured.description_hint) {
|
|
lines.push(`Användarens beskrivning än så länge: "${captured.description_hint}"`)
|
|
} else {
|
|
lines.push('Ny verifikation, inga rader än.')
|
|
}
|
|
lines.push('')
|
|
lines.push(renderAgentGroundRules())
|
|
lines.push('')
|
|
|
|
if (captured.current_lines.length > 0) {
|
|
lines.push('')
|
|
lines.push('Befintliga rader:')
|
|
let debits = 0
|
|
let credits = 0
|
|
for (const r of captured.current_lines) {
|
|
const d = r.debit_amount ?? 0
|
|
const c = r.credit_amount ?? 0
|
|
debits += d
|
|
credits += c
|
|
const dStr = d > 0 ? d.toLocaleString('sv-SE') : ''
|
|
const cStr = c > 0 ? c.toLocaleString('sv-SE') : ''
|
|
lines.push(` ${r.account_number ?? '????'} ${dStr.padStart(12)} ${cStr.padStart(12)} ${r.description ?? ''}`)
|
|
}
|
|
lines.push(` SUMMA ${debits.toLocaleString('sv-SE').padStart(12)} ${credits.toLocaleString('sv-SE').padStart(12)}`)
|
|
if (Math.abs(debits - credits) > 0.005) {
|
|
lines.push(` ⚠ Diff: ${(debits - credits).toLocaleString('sv-SE')}: debet ≠ kredit`)
|
|
}
|
|
}
|
|
|
|
if (captured.underlag.length > 0) {
|
|
lines.push('')
|
|
lines.push(`UNDERLAG kopplat till verifikationen: ${captured.underlag.length} st. Extraherade fält:`)
|
|
for (const u of captured.underlag) {
|
|
const parts: string[] = []
|
|
if (u.document_id) parts.push(`document_id=${u.document_id}`)
|
|
if (u.merchant_name) parts.push(`leverantör=${u.merchant_name}`)
|
|
if (u.receipt_date) parts.push(`datum=${u.receipt_date}`)
|
|
if (u.total_amount != null) {
|
|
parts.push(`total=${u.total_amount.toLocaleString('sv-SE')} ${u.currency ?? 'SEK'}`)
|
|
}
|
|
if (u.vat_amount != null) {
|
|
parts.push(`moms=${u.vat_amount.toLocaleString('sv-SE')} ${u.currency ?? 'SEK'}`)
|
|
}
|
|
lines.push(
|
|
` • ${parts.join(', ') || `${u.file_name ?? 'underlag'} (ingen extraherad data: läs med gnubok_get_document_content)`}`,
|
|
)
|
|
}
|
|
lines.push('')
|
|
lines.push('Extraktionen ovan är det vi REDAN VET: fråga inte om leverantör/belopp som står där. Räcker den inte (t.ex. saknar momsbelopp), läs underlaget med gnubok_get_document_content(document_id=…).')
|
|
}
|
|
|
|
if (captured.period_status) {
|
|
lines.push('')
|
|
lines.push(
|
|
`Period: ${captured.period_status.period_id ?? '?'} (status ${captured.period_status.status ?? '?'}${
|
|
captured.period_status.lock_date ? `, låst t.o.m. ${captured.period_status.lock_date}` : ''
|
|
})`,
|
|
)
|
|
if (captured.period_status.status === 'locked' || captured.period_status.status === 'closed') {
|
|
lines.push('PERIODEN ÄR LÅST: ett utkast kan inte bokföras här. Vägled användaren att ändra verifikationsdatumet till en öppen period (utkast redigeras fritt), eller att låsa upp perioden under Bokföring → Räkenskapsår om datumet måste stå kvar.')
|
|
}
|
|
}
|
|
lines.push('')
|
|
lines.push('Arbetssätt:')
|
|
lines.push('1. UNDERLAG FÖRST. Saknas underlaget i sammanhanget ovan: leta i Dokumentinkorgen med gnubok_list_inbox_items (och gnubok_list_unmatched_documents). Läs det relevanta underlaget med gnubok_get_inbox_item / gnubok_get_document_content och dra fram datum, belopp, moms och motpart INNAN du föreslår konton. Användaren ser ofta inte underlagets innehåll själv: det är just det du hjälper till med.')
|
|
lines.push('2. Föreslå rätt BAS-konton utifrån underlaget och beskrivningen. Syns en motpart: kolla historiken med gnubok_query_journal({ text: "<motpartens namn>", limit: 5 }) och följ tidigare mönster.')
|
|
lines.push('3. Säkerställ att debet = kredit. Förklara varje rad kort (i kategori-/kontonamn, inte kontonummer).')
|
|
lines.push('4. Är detta egentligen en kund-/leverantörsfaktura eller en bankrad? Be användaren matcha den istället: direktbokning skapar dubbletter.')
|
|
lines.push('5. Skapa verifikationen:')
|
|
lines.push(' • NY verifikation (inget utkast visas ovan): staga via gnubok_create_voucher när allt stämmer. Ligger underlaget i Dokumentinkorgen: skicka med inbox_item_id så kvittot kopplas till verifikationen automatiskt vid godkännande.')
|
|
lines.push(' • BEFINTLIGT utkast (visas ovan): föreslå konton/moms och kontrollera balansen så att användaren kan färdigställa utkastet i formuläret. Staga INTE en ny verifikation för ett utkast som redan finns: det skapar en dubblett.')
|
|
lines.push('')
|
|
lines.push('Svara på svenska, kort och konkret.')
|
|
return lines.join('\n')
|
|
},
|
|
})
|