diff --git a/backend/taxonomy.py b/backend/taxonomy.py index beb6d1f..ed294bd 100644 --- a/backend/taxonomy.py +++ b/backend/taxonomy.py @@ -1,11 +1,14 @@ """Taxonomy sync: pull Woo categories/tags/attributes/terms into the local cache. Rebuild happens in one transaction; on any failure the prior cache is kept. -Only fig-* / feat-* tag families are retained (METADATA-15). taxonomy_anchor is a -stable hash of the stored subset, recorded only when it changes. +Only fig-* / feat-* tag families are retained by **slug** prefix with hyphen +(SETTINGS-51 / METADATA-15). taxonomy_anchor is a stable hash of the stored +subset, recorded only when it changes. Display names are HTML-unescaped so +Woo entities like ``&`` store as ``&``. """ import hashlib +import html import json from datetime import datetime, timezone @@ -16,8 +19,23 @@ def _now_iso() -> str: return datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat() +def _decode_name(name: str | None) -> str: + """Decode HTML entities Woo puts in taxonomy display names.""" + return html.unescape(name or "") + + def _scoped_tags(tags: list[dict]) -> list[dict]: - return [t for t in tags if t.get("name", "").startswith(("fig-", "feat-"))] + """Keep product tags whose slug starts with fig- or feat- (hyphen required). + + Live Woo uses human display names (e.g. "Curl") with slugs like fig-curl. + Filtering by name prefix kept 0 live tags; slug is authoritative. + """ + out = [] + for t in tags: + slug = t.get("slug") or "" + if slug.startswith("fig-") or slug.startswith("feat-"): + out.append(t) + return out def _normalize_category(c: dict) -> tuple: @@ -34,7 +52,7 @@ def _normalize_category(c: dict) -> tuple: woo_id = c.get("woo_id", c.get("id")) if woo_id is not None: woo_id = int(woo_id) - return (c["id"], c["name"], c["slug"], parent_id, woo_id) + return (c["id"], _decode_name(c["name"]), c["slug"], parent_id, woo_id) def _subset_hash(categories: list[dict], attributes: list[dict], @@ -90,16 +108,16 @@ def sync(client: WooClient, conn) -> dict: conn.executemany( "INSERT INTO woo_attributes(id, name, slug, woo_id) VALUES (?,?,?,?)", - [(a["id"], a["name"], a["slug"], a.get("woo_id")) for a in attributes], + [(a["id"], _decode_name(a["name"]), a["slug"], a.get("woo_id")) for a in attributes], ) for attr_id, terms in terms_by_attr.items(): conn.executemany( "INSERT INTO woo_attribute_terms(id, attribute_id, name, slug, woo_id) VALUES (?,?,?,?,?)", - [(t["id"], attr_id, t["name"], t["slug"], t.get("woo_id")) for t in terms], + [(t["id"], attr_id, _decode_name(t["name"]), t["slug"], t.get("woo_id")) for t in terms], ) conn.executemany( "INSERT INTO woo_tags(id, name, slug, woo_id) VALUES (?,?,?,?)", - [(t["id"], t["name"], t["slug"], t.get("woo_id")) for t in scoped_tags], + [(t["id"], _decode_name(t["name"]), t["slug"], t.get("woo_id")) for t in scoped_tags], ) anchor = _subset_hash(categories, attributes, terms_by_attr, scoped_tags) diff --git a/backend/tests/test_woo.py b/backend/tests/test_woo.py index d3ab93d..004ed8b 100644 --- a/backend/tests/test_woo.py +++ b/backend/tests/test_woo.py @@ -95,12 +95,29 @@ def test_401_raises_auth_error(db_dir): c.get_categories() +def test_scoped_tags_by_slug_not_name(): + """SETTINGS-51: keep fig-/feat- by slug prefix with hyphen; human names stay.""" + tags = [ + {"id": 1, "name": "Curl", "slug": "fig-curl"}, + {"id": 2, "name": "Knot", "slug": "feat-knot"}, + {"id": 3, "name": "fig-looks-like", "slug": "other"}, # name prefix alone — drop + {"id": 4, "name": "Figured", "slug": "figured"}, # no hyphen — drop + {"id": 5, "name": "other", "slug": "other-tag"}, + ] + kept = taxonomy_mod._scoped_tags(tags) + assert {t["id"] for t in kept} == {1, 2} + + def test_sync_populates_scoped(db_dir): - cats = (200, {"X-WP-TotalPages": "1"}, json.dumps([{"id": 1, "name": "Cedar", "slug": "cedar", "parent_id": None, "woo_id": 10}]).encode()) + # Live Woo: human names + fig-/feat- slugs. Name-prefix filter would keep 0. + cats = (200, {"X-WP-TotalPages": "1"}, json.dumps([ + {"id": 1, "name": "Cookies & Burl Slices", "slug": "cookies-burl", "parent_id": None, "woo_id": 10}, + ]).encode()) tags = (200, {"X-WP-TotalPages": "1"}, json.dumps([ - {"id": 2, "name": "fig-curl", "slug": "fig-curl", "woo_id": 20}, - {"id": 3, "name": "feat-knot", "slug": "feat-knot", "woo_id": 21}, - {"id": 4, "name": "other-tag", "slug": "other-tag", "woo_id": 22}, # must be dropped + {"id": 2, "name": "Curl", "slug": "fig-curl", "woo_id": 20}, + {"id": 3, "name": "Knot", "slug": "feat-knot", "woo_id": 21}, + {"id": 4, "name": "fig-fake", "slug": "other-tag", "woo_id": 22}, # name looks scoped; slug not — drop + {"id": 5, "name": "other-tag", "slug": "other-tag-2", "woo_id": 23}, ]).encode()) attrs = (200, {"X-WP-TotalPages": "1"}, json.dumps([{"id": 5, "name": "Edge Type", "slug": "edge", "woo_id": 30}]).encode()) terms = (200, {"X-WP-TotalPages": "1"}, json.dumps([{"id": 6, "name": "Flat", "slug": "flat", "woo_id": 40}]).encode()) @@ -108,8 +125,11 @@ def test_sync_populates_scoped(db_dir): result = taxonomy_mod.sync(c, db_mod.get_connection()) conn = db_mod.get_connection() assert [r[0] for r in conn.execute("SELECT id FROM woo_categories")] == [1] - tag_ids = {r[0] for r in conn.execute("SELECT id FROM woo_tags")} - assert tag_ids == {2, 3} # fig- + feat- kept, other-tag dropped + assert conn.execute("SELECT name FROM woo_categories WHERE id=1").fetchone()[0] == "Cookies & Burl Slices" + tag_rows = {r[0]: (r[1], r[2]) for r in conn.execute("SELECT id, name, slug FROM woo_tags")} + assert set(tag_rows) == {2, 3} # fig- + feat- by slug; name-only fig-fake dropped + assert tag_rows[2] == ("Curl", "fig-curl") + assert tag_rows[3] == ("Knot", "feat-knot") assert [r[0] for r in conn.execute("SELECT id FROM woo_attribute_terms")] == [6] assert result["taxonomy_anchor"] assert conn.execute("SELECT value FROM settings WHERE key='taxonomy_last_sync_at'").fetchone()[0] diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index 96e60e9..84cec29 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -133,7 +133,7 @@ The listing is built in two inference calls, both optional. Call 1 (vision) read | SETTINGS-40 | test-woo verifies the connection and always resyncs taxonomy on success. | M3 | — | | SETTINGS-45 | test-inference is a two-turn probe returning ok, mode (stateful, stateless, or fail), vision_capable, and detail; fail blocks Call 2 only. | M3 | — | | SETTINGS-50 | Taxonomy sync pulls categories, tags, and attributes with terms, rebuilds the cache in one transaction, and on failure returns 502 while keeping the previous cache. | M3 | — | -| SETTINGS-51 | Taxonomy sync keeps only the store's figure and feature product-tag families; all other store product tags are ignored so non-wood product metadata does not enter the workflow. | M3 | — | +| SETTINGS-51 | Taxonomy sync keeps only product tags whose slug starts with fig- or feat- (hyphen required); all other store product tags are ignored so non-wood product metadata does not enter the workflow. | M3 | backend/tests/test_woo.py | | SETTINGS-55 | Every successful sync updates taxonomy_last_sync_at; taxonomy_anchor bumps only when the stored-subset hash changes, including deletions. | M3 | — | | SETTINGS-60 | The client re-pulls the full taxonomy only when the anchor advances, and publish refreshes when the last sync is over one hour old. | M3 | — | | SETTINGS-65 | woo_create_status defaults to draft. | M3 | — | diff --git a/docs/capabilities/CREATE.md b/docs/capabilities/CREATE.md index 1c6f5f5..a834f88 100644 --- a/docs/capabilities/CREATE.md +++ b/docs/capabilities/CREATE.md @@ -78,7 +78,7 @@ default off (`SETTINGS-90`). Species rates are one flat value per synced leaf (` test-woo returns ok and detail, fails closed on missing credentials or non-HTTPS, and always resyncs taxonomy on success (`SETTINGS-80`, `SETTINGS-40`). test-inference returns ok, mode (stateful, stateless, or fail), vision_capable, and detail; fail blocks Call 2 only -(`SETTINGS-45`). Taxonomy sync keeps only fig-* and feat-* tag families (`SETTINGS-51`), rebuilds +(`SETTINGS-45`). Taxonomy sync keeps only tags whose **slug** starts with `fig-` or `feat-` (`SETTINGS-51`), rebuilds in one transaction, keeps the previous cache on failure (`SETTINGS-50`), and bumps the anchor only on a real stored-subset change (`SETTINGS-55`). The client re-pulls when the anchor advances, and publish refreshes when the last sync is over an hour old (`SETTINGS-60`). diff --git a/docs/contracts/API.md b/docs/contracts/API.md index ddf6f54..33dbe9a 100644 --- a/docs/contracts/API.md +++ b/docs/contracts/API.md @@ -170,7 +170,7 @@ Returns `202`; the client polls. Cites PUBLISH-5, PUBLISH-10, PUBLISH-15, PUBLIS ## 8. Taxonomy - `GET /api/v1/admin/taxonomy` → grouped categories, attributes with terms, and tags, plus - `taxonomy_anchor` and `taxonomy_last_sync_at`. Only fig-* and feat-* tag families are kept. + `taxonomy_anchor` and `taxonomy_last_sync_at`. Only tags whose slug starts with `fig-` or `feat-` are kept. These lists are the only legal picker options. - `POST /api/v1/admin/taxonomy/sync` → pulls from Woo, rebuilds the cache in one transaction, sets `taxonomy_last_sync_at`, bumps `taxonomy_anchor` only when the stored-subset hash changes, and diff --git a/docs/contracts/DATA-MODEL.md b/docs/contracts/DATA-MODEL.md index 9fe6e0f..841f1bd 100644 --- a/docs/contracts/DATA-MODEL.md +++ b/docs/contracts/DATA-MODEL.md @@ -109,7 +109,7 @@ Cites METADATA-5, SETTINGS-50, SETTINGS-51, SETTINGS-55. - `woo_categories` — species and wood-category leaves only; parents group and are never assigned. - `woo_attributes` / `woo_attribute_terms` — exactly the five locked attributes Edge Type, Figure, Grade, Thickness, Moisture, with their terms. -- `woo_tags` — only the fig-* and feat-* families; other store tags are ignored. +- `woo_tags` — only tags whose slug starts with `fig-` or `feat-`; other store tags are ignored. Rebuild is one transaction; on failure the previous cache is kept. diff --git a/frontend/src/create/decodeName.test.ts b/frontend/src/create/decodeName.test.ts new file mode 100644 index 0000000..3735e4c --- /dev/null +++ b/frontend/src/create/decodeName.test.ts @@ -0,0 +1,10 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { decodeName } from './decodeName'; + +test('decodeName: ampersand and common entities', () => { + assert.equal(decodeName('Cookies & Burl Slices'), 'Cookies & Burl Slices'); + assert.equal(decodeName('A < B > C'), 'A < B > C'); + assert.equal(decodeName('say "hi" 'there''), 'say "hi" \'there\''); + assert.equal(decodeName('plain'), 'plain'); +}); diff --git a/frontend/src/create/decodeName.ts b/frontend/src/create/decodeName.ts new file mode 100644 index 0000000..d4fe2bc --- /dev/null +++ b/frontend/src/create/decodeName.ts @@ -0,0 +1,9 @@ +/** Decode common HTML entities Woo puts in taxonomy display names. */ +export function decodeName(name: string): string { + return name + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'"); +} diff --git a/frontend/src/create/review/Review.svelte b/frontend/src/create/review/Review.svelte index 2f2d07a..2924ca9 100644 --- a/frontend/src/create/review/Review.svelte +++ b/frontend/src/create/review/Review.svelte @@ -8,7 +8,8 @@ import type { CalibratedCapture } from '../flow'; import { canCreateTitleFromSpecies, effectiveSpeciesId } from './speciesFromCapture'; import MultiSelect from './MultiSelect.svelte'; - import { deriveThicknessTermId } from './thicknessBand'; + import { decodeName } from '../decodeName'; + import { deriveFigTagIds, scopedFeatTags } from './figTags'; import { keptInOrder, moveItem, missingMinimums, roleLabel, resolvePhotoUrl, type LineupItem, @@ -41,7 +42,6 @@ let figureOpts: Opt[] = []; let gradeOpts: Opt[] = []; let moistureOpts: Opt[] = []; - let figTagOpts: Opt[] = []; let featTagOpts: Opt[] = []; function deriveOptions() { @@ -63,7 +63,7 @@ // attributes ignore it. "2-4 in" → 2; a name with no leading number → undefined. const m = t.name.match(/\d+(?:[./]\d+)?/); const lowIn = m ? Number(m[0]) : undefined; - return { id: t.id, name: t.name, ai: false, lowIn: Number.isFinite(lowIn) ? lowIn : undefined }; + return { id: t.id, name: decodeName(t.name), ai: false, lowIn: Number.isFinite(lowIn) ? lowIn : undefined }; }); }; // Wood categories: parent/child grouped (METADATA-10 — parents group only, never @@ -73,9 +73,9 @@ 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 }; + ? kids.map((c) => ({ id: c.id, name: decodeName(c.name), ai: false })) + : [{ id: parent.id, name: decodeName(parent.name), ai: false }]; + return { parent: { id: parent.id, name: decodeName(parent.name) }, leaves }; }); edgeOpts = termsOf('edge type'); figureOpts = termsOf('figure'); @@ -83,12 +83,12 @@ // No thickness options: the band is derived server-side and never shown here // (METADATA-15 / QUALITY-95). moistureOpts = termsOf('moisture'); - // fig-* / feat-* product tags (METADATA-30): pulled from the live synced set, - // filtered by the family the backend keeps. Never invent a prefix — if the - // store has none, the picker is legitimately empty. + // feat-* product tags (METADATA-30): slug prefix with hyphen. fig-* tags are + // derived from Figure attribute selections (no separate picker). const tags: TaxTag[] = tax.tags ?? []; - figTagOpts = tags.filter((t) => t.slug.startsWith('fig')).map((t) => ({ id: t.id, name: t.name, ai: false })); - featTagOpts = tags.filter((t) => t.slug.startsWith('feat')).map((t) => ({ id: t.id, name: t.name, ai: false })); + featTagOpts = scopedFeatTags(tags).map((t) => ({ + id: t.id, name: decodeName(t.name), ai: false, + })); } // --- Editable model (mirrors the live slab) --- @@ -148,9 +148,6 @@ let genBusy = false; let genError = ''; let canGenerateContent = false; - let aiRecommendations: number[] = []; // union of AI-suggested ids (for row bubbles) - - const num = (v: unknown) => (typeof v === 'number' ? v : null); 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); @@ -327,7 +324,6 @@ figureIds = [...(s.figure_term_ids ?? [])]; gradeSel = s.grade_term_id != null ? String(s.grade_term_id) : ''; moistureId = s.moisture_term_id ?? ''; - figTagIds = [...(s.fig_tag_ids ?? [])]; featTagIds = [...(s.feat_tag_ids ?? [])]; title = s.title ?? ''; shortDescription = s.short_description ?? ''; @@ -338,36 +334,29 @@ sku = s.sku ?? ''; // REVIEW-15: primary defaults to first selected wood cat; keep if still valid. primaryCatId = nextPrimary(woodCatIds, woodCatIds[0] ?? ''); - // Seed AI-recommended markers from stored confidences (>=0.7 means the AI put - // its weight behind the value the owner kept). - aiRecommendations = []; - if (num(s.species_confidence) != null && num(s.species_confidence)! >= 0.7 && s.species_id) - aiRecommendations.push(s.species_id); - if (num(s.figure_confidence) != null && num(s.figure_confidence)! >= 0.7) - aiRecommendations.push(...(s.figure_term_ids ?? [])); + // fig_tag_ids are derived reactively from figureIds (no hydrate of stored fig tags). } - // AI-marked option views (legacy Call 1 badges; Call 1 disabled on this branch). - $: woodCatGroupsView = woodCatGroups.map((g) => ({ - parent: g.parent, - leaves: g.leaves.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) })), - })); - $: figureOptsView = figureOpts.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) })); - $: featTagOptsView = featTagOpts.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) })); + // Call 1 out: no AI badges on options. Groups/opts pass through as-is. + $: woodCatGroupsView = woodCatGroups; + $: figureOptsView = figureOpts; + $: featTagOptsView = featTagOpts; + // fig_tag_ids derived from selected Figure term names ↔ fig-* tag names (casefold). + $: figTagIds = tax + ? deriveFigTagIds(figureIds, figureOpts, tax.tags ?? []) + : figTagIds; // Flat leaf name lookup for the primary radio set (REVIEW-15). $: woodLeafById = new Map( woodCatGroups.flatMap((g) => g.leaves.map((l) => [l.id, l] as [number, Opt])), ); $: primaryOpts = woodCatIds .map((id) => woodLeafById.get(id)) - .filter((o): o is Opt => !!o) - .map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) })); + .filter((o): o is Opt => !!o); // METADATA-15 / QUALITY-95: the thickness band is derived server-side from the // measured thickness against the live synced Woo bands. It is deliberately absent // from this screen — no owner control, no read-only echo. The only thickness the // owner sees here is the measured value in the summary above. - const aiIn = (ids: number[]) => (id: number) => ids.includes(id); // Persist taxonomy/price/sku edits to the slab (PUT), then recompute ready. // #89: the deterministic title/short_description are written here — the server's // review returns the build from the current metadata, and if the field is empty @@ -678,7 +667,7 @@
@@ -714,9 +702,9 @@ {/if}