← back to Designer Wallcoverings
translate-titles: resumable local JA->EN Lilycolor title translator (qwen2.5vl)
70ad589d565a72240128e576977fdcec1ac700cb · 2026-07-01 09:29:04 -0700 · Steve
Files touched
A onboarding/sangetsu-lilycolor/scripts/translate-titles.py
Diff
commit 70ad589d565a72240128e576977fdcec1ac700cb
Author: Steve <steve@designerwallcoverings.com>
Date: Wed Jul 1 09:29:04 2026 -0700
translate-titles: resumable local JA->EN Lilycolor title translator (qwen2.5vl)
---
.../sangetsu-lilycolor/scripts/translate-titles.py | 264 +++++++++++++++++++++
1 file changed, 264 insertions(+)
diff --git a/onboarding/sangetsu-lilycolor/scripts/translate-titles.py b/onboarding/sangetsu-lilycolor/scripts/translate-titles.py
new file mode 100644
index 00000000..614a89de
--- /dev/null
+++ b/onboarding/sangetsu-lilycolor/scripts/translate-titles.py
@@ -0,0 +1,264 @@
+#!/usr/bin/env python3
+"""
+translate-titles.py — Stage English translations of Lilycolor Japanese product
+titles ALONGSIDE the Japanese source. READ-ONLY against the JA source: never
+mutates staging/lilycolor-unified-staging.jsonl.
+
+- Reads each record from the unified staging file, extracts `title_ja`.
+- The JA title is shaped: <pattern-name>|<sku-range>|<suffix>
+ e.g. 山ぶどう|LL6020・6021|サンプル
+ Only the pattern-name segment (before the first |) carries meaning worth
+ translating; the SKU range is preserved as-is and the boilerplate suffix
+ (サンプル=Sample, のり付き壁紙=Pre-Pasted Wallcovering) is normalized.
+- Calls local ollama qwen2.5vl:7b (localhost:11434) to translate the pattern
+ name into natural English Title Case. $0 (local). No paid APIs.
+- Writes staging/lilycolor-titles-en-063026A.jsonl with:
+ {sku, title_ja, title_en, pattern_ja, pattern_en, suffix_en}
+ where title_en is the reassembled English title.
+- RESUMABLE: skips any SKU already present in the output file.
+- Model errors are logged + skipped (SKU not written; picked up on next run).
+
+Standing rules enforced:
+ * The word "Wallpaper" is BANNED -> "Wallcovering".
+ * Title Case output.
+ * Do NOT invent pattern names: bare SKU/code stays faithful.
+"""
+import json
+import os
+import re
+import sys
+import time
+import urllib.request
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+ROOT = os.path.dirname(HERE)
+SRC = os.path.join(ROOT, "staging", "lilycolor-unified-staging.jsonl")
+OUT = os.path.join(ROOT, "staging", "lilycolor-titles-en-063026A.jsonl")
+LOG = os.path.join(ROOT, "staging", "translate-titles.log")
+
+OLLAMA = "http://localhost:11434/api/generate"
+MODEL = "qwen2.5vl:7b"
+
+# Boilerplate suffix map (deterministic; no model needed for these).
+SUFFIX_MAP = {
+ "サンプル": "Sample",
+ "のり付き壁紙": "Pre-Pasted Wallcovering",
+ "のり無し壁紙": "Unpasted Wallcovering",
+}
+
+BANNED = re.compile(r"\bwallpaper(s)?\b", re.IGNORECASE)
+
+# Small skip-lists: pure-ASCII / bare-code pattern names need no translation.
+ASCII_ONLY = re.compile(r"^[\x00-\x7F]+$")
+
+
+def log(msg):
+ line = f"{time.strftime('%Y-%m-%dT%H:%M:%S')} {msg}"
+ print(line, flush=True)
+ with open(LOG, "a", encoding="utf-8") as f:
+ f.write(line + "\n")
+
+
+def title_case(s):
+ """Title Case that leaves already-capitalized acronyms/codes alone and
+ keeps small articles/preps lowercase unless first word."""
+ small = {"a", "an", "the", "and", "but", "or", "of", "in", "on", "at",
+ "for", "to", "with", "by"}
+ words = s.split()
+ out = []
+ for i, w in enumerate(words):
+ # preserve tokens that already contain a digit or are ALLCAPS codes
+ if re.search(r"\d", w) or (w.isupper() and len(w) > 1):
+ out.append(w)
+ elif i > 0 and w.lower() in small:
+ out.append(w.lower())
+ else:
+ out.append(w[:1].upper() + w[1:])
+ return " ".join(out)
+
+
+def clean_en(s):
+ s = s.strip().strip('"').strip("'").strip()
+ # collapse whitespace/newlines
+ s = re.sub(r"\s+", " ", s)
+ # strip a leading "English:"/"->" echo if the model repeats it
+ s = re.sub(r"^(english|translation)\s*[:\-]\s*", "", s, flags=re.IGNORECASE)
+ s = re.sub(r"^->\s*", "", s)
+ # take only the first line if the model rambled onto a second
+ s = s.splitlines()[0].strip() if s.splitlines() else s
+ # enforce Wallcovering-not-Wallpaper
+ s = BANNED.sub(lambda m: "Wallcovering" + ("s" if m.group(1) else ""), s)
+ return s.strip()
+
+
+PROMPT = (
+ "You translate Japanese wallcovering PATTERN NAMES into English for a "
+ "luxury interior-design catalog.\n"
+ "The names are usually a single material, texture, motif, or design word. "
+ "MANY are KATAKANA — i.e. a Japanese phonetic spelling of an English, "
+ "French, or Italian word. In that case recover the ORIGINAL Western word "
+ "(the material/design term the katakana is spelling), NOT a literal "
+ "syllable-by-syllable romanization.\n\n"
+ "Rules:\n"
+ "- Output ONLY the English name, in Title Case. No quotes, no notes, no "
+ "romaji, no parentheses, no extra words.\n"
+ "- Do NOT append 'Wallcovering' or 'Pattern' or any category word — give "
+ "just the pattern name itself.\n"
+ "- Do NOT invent or embellish. If it is already English/Latin or a bare "
+ "product code, return it unchanged.\n"
+ "- NEVER output the word 'Wallpaper'.\n\n"
+ "Examples:\n"
+ "ブークレ -> Bouclé\n"
+ "ヴァーダァグリィース -> Verdigris\n"
+ "ラスト -> Rust\n"
+ "ベロア -> Velour\n"
+ "スタッコ -> Stucco\n"
+ "フロステッドグラス -> Frosted Glass\n"
+ "山ぶどう -> Mountain Grape\n"
+ "板柾 -> Rift-Sawn Board\n\n"
+ "{name} ->"
+)
+
+
+def translate_pattern(name, retries=3):
+ """Translate a single JA pattern-name segment. Returns English str or None."""
+ name = name.strip()
+ if not name:
+ return ""
+ # Pure-ASCII (already English / bare code): pass through, just Title Case.
+ if ASCII_ONLY.match(name):
+ return title_case(clean_en(name))
+ body = json.dumps({
+ "model": MODEL,
+ "prompt": PROMPT.format(name=name),
+ "stream": False,
+ "options": {"temperature": 0, "num_predict": 40},
+ }).encode("utf-8")
+ for attempt in range(1, retries + 1):
+ try:
+ req = urllib.request.Request(
+ OLLAMA, data=body,
+ headers={"Content-Type": "application/json"})
+ with urllib.request.urlopen(req, timeout=120) as resp:
+ data = json.loads(resp.read().decode("utf-8"))
+ raw = data.get("response", "")
+ en = clean_en(raw)
+ if not en:
+ raise ValueError("empty response")
+ en = title_case(en)
+ # sanity: reject a response that's absurdly long (model rambled)
+ if len(en) > 120:
+ en = en.split(".")[0].strip()[:120]
+ return en
+ except Exception as e: # noqa: BLE001 - log + retry/skip
+ log(f"WARN translate attempt {attempt}/{retries} for "
+ f"{name!r}: {e}")
+ time.sleep(1.5 * attempt)
+ return None
+
+
+def split_title(title_ja):
+ """Return (pattern_ja, sku_range, suffix_ja)."""
+ parts = [p.strip() for p in title_ja.split("|")]
+ if len(parts) >= 3:
+ return parts[0], parts[1], parts[-1]
+ if len(parts) == 2:
+ return parts[0], parts[1], ""
+ return parts[0], "", ""
+
+
+def load_done():
+ done = set()
+ if os.path.exists(OUT):
+ with open(OUT, encoding="utf-8") as f:
+ for line in f:
+ line = line.strip()
+ if not line:
+ continue
+ try:
+ done.add(json.loads(line)["sku"])
+ except Exception: # noqa: BLE001
+ continue
+ return done
+
+
+def main():
+ done = load_done()
+ log(f"START translate-titles | source={SRC} | output={OUT} | "
+ f"already_done={len(done)}")
+
+ total = 0
+ translated = 0
+ skipped_no_title = 0
+ skipped_done = 0
+ errors = 0
+
+ with open(SRC, encoding="utf-8") as fin, \
+ open(OUT, "a", encoding="utf-8") as fout:
+ for line in fin:
+ line = line.strip()
+ if not line:
+ continue
+ total += 1
+ try:
+ rec = json.loads(line)
+ except Exception as e: # noqa: BLE001
+ log(f"ERROR bad json line {total}: {e}")
+ errors += 1
+ continue
+ sku = rec.get("mfr_sku")
+ title_ja = rec.get("title_ja")
+
+ if sku in done:
+ skipped_done += 1
+ continue
+ if not title_ja:
+ skipped_no_title += 1
+ continue
+
+ pattern_ja, sku_range, suffix_ja = split_title(title_ja)
+ pattern_en = translate_pattern(pattern_ja)
+ if pattern_en is None:
+ log(f"SKIP {sku}: translation failed for pattern "
+ f"{pattern_ja!r}")
+ errors += 1
+ continue
+
+ suffix_en = SUFFIX_MAP.get(suffix_ja, "")
+ if not suffix_en and suffix_ja:
+ # unknown suffix -> translate it too
+ s = translate_pattern(suffix_ja)
+ suffix_en = s or ""
+
+ # Reassemble a clean English title.
+ bits = [pattern_en]
+ if sku_range:
+ bits.append(sku_range)
+ if suffix_en:
+ bits.append(suffix_en)
+ title_en = " | ".join(b for b in bits if b)
+ title_en = BANNED.sub(lambda m: "Wallcovering", title_en)
+
+ out_rec = {
+ "sku": sku,
+ "title_ja": title_ja,
+ "title_en": title_en,
+ "pattern_ja": pattern_ja,
+ "pattern_en": pattern_en,
+ "suffix_en": suffix_en,
+ }
+ fout.write(json.dumps(out_rec, ensure_ascii=False) + "\n")
+ fout.flush()
+ done.add(sku)
+ translated += 1
+ if translated % 25 == 0:
+ log(f"progress: translated={translated} "
+ f"(scanned {total}) last={sku} -> {title_en!r}")
+
+ log(f"DONE translate-titles | scanned={total} translated={translated} "
+ f"skipped_done={skipped_done} skipped_no_title={skipped_no_title} "
+ f"errors={errors} | output_total={len(done)}")
+
+
+if __name__ == "__main__":
+ main()
← f92f9182 enrich-full: resumable local vision enrichment scaled from p
·
back to Designer Wallcoverings
·
auto-save: 2026-07-01T09:41:09 (5 files) — pending-approval/ 0cffa706 →