fix(review): slug-scoped tags, decode names, drop fig picker & AI labels

- Taxonomy sync keeps fig-/feat- by slug (hyphen), not name; HTML-unescape names
- Review: remove AI assist labels; Features picker for feat-*; derive fig_tag_ids
  from Figure term names (casefold match, no bridges); decodeName on display
- Unit tests for scoped_tags, fig match, Review UI contract; docs slug wording
This commit is contained in:
grokbot_ty
2026-09-26 09:41:30 -07:00
parent c54870f5b9
commit 85786f16f2
13 changed files with 214 additions and 85 deletions
+30 -58
View File
@@ -8,7 +8,8 @@
import type { CalibratedCapture } from '../flow';
import { canCreateTitleFromSpecies, effectiveSpeciesId } from './speciesFromCapture';
import MultiSelect from './MultiSelect.svelte';
import { deriveThicknessTermId } from './thicknessBand';
import { decodeName } from '../decodeName';
import { deriveFigTagIds, scopedFeatTags } from './figTags';
import {
keptInOrder, moveItem, missingMinimums, roleLabel, resolvePhotoUrl,
type LineupItem,
@@ -41,7 +42,6 @@
let figureOpts: Opt[] = [];
let gradeOpts: Opt[] = [];
let moistureOpts: Opt[] = [];
let figTagOpts: Opt[] = [];
let featTagOpts: Opt[] = [];
function deriveOptions() {
@@ -63,7 +63,7 @@
// attributes ignore it. "2-4 in" → 2; a name with no leading number → undefined.
const m = t.name.match(/\d+(?:[./]\d+)?/);
const lowIn = m ? Number(m[0]) : undefined;
return { id: t.id, name: t.name, ai: false, lowIn: Number.isFinite(lowIn) ? lowIn : undefined };
return { id: t.id, name: decodeName(t.name), ai: false, lowIn: Number.isFinite(lowIn) ? lowIn : undefined };
});
};
// Wood categories: parent/child grouped (METADATA-10 — parents group only, never
@@ -73,9 +73,9 @@
woodCatGroups = (childrenOf(wcatGrouper)).map((parent) => {
const kids = cats.filter((c) => c.parent_id === parent.id);
const leaves: Opt[] = kids.length
? kids.map((c) => ({ id: c.id, name: c.name, ai: false }))
: [{ id: parent.id, name: parent.name, ai: false }];
return { parent: { id: parent.id, name: parent.name }, leaves };
? kids.map((c) => ({ id: c.id, name: decodeName(c.name), ai: false }))
: [{ id: parent.id, name: decodeName(parent.name), ai: false }];
return { parent: { id: parent.id, name: decodeName(parent.name) }, leaves };
});
edgeOpts = termsOf('edge type');
figureOpts = termsOf('figure');
@@ -83,12 +83,12 @@
// No thickness options: the band is derived server-side and never shown here
// (METADATA-15 / QUALITY-95).
moistureOpts = termsOf('moisture');
// fig-* / feat-* product tags (METADATA-30): pulled from the live synced set,
// filtered by the family the backend keeps. Never invent a prefix — if the
// store has none, the picker is legitimately empty.
// feat-* product tags (METADATA-30): slug prefix with hyphen. fig-* tags are
// derived from Figure attribute selections (no separate picker).
const tags: TaxTag[] = tax.tags ?? [];
figTagOpts = tags.filter((t) => t.slug.startsWith('fig')).map((t) => ({ id: t.id, name: t.name, ai: false }));
featTagOpts = tags.filter((t) => t.slug.startsWith('feat')).map((t) => ({ id: t.id, name: t.name, ai: false }));
featTagOpts = scopedFeatTags(tags).map((t) => ({
id: t.id, name: decodeName(t.name), ai: false,
}));
}
// --- Editable model (mirrors the live slab) ---
@@ -148,9 +148,6 @@
let genBusy = false;
let genError = '';
let canGenerateContent = false;
let aiRecommendations: number[] = []; // union of AI-suggested ids (for row bubbles)
const num = (v: unknown) => (typeof v === 'number' ? v : null);
const fmt1 = (v: number) => { const s = v.toFixed(1); return s.endsWith('.0') ? s.slice(0, -2) : s; };
const fmt2 = (v: number) => v.toFixed(2);
@@ -327,7 +324,6 @@
figureIds = [...(s.figure_term_ids ?? [])];
gradeSel = s.grade_term_id != null ? String(s.grade_term_id) : '';
moistureId = s.moisture_term_id ?? '';
figTagIds = [...(s.fig_tag_ids ?? [])];
featTagIds = [...(s.feat_tag_ids ?? [])];
title = s.title ?? '';
shortDescription = s.short_description ?? '';
@@ -338,36 +334,29 @@
sku = s.sku ?? '';
// REVIEW-15: primary defaults to first selected wood cat; keep if still valid.
primaryCatId = nextPrimary(woodCatIds, woodCatIds[0] ?? '');
// Seed AI-recommended markers from stored confidences (>=0.7 means the AI put
// its weight behind the value the owner kept).
aiRecommendations = [];
if (num(s.species_confidence) != null && num(s.species_confidence)! >= 0.7 && s.species_id)
aiRecommendations.push(s.species_id);
if (num(s.figure_confidence) != null && num(s.figure_confidence)! >= 0.7)
aiRecommendations.push(...(s.figure_term_ids ?? []));
// fig_tag_ids are derived reactively from figureIds (no hydrate of stored fig tags).
}
// AI-marked option views (legacy Call 1 badges; Call 1 disabled on this branch).
$: woodCatGroupsView = woodCatGroups.map((g) => ({
parent: g.parent,
leaves: g.leaves.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) })),
}));
$: figureOptsView = figureOpts.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) }));
$: featTagOptsView = featTagOpts.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) }));
// Call 1 out: no AI badges on options. Groups/opts pass through as-is.
$: woodCatGroupsView = woodCatGroups;
$: figureOptsView = figureOpts;
$: featTagOptsView = featTagOpts;
// fig_tag_ids derived from selected Figure term names ↔ fig-* tag names (casefold).
$: figTagIds = tax
? deriveFigTagIds(figureIds, figureOpts, tax.tags ?? [])
: figTagIds;
// Flat leaf name lookup for the primary radio set (REVIEW-15).
$: woodLeafById = new Map(
woodCatGroups.flatMap((g) => g.leaves.map((l) => [l.id, l] as [number, Opt])),
);
$: primaryOpts = woodCatIds
.map((id) => woodLeafById.get(id))
.filter((o): o is Opt => !!o)
.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) }));
.filter((o): o is Opt => !!o);
// METADATA-15 / QUALITY-95: the thickness band is derived server-side from the
// measured thickness against the live synced Woo bands. It is deliberately absent
// from this screen — no owner control, no read-only echo. The only thickness the
// owner sees here is the measured value in the summary above.
const aiIn = (ids: number[]) => (id: number) => ids.includes(id);
// Persist taxonomy/price/sku edits to the slab (PUT), then recompute ready.
// #89: the deterministic title/short_description are written here — the server's
// review returns the build from the current metadata, and if the field is empty
@@ -678,7 +667,7 @@
<!-- Wood categories (multi + inline primary radio set, REVIEW-15 / #90 wireframe) -->
<div class="field" data-testid="wood-cat-field">
<label class="lbl">Wood categories <span class="ai">AI assist</span></label>
<label class="lbl">Wood categories</label>
<MultiSelect
label="Wood categories"
groups={woodCatGroupsView}
@@ -703,7 +692,6 @@
>
<span class="ps-radio" aria-hidden="true"></span>
<span class="ps-name">{o.name}</span>
{#if o.ai}<span class="ps-tag">AI</span>{/if}
</button>
{/each}
<p class="ps-note">
@@ -714,9 +702,9 @@
{/if}
</div>
<!-- Edge type (single, AI-assist) -->
<!-- Edge type (single) -->
<div class="field">
<label class="lbl" for="edge">Edge type <span class="ai">AI assist</span></label>
<label class="lbl" for="edge">Edge type</label>
<select id="edge" class="sel" data-testid="edge-type" bind:this={edgeEl} bind:value={edgeSel} on:change={commitReview}>
<option value="">— select —</option>
{#each edgeOpts as o (o.id)}
@@ -725,9 +713,9 @@
</select>
</div>
<!-- Figure (multi, searchable list, AI-assist) -->
<!-- Figure (multi, searchable list); fig-* tags derived from selections -->
<div class="field">
<label class="lbl">Figure <span class="ai">AI assist</span></label>
<label class="lbl">Figure</label>
<MultiSelect
label="Figure"
options={figureOptsView}
@@ -748,28 +736,14 @@
</select>
</div>
<!-- fig-* product tags (optional, owner-only, no AI) -->
<!-- Features = feat-* product tags (optional; Call 1 out — no AI badge) -->
<div class="field">
<label class="lbl">fig-* tags <span class="sug">optional</span></label>
<label class="lbl">Features <span class="sug">optional</span></label>
<MultiSelect
label="fig-* tags"
options={figTagOpts}
selected={figTagIds}
placeholder="Choose fig-* tags"
testid="fig-tag"
onToggle={(id) => (figTagIds = toggleMulti(figTagIds, id))}
on:commit={commitReview}
/>
</div>
<!-- feat-* product tags (optional; Call 1 assist disabled on this branch) -->
<div class="field">
<label class="lbl">feat-* tags <span class="sug">optional</span> <span class="ai">AI assist</span></label>
<MultiSelect
label="feat-* tags"
label="Features"
options={featTagOptsView}
selected={featTagIds}
placeholder="Choose feat-* tags"
placeholder="Choose features"
testid="feat-tag"
onToggle={(id) => (featTagIds = toggleMulti(featTagIds, id))}
on:commit={commitReview}
@@ -875,7 +849,6 @@
.math div { display: flex; justify-content: space-between; border-bottom: 1px solid #2a2a2a; padding: 5px 0; font-size: .85rem; }
.math dt { color: #9a9a9a; margin: 0; } .math dd { margin: 0; font-variant-numeric: tabular-nums; }
.lbl { display: block; font-size: .8rem; color: #bbb; margin: 0 0 4px; }
.lbl .ai { margin-left: 6px; font-size: .58rem; font-weight: 700; padding: 1px 5px; border-radius: 2px; background: #00e5ff; color: #002; }
/* box-sizing: the app has no global reset, so a width:100% control with padding
and a border pokes out of its container by that padding+border (20px here) —
which becomes real page overflow whenever the column sits at the viewport edge
@@ -908,7 +881,6 @@
.ps-opt.on .ps-radio { border-color: #00e5ff; }
.ps-opt.on .ps-radio::after { content: ''; position: absolute; inset: 2px; border-radius: 50%; background: #00e5ff; }
.ps-name { flex: 1; }
.ps-tag { font-size: .56rem; font-weight: 700; padding: 0 4px; border-radius: 2px; background: #00e5ff; color: #002; letter-spacing: .3px; }
.ps-note { font-size: .7rem; color: #777; margin: 6px 0 0; line-height: 1.4; }
.status { margin-top: 14px; font-weight: 700; padding: 10px 12px; border-radius: 3px; background: #2a1512; color: #ffb4a2; font-size: .9rem; }
.status.ok { background: #122a12; color: #7fd37f; }
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
deriveFigTagIds,
normName,
scopedFeatTags,
scopedFigTags,
} from './figTags';
test('scoped tags require hyphenated slug prefix (not bare fig/feat)', () => {
const tags = [
{ id: 1, name: 'Curl', slug: 'fig-curl' },
{ id: 2, name: 'Knot', slug: 'feat-knot' },
{ id: 3, name: 'Figured', slug: 'figured' }, // no hyphen — out
{ id: 4, name: 'Featured', slug: 'featured' },
{ id: 5, name: 'fig-fake-name', slug: 'other' },
];
assert.deepEqual(scopedFigTags(tags).map((t) => t.id), [1]);
assert.deepEqual(scopedFeatTags(tags).map((t) => t.id), [2]);
});
test('deriveFigTagIds: casefold/strip name equality; Curl≠Curly', () => {
const terms = [
{ id: 10, name: 'Curl' },
{ id: 11, name: ' Birdseye ' },
];
const tags = [
{ id: 100, name: 'curl', slug: 'fig-curl' },
{ id: 101, name: 'Curly', slug: 'fig-curly' },
{ id: 102, name: 'Birdseye', slug: 'fig-birdseye' },
{ id: 103, name: 'Knot', slug: 'feat-knot' },
];
assert.deepEqual(deriveFigTagIds([10], terms, tags), [100]);
assert.deepEqual(deriveFigTagIds([10, 11], terms, tags).sort(), [100, 102]);
// Curly tag stays unmatched when only Curl is selected
assert.ok(!deriveFigTagIds([10], terms, tags).includes(101));
assert.deepEqual(deriveFigTagIds([], terms, tags), []);
});
test('normName strips and lowercases', () => {
assert.equal(normName(' Curl '), 'curl');
});
+33
View File
@@ -0,0 +1,33 @@
/**
* Derive fig-* product tag ids from selected Figure attribute terms.
* Match is case-insensitive name equality (casefold/strip) against tags whose
* slug starts with `fig-`. No hardcoded name bridges (Curl ≠ Curly).
*/
export function normName(name: string): string {
// JS has no String.casefold; toLowerCase covers ASCII wood-term names.
return name.trim().toLowerCase();
}
export function scopedFigTags<T extends { slug: string }>(tags: T[]): T[] {
return tags.filter((t) => t.slug.startsWith('fig-'));
}
export function scopedFeatTags<T extends { slug: string }>(tags: T[]): T[] {
return tags.filter((t) => t.slug.startsWith('feat-'));
}
export function deriveFigTagIds(
figureTermIds: number[],
figureTerms: { id: number; name: string }[],
tags: { id: number; name: string; slug: string }[],
): number[] {
const selected = new Set(
figureTerms
.filter((t) => figureTermIds.includes(t.id))
.map((t) => normName(t.name)),
);
if (selected.size === 0) return [];
return scopedFigTags(tags)
.filter((t) => selected.has(normName(t.name)))
.map((t) => t.id);
}
@@ -0,0 +1,34 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { test } from 'node:test';
const dir = dirname(fileURLToPath(import.meta.url));
const reviewSrc = readFileSync(join(dir, 'Review.svelte'), 'utf8');
const figSrc = readFileSync(join(dir, 'figTags.ts'), 'utf8');
test('Review UI: no AI assist labels or AI badge chrome', () => {
assert.equal(reviewSrc.includes('AI assist'), false);
assert.equal(reviewSrc.includes('class="ai"'), false);
assert.equal(reviewSrc.includes('ps-tag'), false);
});
test('Review UI: no fig-* MultiSelect picker', () => {
assert.equal(reviewSrc.includes('data-testid="fig-tag"'), false);
assert.equal(reviewSrc.includes('figTagOpts'), false);
assert.equal(reviewSrc.includes('label="fig-* tags"'), false);
assert.equal(reviewSrc.includes('placeholder="Choose fig-* tags"'), false);
assert.match(reviewSrc, /deriveFigTagIds/);
assert.match(reviewSrc, /Features/);
assert.match(reviewSrc, /testid="feat-tag"/);
});
test('Tag family scope uses hyphenated slug prefix', () => {
assert.match(figSrc, /startsWith\('fig-'\)/);
assert.match(figSrc, /startsWith\('feat-'\)/);
assert.equal(/startsWith\('fig'\)/.test(figSrc), false);
assert.equal(/startsWith\('feat'\)/.test(figSrc), false);
assert.equal(/startsWith\('fig'\)/.test(reviewSrc), false);
assert.equal(/startsWith\('feat'\)/.test(reviewSrc), false);
});