diff --git a/backend/inference.py b/backend/inference.py index 8f93b20..af52c83 100644 --- a/backend/inference.py +++ b/backend/inference.py @@ -61,19 +61,47 @@ def _image_part_from_processed(png_bytes: bytes) -> dict: b64 = base64.b64encode(fitted).decode() return {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}} +# Locked Call 1 seed (#86 / METADATA-40): expert helper + closed Woo lists only. CALL1_SEED = ( - "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." + "You are a wood identification expert for retail lumber slabs. Judge only from the photos " + "and the LISTS below. Use list ids only — never invent a name or id.\n" + "\n" + "Work in this order: (1) species, (2) wood categories, (3) edge type, (4) figure, " + "(5) feature tags. Decide wood categories before edge type — edge rules depend on product form.\n" + "\n" + "For each field, pick from its list and give a confidence 0–1 (1 = certain, 0 = no idea). " + "Prefer null or [] over a weak guess.\n" + "\n" + "Species — which tree species is this slab. Match grain, color, and figure to the species " + "list. If more than one entry is plausible, return only the single highest-confidence match " + "(one id). Null only when none are credible.\n" + "\n" + "Wood categories — product form / how it will be sold (live edge, dimensional, cookies & " + "burl slices, turning blanks, etc.). Choose every wood_categories id that clearly fits; " + "often one, sometimes more. [] if none fit. Finish this field before edge type.\n" + "\n" + "Edge type — the long-edge mill profile. One id from edge_terms, or null only if none fit.\n" + " Meanings (match the closest list name):\n" + " • Square / square edge — both long edges milled straight (2 milled long edges).\n" + " • Live one side / Live Edge 1 side — one long edge milled, the other natural bark or " + "waney (1 milled long edge).\n" + " • Live both / Live edge 2 sides — neither long edge milled; both natural " + "(0 milled long edges).\n" + " • Wild edge — use when the three mill-count cases do not fit, including cookies and burl " + "cuts/slices (no conventional slab long-edge pair). Prefer Wild for those forms rather than null.\n" + "\n" + "Figure — visible figure in the wood (curl, quilt, spalt, plain, burl, …). Zero or more ids " + "from figure_terms that clearly apply.\n" + "\n" + "Feature tags — defects and character marks the store tags for shoppers (knots, checks, " + "inclusions, …). Zero or more ids from feature_tags. If that list is empty, return [].\n" + "\n" + "Return one JSON object with exactly these keys:\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>, ' + '"feature_tag_ids": [,...], "feature_tag_confidence": <0-1>}' ) CALL2_SEED = ( "Write the long product description for the slab from the supplied photos and listing " @@ -100,19 +128,53 @@ def _hash(text: str) -> str: def _seed_prompts(conn) -> None: + """Ensure each call has a prompt row and the shipped seed revision. + + If the shipped seed hash is new and the active revision is still seed-authored + (or missing), activate the new seed so deploys pick up CALL1_SEED / CALL2_SEED + updates without clobbering an owner-activated API revision. + """ for name, seed in _SEEDS.items(): - if conn.execute("SELECT 1 FROM prompts WHERE name=?", (name,)).fetchone(): - continue - cur = conn.execute( - "INSERT INTO prompts(name, active_revision_id, created_at, updated_at) VALUES (?,?,?,?)", - (name, None, _now(), _now()), - ) - pid = cur.lastrowid - cur2 = conn.execute( - "INSERT INTO prompt_revisions(prompt_id, content, content_hash, created_at, created_by) VALUES (?,?,?,?,?)", - (pid, seed, _hash(seed), _now(), "seed"), - ) - conn.execute("UPDATE prompts SET active_revision_id=? WHERE id=?", (cur2.lastrowid, pid)) + prow = conn.execute("SELECT id, active_revision_id FROM prompts WHERE name=?", (name,)).fetchone() + if prow is None: + cur = conn.execute( + "INSERT INTO prompts(name, active_revision_id, created_at, updated_at) VALUES (?,?,?,?)", + (name, None, _now(), _now()), + ) + pid = cur.lastrowid + active_id = None + else: + pid = prow[0] + active_id = prow[1] + h = _hash(seed) + existing = conn.execute( + "SELECT id FROM prompt_revisions WHERE prompt_id=? AND content_hash=?", + (pid, h), + ).fetchone() + if existing is None: + cur2 = conn.execute( + "INSERT INTO prompt_revisions(prompt_id, content, content_hash, created_at, created_by) " + "VALUES (?,?,?,?,?)", + (pid, seed, h, _now(), "seed"), + ) + seed_rev_id = cur2.lastrowid + else: + seed_rev_id = existing[0] + # Activate shipped seed when nothing active, or active is still a seed revision. + activate = False + if active_id is None: + activate = True + else: + act = conn.execute( + "SELECT created_by FROM prompt_revisions WHERE id=?", (active_id,) + ).fetchone() + if act is None or act[0] == "seed": + activate = True + if activate and active_id != seed_rev_id: + conn.execute( + "UPDATE prompts SET active_revision_id=?, updated_at=? WHERE id=?", + (seed_rev_id, _now(), pid), + ) def _active_prompt(conn, call: str) -> tuple[int, str] | None: @@ -243,6 +305,8 @@ async def call1(slab_id: str) -> dict: if not base or not key: return JSONResponse(status_code=409, content=_error("inference_unconfigured", "Inference is not configured")) client = InferenceClient(base, key, model) + _seed_prompts(conn) + conn.commit() act = _active_prompt(conn, "call1") prompt_text = act[1] if act else CALL1_SEED # Build vision message with processed photos (METADATA-35: long edge <= 1024) + taxonomy. @@ -252,7 +316,7 @@ async def call1(slab_id: str) -> dict: ).fetchall() image_parts = [_image_part_from_processed(p[0]) for p in photos] tax = _taxonomy_lists(conn) - text = (f"{prompt_text}\n\nSYNCED TAXONOMY (only legal choices):\n{json.dumps(tax)}\n\n" + text = (f"{prompt_text}\n\nLISTS:\n{json.dumps(tax, ensure_ascii=False)}\n\n" f"SKU={row['sku']} length_in={row['length_in']} thickness_in={row['thickness_in']}") messages = [{"role": "user", "content": image_parts + [{"type": "text", "text": text}]}] t0 = time.time() @@ -270,19 +334,37 @@ async def call1(slab_id: str) -> dict: return {"ok": True, "suggestions": suggestions} +def _feat_display_name(name: str) -> str: + """Strip leading feat- / feat_ for the model (#86); Woo id stays authoritative.""" + n = (name or "").strip() + low = n.lower() + for prefix in ("feat-", "feat_"): + if low.startswith(prefix): + return n[len(prefix):].strip() or n + return n + + def _taxonomy_lists(conn) -> dict: - # 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": []} + """Closed Call 1 lists only (#86 / METADATA-40). + + Keys match the seed prompt. No grade/thickness/moisture (owner fields). + Feature tag names are shown without the feat- prefix; ids are Woo/local tag ids. + Groupers resolved by slug only (QUALITY-105). + """ + out: dict[str, list] = { + "species": [], + "wood_categories": [], + "edge_terms": [], + "figure_terms": [], + "feature_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"].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: @@ -305,12 +387,8 @@ def _taxonomy_lists(conn) -> dict: 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")): + out["wood_categories"].append({"id": r[0], "name": r[1]}) + for attr, key in (("Edge type", "edge_terms"), ("Figure", "figure_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 lower(a.name)=lower(?)", @@ -318,7 +396,7 @@ def _taxonomy_lists(conn) -> dict: ).fetchall() out[key] = [{"id": r[0], "name": r[1]} for r in rows] for r in conn.execute("SELECT id,name FROM woo_tags WHERE slug LIKE 'feat-%'").fetchall(): - out["feat_tags"].append({"id": r[0], "name": r[1]}) + out["feature_tags"].append({"id": r[0], "name": _feat_display_name(r[1])}) return out @@ -339,23 +417,24 @@ def _gate(parsed: dict) -> dict: 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). + wobble cannot silently nuke every suggestion. Grade is never suggested (METADATA-40). + Free-text observation bags are dropped — feature tags are ids only (#86). """ - # 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", + "feature_tag_ids": "feature_tag_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: @@ -371,10 +450,9 @@ def _gate(parsed: dict) -> dict: out["edge_type_term_id"] = v if v := keep("figure_term_ids"): out["figure_term_ids"] = 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] + if v := keep("feature_tag_ids"): + out["feature_tag_ids"] = v + # grade / free-text bags intentionally omitted (METADATA-40, #86). return out diff --git a/backend/tests/test_inference.py b/backend/tests/test_inference.py index f72c9ce..8641fd4 100644 --- a/backend/tests/test_inference.py +++ b/backend/tests/test_inference.py @@ -58,6 +58,101 @@ def test_prompt_seed_and_revision(env): assert client.get("/api/v1/prompts/call1").json()["active_content"] is not None +def test_call1_seed_is_locked_expert_prompt(env): + """#86: shipped Call 1 seed is the locked expert prompt (no free-text bags, no grade).""" + client, _conn = env + from backend.inference import CALL1_SEED + + body = client.get("/api/v1/prompts/call1").json() + assert body["active_content"] == CALL1_SEED + assert "wood identification expert" in CALL1_SEED + assert "feature_tag_ids" in CALL1_SEED + assert "Decide wood categories before edge type" in CALL1_SEED + assert "highest-confidence" in CALL1_SEED + assert "Wild edge" in CALL1_SEED + assert "cookies and burl" in CALL1_SEED.lower() or "cookies and burl" in CALL1_SEED + assert "grade_term_id" not in CALL1_SEED + assert '"character"' not in CALL1_SEED + assert '"inclusions"' not in CALL1_SEED + assert "character\":" not in CALL1_SEED.replace(" ", "") + + +def test_taxonomy_lists_call1_closed_shape(): + """#86: Call 1 lists are closed Woo picks only — no grade/thickness/moisture; feat names stripped.""" + import tempfile, os, shutil + tmp = tempfile.mkdtemp(prefix="tax_") + os.environ["SLAB_DB_PATH"] = os.path.join(tmp, "t.db") + os.environ["SLAB_IMAGES_DIR"] = tmp + os.environ.setdefault("WOO_BASE_URL", "https://x.com") + os.environ.setdefault( + "SLAB_AES_KEY", + base64.b64encode(b"0123456789abcdef0123456789abcdef").decode(), + ) + db_mod._reset_for_tests() + conn = db_mod.init_db() + conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (10,'Species','species',NULL,10)") + conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (11,'Walnut','walnut',10,11)") + conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (20,'Wcat','wcat',NULL,20)") + conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (21,'Live edge wood','liveedge',20,21)") + conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (6,'Edge type','pa_etype',6)") + conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (8,'Figure','pa_fig',8)") + conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (9,'Grade','pa_gr',9)") + conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (10,'Thickness','pa_thk',10)") + conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (7,'Moisture','pa_mst',7)") + conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (321,6,'Square edge','sqe',321)") + conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (328,8,'Plain','pl',328)") + conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (342,9,'Fancy','fnc',342)") + conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (350,10,'1\"- 1 1/2\"','4-6qtr',350)") + conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (326,7,'Kiln dried','kd',326)") + conn.execute("INSERT INTO woo_tags(id,name,slug,woo_id) VALUES (1,'feat-knot','feat-knot',1)") + conn.execute("INSERT INTO woo_tags(id,name,slug,woo_id) VALUES (2,'fig-curl','fig-curl',2)") + conn.commit() + from backend.inference import _taxonomy_lists + + tax = _taxonomy_lists(conn) + assert set(tax.keys()) == {"species", "wood_categories", "edge_terms", "figure_terms", "feature_tags"} + assert tax["species"] == [{"id": 11, "name": "Walnut"}] + assert tax["wood_categories"] == [{"id": 21, "name": "Live edge wood"}] + assert tax["edge_terms"] == [{"id": 321, "name": "Square edge"}] + assert tax["figure_terms"] == [{"id": 328, "name": "Plain"}] + # feat- prefix stripped for the model; fig- tags are not feature_tags + assert tax["feature_tags"] == [{"id": 1, "name": "knot"}] + assert "grade_terms" not in tax + assert "thickness_terms" not in tax + assert "moisture_terms" not in tax + db_mod._reset_for_tests() + shutil.rmtree(tmp, ignore_errors=True) + + +def test_gate_feature_tag_ids_not_free_text(): + """#86: gate keeps feature_tag_ids at threshold; drops free-text bags and grade.""" + from backend.inference import _gate + + parsed = { + "species_id": 11, "species_confidence": 0.9, + "wood_category_ids": [21], "wood_category_confidence": 0.8, + "edge_type_term_id": 321, "edge_confidence": 0.85, + "figure_term_ids": [328, 335], "figure_confidence": 0.75, + "feature_tag_ids": [1], "feature_tag_confidence": 0.8, + "grade_term_id": 342, "grade_confidence": 0.99, + "character": ["rustic"], "inclusions": ["bark"], "voids": [], "checks": ["end"], + } + out = _gate(parsed) + assert out["species_id"] == 11 + assert out["wood_category_ids"] == [21] + assert out["edge_type_term_id"] == 321 + assert out["figure_term_ids"] == [328, 335] + assert out["feature_tag_ids"] == [1] + assert "grade_term_id" not in out + assert "character" not in out + assert "inclusions" not in out + assert "voids" not in out + assert "checks" not in out + # below threshold feature tags dropped + low = {**parsed, "feature_tag_confidence": 0.2} + assert "feature_tag_ids" not in _gate(low) + + def test_call1_confidence_gate(env, monkeypatch): client, conn = env # enable inference @@ -69,11 +164,18 @@ def test_call1_confidence_gate(env, monkeypatch): from backend.inference import InferenceClient def fake_chat(self, messages): + # Capture list header for #86 shape + text = messages[0]["content"][-1]["text"] + assert "LISTS:" in text + assert "grade_terms" not in text + assert "species_leaves" not in text return json.dumps({ "species_id": 1, "species_confidence": 0.9, "wood_category_ids": [1], "wood_category_confidence": 0.5, # below threshold - "grade_term_id": 7, "grade_confidence": 0.3, # below + "grade_term_id": 7, "grade_confidence": 0.99, # never suggested "figure_term_ids": [2], "figure_confidence": 0.8, # at/above + "feature_tag_ids": [9], "feature_tag_confidence": 0.9, + "character": ["x"], "inclusions": ["y"], }) monkeypatch.setattr(InferenceClient, "chat", fake_chat) r = client.post("/api/v1/slabs/s1/inference/call1") @@ -81,8 +183,10 @@ def test_call1_confidence_gate(env, monkeypatch): s = r.json()["suggestions"] assert s.get("species_id") == 1 assert s.get("figure_term_ids") == [2] + assert s.get("feature_tag_ids") == [9] assert "wood_category_ids" not in s # gated out - assert "grade_term_id" not in s # gated out + assert "grade_term_id" not in s # never + assert "character" not in s # inference_log written with call=1 assert conn.execute("SELECT call FROM inference_log").fetchone()["call"] == 1 diff --git a/backend/tests/test_slabs_api.py b/backend/tests/test_slabs_api.py index 8cfe8e3..f9aef2c 100644 --- a/backend/tests/test_slabs_api.py +++ b/backend/tests/test_slabs_api.py @@ -206,14 +206,18 @@ def test_gate_reads_flat_confidence_keys(client, db): # 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```' + 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, "feature_tag_ids": [1], "feature_tag_confidence": 0.8, "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] + assert out["feature_tag_ids"] == [1] # grade must never be gated in (METADATA-40) assert "grade_term_id" not in out + # free-text bags dropped (#86) + assert "character" not in out + assert "inclusions" 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)) diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index fd3e6ea..7687a05 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -62,9 +62,9 @@ The listing is built in two inference calls, both optional. Call 1 (vision) read | METADATA-20 | Missing attributes or terms are skipped, never created. | M3 | — | | METADATA-25 | Species, character, length, and free-text thickness are not product attributes. | 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 returns feature suggestions as feature_tag_ids chosen only from the synced feat-* tag set (display names without the feat- prefix); WooCommerce is the authority for those tags. No free-text observation bags. | M3 | backend/tests/test_inference.py | | 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 | backend/tests/test_inference.py | -| 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-40 | Call 1 returns species, wood categories, edge, figure (multi), and feature-tag id suggestions constrained to the synced LISTS, each with confidence. Grade is not suggested by Call 1 (owner judgment). Wood category is decided before edge; cookies/burl prefer Wild edge when present. | M3 | backend/tests/test_inference.py | | 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/docs/contracts/API.md b/docs/contracts/API.md index 857f77d..ddf6f54 100644 --- a/docs/contracts/API.md +++ b/docs/contracts/API.md @@ -144,9 +144,9 @@ block, and brand/geo context; no provider thread id and no raw Call 1 result. Re and never touches dimensions. Cites METADATA-55, METADATA-65. Call 1 result fields: `species_id`, `species_confidence`, `wood_category_ids`, -`edge_type_term_id`, `figure_term_ids`, `figure_confidence`, `grade_term_id`, `grade_confidence`, -`character`, `inclusions`, `voids`, `checks`. Free-text observations map to synced `feat-*` tags -only. +`wood_category_confidence`, `edge_type_term_id`, `edge_confidence`, `figure_term_ids`, +`figure_confidence`, `feature_tag_ids`, `feature_tag_confidence`. Feature tags are ids from the +synced `feat-*` set only (no free-text bags). Grade is never suggested. Call 2 result field: `description`. The name and short description are assembled by the server from the primary wood category's patterns, not returned by the call. diff --git a/docs/contracts/PROMPTS.md b/docs/contracts/PROMPTS.md index 6c4e570..409f6b6 100644 --- a/docs/contracts/PROMPTS.md +++ b/docs/contracts/PROMPTS.md @@ -46,20 +46,23 @@ true; also on demand or retry. With inference off it is skipped and the fields a **Inputs.** - The clean processed photos at 1024 px long edge or less. -- The synced taxonomy: species leaves, Edge Type, Figure, Grade, Thickness, Moisture terms, each - with its Woo id. These are the only legal choices. +- Closed LISTS from the synced Woo cache only: `species`, `wood_categories`, `edge_terms`, + `figure_terms`, `feature_tags` (each with id + display name). Grade, thickness, and moisture are + not sent to Call 1. - User metadata: SKU, length, thickness. **Constraints.** -- The model may only choose values from the supplied synced lists; it never invents categories or - terms. -- Free-text observations (character, inclusions, voids, checks) are mapped to existing synced - `feat-*` tags only; Woo is authoritative for those tags. +- The model may only choose values from the supplied LISTS; it never invents categories or terms. +- Feature tags are returned as `feature_tag_ids` from the supplied list only. Display names omit a + leading `feat-` prefix; Woo remains authoritative for the tag set. No free-text observation bags. +- Grade is never suggested (owner judgment). +- Decide wood categories before edge type. Cookies and burl cuts prefer Wild edge when that term + exists in the list. -**Result fields.** `species_id` + `species_confidence`; `wood_category_ids`; -`edge_type_term_id`; `figure_term_ids` + `figure_confidence`; `grade_term_id` + -`grade_confidence`; `character`, `inclusions`, `voids`, `checks`. Grade, edge, thickness, and -moisture are singular. +**Result fields.** `species_id` + `species_confidence`; `wood_category_ids` + +`wood_category_confidence`; `edge_type_term_id` + `edge_confidence`; `figure_term_ids` + +`figure_confidence` (multi allowed); `feature_tag_ids` + `feature_tag_confidence`. Edge is singular; +figure and feature tags are multi. **Pre-fill.** At or above the threshold (default 0.7) the field is pre-filled and remains editable; below it the field stays empty and is not shown as a low-confidence guess. Typed values @@ -110,11 +113,47 @@ application. The seed is the starting point for tuning, not a final text. Both calls are revised ad hoc through the §2 interface when UAT surfaces a miss. -**`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 in §3 as JSON. Report a confidence for each classified field. Map free-text observations -(character, inclusions, voids, checks) to the supplied feature-tag list only. Invent no category or -term. +**`call1` seed.** Shipped text in `backend/inference.py` (`CALL1_SEED`), mirrored here for review. + +You are a wood identification expert for retail lumber slabs. Judge only from the photos and the +LISTS below. Use list ids only — never invent a name or id. + +Work in this order: (1) species, (2) wood categories, (3) edge type, (4) figure, (5) feature tags. +Decide wood categories before edge type — edge rules depend on product form. + +For each field, pick from its list and give a confidence 0–1 (1 = certain, 0 = no idea). Prefer +null or [] over a weak guess. + +Species — which tree species is this slab. Match grain, color, and figure to the species list. If +more than one entry is plausible, return only the single highest-confidence match (one id). Null +only when none are credible. + +Wood categories — product form / how it will be sold (live edge, dimensional, cookies & burl +slices, turning blanks, etc.). Choose every wood_categories id that clearly fits; often one, +sometimes more. [] if none fit. Finish this field before edge type. + +Edge type — the long-edge mill profile. One id from edge_terms, or null only if none fit. + Meanings (match the closest list name): + • Square / square edge — both long edges milled straight (2 milled long edges). + • Live one side / Live Edge 1 side — one long edge milled, the other natural bark or waney + (1 milled long edge). + • Live both / Live edge 2 sides — neither long edge milled; both natural (0 milled long edges). + • Wild edge — use when the three mill-count cases do not fit, including cookies and burl + cuts/slices (no conventional slab long-edge pair). Prefer Wild for those forms rather than null. + +Figure — visible figure in the wood (curl, quilt, spalt, plain, burl, …). Zero or more ids from +figure_terms that clearly apply. + +Feature tags — defects and character marks the store tags for shoppers (knots, checks, +inclusions, …). Zero or more ids from feature_tags. If that list is empty, return []. + +Return one JSON object with exactly these keys: +{"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>, "feature_tag_ids": [,...], +"feature_tag_confidence": <0-1>} + +Runtime appends `LISTS:` (JSON of the five closed lists) plus SKU/length/thickness and the photos. **`call2` seed.** Write the long product description for the slab from the supplied photos and listing facts. The name and short description are already built, so do not repeat them. Use the diff --git a/frontend/src/api/slabs.ts b/frontend/src/api/slabs.ts index 5d3cfd0..66c275e 100644 --- a/frontend/src/api/slabs.ts +++ b/frontend/src/api/slabs.ts @@ -269,12 +269,8 @@ export interface Call1Suggestions { edge_type_term_id?: number | null; figure_term_ids?: number[]; figure_confidence?: number | null; - grade_term_id?: number | null; - grade_confidence?: number | null; - character?: string | null; - inclusions?: string | null; - voids?: string | null; - checks?: string | null; + feature_tag_ids?: number[]; + feature_tag_confidence?: number | null; } export interface Call1Result { diff --git a/frontend/src/create/review/Review.svelte b/frontend/src/create/review/Review.svelte index 47a0205..f8f3cc2 100644 --- a/frontend/src/create/review/Review.svelte +++ b/frontend/src/create/review/Review.svelte @@ -226,6 +226,10 @@ } 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.feature_tag_ids?.length) { + featTagIds = [...s.feature_tag_ids]; + recs.push(...s.feature_tag_ids); + } // 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.';