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:
+83
-23
@@ -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\": <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 = (
|
||||
"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 '<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:
|
||||
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}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user