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
+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">