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:
co-authored by
Jakob Wennberg
Claude Opus 4.8
parent
524d9978f1
commit
704bf93e08
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user