feat(categorize): confidence calibration engine + measurement loop (cascade step 4) (#1784)

Turns the selector's raw confidence into a score that means what it says.

- lib/agent/categorize/calibration.ts: the engine. Isotonic regression
  (pool-adjacent-violators, distribution-free + monotonic) over
  (confidence, was_correct) samples → a calibrator; plus reliabilityByBucket,
  ECE, and bandFor(). bandFor NEVER returns 'auto' without a fitted calibrator
  (no silent booking on an unproven score) and never auto-books above an amount
  cap. 12 engine tests (overconfidence pulled down, underconfidence lifted,
  monotonicity, ECE, band gating).
- Measurement loop: migration categorize_calibration_samples (append-only,
  company-scoped RLS, confidence CHECK [0,1]) + POST /api/agent/categorize/
  outcome logging one sample (proposed vs actually booked) fire-and-forget from
  QuickReviewDialog on a successful book (sandbox skipped). AiCategorizeProposal
  surfaces the proposal metadata via onProposal.
- scripts/fit-categorize-calibration.ts (read-only): prints the reliability
  diagram + ECE + fitted calibrator once data has accumulated.

Fitting needs a few hundred real outcomes, so nothing calibrates today — the
loop starts collecting, and "säker" stays uncalibrated (no auto-book) until the
data proves it. 131 unit tests green; RLS covered by a pg-real test.

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 15:55:26 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Opus 4.8
parent 524d9978f1
commit 704bf93e08
11 changed files with 751 additions and 6 deletions
@@ -34,11 +34,22 @@ interface ProposalDto {
reverseCharge: boolean
confidence: number
agreement: number
modelConfidence: 'high' | 'medium' | 'low'
fromCandidate: boolean
reasoning: string
choice: { kind: 'candidate' | 'category' | 'needs_review' }
candidates: CandidateDto[]
}
/** What the dialog needs to log a calibration sample when the user books. */
export interface AiProposalMeta {
account: string
confidence: number
agreement: number
modelConfidence: 'high' | 'medium' | 'low'
source: string
}
type State =
| { status: 'loading' }
| { status: 'error' }
@@ -51,6 +62,8 @@ interface Props {
open: boolean
/** Apply an account + VAT to the dialog fields. */
onApply: (account: string, vat: VatTreatment | 'none') => void
/** Surface the proposal metadata so the dialog can log a calibration sample on book. */
onProposal?: (meta: AiProposalMeta) => void
}
type Band = 'sure' | 'likely' | 'review'
@@ -63,7 +76,7 @@ function bandOf(p: ProposalDto): Band {
const BAND_LABEL: Record<Band, string> = { sure: 'Säker', likely: 'Trolig', review: 'Välj konto' }
export default function AiCategorizeProposal({ transactionId, open, onApply }: Props) {
export default function AiCategorizeProposal({ transactionId, open, onApply, onProposal }: Props) {
const [state, setState] = useState<State>({ status: 'loading' })
// Apply the pick to the dialog exactly once per fetch, so the user's later
// manual edits are never clobbered by a re-render.
@@ -98,15 +111,34 @@ export default function AiCategorizeProposal({ transactionId, open, onApply }: P
}
}, [open, transactionId])
// Pre-fill the dialog once, when a confident proposal with a real account arrives.
const reportedRef = useRef(false)
// When a proposal with a real account arrives: surface its metadata to the
// dialog (for calibration logging on book) and pre-fill the fields.
useEffect(() => {
if (state.status !== 'ready') return
const p = state.proposal
if (!p.account || appliedRef.current === p.account) return
if (bandOf(p) === 'review') return
if (!p.account) return
if (!reportedRef.current) {
reportedRef.current = true
const source = p.fromCandidate
? (p.candidates.find((c) => c.account === p.account)?.source ?? 'candidate')
: 'category'
onProposal?.({
account: p.account,
confidence: p.confidence,
agreement: p.agreement,
modelConfidence: p.modelConfidence,
source,
})
}
// Pre-fill only when it's not the low "review" band, and only once.
if (appliedRef.current === p.account || bandOf(p) === 'review') return
appliedRef.current = p.account
onApply(p.account, p.vatTreatment ?? 'none')
}, [state, onApply])
}, [state, onApply, onProposal])
if (state.status === 'loading') {
return (
+23 -1
View File
@@ -27,7 +27,7 @@ import InboxDocumentPicker from '@/components/bookkeeping/InboxDocumentPicker'
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
import type { AvailableInboxDoc } from '@/components/bookkeeping/InboxDocumentPicker'
import VatTreatmentSelect from './VatTreatmentSelect'
import AiCategorizeProposal from './AiCategorizeProposal'
import AiCategorizeProposal, { type AiProposalMeta } from './AiCategorizeProposal'
import { VAT_TREATMENT_OPTIONS } from './transaction-types'
import type { TransactionWithInvoice } from './transaction-types'
import type { TransactionCategory, VatTreatment, BASAccount, EntityType, LinePatternEntry } from '@/types'
@@ -91,6 +91,9 @@ export default function QuickReviewDialog({
const [accountOverride, setAccountOverride] = useState(defaultAccount ?? '')
const [vatTreatment, setVatTreatment] = useState<VatTreatment | 'none'>(defaultVat)
const [accounts, setAccounts] = useState<BASAccount[]>([])
// The AI proposal shown this session, kept so we can log a calibration sample
// (proposed vs actually booked) once the user confirms.
const [aiProposal, setAiProposal] = useState<AiProposalMeta | null>(null)
const [isProcessing, setIsProcessing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
@@ -291,6 +294,24 @@ export default function QuickReviewDialog({
Object.keys(cleanedDims).length > 0 ? cleanedDims : undefined,
)
// Calibration telemetry: what the model proposed vs what was actually
// booked. Best-effort and fire-and-forget — never blocks the booking.
if (journalEntryId && aiProposal) {
void fetch('/api/agent/categorize/outcome', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
confidence: aiProposal.confidence,
agreement: aiProposal.agreement,
model_confidence: aiProposal.modelConfidence,
source: aiProposal.source,
proposed_account: aiProposal.account,
booked_account: override ?? catDefault,
amount: Math.abs(sekAmount),
}),
}).catch(() => {})
}
// Attach the uploaded underlag to the verifikat the booking just created.
// BFL 5 kap 7 § requires the verifikation to reference its underlag and
// BFL 7 kap requires that underlag to be archived with it; the verifikat
@@ -447,6 +468,7 @@ export default function QuickReviewDialog({
key={tx.id}
transactionId={tx.id}
open={open}
onProposal={setAiProposal}
onApply={(account, vat) => {
handleAccountChange(account)
// handleAccountChange clears VAT for class-2 accounts; for the