Files
accounted/lib/agent/categorize/__tests__/select-account.test.ts
T
b17878e58f feat(categorize): provider-agnostic account selector (auto-booking cascade, Tier 2) (#1779)
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>
2026-08-21 13:19:46 +02:00

175 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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')
})
})
})