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.
609 lines
20 KiB
TypeScript
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`;
|
|
}
|