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:
+81
-21
@@ -20,12 +20,18 @@ from .crypto import decrypt
|
|||||||
router = APIRouter(prefix="/api/v1")
|
router = APIRouter(prefix="/api/v1")
|
||||||
|
|
||||||
CALL1_SEED = (
|
CALL1_SEED = (
|
||||||
"Classify the slab's species, wood categories, edge type, figure, and grade from the "
|
"Classify the slab's species, wood categories, edge type, and figure from the supplied "
|
||||||
"supplied photos, choosing only from the supplied synced taxonomy lists, and return the "
|
"photos, choosing ONLY from the supplied synced taxonomy lists (invent no category, term, "
|
||||||
"result fields as JSON: species_id, wood_category_ids, edge_type_term_id, figure_term_ids, "
|
"or id outside them). Map free-text observations (character, inclusions, voids, checks) to "
|
||||||
"grade_term_id, character, inclusions, voids, checks. Report a confidence (0-1) for each "
|
"the supplied feature-tag list only. Grade is NOT suggested — leave it out.\n"
|
||||||
"classified field. Map free-text observations to the supplied feature-tag list only. "
|
"Return a single JSON object with EXACTLY these top-level keys, in this shape:\n"
|
||||||
"Invent no category or term."
|
"{\"species_id\": <id or null>, \"species_confidence\": <0-1>, "
|
||||||
|
"\"wood_category_ids\": [<id>, ...], \"wood_category_confidence\": <0-1>, "
|
||||||
|
"\"edge_type_term_id\": <id or null>, \"edge_confidence\": <0-1>, "
|
||||||
|
"\"figure_term_ids\": [<id>, ...], \"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 = (
|
CALL2_SEED = (
|
||||||
"Write the long product description for the slab from the supplied photos and listing "
|
"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:
|
def _taxonomy_lists(conn) -> dict:
|
||||||
out = {"species_leaves": [], "edge_terms": [], "figure_terms": [], "grade_terms": [],
|
# METADATA-10: categories are leaves only; parents group only. Resolve groupers BY SLUG
|
||||||
"thickness_terms": [], "moisture_terms": [], "feat_tags": []}
|
# (never hardcoded id, QUALITY-105) and match attribute names case-insensitively (the
|
||||||
for r in conn.execute("SELECT id,name FROM woo_categories WHERE parent_id IS NULL").fetchall():
|
# 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]})
|
out["species_leaves"].append({"id": r[0], "name": r[1]})
|
||||||
for attr, key in (("Edge Type", "edge_terms"), ("Figure", "figure_terms"), ("Grade", "grade_terms"),
|
# wood categories = leaves under the 'wcat' grouper (leaves may sit under sub-groupers).
|
||||||
("Thickness", "thickness_terms"), ("Moisture", "moisture_terms")):
|
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(
|
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,),
|
(attr,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
out[key] = [{"id": r[0], "name": r[1]} for r in rows]
|
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:
|
def _gate(parsed: dict) -> dict:
|
||||||
"""Apply the 0.7 confidence gate; below-threshold fields are omitted (METADATA-45)."""
|
"""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)
|
Confidence is read from the flat '<field>_confidence' keys; as a safety net it also
|
||||||
|
understands the nested 'confidence: {<field>: 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:
|
if isinstance(conf, (int, float)) and conf >= _THRESHOLD and field in parsed:
|
||||||
return parsed[field]
|
return parsed[field]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
out: dict[str, Any] = {}
|
out: dict[str, Any] = {}
|
||||||
if v := keep("species_id", "species_confidence"):
|
if v := keep("species_id"):
|
||||||
out["species_id"] = v
|
out["species_id"] = v
|
||||||
if v := keep("wood_category_ids", "wood_category_confidence"):
|
if v := keep("wood_category_ids"):
|
||||||
out["wood_category_ids"] = v
|
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
|
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
|
out["figure_term_ids"] = v
|
||||||
if v := keep("grade_term_id", "grade_confidence"):
|
# grade intentionally not gated/suggested (METADATA-40: owner judgment).
|
||||||
out["grade_term_id"] = v
|
|
||||||
for k in ("character", "inclusions", "voids", "checks"):
|
for k in ("character", "inclusions", "voids", "checks"):
|
||||||
if parsed.get(k):
|
if parsed.get(k):
|
||||||
out[k] = parsed[k]
|
out[k] = parsed[k]
|
||||||
|
|||||||
@@ -201,6 +201,28 @@ def test_create_null_sku_allowed(client, db):
|
|||||||
assert resp.json()["sku"] is None
|
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):
|
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.
|
# 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
|
from backend.review import compute_ready, sku_valid
|
||||||
|
|||||||
@@ -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-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-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-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-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-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 | — |
|
| METADATA-85 | The primary wood category selects the Call 2 prompt and the title and short-description patterns. | M3 | — |
|
||||||
|
|||||||
@@ -2,11 +2,23 @@
|
|||||||
// Searchable multi-select list (the approved paradigm replacing tap-bubbles).
|
// Searchable multi-select list (the approved paradigm replacing tap-bubbles).
|
||||||
// Squared/slight-radius, type-to-filter, select-all, live count badge, per-row
|
// 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.
|
// 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';
|
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 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 selected: number[];
|
||||||
export let placeholder = 'Choose…';
|
export let placeholder = 'Choose…';
|
||||||
export let testid = '';
|
export let testid = '';
|
||||||
@@ -17,36 +29,33 @@
|
|||||||
let filter = '';
|
let filter = '';
|
||||||
let focused: number | null = null;
|
let focused: number | null = null;
|
||||||
|
|
||||||
$: aiSelected = selected.filter((id) => options.find((o) => o.id === id)?.ai);
|
const grouped = groups.length > 0;
|
||||||
$: filtered = filter.trim()
|
|
||||||
? options.filter((o) => o.name.toLowerCase().includes(filter.trim().toLowerCase()))
|
// Effective selectable set (leaves when grouped, options when flat).
|
||||||
: options;
|
$: 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;
|
$: 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) {
|
function toggle(id: number) {
|
||||||
onToggle(id);
|
onToggle(id);
|
||||||
focused = id;
|
focused = id;
|
||||||
}
|
}
|
||||||
function selectAll() {
|
function selectAll() {
|
||||||
// Toggle every currently-visible (filtered) option.
|
for (const o of filteredFlat) if (!selected.includes(o.id)) onToggle(o.id);
|
||||||
for (const o of filtered) {
|
|
||||||
if (!selected.includes(o.id)) onToggle(o.id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
function clearAll() {
|
function clearAll() {
|
||||||
for (const o of [...filtered]) {
|
for (const o of [...filteredFlat]) if (selected.includes(o.id)) onToggle(o.id);
|
||||||
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; }
|
|
||||||
}
|
}
|
||||||
|
function close() { open = false; filter = ''; dispatch('commit'); }
|
||||||
|
function onKey(e: KeyboardEvent) { if (e.key === 'Escape' && open) open = false; }
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="ms" class:open data-testid={testid} on:keydown={onKey}>
|
<div class="ms" class:open data-testid={testid} on:keydown={onKey}>
|
||||||
@@ -66,8 +75,31 @@
|
|||||||
{allFilteredSelected ? 'None' : 'All'}
|
{allFilteredSelected ? 'None' : 'All'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if grouped}
|
||||||
<ul class="ms-list">
|
<ul class="ms-list">
|
||||||
{#each filtered as o (o.id)}
|
{#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
|
<li
|
||||||
class="ms-row"
|
class="ms-row"
|
||||||
class:on={selected.includes(o.id)}
|
class:on={selected.includes(o.id)}
|
||||||
@@ -77,17 +109,16 @@
|
|||||||
on:click={() => toggle(o.id)}
|
on:click={() => toggle(o.id)}
|
||||||
>
|
>
|
||||||
<span class="ck" aria-hidden="true">{selected.includes(o.id) ? '✓' : ''}</span>
|
<span class="ck" aria-hidden="true">{selected.includes(o.id) ? '✓' : ''}</span>
|
||||||
<span class="nm">
|
<span class="nm">{o.name}{#if o.ai}<span class="ai-tag">AI</span>{/if}</span>
|
||||||
{o.name}
|
|
||||||
{#if o.ai}<span class="ai-tag">AI</span>{/if}
|
|
||||||
</span>
|
|
||||||
</li>
|
</li>
|
||||||
{:else}
|
{:else}
|
||||||
<li class="ms-empty">No matches</li>
|
<li class="ms-empty">No matches</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="ms-foot">
|
<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>
|
<button type="button" on:click={close} data-testid={testid ? `${testid}-done` : 'ms-done'}>Done</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -110,6 +141,10 @@
|
|||||||
.ms-row.on { background: #12262b; }
|
.ms-row.on { background: #12262b; }
|
||||||
.ms-row.on .nm { color: #fff; }
|
.ms-row.on .nm { color: #fff; }
|
||||||
.ms-row .nm { flex: 1; display: flex; align-items: center; gap: 6px; }
|
.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; }
|
.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; }
|
.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; }
|
.ms-row.on .ck { background: #00e5ff; border-color: #00e5ff; }
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
|
|
||||||
// --- Derived taxonomy option lists (resolve by slug, never hardcoded ids) ---
|
// --- Derived taxonomy option lists (resolve by slug, never hardcoded ids) ---
|
||||||
let speciesOpts: Opt[] = [];
|
let speciesOpts: Opt[] = [];
|
||||||
let woodCatOpts: Opt[] = [];
|
let woodCatGroups: import('./MultiSelect.svelte').Group[] = [];
|
||||||
let edgeOpts: Opt[] = [];
|
let edgeOpts: Opt[] = [];
|
||||||
let figureOpts: Opt[] = [];
|
let figureOpts: Opt[] = [];
|
||||||
let gradeOpts: Opt[] = [];
|
let gradeOpts: Opt[] = [];
|
||||||
@@ -57,7 +57,17 @@
|
|||||||
return (attr?.terms ?? []).map((t) => ({ id: t.id, name: t.name, ai: false }));
|
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 }));
|
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');
|
edgeOpts = termsOf('edge type');
|
||||||
figureOpts = termsOf('figure');
|
figureOpts = termsOf('figure');
|
||||||
gradeOpts = termsOf('grade');
|
gradeOpts = termsOf('grade');
|
||||||
@@ -216,7 +226,7 @@
|
|||||||
}
|
}
|
||||||
if (s.edge_type_term_id) edgeTypeId = s.edge_type_term_id;
|
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.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;
|
aiRecommendations = recs;
|
||||||
aiNote = recs.length ? `AI suggested ${recs.length} field${recs.length === 1 ? '' : 's'} — review & edit below.` : 'No confident AI suggestions.';
|
aiNote = recs.length ? `AI suggested ${recs.length} field${recs.length === 1 ? '' : 's'} — review & edit below.` : 'No confident AI suggestions.';
|
||||||
await commitReview();
|
await commitReview();
|
||||||
@@ -330,7 +340,7 @@
|
|||||||
<!-- Wood categories (multi, searchable list, AI-assist) -->
|
<!-- Wood categories (multi, searchable list, AI-assist) -->
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
label="Wood categories"
|
label="Wood categories"
|
||||||
options={woodCatOpts}
|
groups={woodCatGroups}
|
||||||
selected={woodCatIds}
|
selected={woodCatIds}
|
||||||
placeholder="Choose wood categories"
|
placeholder="Choose wood categories"
|
||||||
testid="wood-cat"
|
testid="wood-cat"
|
||||||
@@ -349,6 +359,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Figure (multi, searchable list, AI-assist) -->
|
<!-- Figure (multi, searchable list, AI-assist) -->
|
||||||
|
<div class="field">
|
||||||
|
<label class="lbl">Figure <span class="ai">AI assist</span></label>
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
label="Figure"
|
label="Figure"
|
||||||
options={figureOpts}
|
options={figureOpts}
|
||||||
@@ -357,10 +369,11 @@
|
|||||||
testid="figure"
|
testid="figure"
|
||||||
onToggle={(id) => (figureIds = toggleMulti(figureIds, id))}
|
onToggle={(id) => (figureIds = toggleMulti(figureIds, id))}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Grade / Thickness / Moisture (single, AI-assist) -->
|
<!-- Grade (single, owner judgment — NOT AI-assisted per METADATA-40) -->
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="lbl" for="grade">Grade <span class="ai">AI assist</span></label>
|
<label class="lbl" for="grade">Grade</label>
|
||||||
<select id="grade" class="sel" data-testid="grade" bind:value={gradeId} on:change={commitReview}>
|
<select id="grade" class="sel" data-testid="grade" bind:value={gradeId} on:change={commitReview}>
|
||||||
<option value="">— select —</option>
|
<option value="">— select —</option>
|
||||||
{#each gradeOpts as o (o.id)}<option value={o.id}>{o.name}</option>{/each}
|
{#each gradeOpts as o (o.id)}<option value={o.id}>{o.name}</option>{/each}
|
||||||
|
|||||||
Reference in New Issue
Block a user