← back to Designer Wallcoverings
onboarding/sangetsu-lilycolor/scripts/translate-titles.py
264 lines
#!/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 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()