← back to Dw Photo Capture

build_vendor_profiles.py

149 lines

#!/usr/bin/env python3
"""Build data/vendor_profiles.json — learn HOW EACH VENDOR LABELS THEIR SAMPLES from the
live catalog, so the SKU scanner can detect the brand and lock onto the right code fast.

For every Shopify `vendor` we group all of that vendor's manufacturer codes (+ house SKUs)
and induce a profile:
  • aliases  — brand-name strings to look for in the OCR text (regex-ready)
  • prefixes — histogram of the leading alpha run of their codes (WD, WDW, WHF, …)
  • numeric  — share of codes that are bare numbers (Schumacher 5255-16 style)
  • shape    — typical length of a code
  • examples — a few real codes
This is the deterministic counterpart to live scan-learning (/api/learn): the catalog
teaches the broad strokes instantly; live scans refine the on-swatch reality over time.

Uses one Bulk Operation (server-side full export). Read-only. $0 (local + Shopify read)."""
import json, os, re, sys, time, urllib.request
from collections import defaultdict, Counter
from datetime import datetime, timezone

SHOP = "designer-laboratory-sandbox.myshopify.com"; API = "2024-10"
ROOT = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(ROOT, "data", "vendor_profiles.json")
TOKEN = os.environ.get("SHOPIFY_ADMIN_TOKEN", "")
if not TOKEN:
    for line in open(os.path.expanduser("~/Projects/secrets-manager/.env")):
        if line.startswith("SHOPIFY_ADMIN_TOKEN="):
            TOKEN = line.strip().split("=", 1)[1]; break
assert TOKEN, "no SHOPIFY_ADMIN_TOKEN"

# Front-facing private-label rule: these upstream names must NEVER be surfaced to a
# customer. The scanner is an internal admin tool so it MAY detect them, but we tag the
# profile so any public consumer can skip it. (See MEMORY: Brewster/York/WallQuest…)
PRIVATE_LABEL = re.compile(r"BREWSTER|YORK|WALLQUEST|CHESAPEAKE|NEXTWALL|SEABROOK|COMMAND ?54|DESIMA|CARLSTEN|NICOLETTE ?MAYER", re.I)

def gql(query):
    req = urllib.request.Request(f"https://{SHOP}/admin/api/{API}/graphql.json",
        data=json.dumps({"query": query}).encode(),
        headers={"X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json"})
    return json.load(urllib.request.urlopen(req, timeout=90))

def parts(val):                       # "WDW-2290; Diamond WMT5001" -> ["WDW-2290","Diamond WMT5001"]
    return [p for p in re.split(r"[;,/|]+", val or "") if p.strip()]

def norm(s):                          # display code, trimmed (keep dashes for shape)
    return re.sub(r"\s+", "", (s or "").upper())

def alpha_prefix(code):               # leading letters of a code: WDW2310 -> WDW ; 5255-16 -> ""
    m = re.match(r"[A-Za-z]+", code.strip())
    return m.group(0).upper() if m else ""

def brand_regex(name):                # vendor name -> flexible OCR detection pattern
    words = [w.upper() for w in re.findall(r"[A-Za-z]+", name) if len(w) >= 3]
    if not words: return None
    # single short word (ARTE, ROMO) is noisy — require ≥4 chars; multi-word is safe at ≥3
    if len(words) == 1 and len(words[0]) < 4: return None
    return r"\s*".join(re.escape(w) for w in words)

# ── 1) bulk export: vendor + mfr metafields + variant skus ────────────────────
BULK_Q = '''
{
  products {
    edges { node {
      id status vendor
      mf: metafield(namespace:"custom", key:"manufacturer_sku"){ value }
      mf2: metafield(namespace:"dwc", key:"manufacturer_sku"){ value }
      variants { edges { node { sku } } }
    }}
  }
}'''
start = gql('mutation { bulkOperationRunQuery(query: """%s""") { bulkOperation{ id status } userErrors{ field message } } }' % BULK_Q)
ue = start.get("data", {}).get("bulkOperationRunQuery", {}).get("userErrors", [])
if ue:
    print("bulk start errors:", ue)
    print("current:", gql('{ currentBulkOperation { id status } }')["data"]["currentBulkOperation"])
    sys.exit(1)
print("bulk op started, polling…")

url = None
for _ in range(180):
    time.sleep(5)
    cur = gql('{ currentBulkOperation { id status errorCode objectCount url } }')["data"]["currentBulkOperation"]
    print(f"  status={cur['status']} objects={cur.get('objectCount')}")
    if cur["status"] == "COMPLETED": url = cur["url"]; break
    if cur["status"] in ("FAILED", "CANCELED"): print("bulk failed:", cur); sys.exit(1)
assert url, "bulk op did not complete in time"

# ── 2) parse JSONL: collect codes per vendor ──────────────────────────────────
# Keep the two code namespaces SEPARATE: the manufacturer code (printed on the physical
# swatch — what the scanner reads) vs the DW house SKU (what we resolve to). The profile
# describes the SAMPLE, so we learn from mfr codes first and only fall back to house codes.
data = urllib.request.urlopen(url, timeout=300).read().decode()
prod_vendor = {}                       # gid -> vendor
mfr_by_vendor = defaultdict(list)      # vendor -> [manufacturer code, …]  (on the swatch)
house_by_vendor = defaultdict(list)    # vendor -> [DW house sku, …]       (fallback)
for line in data.splitlines():
    if not line.strip(): continue
    o = json.loads(line)
    if "__parentId" in o and "sku" in o:                 # variant child = house sku
        ven = prod_vendor.get(o["__parentId"])
        sku = (o.get("sku") or "")
        if ven and sku and not sku.lower().endswith("-sample"):
            house_by_vendor[ven].append(sku)
    elif "id" in o and ("vendor" in o or "status" in o): # product line = mfr metafields
        ven = (o.get("vendor") or "").strip()
        prod_vendor[o["id"]] = ven
        if not ven: continue
        for k in ("mf", "mf2"):
            v = (o.get(k) or {}).get("value")
            if v:
                for c in parts(v): mfr_by_vendor[ven].append(c)

# ── 3) induce a profile per vendor ────────────────────────────────────────────
profiles = {}
for vendor in set(mfr_by_vendor) | set(house_by_vendor):
    mfr = [norm(c) for c in mfr_by_vendor.get(vendor, []) if len(norm(c)) >= 4]
    house = [norm(c) for c in house_by_vendor.get(vendor, []) if len(norm(c)) >= 4]
    # learn from the manufacturer codes (the swatch); only fall back to house if too thin
    codes = mfr if len(set(mfr)) >= 3 else house
    code_source = "mfr" if len(set(mfr)) >= 3 else "house"
    if len(codes) < 3: continue                          # too thin to learn anything reliable
    uniq = sorted(set(codes))
    pre = Counter(alpha_prefix(c) for c in codes if alpha_prefix(c))
    numeric = sum(1 for c in codes if not alpha_prefix(c))
    # keep prefixes that are common enough to be a real signal (≥3 hits AND ≥4% of codes)
    keep = [(p, n) for p, n in pre.most_common(8) if n >= 3 and n >= 0.04 * len(codes)]
    lens = Counter(len(c) for c in codes)
    profiles[vendor] = {
        "n": len(uniq),
        "aliases": [vendor.upper()],
        "alias_re": brand_regex(vendor),
        "prefixes": {p: n for p, n in keep},
        "numeric_share": round(numeric / max(1, len(codes)), 2),
        "common_len": lens.most_common(1)[0][0] if lens else 0,
        "examples": uniq[:6],
        "private_label": bool(PRIVATE_LABEL.search(vendor)),
        "source": "catalog",
        "code_source": code_source,        # "mfr" = learned from real swatch codes; "house" = fallback
        "learned_scans": 0,
        "updated_at": datetime.now(timezone.utc).isoformat(),
    }

meta = {"_meta": {"vendors": len(profiles), "products": len(prod_vendor),
                  "built_at": datetime.now(timezone.utc).isoformat(), "source": "catalog-bulk"}}
json.dump({**meta, "profiles": profiles}, open(OUT, "w"), indent=0)
print(f"\nlearned {len(profiles)} vendor profiles from {len(prod_vendor)} products -> {OUT}")
for probe in ("Winfield Thybony", "Schumacher", "Phillip Jeffries", "Thibaut", "Kravet"):
    p = profiles.get(probe)
    if p: print(f"  {probe:22} n={p['n']:5}  prefixes={list(p['prefixes'])}  ex={p['examples'][:3]}")