Implement classification redesign
This commit is contained in:
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user