[object Object]

← back to Designer Wallcoverings

title-fallback.py: synthesize compliant titles for 1580 title-less Lily SKUs (MFR-SKU fallback + Morris LIS map, settlement-flagged, needs-pattern-name reviewable)

f1960768f482a0c9a682cf46c21015625cbd6358 · 2026-07-01 15:04:12 -0700 · Steve

Files touched

Diff

commit f1960768f482a0c9a682cf46c21015625cbd6358
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Jul 1 15:04:12 2026 -0700

    title-fallback.py: synthesize compliant titles for 1580 title-less Lily SKUs (MFR-SKU fallback + Morris LIS map, settlement-flagged, needs-pattern-name reviewable)
---
 .../sangetsu-lilycolor/scripts/title-fallback.py   | 228 +++++++++++++++++++++
 1 file changed, 228 insertions(+)

diff --git a/onboarding/sangetsu-lilycolor/scripts/title-fallback.py b/onboarding/sangetsu-lilycolor/scripts/title-fallback.py
new file mode 100644
index 00000000..4c54f79c
--- /dev/null
+++ b/onboarding/sangetsu-lilycolor/scripts/title-fallback.py
@@ -0,0 +1,228 @@
+#!/usr/bin/env python3
+"""
+title-fallback.py — synthesize compliant EN titles for the title-less Lilycolor SKUs.
+
+DTD VERDICT A (committed) — for the 1,580 records where title_ja is null (NOT
+recoverable from the website feed, swatch OCR, or the PDF price tables):
+  (a) MFR-SKU-as-pattern-name fallback titler for the ~1,556 bulk (LV/LW/LMT/…);
+  (b) proper Morris & Co name match for the 24 LIS "Import Selection" records
+      (LIS42001 = "Bird & Pomegranate" is known; rest fall back to
+      "Morris & Co Import <MfrSku>"), flagged settlement_required (birds/foliage);
+  (c) an optional vendor re-scrape drafted to pending-approval as a later upgrade.
+
+OFFLINE / non-gated / idempotent / JSONL-only. Reads the unified staging file +
+the local design-enrichment .final.jsonl, and EMITS a separate fallback-title
+file — it NEVER mutates the unified staging and NEVER flips a gate.
+
+Title format per DW rules:  "<PatternNameOrMfrSku> <MaterialOrStyleDescriptor> | Lilycolor"
+  - pattern name source order:
+       LIS → known Morris map, else "Morris & Co Import <MfrSku>"
+       else → MFR SKU itself (last-resort pattern name, per CLAUDE.md fallback #4)
+  - descriptor from enrichment material || styles[0] || patterns[0]  (Title Case), else omit
+  - HARD guards: Title Case, "Wallcovering" never "Wallpaper", never "Unknown".
+  - every emitted record carries needs_pattern_name=True so these stay reviewable.
+
+Output: staging/lilycolor-titles-fallback-063026A.jsonl
+  one row per title-less SKU:
+    {sku, title_en, pattern_en, descriptor_en, source, needs_pattern_name,
+     settlement_required?}
+
+Cost: $0 (local).
+"""
+import json
+import os
+import re
+import sys
+
+HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+STAGING = os.path.join(HERE, "staging")
+
+UNIFIED = os.path.join(STAGING, "lilycolor-unified-staging.jsonl")
+ENRICH = os.path.join(STAGING, "enrichment-full-063026A.final.jsonl")
+if not os.path.exists(ENRICH):
+    ENRICH = os.path.join(STAGING, "enrichment-full-063026A.jsonl")
+
+OUT = os.path.join(STAGING, "lilycolor-titles-fallback-063026A.jsonl")
+
+BRAND = "Lilycolor"
+
+# Known Morris & Co pattern names for LIS "Import Selection" SKUs.
+# LIS42001 = "Bird & Pomegranate" is confirmed (task + enrichment desc:
+# "a bird perched on branches ... and a pomegranate"). The rest have NO reliable
+# local SKU->pattern crosswalk (Lily's LIS codes are Lilycolor's own catalog
+# numbers, not Morris's product SKUs), so they fall to the branded-import form.
+MORRIS_NAME_MAP = {
+    "LIS42001": "Bird & Pomegranate",
+}
+
+# Words to keep lowercase in Title Case (unless first word).
+_SMALL = {"a", "an", "the", "and", "but", "or", "for", "nor", "on", "at",
+          "to", "by", "in", "of", "with", "as"}
+_BANNED_RE = re.compile(r"\bwallpaper(s)?\b", re.IGNORECASE)
+
+
+def title_case(s):
+    """Title Case with small-word exceptions; first word always capitalized.
+    Preserves already-mixed tokens like 'McKenzie' and ampersand joins."""
+    if not s:
+        return s
+    words = s.split()
+    out = []
+    for i, w in enumerate(words):
+        lw = w.lower()
+        # keep tokens that already have internal caps (McKenzie, BN) intact
+        if w != lw and w != w.lower() and any(c.isupper() for c in w[1:]):
+            out.append(w)
+            continue
+        if i != 0 and lw in _SMALL:
+            out.append(lw)
+        else:
+            out.append(w[:1].upper() + w[1:] if w else w)
+    return " ".join(out)
+
+
+def clean_wallpaper(s):
+    if not s:
+        return s
+    return _BANNED_RE.sub(lambda m: "Wallcovering" + ("s" if m.group(1) else ""), s)
+
+
+def descriptor_from_enrich(e):
+    """material || styles[0] || patterns[0], Title Cased, else None."""
+    if not e:
+        return None
+    for src in (e.get("material"),):
+        if src and str(src).strip():
+            return title_case(str(src).strip())
+    for listkey in ("styles", "patterns"):
+        vals = e.get(listkey)
+        if isinstance(vals, list) and vals:
+            v = str(vals[0]).strip()
+            if v:
+                return title_case(v)
+    return None
+
+
+def load_by_key(path, key):
+    out = {}
+    if not os.path.exists(path):
+        return out
+    with open(path) as f:
+        for line in f:
+            line = line.strip()
+            if not line:
+                continue
+            try:
+                d = json.loads(line)
+            except json.JSONDecodeError:
+                continue
+            k = d.get(key)
+            if k:
+                out[k] = d
+    return out
+
+
+def assemble_title(pattern_en, descriptor_en):
+    left = pattern_en
+    if descriptor_en:
+        left = f"{pattern_en} {descriptor_en}"
+    title = f"{left} | {BRAND}"
+    title = clean_wallpaper(title)
+    return title
+
+
+def main():
+    enrich = load_by_key(ENRICH, "sku")
+
+    n_in = 0
+    n_out = 0
+    n_lis = 0
+    n_morris_named = 0
+    n_settlement = 0
+    n_with_descriptor = 0
+    seen = set()
+
+    with open(UNIFIED) as fin, open(OUT, "w") as fout:
+        for line in fin:
+            line = line.strip()
+            if not line:
+                continue
+            rec = json.loads(line)
+            n_in += 1
+
+            # Only the title-less universe (title_ja is null) gets a fallback.
+            if rec.get("title_ja") is not None:
+                continue
+
+            sku = rec.get("mfr_sku")
+            if not sku or sku in seen:
+                continue
+            seen.add(sku)
+
+            prefix = rec.get("mfr_prefix") or ""
+            e = enrich.get(sku)
+            descriptor_en = descriptor_from_enrich(e)
+
+            settlement_required = False
+            if prefix == "LIS":
+                n_lis += 1
+                mapped = MORRIS_NAME_MAP.get(sku)
+                if mapped:
+                    pattern_en = mapped
+                    source = "morris-known-map"
+                    n_morris_named += 1
+                else:
+                    # branded import fallback — never "Unknown", never bare SKU
+                    pattern_en = f"Morris & Co Import {sku}"
+                    source = "morris-import-fallback"
+                # LIS = Morris & Co designs: birds / foliage / pomegranate motifs
+                # → the settlement gate MUST run before any publish. Flag it; do
+                # NOT publish here.
+                settlement_required = True
+                n_settlement += 1
+            else:
+                # Bulk (LV/LW/LMT/LL*/…): MFR SKU is the last-resort pattern name
+                # (CLAUDE.md fallback order #4). Never "Unknown".
+                pattern_en = sku
+                source = "mfr-sku-fallback"
+
+            pattern_en = title_case(pattern_en)
+            title_en = assemble_title(pattern_en, descriptor_en)
+
+            # HARD guards — refuse to emit a bad title.
+            low = title_en.lower()
+            assert "unknown" not in low, f"BLOCKED unknown in title for {sku}"
+            assert "wallpaper" not in low, f"BLOCKED wallpaper in title for {sku}"
+
+            if descriptor_en:
+                n_with_descriptor += 1
+
+            row = {
+                "sku": sku,
+                "mfr_prefix": prefix,
+                "title_en": title_en,
+                "pattern_en": pattern_en,
+                "descriptor_en": descriptor_en,
+                "source": source,
+                "needs_pattern_name": True,   # reviewable — synthesized, not scraped
+                "_cost": "$0 (local)",
+            }
+            if settlement_required:
+                row["settlement_required"] = True
+
+            fout.write(json.dumps(row, ensure_ascii=False) + "\n")
+            n_out += 1
+
+    print(f"title-less records scanned : {n_in} total unified rows")
+    print(f"fallback titles written    : {n_out}  -> {os.path.relpath(OUT, HERE)}")
+    print(f"  LIS (Morris & Co)        : {n_lis}  ({n_morris_named} known-named, "
+          f"{n_lis - n_morris_named} branded-import fallback)")
+    print(f"  settlement_required flag : {n_settlement}  (LIS birds/foliage — GATE before publish)")
+    print(f"  with enrichment descriptor: {n_with_descriptor}")
+    print(f"  all flagged needs_pattern_name=True (reviewable)")
+    print(f"  cost: $0 (local)")
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())

← 2eb75f4c merge-staging: read canonical enrichment-full-063026A.final.  ·  back to Designer Wallcoverings  ·  merge-staging: fold DTD-A fallback titles into merged set (t 151d386d →