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>
231 lines
8.0 KiB
TypeScript
231 lines
8.0 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useRef, useState } from 'react'
|
|
import { Sparkles, AlertTriangle } from 'lucide-react'
|
|
import { cn } from '@/lib/utils'
|
|
import type { VatTreatment } from '@/types'
|
|
|
|
/**
|
|
* The auto-booking proposal, inline in the quick-review dialog.
|
|
*
|
|
* Fetches POST /api/agent/categorize (Tier 1 deterministic candidates → Tier 2
|
|
* model selector, provider-agnostic) and shows the model's pick with a
|
|
* confidence pill, a short "Varför", and the candidate alternatives. It
|
|
* pre-fills the dialog's account + VAT when it lands on a real account, and
|
|
* clicking an alternative re-applies. Nothing books here — the dialog's own
|
|
* "Bokför" commits through the existing categorize route.
|
|
*
|
|
* i18n: strings are inline Swedish for now (this is the Swedish-answering
|
|
* assistant surface); lift to messages/{sv,en}.json before final merge.
|
|
*/
|
|
|
|
interface CandidateDto {
|
|
account: string
|
|
label: string
|
|
vatTreatment: VatTreatment | null
|
|
source: string
|
|
confidence: number
|
|
}
|
|
|
|
interface ProposalDto {
|
|
account: string | null
|
|
category: string | null
|
|
vatTreatment: VatTreatment | null
|
|
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' }
|
|
| { status: 'unconfigured' }
|
|
| { status: 'ready'; proposal: ProposalDto }
|
|
|
|
interface Props {
|
|
transactionId: string
|
|
/** Fetch when the dialog is open. */
|
|
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'
|
|
function bandOf(p: ProposalDto): Band {
|
|
if (p.choice.kind === 'needs_review' || !p.account) return 'review'
|
|
if (p.confidence >= 0.8) return 'sure'
|
|
if (p.confidence >= 0.5) return 'likely'
|
|
return 'review'
|
|
}
|
|
|
|
const BAND_LABEL: Record<Band, string> = { sure: 'Säker', likely: 'Trolig', review: 'Välj konto' }
|
|
|
|
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.
|
|
const appliedRef = useRef<string | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
let alive = true
|
|
appliedRef.current = null
|
|
// Initial state is already 'loading'; the dialog mounts this fresh per
|
|
// transaction (keyed on tx.id), so no in-effect reset is needed.
|
|
;(async () => {
|
|
try {
|
|
const res = await fetch('/api/agent/categorize', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ transaction_id: transactionId }),
|
|
})
|
|
if (!alive) return
|
|
if (res.status === 503) return setState({ status: 'unconfigured' })
|
|
if (!res.ok) return setState({ status: 'error' })
|
|
const body = (await res.json()) as { data?: ProposalDto }
|
|
if (!alive) return
|
|
if (!body.data) return setState({ status: 'error' })
|
|
setState({ status: 'ready', proposal: body.data })
|
|
} catch {
|
|
if (alive) setState({ status: 'error' })
|
|
}
|
|
})()
|
|
return () => {
|
|
alive = false
|
|
}
|
|
}, [open, transactionId])
|
|
|
|
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) 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, onProposal])
|
|
|
|
if (state.status === 'loading') {
|
|
return (
|
|
<div className="flex items-center gap-2 rounded-lg border border-border bg-secondary/30 px-3 py-2.5 text-sm text-muted-foreground">
|
|
<Sparkles className="h-3.5 w-3.5 animate-pulse" />
|
|
Assistenten föreslår kontering…
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (state.status === 'error') return null // fall back silently to the deterministic default
|
|
|
|
if (state.status === 'unconfigured') {
|
|
return (
|
|
<div className="flex items-start gap-2 rounded-lg border border-border bg-secondary/30 px-3 py-2.5 text-xs text-muted-foreground">
|
|
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-warning" />
|
|
Assistenten är inte konfigurerad. Välj konto nedan som vanligt.
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const p = state.proposal
|
|
const band = bandOf(p)
|
|
const alternatives = p.candidates.filter((c) => c.account !== p.account).slice(0, 3)
|
|
|
|
return (
|
|
<div className="rounded-lg border border-border bg-card">
|
|
<div className="flex items-start justify-between gap-3 px-3 pt-3">
|
|
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
|
<Sparkles className="h-3.5 w-3.5" />
|
|
Assistentens förslag
|
|
</div>
|
|
<span
|
|
className={cn(
|
|
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[11px] font-medium',
|
|
band === 'sure' && 'bg-success/15 text-success',
|
|
band === 'likely' && 'bg-warning/15 text-warning',
|
|
band === 'review' && 'bg-secondary text-muted-foreground',
|
|
)}
|
|
>
|
|
<span
|
|
className={cn(
|
|
'h-1.5 w-1.5 rounded-full',
|
|
band === 'sure' && 'bg-success',
|
|
band === 'likely' && 'bg-warning',
|
|
band === 'review' && 'bg-muted-foreground',
|
|
)}
|
|
/>
|
|
{BAND_LABEL[band]}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="px-3 pb-3 pt-2">
|
|
{band === 'review' ? (
|
|
<p className="text-sm text-foreground">
|
|
{p.reasoning || 'För lite underlag för att avgöra konto. Välj konto nedan.'}
|
|
</p>
|
|
) : (
|
|
<>
|
|
{p.reasoning && (
|
|
<p className="text-[13px] leading-snug text-muted-foreground">{p.reasoning}</p>
|
|
)}
|
|
{alternatives.length > 0 && (
|
|
<div className="mt-2.5">
|
|
<p className="mb-1.5 text-[11px] text-muted-foreground">Byt konto:</p>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{alternatives.map((c) => (
|
|
<button
|
|
key={c.account}
|
|
type="button"
|
|
onClick={() => {
|
|
appliedRef.current = c.account
|
|
onApply(c.account, c.vatTreatment ?? 'none')
|
|
}}
|
|
className="inline-flex items-center gap-1.5 rounded-full border border-border bg-card px-2.5 py-1 text-xs transition-colors hover:bg-secondary"
|
|
>
|
|
<span className="font-mono">{c.account}</span>
|
|
<span className="text-muted-foreground">{c.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|