From 0531576807eb9668d274e22c80e80e1a21cab7e5 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Fri, 21 Aug 2026 15:04:03 +0200 Subject: [PATCH] feat(transactions): AI booking proposal in the quick-review dialog (cascade step 3) (#1782) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- DECISIONS.md | 1 + .../transactions/AiCategorizeProposal.tsx | 198 ++++++++++++++++++ components/transactions/QuickReviewDialog.tsx | 17 ++ 3 files changed, 216 insertions(+) create mode 100644 components/transactions/AiCategorizeProposal.tsx diff --git a/DECISIONS.md b/DECISIONS.md index e5d3496e..94cdb9c1 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1144,3 +1144,4 @@ One line per decision: `[YYYY-MM-DD] : `. 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]]). diff --git a/components/transactions/AiCategorizeProposal.tsx b/components/transactions/AiCategorizeProposal.tsx new file mode 100644 index 00000000..9df12239 --- /dev/null +++ b/components/transactions/AiCategorizeProposal.tsx @@ -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 = { sure: 'Säker', likely: 'Trolig', review: 'Välj konto' } + +export default function AiCategorizeProposal({ transactionId, open, onApply }: Props) { + const [state, setState] = useState({ 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(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 ( +
+ + Assistenten föreslår kontering… +
+ ) + } + + if (state.status === 'error') return null // fall back silently to the deterministic default + + if (state.status === 'unconfigured') { + return ( +
+ + Assistenten är inte konfigurerad. Välj konto nedan som vanligt. +
+ ) + } + + const p = state.proposal + const band = bandOf(p) + const alternatives = p.candidates.filter((c) => c.account !== p.account).slice(0, 3) + + return ( +
+
+
+ + Assistentens förslag +
+ + + {BAND_LABEL[band]} + +
+ +
+ {band === 'review' ? ( +

+ {p.reasoning || 'För lite underlag för att avgöra konto. Välj konto nedan.'} +

+ ) : ( + <> + {p.reasoning && ( +

{p.reasoning}

+ )} + {alternatives.length > 0 && ( +
+

Byt konto:

+
+ {alternatives.map((c) => ( + + ))} +
+
+ )} + + )} +
+
+ ) +} diff --git a/components/transactions/QuickReviewDialog.tsx b/components/transactions/QuickReviewDialog.tsx index 5f933cbb..9f248e48 100644 --- a/components/transactions/QuickReviewDialog.tsx +++ b/components/transactions/QuickReviewDialog.tsx @@ -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({ )} + {/* AI booking proposal: pre-fills account + VAT and explains why. + Falls back silently to the deterministic defaults on error. */} + {tx.id && ( + { + 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 */}