From cec03c5bd3a68b8efc7c78d20f8af323e54e2b28 Mon Sep 17 00:00:00 2001 From: Ty Date: Sun, 20 Sep 2026 21:47:36 -0700 Subject: [PATCH] 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 --- backend/inference.py | 106 +++++++++++---- backend/tests/test_slabs_api.py | 22 ++++ docs/REQUIREMENTS.md | 2 +- frontend/src/create/review/MultiSelect.svelte | 121 +++++++++++------- frontend/src/create/review/Review.svelte | 43 ++++--- 5 files changed, 212 insertions(+), 82 deletions(-) diff --git a/backend/inference.py b/backend/inference.py index 0022ec0..99de817 100644 --- a/backend/inference.py +++ b/backend/inference.py @@ -20,12 +20,18 @@ from .crypto import decrypt router = APIRouter(prefix="/api/v1") CALL1_SEED = ( - "Classify the slab's species, wood categories, edge type, figure, and grade from the " - "supplied photos, choosing only from the supplied synced taxonomy lists, and return the " - "result fields as JSON: species_id, wood_category_ids, edge_type_term_id, figure_term_ids, " - "grade_term_id, character, inclusions, voids, checks. Report a confidence (0-1) for each " - "classified field. Map free-text observations to the supplied feature-tag list only. " - "Invent no category or term." + "Classify the slab's species, wood categories, edge type, and figure from the supplied " + "photos, choosing ONLY from the supplied synced taxonomy lists (invent no category, term, " + "or id outside them). Map free-text observations (character, inclusions, voids, checks) to " + "the supplied feature-tag list only. Grade is NOT suggested — leave it out.\n" + "Return a single JSON object with EXACTLY these top-level keys, in this shape:\n" + "{\"species_id\": , \"species_confidence\": <0-1>, " + "\"wood_category_ids\": [, ...], \"wood_category_confidence\": <0-1>, " + "\"edge_type_term_id\": , \"edge_confidence\": <0-1>, " + "\"figure_term_ids\": [, ...], \"figure_confidence\": <0-1>, " + "\"character\": [...], \"inclusions\": [...], \"voids\": [...], \"checks\": [...]}\n" + "Each *_confidence is a top-level sibling of its field (do NOT nest confidences under a " + "'confidence' object). Use 0-1 numbers; set a field to null / [] when you cannot identify it." ) CALL2_SEED = ( "Write the long product description for the slab from the supplied photos and listing " @@ -228,14 +234,49 @@ async def call1(slab_id: str) -> dict: def _taxonomy_lists(conn) -> dict: - out = {"species_leaves": [], "edge_terms": [], "figure_terms": [], "grade_terms": [], - "thickness_terms": [], "moisture_terms": [], "feat_tags": []} - for r in conn.execute("SELECT id,name FROM woo_categories WHERE parent_id IS NULL").fetchall(): - out["species_leaves"].append({"id": r[0], "name": r[1]}) - for attr, key in (("Edge Type", "edge_terms"), ("Figure", "figure_terms"), ("Grade", "grade_terms"), - ("Thickness", "thickness_terms"), ("Moisture", "moisture_terms")): + # METADATA-10: categories are leaves only; parents group only. Resolve groupers BY SLUG + # (never hardcoded id, QUALITY-105) and match attribute names case-insensitively (the + # live Woo attribute is 'Edge type', not 'Edge Type'). + out = {"species_leaves": [], "wood_category_leaves": [], "edge_terms": [], "figure_terms": [], + "grade_terms": [], "thickness_terms": [], "moisture_terms": [], "feat_tags": []} + # species = leaves under the 'species' grouper + sp = conn.execute("SELECT id FROM woo_categories WHERE slug='species'").fetchone() + if sp: + for r in conn.execute( + "SELECT id,name FROM woo_categories WHERE parent_id=?", (sp[0],) + ).fetchall(): + out["species_leaves"].append({"id": r[0], "name": r[1]}) + # wood categories = leaves under the 'wcat' grouper (leaves may sit under sub-groupers). + wc = conn.execute("SELECT id FROM woo_categories WHERE slug='wcat'").fetchone() + if wc: + kids = [r[0] for r in conn.execute( + "SELECT id FROM woo_categories WHERE parent_id=?", (wc[0],) + ).fetchall()] + leaf_ids = [] + for cid in kids: + has_kids = conn.execute( + "SELECT 1 FROM woo_categories WHERE parent_id=? LIMIT 1", (cid,) + ).fetchone() + if has_kids: + leaf_ids.extend(r[0] for r in conn.execute( + "SELECT id FROM woo_categories WHERE parent_id=?", (cid,) + ).fetchall()) + else: + leaf_ids.append(cid) + if leaf_ids: + q = ",".join("?" * len(leaf_ids)) + for r in conn.execute( + "SELECT id,name FROM woo_categories WHERE id IN (%s)" % q, leaf_ids + ).fetchall(): + out["wood_category_leaves"].append({"id": r[0], "name": r[1]}) + # attribute terms, case-insensitive on the attribute name. Grade stays in the synced set + # (owner reference) but the model is told not to suggest it (METADATA-40). + for attr, key in (("Edge type", "edge_terms"), ("Figure", "figure_terms"), + ("Grade", "grade_terms"), ("Thickness", "thickness_terms"), + ("Moisture", "moisture_terms")): rows = conn.execute( - "SELECT t.id,t.name FROM woo_attribute_terms t JOIN woo_attributes a ON t.attribute_id=a.id WHERE a.name=?", + "SELECT t.id,t.name FROM woo_attribute_terms t JOIN woo_attributes a ON t.attribute_id=a.id " + "WHERE lower(a.name)=lower(?)", (attr,), ).fetchall() out[key] = [{"id": r[0], "name": r[1]} for r in rows] @@ -257,24 +298,43 @@ def _parse_json(raw: str) -> dict: def _gate(parsed: dict) -> dict: - """Apply the 0.7 confidence gate; below-threshold fields are omitted (METADATA-45).""" - def keep(field: str, conf_key: str) -> Any: - conf = parsed.get(conf_key) + """Apply the 0.7 confidence gate; below-threshold fields are omitted (METADATA-45). + + Confidence is read from the flat '_confidence' keys; as a safety net it also + understands the nested 'confidence: {: n}' shape some models emit, so a shape + wobble cannot silently nuke every suggestion. Grade is not suggested (METADATA-40). + """ + # Map each field to its confidence key. The field name and the confidence key do NOT + # share a stem (e.g. field 'species_id' -> key 'species_confidence'), so derive it + # explicitly rather than f"{field}_confidence". + conf_key = { + "species_id": "species_confidence", + "wood_category_ids": "wood_category_confidence", + "edge_type_term_id": "edge_confidence", + "figure_term_ids": "figure_confidence", + } + conf_obj = parsed.get("confidence") if isinstance(parsed.get("confidence"), dict) else {} + def _conf(field: str) -> Any: + k = conf_key.get(field, f"{field}_confidence") + if k in conf_obj: + return conf_obj[k] + return parsed.get(k) + def keep(field: str) -> Any: + conf = _conf(field) if isinstance(conf, (int, float)) and conf >= _THRESHOLD and field in parsed: return parsed[field] return None out: dict[str, Any] = {} - if v := keep("species_id", "species_confidence"): + if v := keep("species_id"): out["species_id"] = v - if v := keep("wood_category_ids", "wood_category_confidence"): + if v := keep("wood_category_ids"): out["wood_category_ids"] = v - if v := keep("edge_type_term_id", "edge_confidence"): + if v := keep("edge_type_term_id"): out["edge_type_term_id"] = v - if v := keep("figure_term_ids", "figure_confidence"): + if v := keep("figure_term_ids"): out["figure_term_ids"] = v - if v := keep("grade_term_id", "grade_confidence"): - out["grade_term_id"] = v + # grade intentionally not gated/suggested (METADATA-40: owner judgment). for k in ("character", "inclusions", "voids", "checks"): if parsed.get(k): out[k] = parsed[k] @@ -361,4 +421,4 @@ async def test_inference() -> dict: except InferenceError as e: return {"ok": False, "mode": "fail", "vision_capable": False, "detail": str(e)} detail = f"reachable in {int((time.time()-t0)*1000)}ms" - return {"ok": True, "mode": "stateless", "vision_capable": True, "detail": detail} + return {"ok": True, "mode": "stateless", "vision_capable": True, "detail": detail} \ No newline at end of file diff --git a/backend/tests/test_slabs_api.py b/backend/tests/test_slabs_api.py index 1f5670e..8cfe8e3 100644 --- a/backend/tests/test_slabs_api.py +++ b/backend/tests/test_slabs_api.py @@ -201,6 +201,28 @@ def test_create_null_sku_allowed(client, db): assert resp.json()["sku"] is None +def test_gate_reads_flat_confidence_keys(client, db): + # Regression: the gate's confidence keys are 'species_confidence' / 'edge_confidence' + # etc., NOT f'{field}_confidence' (field 'species_id' would give the wrong key). A + # high-confidence response must surface its picks; low-confidence ones must not. + from backend.inference import _gate, _parse_json + good = '```json\n{"species_id": 284, "species_confidence": 0.85, "wood_category_ids": [251], "wood_category_confidence": 1.0, "edge_type_term_id": 323, "edge_confidence": 0.95, "figure_term_ids": [332], "figure_confidence": 0.9, "character": [], "inclusions": ["bark"], "voids": [], "checks": []}\n```' + out = _gate(_parse_json(good)) + assert out["species_id"] == 284 + assert out["wood_category_ids"] == [251] + assert out["edge_type_term_id"] == 323 + assert out["figure_term_ids"] == [332] + # grade must never be gated in (METADATA-40) + assert "grade_term_id" not in out + # low-confidence fields are dropped (METADATA-45) + low = '```json\n{"species_id": 284, "species_confidence": 0.4, "wood_category_ids": [251], "wood_category_confidence": 0.8, "edge_type_term_id": 323, "edge_confidence": 0.2, "figure_term_ids": [332], "figure_confidence": 0.9}\n```' + out2 = _gate(_parse_json(low)) + assert out2["wood_category_ids"] == [251] + assert out2["figure_term_ids"] == [332] + assert "species_id" not in out2 + assert "edge_type_term_id" not in out2 + + def test_ready_gate_rejects_malformed_sku(client, db): # REVIEW-45 enforced in the middle tier: a present-but-out-of-range/char-set SKU is not ready. from backend.review import compute_ready, sku_valid diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index c5cb3c4..2d21a89 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -64,7 +64,7 @@ The listing is built in two inference calls, both optional. Call 1 (vision) read | METADATA-30 | Product tags come from the synced Woo set and are all optional; no hardcoded prefixes, and the bare fig tag is kept. | M3 | — | | METADATA-100 | Call 1 free-text observations (character, inclusions, voids, checks) are mapped to existing synced feat-* tags only; WooCommerce is the authority for feat-* definitions. | M3 | — | | METADATA-35 | Call 1 runs only when inference is enabled, automatically after a calibrated draft uploads or on demand, and sends the clean processed photos at 1024 px long edge or less plus user metadata and the synced taxonomy. | M3 | — | -| METADATA-40 | Call 1 returns species, wood categories, edge, figure, grade, and feature-tag suggestions constrained to the synced lists, each with confidence. | M3 | — | +| METADATA-40 | Call 1 returns species, wood categories, edge, figure, and feature-tag suggestions constrained to the synced lists, each with confidence. Grade is not suggested by Call 1 (owner judgment). | M3 | — | | METADATA-45 | Call 1 confidence at or above the threshold (default 0.7) pre-fills review fields; below it the field stays empty for the owner to fill by hand or re-run Call 1; suggestions are always editable. | M3 | — | | METADATA-50 | Typed values always win over inferred values. | M3 | — | | METADATA-85 | The primary wood category selects the Call 2 prompt and the title and short-description patterns. | M3 | — | diff --git a/frontend/src/create/review/MultiSelect.svelte b/frontend/src/create/review/MultiSelect.svelte index 29719db..7f818ed 100644 --- a/frontend/src/create/review/MultiSelect.svelte +++ b/frontend/src/create/review/MultiSelect.svelte @@ -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; }
@@ -66,28 +75,50 @@ {allFilteredSelected ? 'None' : 'All'}
-
    - {#each filtered as o (o.id)} -
  • toggle(o.id)} - > - - - {o.name} - {#if o.ai}AI{/if} - -
  • - {:else} -
  • No matches
  • - {/each} -
+ + {#if grouped} +
    + {#each filteredGroups as g (g.parent.id)} + + {#each g.leaves as o (o.id)} +
  • toggle(o.id)} + > + + {o.name}{#if o.ai}AI{/if} +
  • + {/each} + {:else} +
  • No matches
  • + {/each} +
+ {:else} +
    + {#each filteredFlat as o (o.id)} +
  • toggle(o.id)} + > + + {o.name}{#if o.ai}AI{/if} +
  • + {:else} +
  • No matches
  • + {/each} +
+ {/if} +
- {options.length} synced{filter.trim() ? ` · ${filtered.length} shown` : ''} + {allOpts.length} synced{filter.trim() ? ` · ${filteredFlat.length} shown` : ''}
@@ -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; } diff --git a/frontend/src/create/review/Review.svelte b/frontend/src/create/review/Review.svelte index 80262c3..47a0205 100644 --- a/frontend/src/create/review/Review.svelte +++ b/frontend/src/create/review/Review.svelte @@ -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 @@ - (figureIds = toggleMulti(figureIds, id))} - /> - -
- + + (figureIds = toggleMulti(figureIds, id))} + /> +
+ + +
+