fix(m3): AI-assist scope + confidence gate + grouped wood categories

- inference: _taxonomy_lists hands the model the synced species leaves + wood
  leaves + case-insensitive attribute terms (was furniture roots / empty)
- inference: CALL1_SEED pins exact flat schema + *_confidence keys, drops grade
- inference: _gate maps field->confidence key correctly (was species_id_confidence),
  reads nested shape as safety net, omits grade (METADATA-40)
- review.svelte: figure gets AI assist, grade dropped from AI (owner judgment)
- MultiSelect: grouped mode (parents non-selectable headers, selectable leaves)
- METADATA-40 updated: grade not suggested by Call 1
This commit is contained in:
Ty
2026-09-20 21:47:36 -07:00
parent e36979617b
commit cec03c5bd3
5 changed files with 212 additions and 82 deletions
+78 -43
View File
@@ -2,11 +2,23 @@
// Searchable multi-select list (the approved paradigm replacing tap-bubbles).
// Squared/slight-radius, type-to-filter, select-all, live count badge, per-row
// AI bubble on AI-recommended options. Works for 6 items or 60.
//
// Two modes:
// - flat: pass `options` (single-level list).
// - grouped: pass `groups` (parent/child). Parents are non-selectable header rows
// (METADATA-10: "parents group only and are never assigned"); only the
// leaf rows are selectable. Filtering hides a parent group when none of
// its leaves match.
import { createEventDispatcher } from 'svelte';
interface Opt { id: number; name: string; ai: boolean }
export interface Opt { id: number; name: string; ai: boolean }
export interface Group { parent: { id: number; name: string }; leaves: Opt[] }
export let label: string;
export let options: Opt[];
export let options: Opt[] = [];
// Optional grouped shape. When provided, `options` is ignored and the panel renders
// parent headers + selectable leaf rows.
export let groups: Group[] = [];
export let selected: number[];
export let placeholder = 'Choose…';
export let testid = '';
@@ -17,36 +29,33 @@
let filter = '';
let focused: number | null = null;
$: aiSelected = selected.filter((id) => options.find((o) => o.id === id)?.ai);
$: filtered = filter.trim()
? options.filter((o) => o.name.toLowerCase().includes(filter.trim().toLowerCase()))
: options;
const grouped = groups.length > 0;
// Effective selectable set (leaves when grouped, options when flat).
$: allOpts = (grouped ? groups.flatMap((g) => g.leaves) : options) as Opt[];
$: q = filter.trim().toLowerCase();
$: filteredFlat = q ? allOpts.filter((o) => o.name.toLowerCase().includes(q)) : allOpts;
// Grouped: keep a parent only if at least one of its leaves survives the filter.
$: filteredGroups = grouped
? groups
.map((g) => ({ parent: g.parent, leaves: q ? g.leaves.filter((o) => o.name.toLowerCase().includes(q)) : g.leaves }))
.filter((g) => g.leaves.length > 0)
: [];
$: count = selected.length;
$: allFilteredSelected = filtered.length > 0 && filtered.every((o) => selected.includes(o.id));
$: allFilteredSelected = filteredFlat.length > 0 && filteredFlat.every((o) => selected.includes(o.id));
function toggle(id: number) {
onToggle(id);
focused = id;
}
function selectAll() {
// Toggle every currently-visible (filtered) option.
for (const o of filtered) {
if (!selected.includes(o.id)) onToggle(o.id);
}
for (const o of filteredFlat) if (!selected.includes(o.id)) onToggle(o.id);
}
function clearAll() {
for (const o of [...filtered]) {
if (selected.includes(o.id)) onToggle(o.id);
}
}
function close() {
open = false;
filter = '';
dispatch('commit');
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape' && open) { open = false; }
for (const o of [...filteredFlat]) if (selected.includes(o.id)) onToggle(o.id);
}
function close() { open = false; filter = ''; dispatch('commit'); }
function onKey(e: KeyboardEvent) { if (e.key === 'Escape' && open) open = false; }
</script>
<div class="ms" class:open data-testid={testid} on:keydown={onKey}>
@@ -66,28 +75,50 @@
{allFilteredSelected ? 'None' : 'All'}
</button>
</div>
<ul class="ms-list">
{#each filtered as o (o.id)}
<li
class="ms-row"
class:on={selected.includes(o.id)}
role="option"
aria-selected={selected.includes(o.id)}
data-testid={testid ? `${testid}-opt-${o.id}` : `ms-opt-${o.id}`}
on:click={() => toggle(o.id)}
>
<span class="ck" aria-hidden="true">{selected.includes(o.id) ? '✓' : ''}</span>
<span class="nm">
{o.name}
{#if o.ai}<span class="ai-tag">AI</span>{/if}
</span>
</li>
{:else}
<li class="ms-empty">No matches</li>
{/each}
</ul>
{#if grouped}
<ul class="ms-list">
{#each filteredGroups as g (g.parent.id)}
<li class="ms-group" aria-hidden="true">{g.parent.name}</li>
{#each g.leaves as o (o.id)}
<li
class="ms-row leaf"
class:on={selected.includes(o.id)}
role="option"
aria-selected={selected.includes(o.id)}
data-testid={testid ? `${testid}-opt-${o.id}` : `ms-opt-${o.id}`}
on:click={() => toggle(o.id)}
>
<span class="ck" aria-hidden="true">{selected.includes(o.id) ? '✓' : ''}</span>
<span class="nm">{o.name}{#if o.ai}<span class="ai-tag">AI</span>{/if}</span>
</li>
{/each}
{:else}
<li class="ms-empty">No matches</li>
{/each}
</ul>
{:else}
<ul class="ms-list">
{#each filteredFlat as o (o.id)}
<li
class="ms-row"
class:on={selected.includes(o.id)}
role="option"
aria-selected={selected.includes(o.id)}
data-testid={testid ? `${testid}-opt-${o.id}` : `ms-opt-${o.id}`}
on:click={() => toggle(o.id)}
>
<span class="ck" aria-hidden="true">{selected.includes(o.id) ? '✓' : ''}</span>
<span class="nm">{o.name}{#if o.ai}<span class="ai-tag">AI</span>{/if}</span>
</li>
{:else}
<li class="ms-empty">No matches</li>
{/each}
</ul>
{/if}
<div class="ms-foot">
<span>{options.length} synced{filter.trim() ? ` · ${filtered.length} shown` : ''}</span>
<span>{allOpts.length} synced{filter.trim() ? ` · ${filteredFlat.length} shown` : ''}</span>
<button type="button" on:click={close} data-testid={testid ? `${testid}-done` : 'ms-done'}>Done</button>
</div>
</div>
@@ -110,6 +141,10 @@
.ms-row.on { background: #12262b; }
.ms-row.on .nm { color: #fff; }
.ms-row .nm { flex: 1; display: flex; align-items: center; gap: 6px; }
/* grouped: indented leaves under a non-selectable parent header */
.ms-group { padding: 8px 12px 3px; font-size: .72rem; font-weight: 700; letter-spacing: .4px;
text-transform: uppercase; color: #7f8a8a; background: #101010; border-top: 1px solid #222; }
.ms-row.leaf { padding-left: 26px; }
.ai-tag { font-size: .56rem; font-weight: 700; padding: 0 4px; border-radius: 2px; background: #00e5ff; color: #002; letter-spacing: .3px; }
.ck { width: 16px; height: 16px; border: 1px solid #555; border-radius: 2px; display: flex; align-items: center; justify-content: center; font-size: .72rem; color: #002; background: transparent; flex: none; }
.ms-row.on .ck { background: #00e5ff; border-color: #00e5ff; }
+28 -15
View File
@@ -30,7 +30,7 @@
// --- Derived taxonomy option lists (resolve by slug, never hardcoded ids) ---
let speciesOpts: Opt[] = [];
let woodCatOpts: Opt[] = [];
let woodCatGroups: import('./MultiSelect.svelte').Group[] = [];
let edgeOpts: Opt[] = [];
let figureOpts: Opt[] = [];
let gradeOpts: Opt[] = [];
@@ -57,7 +57,17 @@
return (attr?.terms ?? []).map((t) => ({ id: t.id, name: t.name, ai: false }));
};
speciesOpts = childrenOf(speciesGrouper).map((c) => ({ id: c.id, name: c.name, ai: false }));
woodCatOpts = childrenOf(wcatGrouper).map((c) => ({ id: c.id, name: c.name, ai: false }));
// Wood categories: parent/child grouped (METADATA-10 — parents group only, never
// assigned). Each direct child of `wcat` that has its own children becomes a parent
// group with those children as leaves; a child with no children is a leaf under the
// (implicit) top group. This keeps the full tree visible with non-selectable parents.
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 };
});
edgeOpts = termsOf('edge type');
figureOpts = termsOf('figure');
gradeOpts = termsOf('grade');
@@ -216,7 +226,7 @@
}
if (s.edge_type_term_id) edgeTypeId = s.edge_type_term_id;
if (s.figure_term_ids) { figureIds = [...s.figure_term_ids]; recs.push(...s.figure_term_ids); }
if (s.grade_term_id) gradeId = s.grade_term_id;
// Grade is not AI-suggested (METADATA-40) — owner judgment. Do not auto-fill.
aiRecommendations = recs;
aiNote = recs.length ? `AI suggested ${recs.length} field${recs.length === 1 ? '' : 's'} — review & edit below.` : 'No confident AI suggestions.';
await commitReview();
@@ -330,7 +340,7 @@
<!-- Wood categories (multi, searchable list, AI-assist) -->
<MultiSelect
label="Wood categories"
options={woodCatOpts}
groups={woodCatGroups}
selected={woodCatIds}
placeholder="Choose wood categories"
testid="wood-cat"
@@ -349,18 +359,21 @@
</div>
<!-- Figure (multi, searchable list, AI-assist) -->
<MultiSelect
label="Figure"
options={figureOpts}
selected={figureIds}
placeholder="Choose figure"
testid="figure"
onToggle={(id) => (figureIds = toggleMulti(figureIds, id))}
/>
<!-- Grade / Thickness / Moisture (single, AI-assist) -->
<div class="field">
<label class="lbl" for="grade">Grade <span class="ai">AI assist</span></label>
<label class="lbl">Figure <span class="ai">AI assist</span></label>
<MultiSelect
label="Figure"
options={figureOpts}
selected={figureIds}
placeholder="Choose figure"
testid="figure"
onToggle={(id) => (figureIds = toggleMulti(figureIds, id))}
/>
</div>
<!-- Grade (single, owner judgment — NOT AI-assisted per METADATA-40) -->
<div class="field">
<label class="lbl" for="grade">Grade</label>
<select id="grade" class="sel" data-testid="grade" bind:value={gradeId} on:change={commitReview}>
<option value="">— select —</option>
{#each gradeOpts as o (o.id)}<option value={o.id}>{o.name}</option>{/each}