From fca32396c03860716a1c9a75d7391eb214f055a3 Mon Sep 17 00:00:00 2001 From: grokbot_ty Date: Sat, 26 Sep 2026 22:50:15 -0700 Subject: [PATCH] fix(review): Create title no silent fail; gate enables content LLM Create title used to look enabled while review echoed a pre-typed title when species/primary were missing from taxonomy (no error). Persist wood cats before the deterministic build, return title_built / title_build_missing from review, and surface a Create-title nudge like Create desc. WooSetupGate now turns on content_llm_enabled when inference URL+key are set (Call 2 / Create desc). Call 1 stays out. Create-desc gate also requires inference URL+key so empty Settings show a clear missing list. --- backend/review.py | 33 +++++++++--- backend/tests/test_review.py | 24 +++++++++ frontend/src/api/slabs.ts | 3 ++ frontend/src/create/review/Review.svelte | 54 +++++++++++++++---- .../src/create/review/createDescGate.test.ts | 11 +++- frontend/src/create/review/createDescGate.ts | 7 +++ .../src/create/review/createTitleGate.test.ts | 23 ++++++++ frontend/src/create/review/createTitleGate.ts | 38 +++++++++++++ .../create/review/reviewUi.contract.test.ts | 2 + .../src/create/settings/WooSetupGate.svelte | 11 +++- 10 files changed, 187 insertions(+), 19 deletions(-) create mode 100644 frontend/src/create/review/createTitleGate.test.ts create mode 100644 frontend/src/create/review/createTitleGate.ts diff --git a/backend/review.py b/backend/review.py index 2488a0d..cc6c6f7 100644 --- a/backend/review.py +++ b/backend/review.py @@ -184,12 +184,23 @@ async def review_slab(slab_id: str, payload: dict[str, Any] | None = None) -> di # they are produced by the deterministic build below, not persisted from the # payload (so the review POST can't clobber the owner's Create workflow). cats, terms = _taxonomy_lookup(conn) - primary_id = payload.get("primary_wood_category_id") + + def _as_cat_id(v): + if v is None or v == "": + return None + try: + return int(v) + except (TypeError, ValueError): + return None + + primary_id = _as_cat_id(payload.get("primary_wood_category_id")) wood_cats = slab.get("wood_category_ids") or [] if not primary_id: - primary_id = wood_cats[0] if wood_cats else None - primary_leaf = cats.get(primary_id, {}) if primary_id else {} - species_name = cats.get(slab.get("species_id"), {}).get("name", "") if slab.get("species_id") else "" + primary_id = _as_cat_id(wood_cats[0]) if wood_cats else None + # Taxonomy rows use integer PKs; coerce so string JSON ids still resolve. + primary_leaf = cats.get(primary_id, {}) if primary_id is not None else {} + species_id = _as_cat_id(slab.get("species_id")) + species_name = cats.get(species_id, {}).get("name", "") if species_id is not None else "" figure_term_ids = slab.get("figure_term_ids") or [] figure = terms.get(figure_term_ids[0], {}).get("name") if figure_term_ids else None @@ -210,14 +221,22 @@ async def review_slab(slab_id: str, payload: dict[str, Any] | None = None) -> di # Deterministic title / short_description, re-derived from the current # metadata on every review (the Create button's source of truth). # #98: pass synced terms so Live Edge comes from the edge term, not any id. + # title_built tells the client whether the Create-title path actually ran + # (vs echoing a pre-typed title when species/primary are missing from taxonomy). + title_build_missing: list[str] = [] + if not species_name: + title_build_missing.append("species") + if not primary_leaf: + title_build_missing.append("primary_wood_category") + title_built = not title_build_missing title = ( build_title(slab, figure, species_name, primary_leaf, terms) - if (primary_leaf and species_name) + if title_built else slab.get("title") or None ) short_desc = ( build_short_description(slab, figure, species_name, primary_leaf, terms) - if (primary_leaf and species_name) + if title_built else slab.get("short_description") or None ) @@ -227,6 +246,8 @@ async def review_slab(slab_id: str, payload: dict[str, Any] | None = None) -> di "ready": ready, "missing": missing, "title": title, "short_description": short_desc, "price": price, "price_suggestion": price_suggestion(conn, slab), "primary_wood_category_id": primary_id, + "title_built": title_built, + "title_build_missing": title_build_missing, } diff --git a/backend/tests/test_review.py b/backend/tests/test_review.py index e1540ea..a5a11df 100644 --- a/backend/tests/test_review.py +++ b/backend/tests/test_review.py @@ -62,6 +62,8 @@ def test_title_live_edge_no_dims(env): body = r.json() assert body["title"] == "Figured Black Walnut Live Edge Slab" assert body["short_description"].startswith("Figured Black Walnut Live Edge Slab, 9ft 6in x 24.3in x 1.75in") + assert body["title_built"] is True + assert body["title_build_missing"] == [] assert "18.4 bdft" in body["short_description"] @@ -220,3 +222,25 @@ def test_ready_requires_figure_term_ids(env): }) # May still miss other fields depending on fixture; figure must not be listed assert "figure_term_ids" not in missing2 + + +def test_review_title_built_false_without_species_or_primary(env): + """Create-title silent-fail fix: report title_built=false when taxonomy cannot resolve.""" + client, conn = env + conn.execute( + "INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in," + "species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description," + "content_source,status,server_rev,client_rev,created_at,updated_at) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ("s1", None, 114.0, 1.75, 30.0, 18.4, 18.0, 24.3, 24.3, None, "[]", "[]", None, None, + "Typed Title", None, None, "manual", "calibrated", 1, 1, "t", "t"), + ) + conn.commit() + r = client.post("/api/v1/slabs/s1/review", json={}) + assert r.status_code == 200, r.text + body = r.json() + assert body["title_built"] is False + assert "species" in body["title_build_missing"] + assert "primary_wood_category" in body["title_build_missing"] + assert body["title"] == "Typed Title" + assert body["short_description"] is None diff --git a/frontend/src/api/slabs.ts b/frontend/src/api/slabs.ts index 66e1077..1ea82c6 100644 --- a/frontend/src/api/slabs.ts +++ b/frontend/src/api/slabs.ts @@ -306,6 +306,9 @@ export interface ReviewResult { price: number | null; price_suggestion: number | null; primary_wood_category_id: number | null; + /** True when deterministic title/short_desc were built from taxonomy. */ + title_built?: boolean; + title_build_missing?: string[]; } export interface ReviewInput { diff --git a/frontend/src/create/review/Review.svelte b/frontend/src/create/review/Review.svelte index f249494..e25d75d 100644 --- a/frontend/src/create/review/Review.svelte +++ b/frontend/src/create/review/Review.svelte @@ -7,7 +7,7 @@ type Call2Prompt, } from '../../api/slabs'; import type { CalibratedCapture } from '../flow'; - import { canCreateTitleFromSpecies, effectiveSpeciesId } from './speciesFromCapture'; + import { effectiveSpeciesId } from './speciesFromCapture'; import MultiSelect from './MultiSelect.svelte'; import { decodeName } from '../decodeName'; import { deriveFigTagIds, scopedFeatTags } from './figTags'; @@ -17,6 +17,7 @@ } from './lineup'; import { nextPrimary } from './woodPrimary'; import { missingCreateDescLabels } from './createDescGate'; + import { missingCreateTitleLabels, titleBuildMissingLabels } from './createTitleGate'; import { orderWoodCatGroups } from './woodCatOrder'; import { applyMoistureDefault } from './moistureDefault'; @@ -130,6 +131,7 @@ ready: boolean; missing: string[]; title: string | null; short_description: string | null; price: number | null; price_suggestion: number | null; primary_wood_category_id: number | null; + title_built?: boolean; title_build_missing?: string[]; } let review: ReviewOut | null = null; let reviewBusy = false; @@ -142,11 +144,17 @@ let createError = ''; // Prefer slab.species_id; fall back to Capture handoff if createSlab omitted it. $: resolvedSpeciesId = effectiveSpeciesId(slab?.species_id, capture?.speciesId ?? null); - $: canCreateTitle = canCreateTitleFromSpecies(resolvedSpeciesId, woodCatIds.length); + $: createTitleMissing = missingCreateTitleLabels({ + speciesId: resolvedSpeciesId, + woodCategoryCount: woodCatIds.length, + }); + $: canCreateTitle = createTitleMissing.length === 0; // Create-desc gate: recompute on every field change (no blur required). // Short/description are Call 2 outputs; price is publish-only. + // Needs content_llm_enabled + inference URL/key (Call 2); Call 1 stays out. $: createDescMissing = missingCreateDescLabels({ contentLlmEnabled: canGenerateContent, + inferenceConfigured, title, speciesId: resolvedSpeciesId, woodCategoryIds: woodCatIds, @@ -185,6 +193,12 @@ let genBusy = false; let genError = ''; let canGenerateContent = false; + /** Inference URL + API key both configured in Settings (Call 2 creds). */ + let inferenceConfigured = false; + const secretConfigured = (s: { configured: boolean } | string | null | undefined): boolean => { + if (s == null) return false; + return typeof s === 'string' ? s.length > 0 : !!s.configured; + }; const fmt1 = (v: number) => { const s = v.toFixed(1); return s.endsWith('.0') ? s.slice(0, -2) : s; }; const fmt2 = (v: number) => v.toFixed(2); @@ -487,22 +501,35 @@ // auto-fills only empty fields; this is the explicit "force overwrite" action. async function createTitle(): Promise { if (!slab || createBusy) return; + if (!canCreateTitle) { + createError = `Create title needs: ${createTitleMissing.join(', ')}`; + return; + } createBusy = true; createError = ''; try { - // 0. Repair: if Measure chose a species but createSlab omitted it, write it - // before the deterministic build (review reads species_id from the row). - if (resolvedSpeciesId != null && slab.species_id == null) { - slab = await updateSlab(slabId, { - species_id: resolvedSpeciesId, - client_rev: slab.server_rev, - }); - } + // 0. Persist species + wood categories before the deterministic build. + // Review reads the row / taxonomy; UI-only wood cats used to make Create + // title look enabled while the server echoed a pre-typed title (silent no-op). + const prePatch: Parameters[1] = { + wood_category_ids: woodCatIds, + client_rev: slab.server_rev, + ...(resolvedSpeciesId != null ? { species_id: resolvedSpeciesId } : {}), + }; + slab = await updateSlab(slabId, prePatch); // 1. Run the deterministic review to get title / short_description. const res = await reviewSlab(slabId, { primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId), }); if (res.primary_wood_category_id != null) primaryCatId = res.primary_wood_category_id; + // 1b. Surface build failure instead of silently keeping a typed title. + if (res.title_built === false) { + const why = titleBuildMissingLabels(res.title_build_missing); + createError = why.length + ? `Create title needs: ${why.join(', ')}. Sync taxonomy via Settings → Test Woo if leaves look empty.` + : 'Could not build title from species + primary wood category. Sync taxonomy in Settings.'; + return; + } // 2. Write the deterministic values into the bound fields — always overwrite. if (res.title) title = res.title; if (res.short_description) shortDescription = res.short_description; @@ -663,6 +690,8 @@ deriveOptions(); hydrate(s, t); canGenerateContent = !!set.content_llm_enabled; + inferenceConfigured = + secretConfigured(set.inference_base_url) && secretConfigured(set.inference_api_key); // #90: sync the