The core of the "optimal" RIP-4 categorizer, built to the researched 2026
architecture (retrieve → SELECT → escalate). Given a transaction, its underlag,
and the deterministic candidate accounts the engine already retrieved, the
model reasons and then CHOOSES from a closed set:
- a retrieved candidate account (the known path), or
- a standard business category → deterministic BAS account (the novel path,
a first-time vendor with no candidate), or
- needs_review (routed to a human, never auto-applied).
Because it picks from a closed enum, the model can't invent an account; the
account + VAT resolution stays deterministic and validated (the model chooses,
code resolves the numbers). It runs on any backend via getAiService()
.generateStructured — Bedrock or a local model.
Founder chose the optimal path (the model selects on every transaction, LLM
calls are fine), so confidence uses self-consistency: N samples (default 3),
majority vote, agreement fraction, combined with the model's stated confidence
and floored by the winning candidate's deterministic confidence — never the
model's verbalized confidence alone (systematically overconfident). reasoning
precedes choice in the schema (reason-before-choice); an unknown/hallucinated
choice degrades to needs_review.
13 unit tests (candidate/category/needs_review resolution, reverse-charge gating,
self-consistency majority + agreement + candidate floor, prompt/schema shape).
Not yet wired: Tier 1 candidate gathering + a route + the ApprovalCard UI.
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
175 lines
7.0 KiB
TypeScript
175 lines
7.0 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||
import {
|
||
getDefaultAccountForCategory,
|
||
getDefaultVatTreatmentForCategory,
|
||
} from '@/lib/bookkeeping/category-mapping'
|
||
import type { AccountCandidate, SelectAccountInput } from '../select-account'
|
||
|
||
const generateStructured = vi.fn()
|
||
vi.mock('@/lib/ai', () => ({ getAiService: () => ({ generateStructured }) }))
|
||
|
||
import { selectAccount } from '../select-account'
|
||
|
||
function pick(
|
||
choice: string,
|
||
opts: { confidence?: 'high' | 'medium' | 'low'; reverse_charge?: boolean; reasoning?: string } = {},
|
||
) {
|
||
return {
|
||
value: {
|
||
reasoning: opts.reasoning ?? 'resonemang',
|
||
choice,
|
||
confidence: opts.confidence ?? 'high',
|
||
reverse_charge: opts.reverse_charge ?? false,
|
||
},
|
||
model: 'qwen3.8',
|
||
usage: {},
|
||
}
|
||
}
|
||
|
||
const CAND: AccountCandidate = {
|
||
account: '5410',
|
||
label: 'Förbrukningsinventarier',
|
||
vatTreatment: 'standard_25',
|
||
source: 'counterparty_template',
|
||
confidence: 0.9,
|
||
}
|
||
|
||
function input(over: Partial<SelectAccountInput> = {}): SelectAccountInput {
|
||
return {
|
||
transaction: { merchantName: 'Biltema', description: 'Kortköp Biltema', amount: -499, currency: 'SEK' },
|
||
candidates: [CAND],
|
||
entityType: 'aktiebolag',
|
||
vatRegistered: true,
|
||
samples: 1,
|
||
...over,
|
||
}
|
||
}
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks()
|
||
generateStructured.mockResolvedValue(pick('cand:0'))
|
||
})
|
||
|
||
describe('selectAccount', () => {
|
||
it('resolves a chosen candidate to its account + VAT, flagged fromCandidate', async () => {
|
||
const res = await selectAccount(input())
|
||
expect(res.account).toBe('5410')
|
||
expect(res.fromCandidate).toBe(true)
|
||
expect(res.vatTreatment).toBe('standard_25')
|
||
expect(res.choice).toEqual({ kind: 'candidate', account: '5410' })
|
||
expect(res.confidence).toBeGreaterThan(0)
|
||
expect(res.model).toBe('qwen3.8')
|
||
})
|
||
|
||
it('resolves a chosen category to the deterministic default account + VAT (novel path)', async () => {
|
||
generateStructured.mockResolvedValue(pick('cat:expense_software'))
|
||
const res = await selectAccount(input({ candidates: [] }))
|
||
expect(res.category).toBe('expense_software')
|
||
expect(res.account).toBe(getDefaultAccountForCategory('expense_software', 'aktiebolag'))
|
||
expect(res.vatTreatment).toBe(getDefaultVatTreatmentForCategory('expense_software'))
|
||
expect(res.fromCandidate).toBe(false)
|
||
})
|
||
|
||
it('needs_review yields no account and zero confidence', async () => {
|
||
generateStructured.mockResolvedValue(pick('needs_review'))
|
||
const res = await selectAccount(input())
|
||
expect(res.account).toBeNull()
|
||
expect(res.category).toBeNull()
|
||
expect(res.confidence).toBe(0)
|
||
expect(res.choice).toEqual({ kind: 'needs_review' })
|
||
})
|
||
|
||
it('degrades an unknown/hallucinated choice to needs_review', async () => {
|
||
generateStructured.mockResolvedValue(pick('cand:99'))
|
||
const res = await selectAccount(input())
|
||
expect(res.choice).toEqual({ kind: 'needs_review' })
|
||
expect(res.account).toBeNull()
|
||
})
|
||
|
||
it('applies reverse charge only for a VAT-registered company', async () => {
|
||
generateStructured.mockResolvedValue(pick('cat:expense_professional_services', { reverse_charge: true }))
|
||
const yes = await selectAccount(input({ candidates: [], vatRegistered: true }))
|
||
expect(yes.reverseCharge).toBe(true)
|
||
expect(yes.vatTreatment).toBe('reverse_charge')
|
||
|
||
generateStructured.mockResolvedValue(pick('cat:expense_professional_services', { reverse_charge: true }))
|
||
const no = await selectAccount(input({ candidates: [], vatRegistered: false }))
|
||
expect(no.vatTreatment).not.toBe('reverse_charge')
|
||
})
|
||
|
||
it('never applies reverse charge to a needs_review outcome', async () => {
|
||
generateStructured.mockResolvedValue(pick('needs_review', { reverse_charge: true }))
|
||
const res = await selectAccount(input())
|
||
expect(res.reverseCharge).toBe(false)
|
||
})
|
||
|
||
describe('self-consistency', () => {
|
||
it('defaults to 3 samples and majority-votes the winner', async () => {
|
||
generateStructured
|
||
.mockResolvedValueOnce(pick('cand:0'))
|
||
.mockResolvedValueOnce(pick('cand:0'))
|
||
.mockResolvedValueOnce(pick('cat:expense_other'))
|
||
const res = await selectAccount(input({ samples: undefined }))
|
||
expect(generateStructured).toHaveBeenCalledTimes(3)
|
||
expect(res.choice).toEqual({ kind: 'candidate', account: '5410' })
|
||
expect(res.agreement).toBe(0.67)
|
||
})
|
||
|
||
it('single sample has full agreement', async () => {
|
||
const res = await selectAccount(input({ samples: 1 }))
|
||
expect(res.agreement).toBe(1)
|
||
expect(generateStructured).toHaveBeenCalledTimes(1)
|
||
})
|
||
|
||
it('lower agreement lowers the combined confidence', async () => {
|
||
generateStructured
|
||
.mockResolvedValueOnce(pick('cat:expense_office', { confidence: 'high' }))
|
||
.mockResolvedValueOnce(pick('cat:expense_office', { confidence: 'high' }))
|
||
.mockResolvedValueOnce(pick('cat:expense_travel', { confidence: 'high' }))
|
||
const split = await selectAccount(input({ candidates: [], samples: 3 }))
|
||
// 2/3 agreement × 0.95 model weight
|
||
expect(split.confidence).toBeCloseTo(0.63, 1)
|
||
expect(split.agreement).toBe(0.67)
|
||
})
|
||
|
||
it('a high-confidence candidate floors the score at its deterministic confidence × agreement', async () => {
|
||
// model says "low", but the chosen candidate is a 0.9 counterparty template
|
||
generateStructured.mockResolvedValue(pick('cand:0', { confidence: 'low' }))
|
||
const res = await selectAccount(input({ samples: 1 }))
|
||
// low weight 0.5 vs candidate 0.9 × agreement 1 → floored to 0.9
|
||
expect(res.confidence).toBe(0.9)
|
||
})
|
||
})
|
||
|
||
describe('prompt + schema', () => {
|
||
it('builds a closed enum of candidate ids + category ids + needs_review, reasoning first', async () => {
|
||
await selectAccount(input())
|
||
const call = generateStructured.mock.calls[0][0]
|
||
const props = call.schema.jsonSchema.properties
|
||
expect(Object.keys(props)[0]).toBe('reasoning') // reason-before-choice
|
||
expect(props.choice.enum).toContain('cand:0')
|
||
expect(props.choice.enum).toContain('cat:expense_software')
|
||
expect(props.choice.enum).toContain('needs_review')
|
||
expect(call.system).toContain('BAS')
|
||
})
|
||
|
||
it('puts the candidate and the underlag into the prompt', async () => {
|
||
await selectAccount(input({ underlag: 'Leverantör: Biltema AB\nSumma: 499 kr' }))
|
||
const prompt = generateStructured.mock.calls[0][0].prompt as string
|
||
expect(prompt).toContain('KANDIDATKONTON')
|
||
expect(prompt).toContain('konto 5410')
|
||
expect(prompt).toContain('Biltema')
|
||
expect(prompt).toContain('Underlag')
|
||
expect(prompt).toContain('Summa: 499 kr')
|
||
})
|
||
|
||
it('omits the candidate block when there are none', async () => {
|
||
generateStructured.mockResolvedValue(pick('cat:expense_other'))
|
||
await selectAccount(input({ candidates: [] }))
|
||
const prompt = generateStructured.mock.calls[0][0].prompt as string
|
||
expect(prompt).not.toContain('KANDIDATKONTON')
|
||
expect(prompt).toContain('KATEGORIER')
|
||
})
|
||
})
|
||
})
|