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>
This commit is contained in:
Jakob Wennberg
2026-08-21 13:19:46 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Opus 4.8
parent 148ec0ce85
commit b17878e58f
3 changed files with 532 additions and 0 deletions
@@ -0,0 +1,174 @@
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')
})
})
})