← back to Novasuede Onboard
plan_writes.py
123 lines
#!/usr/bin/env python3
"""
Turn data/enriched.ndjson into the final per-product tag set + body_html.
Tag rule (Steve's decision: "replace descriptors, keep spam"):
KEEP : spec/operational, SEO-spam, room, material, SKU codes (DWCC-*, T3..W),
'novasuede <name>', and THIS product's own vendor name.
DROP : color words, Color:/color: tags, interior-style words, mood words,
and the old AI-Analyzed-v2 marker.
ADD : Color: <Primary> [+ secondary], bare colorway, undertone, 2-3 styles,
and marker 'AI-Lexicon-v3'.
Emits data/plan.ndjson (id,title,old_tags,new_tags,old_body,new_body,added,removed)
and data/before_after.csv for human audit.
"""
import json, csv, re, pathlib
ROOT = pathlib.Path(__file__).resolve().parent
DATA = ROOT / "data"
# ---- KEEP whitelist (exact, case-insensitive) ----
KEEP_EXACT = {t.lower() for t in [
# spec / operational / material
"Architectural","Cal 117","Commercial","display_variant","Fabric","Fire Rated",
"Microfiber Suede","NFPA 260","Novasuede","UFAC Class I","Wallcovering","Upholstery",
"Class A Fire Rated","100% Nylon Fiber Matrix","Suede","Solid","Textured","Texture",
"Solid/Textural","100000 double rubs","54 inch wide fabric","by the yard","wyzenbeek rated",
"ufac class 1","Paper","Non-woven","Fabric-backed Vinyl","Novasuede™ Fabric & Wallcovering",
# SEO-spam (keep per Steve)
"aircraft fabric","cal 117 fabric","commercial grade fabric","contract fabric","designer fabric",
"faux suede fabric","fire rated fabric","hospitality fabric","majilite novasuede",
"microfiber suede fabric","microfiber wallcovering","microsuede fabric","synthetic suede",
"upholstery fabric","wallcovering fabric","yacht fabric",
# room / application
"Bedroom","Living Room","Office","Hallway","Bathroom","Nursery","Primary Suite","Hotel Lobby",
# marketing / identity
"Showroom Line","New Arrival",
]}
KEEP_PREFIX = ("novasuede ",) # lowercase vendor SEO tags
KEEP_RE = [re.compile(r"^DWCC-\d+$", re.I), re.compile(r"^T3[A-Z0-9]{2,3}W$", re.I)]
DROP_MARKERS = {"ai-analyzed-v2"}
def keep_tag(tag, vendor_name):
t = tag.strip()
if not t: return False
tl = t.lower()
if tl in DROP_MARKERS: return False
if t == vendor_name: return True # product's own decorative identity
if tl == vendor_name.lower(): return True
if tl in KEEP_EXACT: return True
if tl.startswith(KEEP_PREFIX): return True
if any(rx.match(t) for rx in KEEP_RE): return True
return False # everything else = descriptor → drop
def title_case(s): # 'mid-century modern' stays as given; colorways already cased
return s
def build(rec):
vname = rec["vendor_name"]
old_tags = [x.strip() for x in (rec.get("_old_tags","") ).split(",") if x.strip()]
kept = [t for t in old_tags if keep_tag(t, vname)]
prim = (rec.get("primary_colorway") or "").strip().replace("-", " ")
sec = (rec.get("secondary_colorway") or "").strip().replace("-", " ")
und = (rec.get("undertone") or "").strip().capitalize()
styles = [s.strip() for s in (rec.get("styles") or []) if s.strip()][:3]
add = []
if prim:
add += [f"Color: {prim}", prim]
if sec and sec.lower() != prim.lower():
add += [f"Color: {sec}", sec]
if und: add.append(und)
add += styles
add.append("AI-Lexicon-v3")
# merge kept + add, de-dup case-insensitively, preserve order (kept first)
seen=set(); final=[]
for t in kept + add:
k=t.lower()
if k in seen: continue
seen.add(k); final.append(t)
# body_html
desc = (rec.get("description") or "").strip()
depth = (rec.get("depth") or "").strip()
color_line = prim + (f" / {sec}" if sec and sec.lower()!=prim.lower() else "")
style_line = ", ".join(styles)
body = (f"<p>{desc}</p>\n"
f"<p><strong>Color:</strong> {color_line} ({und.lower()}, {depth}) · "
f"<strong>Style:</strong> {style_line} · 100% nylon microfiber suede, "
f"54" wide, Cal 117 / NFPA 260 / UFAC Class I, 100,000+ double rubs.</p>")
removed = [t for t in old_tags if t not in final]
added = [t for t in final if t not in old_tags]
return final, body, added, removed
def main():
raw = {json.loads(l)["id"]: json.loads(l)
for l in (DATA/"novasuede_raw.ndjson").read_text().splitlines()}
out = (DATA/"plan.ndjson").open("w")
csvf = (DATA/"before_after.csv").open("w", newline="")
w = csv.writer(csvf)
w.writerow(["id","title","vendor_name","primary","secondary","undertone","depth",
"styles","removed_tags","added_tags","new_body"])
n=0; errs=0
for line in (DATA/"enriched.ndjson").read_text().splitlines():
rec = json.loads(line)
if not rec.get("_ok"):
errs+=1; continue
rec["_old_tags"] = raw[rec["id"]].get("tags","")
final, body, added, removed = build(rec)
plan = {"id":rec["id"],"title":rec["title"],"new_tags":", ".join(final),
"new_body":body,"old_tags":rec["_old_tags"],
"added":added,"removed":removed,
"primary":rec.get("primary_colorway"),"secondary":rec.get("secondary_colorway"),
"styles":rec.get("styles")}
out.write(json.dumps(plan)+"\n")
w.writerow([rec["id"],rec["title"],rec["vendor_name"],rec.get("primary_colorway"),
rec.get("secondary_colorway"),rec.get("undertone"),rec.get("depth"),
" | ".join(rec.get("styles") or []),
" | ".join(removed)," | ".join(added), re.sub("<[^>]+>"," ",body)])
n+=1
out.close(); csvf.close()
print(f"planned {n} products ({errs} enrichment errors skipped)")
print(f"-> {DATA/'plan.ndjson'}\n-> {DATA/'before_after.csv'}")
if __name__ == "__main__":
main()