← 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())