← back to Designer Wallcoverings
onboarding/sangetsu-lilycolor/scripts/title-fallback.py
257 lines
#!/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",
}
# Real Lilycolor collection/book names carried on each source record as
# `source_catalog` (from the pricebook PDFs). Confirmed 1:1 with the SKU
# prefixes and proven to be the ONLY recoverable real name — the pricebook
# article codes (LV/LW/LMT/LL*) return 0 results in Lilycolor's public web
# search (verified 2026-07-02 via headless browser + the dga JS search API:
# LW1002/LMT16001 -> 0 hits; control 壁紙 -> 38,302). So the collection name is
# a genuine, better-than-bare-SKU pattern-level name. LIS keeps the Morris path.
COLLECTION_DISPLAY = {
"WILL": "Will",
"LIGHT": "Light",
"V-wall": "V-Wall",
"MATERIALS": "Materials",
"ImportSelection": "Import Selection",
}
# 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)
# Material/style descriptor is AI-VISION-DERIVED (enrichment _provider
# local-hybrid) with NO authoritative pricebook backing (functions field
# is empty). Per DW rule "AI attributes -> tags, never titles", we do NOT
# put it in the customer-facing title — a wrong "Grasscloth" on a vinyl is
# a false material claim a trade buyer would rely on. It stays in
# design.material (already carried in the merged record) for tag use.
descriptor_en = None
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*/…): use the real Lilycolor COLLECTION name
# (source_catalog) + the SKU for uniqueness, e.g. "Will LW1002".
# This beats the bare-SKU fallback (CLAUDE.md #4) with a genuine
# product-line name. Falls back to bare SKU only if catalog missing.
coll = COLLECTION_DISPLAY.get(rec.get("source_catalog") or "")
if coll:
pattern_en = f"{coll} {sku}"
source = "collection-sku"
else:
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())