weiningho/localization-agent
0
1"""2Game Localization Agent — AI Product Manager Technical Assessment3Chat-based CSV translation agent (Traditional Chinese source -> multiple target languages).4UI internationalized (English default; switchable to zh-Hant / zh-Hans / ja).5 6Design notes (map to the written summary):7- Deterministic skip-detection decides WHAT to translate before any model call -> saves tokens.8- Probabilistic step = the LLM does the actual translation (thinking disabled for low latency).9- Placeholders {x}, %s, <html> tags preserved by instruction + validated afterwards.10- Batches run CONCURRENTLY (all target languages in one call per batch) -> low wall-clock latency.11- Source-language column is reused, not re-translated -> zero wasted tokens.12- needs_review flag = lightweight human-in-the-loop signal.13"""14 15import os16import re17import json18import time19import tempfile20import concurrent.futures21import pandas as pd22import gradio as gr23from google import genai24from google.genai import types25 26MODEL = "gemini-2.5-flash"27BATCH_SIZE = 4028MAX_WORKERS = 1 # sequential: gentle on the free-tier rate limit (backoff handles bursts)29SOURCE_CODE = "zh-Hant"30 31client = genai.Client(api_key=os.environ.get("GEMINI_API_KEY", ""))32 33LANG_MAP = [34 (["繁體中文", "繁中", "traditional", "zh-hant", "zh-tw", "臺灣", "台灣", "繁体字"], "zh-Hant"),35 (["簡體中文", "简体中文", "簡中", "simplified", "zh-hans", "zh-cn", "簡体字"], "zh-Hans"),36 (["日文", "日語", "日本語", "japanese", "ja"], "ja"),37 (["英文", "英語", "english", "en"], "en"),38 (["韓文", "韓語", "korean", "ko"], "ko"),39]40COL_NAME = {"zh-Hant": "Traditional Chinese", "zh-Hans": "Simplified Chinese",41 "ja": "Japanese", "en": "English", "ko": "Korean"}42DISP = {43 "zh-Hant": {"en": "Traditional Chinese", "zh-Hant": "繁體中文", "zh-Hans": "繁体中文", "ja": "繁体字中国語"},44 "zh-Hans": {"en": "Simplified Chinese", "zh-Hant": "簡體中文", "zh-Hans": "简体中文", "ja": "簡体字中国語"},45 "ja": {"en": "Japanese", "zh-Hant": "日文", "zh-Hans": "日文", "ja": "日本語"},46 "en": {"en": "English", "zh-Hant": "英文", "zh-Hans": "英文", "ja": "英語"},47 "ko": {"en": "Korean", "zh-Hant": "韓文", "zh-Hans": "韩文", "ja": "韓国語"},48}49 50 51def disp(code, ui):52 return DISP.get(code, {}).get(ui, code)53 54 55def parse_languages(msg):56 m = msg.lower()57 found, seen = [], set()58 for keys, code in LANG_MAP:59 if any(k.lower() in m for k in keys) and code not in seen:60 found.append(code)61 seen.add(code)62 return found63 64 65CJK = re.compile(r"[\u3400-\u9fff\uf900-\ufaff]")66PLACEHOLDER = re.compile(r"\{[^}]+\}|%[sd]|<[^>]+>")67 68 69def has_cjk(s):70 return bool(CJK.search(str(s)))71 72 73def needs_translation(s):74 return has_cjk(s)75 76 77def detect_source_col(df):78 best, score = df.columns[0], -179 for c in df.columns:80 s = df[c].astype(str).apply(has_cjk).sum()81 if s > score:82 best, score = c, s83 return best84 85 86def translate_batch(rows, target_codes):87 codes_str = ", ".join(f"{COL_NAME[c]} ({c})" for c in target_codes)88 payload = [{"i": i, "src": t} for i, t in rows]89 prompt = f"""You are a professional game localization translator.90Translate each game UI/content string from Traditional Chinese into these target languages: {codes_str}.91Rules:92- Keep any {{curly_brace_placeholders}}, %s, %d, and HTML tags like <b> EXACTLY unchanged.93- Keep a concise tone suitable for a game UI.94- For Simplified Chinese, use mainland-China vocabulary and conventions, not just character conversion.95- No explanations.96Return ONLY a JSON array. Each element: {{"i": <index>, "t": {{"<code>": "<translation>", ...}}}}.97Strings:98{json.dumps(payload, ensure_ascii=False)}"""99 resp = client.models.generate_content(100 model=MODEL,101 contents=prompt,102 config=types.GenerateContentConfig(103 thinking_config=types.ThinkingConfig(thinking_budget=0),104 response_mime_type="application/json",105 temperature=0.2,106 ),107 )108 text = (resp.text or "").strip()109 text = re.sub(r"^```(?:json)?|```$", "", text, flags=re.MULTILINE).strip()110 data = json.loads(text)111 return {item["i"]: item.get("t", {}) for item in data}112 113 114def translate_batch_with_retry(rows, target_codes, retries=4):115 """Fallback/degradation: on rate-limit (429) back off and retry rather than fail."""116 delay = 8117 for attempt in range(retries):118 try:119 return translate_batch(rows, target_codes)120 except Exception as e:121 transient = ("429" in str(e)) or ("RESOURCE_EXHAUSTED" in str(e)) or ("quota" in str(e).lower())122 if transient and attempt < retries - 1:123 time.sleep(delay)124 delay *= 2125 continue126 raise127 128 129def placeholders_ok(src, tgt):130 return set(PLACEHOLDER.findall(str(src))) == set(PLACEHOLDER.findall(str(tgt)))131 132 133def run_translation(df, src_col, target_codes):134 api_targets = [c for c in target_codes if c != SOURCE_CODE]135 for c in target_codes:136 df[COL_NAME[c]] = ""137 df["needs_review"] = ""138 df["status"] = ""139 140 rows_to_do = [(i, str(df.at[i, src_col])) for i in df.index if needs_translation(df.at[i, src_col])]141 skipped = [i for i in df.index if not needs_translation(df.at[i, src_col])]142 143 if SOURCE_CODE in target_codes:144 for i in df.index:145 df.at[i, COL_NAME[SOURCE_CODE]] = df.at[i, src_col]146 for i in skipped:147 for c in api_targets:148 df.at[i, COL_NAME[c]] = df.at[i, src_col]149 df.at[i, "status"] = "skipped"150 151 flagged = 0152 if api_targets:153 batches = [rows_to_do[b:b + BATCH_SIZE] for b in range(0, len(rows_to_do), BATCH_SIZE)]154 results, errored = {}, {}155 with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_WORKERS) as ex:156 fut_map = {ex.submit(translate_batch_with_retry, b, api_targets): b for b in batches}157 for fut in concurrent.futures.as_completed(fut_map):158 b = fut_map[fut]159 try:160 results.update(fut.result())161 except Exception as e:162 for i, _ in b:163 errored[i] = str(e)164 for i, src in rows_to_do:165 if i in errored:166 df.at[i, "needs_review"] = f"error: {errored[i]}"167 df.at[i, "status"] = "error"168 flagged += 1169 continue170 t = results.get(i, {})171 df.at[i, "status"] = "translated"172 issues = []173 for c in api_targets:174 val = t.get(c, "")175 df.at[i, COL_NAME[c]] = val176 if not val:177 issues.append(f"{c}:empty")178 elif not placeholders_ok(src, val):179 issues.append(f"{c}:placeholder")180 if issues:181 df.at[i, "needs_review"] = "; ".join(issues)182 flagged += 1183 184 stats = {"total": len(df), "translated": len(rows_to_do),185 "skipped": len(skipped), "flagged": flagged,186 "langs": [COL_NAME[c] for c in target_codes]}187 return df, stats188 189 190I18N = {191 "en": {192 "title": "# 🎮 Game Localization Agent\nUpload a Chinese game-string CSV, specify target languages in chat, and download a localized multi-language CSV.",193 "chat": "Conversation", "msg": "Message",194 "ph": "Enter target languages, e.g. Traditional Chinese, Simplified Chinese, Japanese, English (press Enter)",195 "upload": "① Upload CSV", "download": "② Download result", "uilang": "Interface language",196 "need_upload": "Please upload a CSV file first.",197 "read": "Read **{n}** rows. Detected source column: **`{col}`** (Traditional Chinese).\n\nWhich languages should I translate into? e.g. `Traditional Chinese, Simplified Chinese, Japanese, English`",198 "nolang": "I didn't recognize a language. Try: `Simplified Chinese, Japanese, English`.",199 "confirm": "Target languages: **{langs}**.\n\nDetected **{total}** rows; **{skip}** don't need translation (numbers / versions / URLs / IDs / placeholders / empty), so **{do}** rows will be translated.{note}\n\nType **start** to confirm.",200 "note": "\n\n(Note: the source is already {src}, so that column reuses the original — no tokens wasted.)",201 "translating": "Translating in parallel batches, please wait…",202 "done": "✅ Done!\n\n- Total rows: **{total}**\n- Translated: **{translated}**\n- Auto-skipped (numbers/IDs/placeholders/empty): **{skipped}**\n- Flagged for review: **{flagged}**\n- Target languages: {langs}\n\nDownload the result CSV on the right.",203 },204 "zh-Hant": {205 "title": "# 🎮 遊戲在地化代理\n上傳中文遊戲字串 CSV,用對話指定目標語言,產出多語在地化 CSV。",206 "chat": "對話", "msg": "訊息",207 "ph": "輸入目標語言,例如:繁體中文、簡體中文、日文、英文(打完按 Enter)",208 "upload": "① 上傳 CSV", "download": "② 下載結果", "uilang": "介面語言",209 "need_upload": "請先上傳 CSV 檔案。",210 "read": "已讀取 **{n}** 行,偵測到來源欄位為 **`{col}`**(繁體中文)。\n\n請輸入要翻譯成哪些語言,例如:`繁體中文、簡體中文、日文、英文`",211 "nolang": "我沒有辨識到語言。請輸入像是:`簡體中文、日文、英文`。",212 "confirm": "目標語言:**{langs}**。\n\n偵測到 **{total}** 行,其中 **{skip}** 行不需翻譯(數字/版本號/URL/ID/placeholder/空值),將翻譯 **{do}** 行。{note}\n\n輸入「**開始**」確認執行。",213 "note": "\n\n(註:來源已是 {src},該欄將直接沿用原文,不浪費 token。)",214 "translating": "並行分批翻譯中,請稍候…",215 "done": "✅ 完成!\n\n- 總行數:**{total}**\n- 已翻譯:**{translated}**\n- 自動跳過(數字/ID/placeholder/空值):**{skipped}**\n- 建議人工檢查:**{flagged}**\n- 目標語言:{langs}\n\n請從右側下載結果 CSV。",216 },217 "zh-Hans": {218 "title": "# 🎮 游戏本地化代理\n上传中文游戏字符串 CSV,用对话指定目标语言,生成多语本地化 CSV。",219 "chat": "对话", "msg": "消息",220 "ph": "输入目标语言,例如:繁体中文、简体中文、日文、英文(输入后按 Enter)",221 "upload": "① 上传 CSV", "download": "② 下载结果", "uilang": "界面语言",222 "need_upload": "请先上传 CSV 文件。",223 "read": "已读取 **{n}** 行,检测到来源列为 **`{col}`**(繁体中文)。\n\n请输入要翻译成哪些语言,例如:`繁体中文、简体中文、日文、英文`",224 "nolang": "我没有识别到语言。请输入例如:`简体中文、日文、英文`。",225 "confirm": "目标语言:**{langs}**。\n\n检测到 **{total}** 行,其中 **{skip}** 行无需翻译(数字/版本号/URL/ID/placeholder/空值),将翻译 **{do}** 行。{note}\n\n输入「**开始**」确认执行。",226 "note": "\n\n(注:来源已是 {src},该列将直接沿用原文,不浪费 token。)",227 "translating": "并行分批翻译中,请稍候…",228 "done": "✅ 完成!\n\n- 总行数:**{total}**\n- 已翻译:**{translated}**\n- 自动跳过(数字/ID/placeholder/空值):**{skipped}**\n- 建议人工检查:**{flagged}**\n- 目标语言:{langs}\n\n请从右侧下载结果 CSV。",229 },230 "ja": {231 "title": "# 🎮 ゲームローカライズエージェント\n中国語のゲーム文字列 CSV をアップロードし、チャットで対象言語を指定して、多言語 CSV を出力します。",232 "chat": "会話", "msg": "メッセージ",233 "ph": "対象言語を入力(例:繁体字中国語、簡体字中国語、日本語、英語) Enter で送信",234 "upload": "① CSV をアップロード", "download": "② 結果をダウンロード", "uilang": "表示言語",235 "need_upload": "先に CSV ファイルをアップロードしてください。",236 "read": "**{n}** 行を読み込みました。元言語の列:**`{col}`**(繁体字中国語)。\n\nどの言語に翻訳しますか?例:`繁体字中国語、簡体字中国語、日本語、英語`",237 "nolang": "言語を認識できませんでした。例:`簡体字中国語、日本語、英語`。",238 "confirm": "対象言語:**{langs}**。\n\n**{total}** 行を検出。うち **{skip}** 行は翻訳不要(数字/バージョン/URL/ID/プレースホルダー/空)。**{do}** 行を翻訳します。{note}\n\n「**開始**」と入力して実行。",239 "note": "\n\n(注:元言語はすでに {src} のため、その列は原文を再利用しトークンを消費しません。)",240 "translating": "並列バッチで翻訳中です…",241 "done": "✅ 完了!\n\n- 総行数:**{total}**\n- 翻訳済み:**{translated}**\n- 自動スキップ(数字/ID/プレースホルダー/空):**{skipped}**\n- 要確認:**{flagged}**\n- 対象言語:{langs}\n\n右側から結果 CSV をダウンロードできます。",242 },243}244 245CONFIRM_WORDS = ["start", "go", "yes", "開始", "开始", "確認", "确认", "好", "はい", "スタート"]246 247 248def on_upload(file, history, ui):249 t = I18N[ui]250 if file is None:251 return history, None, None252 df = pd.read_csv(file.name, dtype=str).fillna("")253 src = detect_source_col(df)254 history = history + [{"role": "assistant", "content": t["read"].format(n=len(df), col=src)}]255 return history, df, src256 257 258def on_message(msg, history, df, src, langs, confirmed, ui):259 t = I18N[ui]260 history = history + [{"role": "user", "content": msg}]261 262 if df is None:263 history.append({"role": "assistant", "content": t["need_upload"]})264 return history, "", df, src, langs, confirmed, gr.update()265 266 is_confirm = any(w in msg.lower() for w in CONFIRM_WORDS)267 268 if langs and is_confirm and not confirmed:269 out_df, stats = run_translation(df.copy(), src, langs)270 path = os.path.abspath("translated_output.csv")271 out_df.to_csv(path, index=False, encoding="utf-8-sig")272 msg_done = t["done"].format(total=stats["total"], translated=stats["translated"],273 skipped=stats["skipped"], flagged=stats["flagged"],274 langs=", ".join(stats["langs"]))275 history.append({"role": "assistant", "content": msg_done})276 return history, "", df, src, langs, True, path277 278 parsed = parse_languages(msg)279 if not parsed:280 history.append({"role": "assistant", "content": t["nolang"]})281 return history, "", df, src, langs, confirmed, gr.update()282 283 skip_n = sum(1 for i in df.index if not needs_translation(df.at[i, src]))284 do_n = len(df) - skip_n285 note = t["note"].format(src=disp(SOURCE_CODE, ui)) if SOURCE_CODE in parsed else ""286 sep = "、" if (ui.startswith("zh") or ui == "ja") else ", "287 labels = sep.join(disp(c, ui) for c in parsed)288 history.append({"role": "assistant",289 "content": t["confirm"].format(langs=labels, total=len(df), skip=skip_n, do=do_n, note=note)})290 return history, "", df, src, parsed, False, gr.update()291 292 293def set_ui(ui):294 t = I18N[ui]295 return (ui, gr.update(value=t["title"]), gr.update(label=t["chat"]),296 gr.update(label=t["msg"], placeholder=t["ph"]),297 gr.update(label=t["upload"]), gr.update(label=t["download"]))298 299 300DEFAULT = "en"301t0 = I18N[DEFAULT]302with gr.Blocks(title="Game Localization Agent") as demo:303 ui_state = gr.State(DEFAULT)304 df_state = gr.State(None)305 src_state = gr.State(None)306 langs_state = gr.State(None)307 confirmed_state = gr.State(False)308 309 with gr.Row():310 title_md = gr.Markdown(t0["title"])311 ui_lang = gr.Dropdown(312 choices=[("English", "en"), ("繁體中文", "zh-Hant"), ("简体中文", "zh-Hans"), ("日本語", "ja")],313 value="en", label=t0["uilang"], scale=0, min_width=180)314 315 with gr.Row():316 with gr.Column(scale=3):317 chat = gr.Chatbot(height=420, label=t0["chat"])318 txt = gr.Textbox(placeholder=t0["ph"], label=t0["msg"])319 with gr.Column(scale=2):320 up = gr.File(label=t0["upload"], file_types=[".csv"])321 out = gr.File(label=t0["download"]) # always visible -> no re-mount glitch322 323 ui_lang.change(set_ui, [ui_lang], [ui_state, title_md, chat, txt, up, out])324 up.change(on_upload, [up, chat, ui_state], [chat, df_state, src_state])325 txt.submit(on_message,326 [txt, chat, df_state, src_state, langs_state, confirmed_state, ui_state],327 [chat, txt, df_state, src_state, langs_state, confirmed_state, out])328 329if __name__ == "__main__":330 demo.queue()331 demo.launch(allowed_paths=[os.getcwd(), tempfile.gettempdir()])332 