← 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
A scripts/build-drafts.pyA scripts/enrich.py
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 →