Implement classification redesign

This commit is contained in:
Lars Nolden
2026-09-11 22:46:17 +02:00
parent cc43a2f9a7
commit 87f052a3ea
23 changed files with 1602 additions and 296 deletions
+29 -6
View File
@@ -69,11 +69,11 @@ export function Classification({
<div>
<strong>Review first. Apply only what you choose.</strong>
<p>
Only allowlisted, sanitized fields are sent to the classification
provider. Known identifiers and counterparty names are removed; free
text can still contain sensitive information. Amount sharing is{" "}
{state.settings.include_amount ? "enabled" : "disabled"} in
Settings. AI requests may incur provider charges.
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>
@@ -128,8 +128,27 @@ export function Classification({
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.map((c) => c.id));
setSelected(
result.changes
.filter(
(change) =>
change.after.classification.confidence !== "low",
)
.map((change) => change.id),
);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
@@ -286,6 +305,10 @@ export function Classification({
<div>
<strong>{change.description || change.id}</strong>
<small className="muted">{change.id}</small>
<span className="badge neutral">
Confidence:{" "}
{change.after.classification.confidence || "unknown"}
</span>
<div className="diff">
<EnrichmentView
data={state.data}
+219 -8
View File
@@ -10,8 +10,16 @@ import {
CandlestickChart,
ChevronRight,
} from "lucide-react";
import type { Category, Dataset, Instrument, Merchant, Tag } from "./api";
import { categoryPath } from "./api";
import type {
Category,
Dataset,
Instrument,
Merchant,
State,
Tag,
TaxonomyPreview,
} from "./api";
import { categoryPath, request } from "./api";
import {
CategoryOptions,
Empty,
@@ -41,10 +49,16 @@ export function Registry({
entity,
data,
mutate,
acceptState,
revision,
model,
}: {
entity: Entity;
data: Dataset;
mutate: Mutate;
acceptState?: (state: State, message?: string) => void;
revision?: string;
model?: string;
}) {
const [editing, setEditing] = useState<Item | null>(null);
const [action, setAction] = useState<{
@@ -97,6 +111,7 @@ export function Registry({
{item.use_defaults ? " · Defaults enabled" : ""}
</small>
)}
{"hint" in item && item.hint && <small>{item.hint}</small>}
{"isin" in item && (
<small>
{item.isin} · {item.currency}
@@ -169,10 +184,19 @@ export function Registry({
: "Recognize familiar names and choose explicit classification defaults."}
</p>
</div>
<button className="button primary" onClick={create}>
<Plus size={17} />
New {entity}
</button>
<div className="row-actions">
{entity === "category" && acceptState && revision && model && (
<TaxonomyPanel
revision={revision}
model={model}
acceptState={acceptState}
/>
)}
<button className="button primary" onClick={create}>
<Plus size={17} />
New {entity}
</button>
</div>
</div>
<section className="panel registry">
{items.length ? (
@@ -210,6 +234,176 @@ export function Registry({
</>
);
}
function TaxonomyPanel({
revision,
model,
acceptState,
}: {
revision: string;
model: string;
acceptState: (state: State, message?: string) => void;
}) {
const [preview, setPreview] = useState<TaxonomyPreview | null>(null);
const [selectedCategories, setSelectedCategories] = useState<Set<number>>(
new Set(),
);
const [selectedTags, setSelectedTags] = useState<Set<number>>(new Set());
const [selectedMerchants, setSelectedMerchants] = useState<Set<number>>(
new Set(),
);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const toggle = (
setSelected: React.Dispatch<React.SetStateAction<Set<number>>>,
index: number,
) =>
setSelected((current) => {
const next = new Set(current);
if (next.has(index)) next.delete(index);
else next.add(index);
return next;
});
const close = () => {
setPreview(null);
setError("");
};
return (
<>
<button
className="button secondary"
disabled={busy}
onClick={async () => {
setBusy(true);
setError("");
try {
const result = await request<TaxonomyPreview>(
"/api/taxonomy/propose",
{ revision, model },
);
setPreview(result);
setSelectedCategories(new Set());
setSelectedTags(new Set());
setSelectedMerchants(new Set());
} catch (err) {
setError(String(err instanceof Error ? err.message : err));
} finally {
setBusy(false);
}
}}
>
{busy ? "Sampling…" : "Propose categories and tags"}
</button>
{preview && (
<Modal title="Review taxonomy proposal" close={close}>
<div className="form-body">
<ErrorMessage error={error} />
<p className="muted">
Select each item to write. Existing categories, tags, and
merchants are never changed by this proposal.
</p>
<h3>Categories</h3>
{preview.proposal.categories.map((category, index) => (
<label className="checkbox-row" key={`${category.name}-${index}`}>
<input
type="checkbox"
checked={selectedCategories.has(index)}
onChange={() => toggle(setSelectedCategories, index)}
/>
<span>
<strong>{category.name}</strong>
<small>
{category.kind}
{category.parent ? ` · ${category.parent}` : ""}
{category.hint ? ` · ${category.hint}` : ""}
</small>
{category.because.length > 0 && (
<small>Seen in: {category.because.join(" · ")}</small>
)}
</span>
</label>
))}
<h3>Tags</h3>
{preview.proposal.tags.map((tag, index) => (
<label className="checkbox-row" key={`${tag.name}-${index}`}>
<input
type="checkbox"
checked={selectedTags.has(index)}
onChange={() => toggle(setSelectedTags, index)}
/>
<span>
<strong>{tag.name}</strong>
{tag.hint && <small>{tag.hint}</small>}
</span>
</label>
))}
<h3>Merchants</h3>
{preview.proposal.merchants.map((merchant, index) => (
<label className="checkbox-row" key={`${merchant.name}-${index}`}>
<input
type="checkbox"
checked={selectedMerchants.has(index)}
onChange={() => toggle(setSelectedMerchants, index)}
/>
<span>
<strong>{merchant.name}</strong>
<small>
{merchant.aliases.length
? merchant.aliases.join(" · ")
: "No aliases"}
</small>
</span>
</label>
))}
<div className="form-actions">
<button className="button" type="button" onClick={close}>
Cancel
</button>
<button
className="button primary"
type="button"
disabled={
busy ||
(selectedCategories.size === 0 &&
selectedTags.size === 0 &&
selectedMerchants.size === 0)
}
onClick={async () => {
setBusy(true);
setError("");
try {
const applied = await request<State>("/api/taxonomy/apply", {
id: preview.id,
revision: preview.revision,
approved: {
categories: preview.proposal.categories.filter((_, i) =>
selectedCategories.has(i),
),
tags: preview.proposal.tags.filter((_, i) =>
selectedTags.has(i),
),
merchants: preview.proposal.merchants.filter((_, i) =>
selectedMerchants.has(i),
),
},
});
acceptState(applied, "Approved taxonomy written");
close();
} catch (err) {
setError(String(err instanceof Error ? err.message : err));
} finally {
setBusy(false);
}
}}
>
Apply selected
</button>
</div>
</div>
</Modal>
)}
</>
);
}
function RegistryEditor({
entity,
item,
@@ -228,6 +422,7 @@ function RegistryEditor({
const [parent, setParent] = useState(
"parent_id" in item ? item.parent_id || "" : "",
);
const [hint, setHint] = useState("hint" in item ? item.hint || "" : "");
const merchant = "aliases" in item ? item : null;
const [aliases, setAliases] = useState(merchant?.aliases.join("\n") || "");
const [category, setCategory] = useState(merchant?.default_category_id || "");
@@ -262,7 +457,13 @@ function RegistryEditor({
try {
const result =
entity === "category"
? { id: item.id, name: name.trim(), kind, parent_id: parent }
? {
id: item.id,
name: name.trim(),
kind,
parent_id: parent,
hint: hint.trim(),
}
: entity === "merchant"
? {
id: item.id,
@@ -286,7 +487,7 @@ function RegistryEditor({
name: name.trim(),
currency: currency.toUpperCase(),
}
: { id: item.id, name: name.trim() };
: { id: item.id, name: name.trim(), hint: hint.trim() };
await mutate(
`/api/${plurals[entity]}`,
{ [entity]: result },
@@ -311,6 +512,16 @@ function RegistryEditor({
autoFocus
/>
</Field>
{(entity === "category" || entity === "tag") && (
<Field label="Hint" hint="Explain when this category or tag applies to the AI classifier.">
<textarea
rows={2}
maxLength={200}
value={hint}
onChange={(e) => setHint(e.target.value)}
/>
</Field>
)}
{entity === "category" && (
<>
<Field label="Kind">
+20 -17
View File
@@ -13,8 +13,8 @@ import { ErrorMessage, Field, Modal } from "./ui";
import type { Mutate } from "./ui";
export function Settings({ state, mutate }: { state: State; mutate: Mutate }) {
const [model, setModel] = useState(state.settings.model);
const [includeAmount, setIncludeAmount] = useState(
state.settings.include_amount,
const [privateNames, setPrivateNames] = useState(
state.settings.private_names.join("; "),
);
const [classifyOnImport, setClassifyOnImport] = useState(
state.settings.classify_on_import,
@@ -339,7 +339,10 @@ export function Settings({ state, mutate }: { state: State; mutate: Mutate }) {
"/api/settings",
{
model: model.trim(),
include_amount: includeAmount,
private_names: privateNames
.split(";")
.map((name) => name.trim())
.filter(Boolean),
classify_on_import: classifyOnImport,
},
"Classification preferences saved",
@@ -361,18 +364,17 @@ export function Settings({ state, mutate }: { state: State; mutate: Mutate }) {
onChange={(e) => setModel(e.target.value)}
/>
</Field>
<label className="checkbox">
<Field
label="Private names"
hint="Semicolon-separated names to redact from every AI text field. A semicolon inside a name is not supported."
>
<input
type="checkbox"
checked={includeAmount}
onChange={(e) => setIncludeAmount(e.target.checked)}
value={privateNames}
maxLength={2000}
onChange={(e) => setPrivateNames(e.target.value)}
placeholder="Your name; household member"
/>
Include transaction amount in AI requests
</label>
<p className="muted small">
Disabled by default for privacy. Enabling this shares the amount
with the configured AI provider to help classification.
</p>
</Field>
<label className="checkbox">
<input
type="checkbox"
@@ -415,10 +417,11 @@ export function Settings({ state, mutate }: { state: State; mutate: Mutate }) {
<h4>Explicit external services</h4>
<p>
Bank authorization and sync use Enable Banking. AI classification
sends allowlisted, sanitized fields to the configured provider.
Known personal identifiers, counterparty names and bank references
are stripped, but sanitization cannot guarantee that free-text
descriptions contain no sensitive information.
sends merchant and counterparty text, amount, date and currency
to the configured provider after identifier-only redaction. Your
own account identifiers and configured private names are never
sent. A third party's payee name can be sent when it is not in
your private-name list.
</p>
<h4>Immutable originals</h4>
<p>
+19 -2
View File
@@ -90,6 +90,7 @@ export function Transactions({
mutate: Mutate;
}) {
const [query, setQuery] = useState("");
const [needsReview, setNeedsReview] = useState(false);
const [editing, setEditing] = useState<Transaction | null>(null);
const [page, setPage] = useState(0);
const filtered = useMemo(() => {
@@ -113,8 +114,13 @@ export function Transactions({
(!filter.from || f.booking_date >= filter.from) &&
(!filter.to || f.booking_date <= filter.to) &&
(!filter.currency || f.currency === filter.currency) &&
(!filter.account_id || f.account_id === filter.account_id) &&
(!filter.category_id || categories.has(e.category_id || "")) &&
(!needsReview ||
e.classification.confidence !== "high" ||
e.category_id ===
(e.kind === "income"
? "cat_income_unclassified"
: "cat_expenses_unclassified")) &&
(!filter.tag_id || e.tag_ids.includes(filter.tag_id)) &&
(!filter.merchant_id || e.merchant_id === filter.merchant_id) &&
(!query ||
@@ -127,7 +133,7 @@ export function Transactions({
b.facts.booking_date.localeCompare(a.facts.booking_date) ||
a.facts.id.localeCompare(b.facts.id),
);
}, [data, filter, query]);
}, [data, filter, query, needsReview]);
const currentPage = Math.min(
page,
Math.max(0, Math.ceil(filtered.length / 40) - 1),
@@ -163,6 +169,17 @@ export function Transactions({
}}
/>
</label>
<label className="checkbox">
<input
type="checkbox"
checked={needsReview}
onChange={(e) => {
setNeedsReview(e.target.checked);
setPage(0);
}}
/>
Needs review
</label>
<span className="muted small">
<SlidersHorizontal size={15} /> Click a transaction to edit
</span>
+37 -1
View File
@@ -51,6 +51,7 @@ export interface Facts {
export interface Provenance {
source: string;
model?: string;
confidence?: "high" | "medium" | "low" | string;
timestamp?: string;
error?: string;
}
@@ -71,10 +72,12 @@ export interface Category {
name: string;
parent_id?: string;
kind: string;
hint?: string;
}
export interface Tag {
id: string;
name: string;
hint?: string;
}
export interface Merchant {
id: string;
@@ -133,7 +136,7 @@ export interface State {
};
settings: {
model: string;
include_amount: boolean;
private_names: string[];
classify_on_import: boolean;
};
sessions: { session_id: string; valid_until: string; accounts: Account[] }[];
@@ -185,6 +188,39 @@ export interface Preview {
unchanged: number;
errors: { id: string; error: string }[];
}
export interface ProposedCategory {
name: string;
parent?: string;
kind: string;
hint?: string;
because: string[];
}
export interface ProposedTag {
name: string;
hint?: string;
}
export interface ProposedMerchant {
name: string;
aliases: string[];
}
export interface TaxonomyProposal {
categories: ProposedCategory[];
tags: ProposedTag[];
merchants: ProposedMerchant[];
}
export interface TaxonomyPreview {
id: string;
revision: string;
sample: {
date: string;
amount: string;
currency: string;
kind: string;
description: string;
counterparty: string;
}[];
proposal: TaxonomyProposal;
}
export interface CSVColumn {
field: string;
column: string;
+4 -1
View File
@@ -365,6 +365,9 @@ function App() {
entity="category"
data={state.data}
mutate={mutate}
acceptState={acceptState}
revision={state.revision}
model={state.settings.model}
/>
)}
{page === "tags" && (
@@ -404,7 +407,7 @@ function App() {
)}
{page === "settings" && (
<Settings
key={`${state.settings.model}-${state.settings.include_amount}-${state.settings.classify_on_import}`}
key={`${state.settings.model}-${state.settings.classify_on_import}-${state.settings.private_names.join(",")}`}
state={state}
mutate={mutate}
/>