Files
finance-duck/web/src/Classification.tsx
T
Lars Nolden 10314fb1cd Batch Analyse requests and survive opaque provider schema budgets
Analyse now classifies up to ten same-kind transactions per provider
request: the registry and history travel once per batch, so a
thousand-row backfill costs about a hundred paced requests instead of a
thousand. The answer schema appears once — an array item carrying an
enum-bound ref — because providers meter strict schemas by token cost:
duplicating registry enums per row, or bounding arrays with
minItems/maxItems that Gemini expands per element, rejects real
registries with a bare HTTP 400. Row count, duplicate refs, duplicate
tags and taxonomy bounds are all enforced server-side instead, and a
request still rejected outright halves until accepted, remembering the
working size for the run. Batch requests scale the HTTP budget by row
count, chunk failures cannot abort a run whose later rows succeeded,
and rows resolved against one snapshot share one minted merchant.

Measured on a real 165-row month over a zero-data-retention route:
165 analysed, 152 proposals, 0 errors, 17 requests, under 8 minutes.

Fresh installs default to google/gemini-3.8-flash, the model that
demonstrably honors strict structured outputs over a ZDR route. Preview
changes now carry counterparty, amount and currency, and the review
list shows the amount with a counterparty fallback for banks that leave
descriptions empty.
2026-09-13 13:37:06 +02:00

609 lines
20 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { Sparkles, ShieldCheck, Check, X, ArrowRight } from "lucide-react";
import type {
Dataset,
Enrichment,
Preview,
PreviewProgress,
State,
} from "./api";
import { categoryPath, money, request } from "./api";
import {
DateField,
Empty,
ErrorMessage,
Field,
Modal,
ModelOptions,
} from "./ui";
export function Classification({
state,
acceptState,
}: {
state: State;
acceptState: (state: State, message?: string) => void;
}) {
const dates = state.data.transactions.map((t) => t.facts.booking_date).sort();
const [from, setFrom] = useState(dates[0] || "");
const [to, setTo] = useState(dates[dates.length - 1] || "");
const [model, setModel] = useState(state.settings.model);
const [fields, setFields] = useState({
merchant: true,
category: true,
tags: true,
});
const [preview, setPreview] = useState<Preview | null>(null);
const [selected, setSelected] = useState<string[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [confirm, setConfirm] = useState(false);
const [running, setRunning] = useState<PreviewProgress | null>(null);
const runStart = useRef({ time: 0, analysed: 0 });
const finalize = (result: Preview) => {
result.changes ??= [];
result.errors ??= [];
result.new_merchants ??= [];
for (const change of result.changes) {
change.before.tag_ids ??= [];
change.after.tag_ids ??= [];
}
const confidenceRank: Record<string, number> = {
low: 0,
medium: 1,
high: 2,
};
result.changes.sort(
(a, b) =>
(confidenceRank[a.after.classification.confidence || "low"] ?? 0) -
(confidenceRank[b.after.classification.confidence || "low"] ?? 0),
);
setPreview(result);
setSelected(
result.changes
.filter((change) => change.after.classification.confidence !== "low")
.map((change) => change.id),
);
};
// A run keeps going on the server while this page is closed; re-attach to
// it on mount instead of presenting a fresh, contradictory setup form.
useEffect(() => {
let stale = false;
(async () => {
try {
const p = await request<PreviewProgress>("/api/reclassify/progress", {
id: "",
});
if (stale) return;
if (!p.done) {
runStart.current = { time: Date.now(), analysed: p.analysed };
setRunning(p);
} else if (
!p.error &&
p.preview &&
p.preview.revision === state.revision
) {
finalize(p.preview);
}
} catch {
// No run to re-attach to.
}
})();
return () => {
stale = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!running || running.done) return;
const timer = setTimeout(async () => {
try {
const p = await request<PreviewProgress>("/api/reclassify/progress", {
id: running.id,
});
p.errors ??= [];
if (!p.done) {
setRunning(p);
return;
}
setRunning(null);
if (p.error) setError(p.error);
else if (p.preview) finalize(p.preview);
} catch (err) {
setRunning(null);
setError(err instanceof Error ? err.message : String(err));
}
}, 1200);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [running]);
const cancel = async (id: string) => {
setBusy(true);
setError("");
try {
const response = await request<{ ok: boolean }>(
"/api/reclassify/cancel",
{ id },
);
if (!response.ok)
throw new Error("The server did not confirm cancellation.");
setRunning(null);
setPreview(null);
setSelected([]);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
const previewData = preview
? {
...state.data,
merchants: [...state.data.merchants, ...preview.new_merchants],
}
: state.data;
return (
<>
<div className="section-heading">
<div>
<h2>AI classification</h2>
<p>A second look at your transactions. You stay in control.</p>
</div>
<span
className={`badge ${state.status.ai_configured ? "" : "neutral"}`}
>
<Sparkles size={14} />
{state.status.ai_configured ? "AI configured" : "AI not configured"}
</span>
</div>
<ErrorMessage error={error} />
<div className="privacy-banner">
<ShieldCheck size={24} />
<div>
<strong>Review first. Apply only what you choose.</strong>
<p>
Only identifier-shaped values are redacted before sending. Merchant
and counterparty text, amount, date and currency are sent so the
provider can classify the row. Your own account IBAN, account IDs,
transaction IDs, references and configured private names are never
sent. AI requests may incur provider charges.
</p>
</div>
</div>
{running ? (
<section className="panel">
<div className="panel-heading">
<div>
<h3>Classifying transactions</h3>
<p>
{running.analysed} of {running.total} analysed ·{" "}
{running.changes} proposed changes · {running.unchanged}{" "}
unchanged · {running.errors.length} errors
</p>
</div>
</div>
<div className="form-body">
<div
className="progress-track"
role="progressbar"
aria-valuemin={0}
aria-valuemax={running.total}
aria-valuenow={running.analysed}
>
<div
className="progress-fill"
style={{
width: running.total
? `${Math.round((running.analysed / running.total) * 100)}%`
: "100%",
}}
/>
</div>
<p role="status" className="muted">
Provider requests are spaced several seconds apart to respect rate
limits
{remainingEstimate(running, runStart.current)}. You can leave this
page; the preview keeps building and will be here when you return.
</p>
{running.errors.length > 0 && (
<div className="alert error">
<div>
<strong>
{running.errors.length} transaction
{running.errors.length === 1 ? "" : "s"} failed so far
</strong>
<p>{running.errors[running.errors.length - 1].error}</p>
</div>
</div>
)}
<div className="form-actions">
<button
className="button secondary"
disabled={busy}
onClick={() => cancel(running.id)}
>
<X size={16} />
Stop
</button>
</div>
</div>
</section>
) : !preview ? (
<section className="panel classification-setup">
<div className="panel-heading">
<div>
<h3>Prepare a preview</h3>
<p>
Nothing in your journal changes until you explicitly apply a
preview.
</p>
</div>
</div>
<form
className="form-body"
onSubmit={async (e) => {
e.preventDefault();
// The month-name picker is not a native date control, so the
// range is validated here instead of by form constraints.
if (!from || !to) {
setError("Choose a start and an end date for the range.");
return;
}
setBusy(true);
setError("");
try {
const start = await request<PreviewProgress>(
"/api/reclassify/preview",
{
revision: state.revision,
from,
to,
model: model.trim(),
fields,
},
);
if (!start.id)
throw new Error(
"The server returned an incompatible preview run.",
);
start.errors ??= [];
runStart.current = { time: Date.now(), analysed: 0 };
setRunning(start);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}}
>
<div className="two-columns">
<DateField
label="From"
value={from}
max={to || undefined}
onChange={setFrom}
/>
<DateField
label="To"
value={to}
min={from || undefined}
onChange={setTo}
/>
</div>
<Field
label="Model"
hint="An OpenAI-compatible model supported by your configured provider."
>
<input
required
value={model}
onChange={(e) => setModel(e.target.value)}
list="model-options"
/>
<ModelOptions id="model-options" />
</Field>
<fieldset className="tag-picker">
<legend>Fields to reclassify</legend>
{(["merchant", "category", "tags"] as const).map((field) => (
<label className="check-chip" key={field}>
<input
type="checkbox"
checked={fields[field]}
onChange={(e) =>
setFields({ ...fields, [field]: e.target.checked })
}
/>
{field === "tags"
? "Tags"
: field[0].toUpperCase() + field.slice(1)}
</label>
))}
</fieldset>
{!state.status.ai_configured && (
<p className="muted">
Configure your AI provider API key on the server to generate
previews. Existing manual classifications remain usable without
AI.
</p>
)}
<button
className="button primary"
disabled={
busy ||
!state.status.ai_configured ||
!Object.values(fields).some(Boolean) ||
!state.data.transactions.length
}
>
<Sparkles size={17} />
{busy ? "Starting…" : "Generate preview"}
</button>
{!state.data.transactions.length && (
<p className="muted">
Import transactions from Accounts before generating a preview.
</p>
)}
</form>
</section>
) : (
<>
<div className="preview-summary">
<span>
<strong>{preview.analysed}</strong> analysed
</span>
<span>
<strong>{preview.changes.length}</strong> proposed changes
</span>
<span>
<strong>{preview.unchanged}</strong> unchanged
</span>
<span>
<strong>{preview.errors.length}</strong> errors
</span>
</div>
{preview.revision !== state.revision && (
<div className="alert">
Your journal changed since this preview. Selected changes still
apply as long as their transactions were not edited in the
meantime.
</div>
)}
<section className="panel">
<div className="panel-heading">
<div>
<h3>Review changes</h3>
<p>
{selected.length} of {preview.changes.length} selected
</p>
</div>
<div className="row-actions">
<button
className="button subtle"
disabled={busy}
onClick={() => setSelected(preview.changes.map((c) => c.id))}
>
Select all
</button>
<button
className="button subtle"
disabled={busy}
onClick={() => setSelected([])}
>
Clear selection
</button>
</div>
</div>
{preview.changes.length ? (
<div className="preview-list">
{preview.changes.map((change) => (
<label
className={`preview-row ${selected.includes(change.id) ? "selected" : ""}`}
key={change.id}
>
<input
type="checkbox"
checked={selected.includes(change.id)}
disabled={busy}
onChange={(e) =>
setSelected(
e.target.checked
? [...selected, change.id]
: selected.filter((id) => id !== change.id),
)
}
/>
<div>
<strong>
{change.description || change.counterparty || change.id}
</strong>
<span className="amount">
{money(change.amount, change.currency)}
</span>
<small className="muted">{change.id}</small>
<span className="badge neutral">
Confidence:{" "}
{change.after.classification.confidence || "unknown"}
</span>
<div className="diff">
<EnrichmentView
data={state.data}
value={change.before}
label="Before"
/>
<ArrowRight size={18} />
<EnrichmentView
data={previewData}
value={change.after}
label="Proposed"
/>
</div>
</div>
</label>
))}
</div>
) : (
<Empty title="No changes proposed">
Your classifications already match the result for this
selection.
</Empty>
)}
<div className="form-actions">
<button
className="button secondary"
disabled={busy}
onClick={() => cancel(preview.id)}
>
<X size={16} />
{busy ? "Working…" : "Cancel preview"}
</button>
<button
className="button primary"
disabled={busy || !selected.length}
onClick={() => setConfirm(true)}
>
<Check size={16} />
Apply {selected.length} selected
</button>
</div>
</section>
{preview.errors.length > 0 && (
<section className="panel">
<div className="panel-heading">
<h3>Transactions that could not be classified</h3>
</div>
<div className="form-body">
{preview.errors.map((item, i) => (
<div className="alert error" key={`${item.id}-${i}`}>
<div>
<strong>{item.id}</strong>
<p>{item.error}</p>
</div>
</div>
))}
</div>
</section>
)}
</>
)}
{confirm && preview && (
<Modal
title="Apply selected classifications?"
close={() => {
if (!busy) setConfirm(false);
}}
>
<div className="form-body">
<p>
This will replace the selected enrichment fields on{" "}
<strong>{selected.length} transactions</strong> in one journal
commit. Unselected proposals will not be applied. Original bank
facts remain unchanged.
</p>
<ErrorMessage error={error} />
</div>
<div className="form-actions">
<button
className="button secondary"
disabled={busy}
onClick={() => setConfirm(false)}
>
Keep reviewing
</button>
<button
className="button primary"
disabled={busy}
onClick={async () => {
setBusy(true);
setError("");
try {
const result = await request<State>("/api/reclassify/apply", {
id: preview.id,
revision: preview.revision,
transaction_ids: selected,
});
acceptState(
result,
`Applied ${selected.length} classifications`,
);
const remaining = preview.changes.filter(
(c) => !selected.includes(c.id),
);
setPreview(
remaining.length
? { ...preview, changes: remaining }
: null,
);
setSelected([]);
setConfirm(false);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}}
>
{busy ? "Applying…" : "Confirm and apply"}
</button>
</div>
</Modal>
)}
</>
);
}
function EnrichmentView({
data,
value,
label,
}: {
data: Dataset;
value: Enrichment;
label: string;
}) {
return (
<div className="diff-value">
<span className="eyebrow">{label}</span>
<dl>
<div>
<dt>Merchant</dt>
<dd>
{value.merchant_id
? data.merchants.find((m) => m.id === value.merchant_id)?.name ||
`New merchant (${value.merchant_id})`
: "None"}
</dd>
</div>
<div>
<dt>Category</dt>
<dd>{categoryPath(data, value.category_id)}</dd>
</div>
<div>
<dt>Tags</dt>
<dd>
{value.tag_ids.length
? value.tag_ids
.map((id) => data.tags.find((t) => t.id === id)?.name || id)
.join(", ")
: "None"}
</dd>
</div>
</dl>
</div>
);
}
// remainingEstimate projects the finish time from the pace observed since
// this page attached to the run; the server paces provider requests, so the
// first sample is meaningless and re-attaching mid-run must not count work
// done before it.
function remainingEstimate(
p: PreviewProgress,
start: { time: number; analysed: number },
): string {
const sampled = p.analysed - start.analysed;
const remaining = p.total - p.analysed;
if (remaining <= 0 || sampled < 2 || !start.time) return "";
const seconds = Math.round(
((Date.now() - start.time) / sampled / 1000) * remaining,
);
if (seconds < 90) return ` — roughly ${seconds} seconds remaining`;
return ` — roughly ${Math.round(seconds / 60)} minutes remaining`;
}