CoolFace
Apppublic

weiningho/localization-agent

sourceHugging Facemitupdated 4mo agoView on Hugging Face
0likes
app.py332 linesDownload Raw Back to root
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