[object Object]

← back to Fentucci Naturals

increment 2: enrichment engine (qwen3:14b per-pattern copy + deterministic colorway/tags/specs) + draft-payload builder

51154619efc44469f2e593f725cb13554f67693f · 2026-07-22 17:56:21 -0700 · Steve Abrams

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Files touched

Diff

commit 51154619efc44469f2e593f725cb13554f67693f
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jul 22 17:56:21 2026 -0700

    increment 2: enrichment engine (qwen3:14b per-pattern copy + deterministic colorway/tags/specs) + draft-payload builder
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---
 scripts/build-drafts.py |  84 +++++++++++++++++++++++
 scripts/enrich.py       | 179 ++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 263 insertions(+)

diff --git a/scripts/build-drafts.py b/scripts/build-drafts.py
new file mode 100644
index 0000000..8eefae4
--- /dev/null
+++ b/scripts/build-drafts.py
@@ -0,0 +1,84 @@
+#!/usr/bin/env python3
+"""Build data/shopify-drafts.jsonl from enriched dw_unified.tokiwa_catalog.
+
+STRUCTURE DECISION: matches the existing live Fentucci Naturals family (148
+DWNAT products) = ONE PRODUCT PER COLORWAY (504 drafts), option "Size" with
+Sample + Per Yard variants. Quote-only posture per the RIGO precedent (Yard
+$0.00 + quotes/Needs-Price tags, Sample $4.25 sku {DW_SKU}-Sample).
+
+Title = "<Pattern> <Color> | Fentucci Naturals" (family convention; the word
+"Wallpaper" is banned house-wide — "Wallcovering" only).
+
+Rows with settlement_status != 'clear' are EXCLUDED and listed on stderr.
+FILES ONLY — no API calls, no shopify_products writes.
+"""
+import json, os, re, subprocess, sys
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+ROOT = os.path.dirname(HERE)
+OUT = os.path.join(ROOT, "data", "shopify-drafts.jsonl")
+CONN = "host=/tmp dbname=dw_unified"
+
+def psql(sql):
+    return subprocess.run(["psql", CONN, "-Atc", sql], capture_output=True, text=True, check=True).stdout.strip()
+
+rows = json.loads(psql(
+    "select coalesce(json_agg(row_to_json(t))::text,'[]') from (select * from tokiwa_catalog "
+    "where enriched_at is not null order by dw_sku) t"))
+
+slug = lambda s: re.sub(r"-+", "-", re.sub(r"[^a-z0-9]+", "-", s.lower())).strip("-")
+excluded, written = [], 0
+
+with open(OUT, "w") as f:
+    for r in rows:
+        if r["settlement_status"] != "clear":
+            excluded.append((r["dw_sku"], r["pattern_name"], r["color_name"], r["settlement_status"]))
+            continue
+        title = f"{r['pattern_name']} {r['color_name']} | Fentucci Naturals"
+        body = (
+            f"<p>{r['description']}</p>"
+            f"<table>"
+            f"<tr><td><strong>Pattern</strong></td><td>{r['pattern_name']}</td></tr>"
+            f"<tr><td><strong>Color</strong></td><td>{r['color_name']}</td></tr>"
+            f"<tr><td><strong>Collection</strong></td><td>Fentucci Naturals</td></tr>"
+            f"<tr><td><strong>Material</strong></td><td>{r['material']}</td></tr>"
+            f"<tr><td><strong>Type</strong></td><td>Sidewall</td></tr>"
+            f"<tr><td><strong>Pricing</strong></td><td>By quote — order a memo sample to see the material in hand</td></tr>"
+            f"</table>")
+        rec = {
+            "mfr_sku": r["mfr_sku"],
+            "sku": r["dw_sku"],
+            "local_image_path": os.path.join(ROOT, r["image_local"]),
+            "product": {
+                "title": title,
+                "handle": f"{slug(r['pattern_name'])}-{slug(r['color_name'])}-fentucci-naturals",
+                "vendor": "Fentucci Naturals",
+                "product_type": "Wallcovering",
+                "status": "draft",
+                "tags": ", ".join(r["tags"]),
+                "body_html": body,
+                "options": [{"name": "Size"}],
+                "variants": [
+                    {"sku": f"{r['dw_sku']}-Sample", "price": "4.25", "option1": "Sample",
+                     "taxable": True, "requires_shipping": True, "inventory_management": None},
+                    {"sku": r["dw_sku"], "price": "0.00", "option1": "Per Yard",
+                     "taxable": True, "requires_shipping": True, "inventory_management": None},
+                ],
+            },
+            "metafields": [
+                {"namespace": "custom", "key": "manufacturer_sku", "type": "single_line_text_field", "value": r["mfr_sku"]},
+                {"namespace": "global", "key": "Brand", "type": "single_line_text_field", "value": "Fentucci Naturals"},
+                {"namespace": "custom", "key": "material", "type": "single_line_text_field", "value": r["material"]},
+                {"namespace": "custom", "key": "color_family", "type": "single_line_text_field", "value": r["specs"]["color_family"]},
+            ],
+        }
+        # 5-field + image gate: refuse to write an incomplete draft
+        assert r["description"] and len(r["tags"]) >= 2 and os.path.exists(rec["local_image_path"]), r["dw_sku"]
+        f.write(json.dumps(rec, ensure_ascii=False) + "\n")
+        written += 1
+
+print(f"wrote {written} draft payloads -> {OUT}")
+if excluded:
+    print(f"EXCLUDED {len(excluded)} (settlement review):", file=sys.stderr)
+    for e in excluded:
+        print("  " + " | ".join(map(str, e)), file=sys.stderr)
diff --git a/scripts/enrich.py b/scripts/enrich.py
new file mode 100644
index 0000000..a2f4734
--- /dev/null
+++ b/scripts/enrich.py
@@ -0,0 +1,179 @@
+#!/usr/bin/env python3
+"""Fentucci Naturals (Tokiwa PL) enrichment — increment 2.
+
+Per-PATTERN luxury copy via Mac1 Ollama qwen3:14b (think:false, $0 local),
+cached in data/pattern-copy.json (resumable). Per-ROW description = pattern
+copy + deterministic colorway sentence. Tags/specs deterministic. Writes back
+to dw_unified.tokiwa_catalog (description, tags, specs, enriched_at,
+settlement_status).
+
+Fallback: if Ollama unreachable, deterministic material-copy template blocks.
+
+HARD RULES: never the word "wallpaper"; never "Tokiwa"/mill identifiers.
+"Washi" / "Japanese silk" are generic MATERIAL terms and allowed.
+"""
+import json, os, re, subprocess, sys, time, colorsys, urllib.request
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+ROOT = os.path.dirname(HERE)
+CACHE = os.path.join(ROOT, "data", "pattern-copy.json")
+CONN = "host=/tmp dbname=dw_unified"
+OLLAMA = "http://192.168.1.133:11434/api/generate"
+MODEL = "qwen3:14b"
+
+BANNED = re.compile(r"wallpaper|tokiwa|japan(?!ese silk)|mill\b|imported", re.I)
+SETTLE = re.compile(r"\b(leaf|leaves|foliage|palm|frond|botanic\w*|floral|flower|bird|butterfl\w*|banana|grape|vine|tropic\w*)\b", re.I)
+
+def psql(sql):
+    return subprocess.run(["psql", CONN, "-Atc", sql], capture_output=True, text=True, check=True).stdout.strip()
+
+# ---------- deterministic template blocks (fallback + colorway sentences) ----------
+MATERIAL_COPY = {
+    "Abaca": "Hand-woven abaca fibers lend this wallcovering an artisanal, sun-washed texture with the relaxed elegance of the Italian coast.",
+    "Sisal": "Natural sisal is woven strand by strand into a crisp, tailored texture that brings quiet organic warmth to refined interiors.",
+    "Raffia": "Supple raffia is hand-loomed into a softly irregular weave, carrying the breezy sophistication of a seaside villa.",
+    "Paperweave": "An intricately hand-knotted paperweave delivers dimensional texture with a light, architectural rhythm.",
+    "Washi Paper": "Delicate washi paper is layered into a serene, luminous surface with a whisper-soft tactility.",
+    "Gilding Washi": "Gilded washi paper catches the light with a burnished, jewel-box radiance over a serene handcrafted ground.",
+    "Japanese Silk": "Lustrous Japanese silk threads are woven into a shimmering, couture-grade surface that shifts with the light.",
+    "Metal Leaf": "Hand-applied metal leaf creates a gleaming, faceted surface with the opulence of an artisan atelier.",
+    "Glass Beads": "Thousands of glass beads are set across the surface, scattering light into a subtle, champagne-like sparkle.",
+    "Wood Veneer": "Fine wood veneer brings the warmth of natural grain to the wall in a sleek, tailored format.",
+    "Water Hyacinth": "Hand-braided water hyacinth lends a robust, basket-woven texture with grounded organic character.",
+    "Fine Arrowroot": "Finely woven arrowroot creates a refined, linear texture with an understated natural sheen.",
+    "Mica": "Crushed mica minerals give the surface a stony, shimmering depth that reads as quiet luxury.",
+    "Bamboo": "Slender bamboo reeds are aligned in a rhythmic, architectural weave with a warm natural finish.",
+    "Heavy Jute": "Heavyweight jute is woven into a bold, tactile texture with rustic-luxe presence.",
+    "Denim": "Woven denim cloth brings an unexpected, tailored softness and casual-luxe depth to the wall.",
+    "Textile": "A finely woven textile surface adds tailored softness and dimensional warmth to the wall.",
+}
+STYLE_TAGS = {
+    "Abaca": ["Grasscloth", "Organic", "Coastal"],
+    "Sisal": ["Grasscloth", "Organic", "Tailored"],
+    "Raffia": ["Grasscloth", "Organic", "Coastal"],
+    "Paperweave": ["Grasscloth", "Organic", "Textured"],
+    "Washi Paper": ["Textured", "Serene", "Modern"],
+    "Gilding Washi": ["Metallic", "Luxe", "Glamour"],
+    "Japanese Silk": ["Silk", "Elegant", "Luxe"],
+    "Metal Leaf": ["Metallic", "Luxe", "Glamour"],
+    "Glass Beads": ["Beaded", "Luxe", "Glamour"],
+    "Wood Veneer": ["Wood", "Warm Modern", "Organic"],
+    "Water Hyacinth": ["Woven", "Organic", "Coastal"],
+    "Fine Arrowroot": ["Grasscloth", "Organic", "Refined"],
+    "Mica": ["Mineral", "Shimmer", "Modern"],
+    "Bamboo": ["Woven", "Organic", "Zen"],
+    "Heavy Jute": ["Woven", "Organic", "Rustic Luxe"],
+    "Denim": ["Woven", "Casual Luxe", "Modern"],
+    "Textile": ["Woven", "Tailored", "Modern"],
+}
+
+def color_family(hexstr, name):
+    n = (name or "").lower()
+    try:
+        h = hexstr.lstrip("#"); r, g, b = (int(h[i:i+2], 16)/255 for i in (0, 2, 4))
+        hue, lig, sat = colorsys.rgb_to_hls(r, g, b)
+    except Exception:
+        return "Neutral"
+    if sat < 0.12:
+        return "Charcoal & Black" if lig < 0.3 else ("White & Ivory" if lig > 0.8 else "Gray")
+    deg = hue * 360
+    if deg < 45 or deg >= 330: fam = "Warm Neutral" if sat < 0.35 else "Terracotta & Red"
+    elif deg < 70: fam = "Gold & Champagne"
+    elif deg < 160: fam = "Green"
+    elif deg < 260: fam = "Blue"
+    else: fam = "Plum & Purple"
+    if fam in ("Warm Neutral", "Gold & Champagne") and lig > 0.75: fam = "Cream & Beige"
+    return fam
+
+def colorway_sentence(color, fam):
+    return f" Shown in {color}, a {fam.lower()} colorway that settles beautifully into layered, light-filled interiors."
+
+# ---------- LLM per-pattern copy ----------
+def ollama_up():
+    try:
+        urllib.request.urlopen("http://192.168.1.133:11434/api/tags", timeout=5)
+        return True
+    except Exception:
+        return False
+
+def llm_pattern_copy(pattern, material):
+    prompt = (
+        f"You are the copywriter for Designer Wallcoverings, a luxury brand. Write EXACTLY 2 sentences of "
+        f"elegant product copy for a wallcovering pattern named \"{pattern}\" from the Fentucci Naturals "
+        f"collection, made of {material}. Tell the material story (how it is crafted/woven and how it feels "
+        f"in a room). Rules: NEVER use the word 'wallpaper' (say 'wallcovering'). Never mention any "
+        f"manufacturer, country of origin, or the words Tokiwa or Japan (the material name itself is fine). "
+        f"No color names. No quotes, no markdown, just the 2 sentences."
+    )
+    body = json.dumps({"model": MODEL, "prompt": prompt, "stream": False, "think": False,
+                       "options": {"temperature": 0.7, "num_predict": 160}}).encode()
+    req = urllib.request.Request(OLLAMA, data=body, headers={"Content-Type": "application/json"})
+    with urllib.request.urlopen(req, timeout=120) as r:
+        out = json.loads(r.read())["response"].strip()
+    out = re.sub(r"\s+", " ", out).strip().strip('"')
+    return out
+
+def get_pattern_copy(rows):
+    cache = {}
+    if os.path.exists(CACHE):
+        cache = json.load(open(CACHE))
+    use_llm = ollama_up()
+    patterns = sorted({(r["pattern_name"], r["material"]) for r in rows})
+    t0 = time.time(); done = 0
+    for pat, mat in patterns:
+        if pat in cache and not BANNED.search(cache[pat]):
+            continue
+        copy = None
+        if use_llm:
+            for attempt in range(3):
+                try:
+                    c = llm_pattern_copy(pat, mat)
+                    if c and not BANNED.search(c) and c.count(".") >= 1 and len(c) > 80:
+                        copy = c; break
+                except Exception as e:
+                    print(f"  llm fail {pat} attempt {attempt+1}: {e}", flush=True)
+        if not copy:
+            copy = f"{pat} is a study in natural craft. " + MATERIAL_COPY.get(mat, MATERIAL_COPY["Textile"])
+            print(f"  template fallback: {pat}", flush=True)
+        cache[pat] = copy; done += 1
+        json.dump(cache, open(CACHE, "w"), indent=1)
+        if done % 8 == 0:
+            el = time.time() - t0
+            print(f"  {done}/{len(patterns)} patterns, {el/done:.1f}s/pattern", flush=True)
+    return cache, use_llm
+
+def main():
+    rows = json.loads(psql(
+        "select coalesce(json_agg(row_to_json(t))::text,'[]') from (select id, dw_sku, mfr_sku, pattern_name, "
+        "color_name, color_hex, material, source_category from tokiwa_catalog where enriched_at is null order by dw_sku) t"))
+    if not rows:
+        print("nothing to enrich (all rows have enriched_at)"); return
+    cache, use_llm = get_pattern_copy(rows)
+    print(f"pattern copy ready ({'LLM qwen3:14b' if use_llm else 'TEMPLATE fallback'}), enriching {len(rows)} rows", flush=True)
+
+    t0 = time.time()
+    for i, r in enumerate(rows):
+        fam = color_family(r["color_hex"], r["color_name"])
+        desc = cache[r["pattern_name"]] + colorway_sentence(r["color_name"], fam)
+        mat = r["material"]
+        tags = ["Fentucci Naturals", "Fentucci Naturals Collection", "Natural Wallcovering",
+                "Natural Fiber", "Wallcovering", mat, r["pattern_name"], r["color_name"], fam,
+                r["dw_sku"], "display_variant", "quotes", "Needs-Price", "Needs-Width"] + STYLE_TAGS.get(mat, [])
+        tags = list(dict.fromkeys(tags))
+        specs = {"material": mat, "type": "Sidewall", "product_type": "Wallcovering",
+                 "collection": "Fentucci Naturals", "pattern": r["pattern_name"],
+                 "color": r["color_name"], "color_family": fam, "sold_by": "quote_only",
+                 "sample_price": 4.25}
+        hay = " ".join([desc, r["pattern_name"], r["source_category"] or "", mat])
+        settle = "review" if SETTLE.search(hay) else "clear"
+        esc = lambda s: s.replace("'", "''")
+        arr = "ARRAY[" + ",".join(f"'{esc(t)}'" for t in tags) + "]"
+        psql(f"update tokiwa_catalog set description='{esc(desc)}', tags={arr}, "
+             f"specs='{esc(json.dumps(specs))}'::jsonb, settlement_status='{settle}', "
+             f"enriched_at=now() where id={r['id']}")
+        if (i + 1) % 100 == 0:
+            print(f"  rows {i+1}/{len(rows)} ({(time.time()-t0)/(i+1):.2f}s/row)", flush=True)
+    print(f"done: {len(rows)} rows in {time.time()-t0:.0f}s (path={'llm' if use_llm else 'template'})")
+
+if __name__ == "__main__":
+    main()

← 7824e0c stage: 504 SKUs / 64 patterns into dw_unified.tokiwa_catalog  ·  back to Fentucci Naturals  ·  enrich: 504/504 rows via Mac1 qwen3:14b (64 pattern calls, 3 ea8b38b →