← back to Fentucci Naturals

scripts/retown-collisions.py

170 lines

#!/usr/bin/env python3
"""Re-town pattern-name collisions against the LIVE Fentucci family.

The original town assignment (normalize.js) picked Italian coastal towns without
checking the 748 LIVE/DRAFT products already under vendor ILIKE '%fentucci%' on
the DW store — 27 of the 64 assigned towns already appear as pattern names in
live titles (e.g. "Portofino Ostrica | Fentucci Naturals",
"Salerno Marmo | Fentucci Naturals", "Gaeta Grasscloth Wallcovering | Fentucci").

This script is DETERMINISTIC and IDEMPOTENT:
  - collision = assigned town word-boundary-matches any live/draft Fentucci title
  - colliding rename-map keys are processed in sorted order
  - replacements come from the fixed CANDIDATES list below, in order, taking the
    first town that (a) does not word-boundary-match ANY live Fentucci title and
    (b) is not used by any other assignment (existing or newly chosen)
  - non-colliding assignments are NEVER touched (map stability rule)

Applies the rename to:
  - data/rename-map.json          (town value)
  - data/products.json            (pattern_name per row)
  - data/pattern-copy.json        (re-key town -> town, word-boundary swap in copy)
  - dw_unified.tokiwa_catalog     (pattern_name, description regex swap, tags array)

data/shopify-drafts.jsonl is NOT touched here — regenerate it afterwards with
scripts/build-drafts.py (it rebuilds titles/handles/copy from tokiwa_catalog;
DW SKUs are unaffected).

Usage: python3 scripts/retown-collisions.py [--dry-run]
"""
import json, os, re, subprocess, sys

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
CONN = "host=/tmp dbname=dw_unified"

# Fixed, ordered replacement pool — Italian coastal / riviera comuni not used by
# the original 64 assignments. Order matters for determinism; never reshuffle,
# only append.
CANDIDATES = [
    "Tellaro", "Bonassola", "Framura", "Moneglia", "Varazze", "Spotorno",
    "Bergeggi", "Laigueglia", "Andora", "Taggia", "Cervia", "Chioggia",
    "Fano", "Termoli", "Vasto", "Ortona", "Giulianova", "Pineto",
    "Peschici", "Ostuni", "Fasano", "Maratea", "Sapri", "Palinuro",
    "Agropoli", "Formia", "Anzio", "Nettuno", "Sabaudia", "Talamone",
    "Orbetello", "Follonica", "Piombino", "Capoliveri", "Stintino",
    "Alghero", "Bosa", "Carloforte", "Villasimius", "Milazzo", "Sciacca",
    "Trapani", "Marsala", "Mondello", "Lipari", "Salina", "Favignana",
    "Pantelleria",
]


def psql(sql, args=None):
    cmd = ["psql", CONN, "-Atc", sql]
    return subprocess.run(cmd, capture_output=True, text=True, check=True).stdout


def word_hit(town, titles):
    pat = re.compile(r"\b" + re.escape(town) + r"\b", re.I)
    return [t for t in titles if pat.search(t)]


def main():
    dry = "--dry-run" in sys.argv

    live_titles = [
        l.strip() for l in psql(
            "SELECT title FROM shopify_products "
            "WHERE vendor ILIKE '%fentucci%' AND status IN ('ACTIVE','DRAFT')"
        ).splitlines() if l.strip()
    ]
    print(f"live/draft Fentucci titles: {len(live_titles)}")

    rm_path = os.path.join(ROOT, "data", "rename-map.json")
    rename_map = json.load(open(rm_path))
    assigned = {k: v["town"] for k, v in rename_map.items()}
    if len(set(assigned.values())) != len(assigned):
        sys.exit("FATAL: intra-line duplicate towns already present in rename-map")

    colliding = {k: t for k, t in sorted(assigned.items()) if word_hit(t, live_titles)}
    print(f"colliding assignments: {len(colliding)}")
    if not colliding:
        print("nothing to do — map is clean vs live titles")
        return

    used = set(assigned.values())
    pool = iter(CANDIDATES)
    mapping = {}  # key -> (old_town, new_town)
    for key, old in colliding.items():
        while True:
            try:
                cand = next(pool)
            except StopIteration:
                sys.exit("FATAL: candidate pool exhausted — append more comuni")
            if cand in used:
                continue
            if word_hit(cand, live_titles):
                print(f"  pool skip {cand}: collides with live title")
                continue
            break
        mapping[key] = (old, cand)
        used.add(cand)
        used.discard(old)

    print("\nOLD TOWN -> NEW TOWN")
    for key, (old, new) in mapping.items():
        print(f"  {key:28s} {old:12s} -> {new}")

    if dry:
        print("\n--dry-run: no files/DB touched")
        return

    old_to_new = {old: new for old, new in mapping.values()}

    # 1. rename-map.json
    for key, (old, new) in mapping.items():
        rename_map[key]["town"] = new
    json.dump(rename_map, open(rm_path, "w"), indent=2, sort_keys=True, ensure_ascii=False)

    # 2. products.json
    pj_path = os.path.join(ROOT, "data", "products.json")
    products = json.load(open(pj_path))
    n = 0
    for p in products:
        if p["pattern_key"] in mapping:
            p["pattern_name"] = mapping[p["pattern_key"]][1]
            n += 1
    json.dump(products, open(pj_path, "w"), indent=2, ensure_ascii=False)
    print(f"products.json: {n} rows re-towned")

    # 3. pattern-copy.json (keyed by town; copy text mentions the town)
    pc_path = os.path.join(ROOT, "data", "pattern-copy.json")
    pc = json.load(open(pc_path))
    for old, new in old_to_new.items():
        if old in pc:
            pc[new] = re.sub(r"\b" + re.escape(old) + r"\b", new, pc.pop(old))
    json.dump(pc, open(pc_path, "w"), indent=2, sort_keys=True, ensure_ascii=False)

    # 4. tokiwa_catalog (PostgreSQL BEFORE Shopify — Shopify untouched here)
    for old, new in old_to_new.items():
        sql = (
            "UPDATE tokiwa_catalog SET "
            f"pattern_name = '{new}', "
            f"description = regexp_replace(description, '\\m{old}\\M', '{new}', 'g'), "
            f"tags = array_replace(tags, '{old}', '{new}') "
            f"WHERE pattern_name = '{old}'"
        )
        out = psql(sql)
        print(f"tokiwa_catalog: {old} -> {new}: {out.strip()}")

    # 4b. specs jsonb carries its own pattern copy — sync it (2026-07-22 gap:
    # the first run left 235 rows with the OLD town in specs->>'pattern')
    out = psql(
        "UPDATE tokiwa_catalog SET specs = jsonb_set(specs, '{pattern}', to_jsonb(pattern_name)) "
        "WHERE specs->>'pattern' IS DISTINCT FROM pattern_name"
    )
    print(f"tokiwa_catalog specs.pattern sync: {out.strip()}")

    # ---- GATE: re-check ----
    assigned2 = {k: v["town"] for k, v in json.load(open(rm_path)).items()}
    dupes = len(assigned2) - len(set(assigned2.values()))
    post = {k: t for k, t in assigned2.items() if word_hit(t, live_titles)}
    print(f"\nGATE: post-fix collisions vs live titles = {len(post)}; intra-line duplicates = {dupes}")
    if post or dupes:
        sys.exit("GATE FAILED")
    print("GATE PASSED — now regenerate drafts: python3 scripts/build-drafts.py")


if __name__ == "__main__":
    main()