feat(transactions): AI booking proposal in the quick-review dialog (cascade step 3) (#1782)

Wires the auto-booking cascade to the UI. When the quick-review dialog opens
for a transaction, AiCategorizeProposal fetches POST /api/agent/categorize
(Tier 1 deterministic candidates → Tier 2 model selector, provider-agnostic)
and:

- pre-fills the dialog's account + VAT from the model's pick (through
  handleAccountChange, so class-2 VAT clearing is preserved),
- shows a confidence band (säker / trolig / välj konto), a short "Varför", and
  the candidate alternatives (click one to re-apply),
- falls back SILENTLY to the deterministic defaults on error, and shows a soft
  note when no AI backend is configured — the dialog always works without AI.

Surfaced inside the existing dialog (not a new card) so it reuses the proven,
balanced commit path. No silent auto-posting: "säker" is pre-filled and booked
with the dialog's existing one-tap Bokför; hands-off auto-book waits for
calibration (avoids any storno-on-undo).

lint + guards clean; 94 transaction/categorize tests green (no regressions).
Strings inline Swedish for now (lift to messages/{sv,en} pre-merge). UI PR:
awaiting founder visual sign-off before merge.

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:04:03 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Opus 4.8
parent e7a5e65ecf
commit 0531576807
3 changed files with 216 additions and 0 deletions
+1
View File
@@ -1144,3 +1144,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-21] SCHABLONINTAKT_RATE_BY_CLOSING_YEAR backfilled 2020-2024 (SLR 30 Nov per Riksgalden: -0.09/-0.10/0.23 floored to 0.5 %, 1.94 %, 2.62 %) and the rate now resolves lazily (resolveSchablonintaktRate: 0 when no 212X account carried an opening balance): the table only covered 2025/2026 and the builder consulted it unconditionally, so every AB closing a pre-2025 year got a generic 500 at bokslut step 3 (126 open FY2024 periods on prod, incl. a byra trial). 2019 and earlier stay unmapped on purpose: the 100 %-of-SLR rule keys on beskattningsar STARTING 2019-01-01+ (prop. 2017/18:245), so a 2019 closing can be a brutet ar under the old 72 % factor. Unmapped-year-with-fonder now raises SCHABLONINTAKT_RATE_NOT_CONFIGURED (typed, 500 so runtime-error clustering still flags the missed December update) instead of INTERNAL_ERROR.
[2026-08-21] RIP-4 "optimal" auto-booking cascade, Tier 2 = the provider-agnostic account SELECTOR (lib/agent/categorize/select-account.ts), built per the 2026 research (artifact dc0c2760): the model does NOT free-form a categorizer; it CHOOSES from a closed set — the deterministic candidate accounts (Tier 1) + the 19 standard business categories (each maps deterministically to a BAS account via getDefaultAccountForCategory) + "needs_review". So the model can't invent an account, account/VAT stays deterministic and validated, and it runs on any provider (Bedrock or a local model) via getAiService().generateStructured. Founder chose the optimal path (model selects on EVERY transaction, LLM calls are fine), so confidence uses SELF-CONSISTENCY (default 3 samples, 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 (research: systematically overconfident). reasoning field precedes choice in the schema (reason-before-choice). needs_review is never auto-applied. Calibration of the combined score → the auto-book/suggest/review gate is a later tier. Not yet wired: Tier 1 candidate gathering (counterparty templates + getSuggestedCategories) + a route + the ApprovalCard UI (next PRs).
[2026-08-21] RIP-4 cascade Tier 1 (candidate gathering) + the proposal route. lib/agent/categorize/candidates.ts assembles the deterministic candidate slate for a transaction exactly like the gnubok_suggest_categories MCP tool (mapping_rules + per-merchant history via buildMerchantHistory/getSuggestedCategories + the learned counterparty template), NO model call, deduped by account (highest confidence wins) and capped. Suggestions carry no VAT so it derives the category default (getDefaultVatTreatmentForCategory); the counterparty template carries its own. POST /api/agent/categorize runs Tier 1 → Tier 2 selectAccount and returns the proposal + the candidate slate; it NEVER posts (the caller renders an approval card). Gated on getAiStatus().configured (any provider incl. local), same gates as /api/agent/ask (auth via requireAuth, rate, membership, sandbox, capability). Next: wire the transaction row to this route + the ApprovalCard (UI, visual sign-off), then calibration + the auto-book gate.
[2026-08-21] RIP-4 cascade step 3 (UI): the AI booking proposal is surfaced INSIDE the existing QuickReviewDialog rather than a new inline-row card, so it reuses that dialog's proven, deterministic, balanced commit path (POST /api/transactions/[id]/categorize) instead of a parallel one. components/transactions/AiCategorizeProposal.tsx fetches POST /api/agent/categorize on dialog open (keyed on tx.id so it remounts per transaction), pre-fills accountOverride + vatTreatment via handleAccountChange (class-2 VAT clearing preserved), and shows the confidence band (säker/trolig/välj konto) + "Varför" + the candidate alternatives (click to re-apply). Falls back SILENTLY to the deterministic defaults on error, and shows a soft note on 503 (ai_unconfigured) — the dialog always works without AI. NO silent auto-posting (founder call, avoids the storno-on-undo mess): "säker" = pre-filled, one-tap Bokför via the dialog's existing button; true hands-off auto-book waits for calibration. i18n: strings inline Swedish for now (assistant surface), lift to messages/{sv,en}.json before final merge. Confidence bands (0.8/0.5) are placeholders until calibration. Needs founder visual sign-off before merge ([[project_nav_ia_redesign]]).
@@ -0,0 +1,198 @@
'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
reasoning: string
choice: { kind: 'candidate' | 'category' | 'needs_review' }
candidates: CandidateDto[]
}
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
}
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 }: 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])
// Pre-fill the dialog once, when a confident proposal with a real account arrives.
useEffect(() => {
if (state.status !== 'ready') return
const p = state.proposal
if (!p.account || appliedRef.current === p.account) return
if (bandOf(p) === 'review') return
appliedRef.current = p.account
onApply(p.account, p.vatTreatment ?? 'none')
}, [state, onApply])
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>
)
}
@@ -27,6 +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 { VAT_TREATMENT_OPTIONS } from './transaction-types'
import type { TransactionWithInvoice } from './transaction-types'
import type { TransactionCategory, VatTreatment, BASAccount, EntityType, LinePatternEntry } from '@/types'
@@ -439,6 +440,22 @@ export default function QuickReviewDialog({
</div>
)}
{/* AI booking proposal: pre-fills account + VAT and explains why.
Falls back silently to the deterministic defaults on error. */}
{tx.id && (
<AiCategorizeProposal
key={tx.id}
transactionId={tx.id}
open={open}
onApply={(account, vat) => {
handleAccountChange(account)
// handleAccountChange clears VAT for class-2 accounts; for the
// rest, apply the proposed treatment.
if (!account.startsWith('2')) setVatTreatment(vat)
}}
/>
)}
{/* Template or Category */}
<div>
<label className="text-sm font-medium text-muted-foreground">