Skip to content

文档:如何将 LoCoMo 数据集翻译为简体中文(translate_locomo_zh.py) #37

Description

@huyyxy

背景

LoCoMo 评测数据(如 locomo10.json)为英文对话、摘要、问答等。我提供脚本 translate_locomo_zh.py,通过 OpenAI 兼容的 /v1/chat/completions 接口(httpx 调用,无需官方 SDK),将数据中的可翻译字段批量译为简体中文,并写出新的 JSON 文件。

注意:脚本会在原内存中的结构上就地改写各字段,最后整文件写入 -o 指定路径;请始终对副本运行或指定新输出路径,避免覆盖原始数据。


1. 依赖

  • Python 3
  • httpx(必需)
  • tqdm(可选,用于进度条;未安装时仍可运行,加 --no-progress 或自动无进度条)
pip install httpx tqdm

2. 认证与环境变量

  • OPENAI_API_KEY:Bearer Token(也可用 --api-key)
  • OPENAI_BASE_URL:可选,作为 --base-url 的默认值
  • OPENAI_COMPAT_CHAT_URL:可选,若设置则作为 --api-url 的默认值(完整 chat completions URL,优先级高于 --base-url)
  • OPENAI_MODEL:可选,作为 --model 的默认值

3. 基本用法

export OPENAI_API_KEY=sk-...

python3 translate_locomo_zh.py path/to/locomo10.json -o path/to/locomo10_zh.json \
  --base-url https://api.openai.com/v1 \
  --model gpt-4o-mini

未指定 -o 时,默认在输入文件名后加 _zh(例如 locomo10.json → locomo10_zh.json)。

输入格式要求:顶层 JSON 必须是数组(与官方 locomo10.json 结构一致),否则脚本会报错退出。


4. 各云厂商 / 兼容网关示例(与脚本 docstring 一致)

火山引擎 Coding Plan:

python3 translate_locomo_zh.py locomo10.json -o locomo10_zh.json \
  --base-url https://ark.cn-beijing.volces.com/api/coding/v3 \
  --model doubao-seed-2.0-lite

阿里云(兼容模式):

python3 translate_locomo_zh.py locomo10.json -o locomo10_zh.json \
  --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 \
  --model qwen-plus

MiniMax:

python3 translate_locomo_zh.py locomo10.json -o locomo10_zh.json \
  --base-url https://api.minimaxi.com/v1 \
  --model minimax-m2.7

若网关的 chat 地址不是 {base-url}/chat/completions,可使用 --api-url 指向完整 URL。


5. 常用参数说明

参数 含义
input 输入 JSON 路径
-o / --output 输出路径
--base-url API 根地址,请求 {base-url}/chat/completions
--api-url 完整 chat completions URL(设置后忽略 --base-url)
--api-key Bearer;默认读 OPENAI_API_KEY
--model 模型名
--timeout 单次请求超时(秒),默认 120
--max-retries 失败重试次数,默认 3(对 429/502/503/504 等会退避)
--delay 每次翻译请求后的休眠秒数,用于限流,默认 0
--no-progress 不显示 tqdm 进度条

运行开始会在 stderr 打印 预计 API 调用次数(每个可翻译字符串单独一次请求),便于估算费用与时间。


6. 会翻译哪些字段?

对每个样本大致包括:

  1. qa:question、answer(非空字符串)
  2. conversation:匹配 session_N 的列表中,每条消息的 text、blip_caption、query
  3. event_summary:events_session_N 下除 date 外的列表中的英文字符串
  4. observation:session_N_observation 下,每条观测为 [自然语言, 引用] 时,只翻译第一项;第二项常为 D数字:数字 引用,保持不译
  5. session_summary:session_N_summary 字符串

系统提示词约束:人名、地名、时间/日期保持原文;类似 D1:3、D12:45 的对话/证据引用标签保持不变;模型只输出译文,无解释。


7. 实践建议

  • 全量 locomo10.json 调用量很大:先用小文件跑通再全量;必要时加大 --delay 或换限额更高的密钥。
  • 可重复性:不同模型/温度(脚本内 temperature=0.2)下译文会有差异,评测对比时请固定模型与版本。
  • 校验:翻译后抽查 qa 与 conversation 中的引用标签、日期字段是否与英文版对齐。

"""
将 locomo 风格 JSON(如 locomo10.json)中的英文内容批量翻译为简体中文。

使用 OpenAI 兼容的 /v1/chat/completions 接口,通过 httpx 调用(不使用官方 SDK)。
每个可翻译字段单独请求一次 API。

用法示例:
  export OPENAI_API_KEY=sk-...

  python3 translate_locomo_zh.py locomo10.json -o locomo10_zh.json \
    --base-url https://api.openai.com/v1 --model gpt-4o-mini

  火山引擎Coding Plan:
  python3 translate_locomo_zh.py locomo10.json -o locomo10_zh.json \
    --base-url https://ark.cn-beijing.volces.com/api/coding/v3 \
    --model doubao-seed-2.0-lite

  阿里云:
  python3 translate_locomo_zh.py locomo10.json -o locomo10_zh.json \
    --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 \
    --model qwen-plus

  minimax:
  python3 translate_locomo_zh.py locomo10.json -o locomo10_zh.json \
    --base-url https://api.minimaxi.com/v1 \
    --model minimax-m2.7

也可使用 --api-url 直接指定完整的 chat completions 地址。
"""

from __future__ import annotations

import argparse
import json
import os
import re
import sys
import time
from typing import Any

import httpx

try:
    from tqdm import tqdm
except ImportError:
    tqdm = None  # type: ignore[misc, assignment]

SESSION_LIST_KEY = re.compile(r"^session_\d+$")
EVENTS_SESSION_KEY = re.compile(r"^events_session_\d+$")
OBS_SESSION_KEY = re.compile(r"^session_\d+_observation$")
SUMMARY_KEY = re.compile(r"^session_\d+_summary$")

SYSTEM_PROMPT = """You are a professional translator. Translate the following English text into Simplified Chinese.

Strict rules:
- Keep all person names, place names, and time/date expressions exactly as in the source (do not translate them or rewrite them into Chinese-style forms).
- Keep dialogue/evidence reference tags matching the pattern like D1:3, D12:45 (letter D, digits, colon, digits) unchanged wherever they appear.
- Output only the translated Chinese text, with no quotes or explanation."""


def build_chat_url(base_url: str | None, api_url: str | None) -> str:
    if api_url:
        return api_url.rstrip("/")
    if not base_url:
        base_url = "https://api.openai.com/v1"
    return base_url.rstrip("/") + "/chat/completions"


def translate_one(
    client: httpx.Client,
    url: str,
    api_key: str,
    model: str,
    text: str,
    timeout: float,
    max_retries: int,
) -> str:
    headers = {
        "Authorization": f"Bearer {api_key}",
        "Content-Type": "application/json",
    }
    payload: dict[str, Any] = {
        "model": model,
        "messages": [
            {"role": "system", "content": SYSTEM_PROMPT},
            {"role": "user", "content": text},
        ],
        "temperature": 0.2,
    }
    last_err: Exception | None = None
    for attempt in range(max_retries + 1):
        try:
            r = client.post(url, json=payload, headers=headers, timeout=timeout)
            r.raise_for_status()
            data = r.json()
            choice = data["choices"][0]["message"]["content"]
            if isinstance(choice, str):
                return choice.strip()
            return str(choice).strip()
        except (httpx.HTTPStatusError, httpx.RequestError, KeyError, json.JSONDecodeError) as e:
            last_err = e
            if isinstance(e, httpx.HTTPStatusError) and e.response.status_code in (429, 502, 503, 504):
                wait = min(2**attempt, 60)
                time.sleep(wait)
                continue
            if attempt < max_retries:
                time.sleep(min(2**attempt, 10))
                continue
            raise
    assert last_err is not None
    raise last_err


def should_skip_text(s: str) -> bool:
    if not isinstance(s, str):
        return True
    if not s.strip():
        return True
    return False


class Translator:
    def __init__(
        self,
        client: httpx.Client,
        url: str,
        api_key: str,
        model: str,
        timeout: float,
        max_retries: int,
        delay_s: float,
        progress: Any,
    ) -> None:
        self._client = client
        self._url = url
        self._api_key = api_key
        self._model = model
        self._timeout = timeout
        self._max_retries = max_retries
        self._delay_s = delay_s
        self._progress = progress

    def __call__(self, text: str) -> str:
        if should_skip_text(text):
            return text
        out = translate_one(
            self._client,
            self._url,
            self._api_key,
            self._model,
            text,
            self._timeout,
            self._max_retries,
        )
        if self._progress is not None:
            self._progress.update(1)
        if self._delay_s > 0:
            time.sleep(self._delay_s)
        return out


def process_qa(qa: list[dict[str, Any]], tr: Translator) -> None:
    for item in qa:
        if "question" in item:
            item["question"] = tr(str(item["question"]))
        if "answer" in item and item["answer"] is not None:
            a = item["answer"]
            item["answer"] = tr(str(a))


def process_conversation(conv: dict[str, Any], tr: Translator) -> None:
    for key, val in conv.items():
        if not SESSION_LIST_KEY.match(key):
            continue
        if not isinstance(val, list):
            continue
        for msg in val:
            if not isinstance(msg, dict):
                continue
            if "text" in msg and msg["text"] is not None:
                msg["text"] = tr(str(msg["text"]))
            if "blip_caption" in msg and msg["blip_caption"] is not None:
                msg["blip_caption"] = tr(str(msg["blip_caption"]))
            if "query" in msg and msg["query"] is not None:
                msg["query"] = tr(str(msg["query"]))


def process_event_summary(event_summary: dict[str, Any], tr: Translator) -> None:
    for ek, ev in event_summary.items():
        if not EVENTS_SESSION_KEY.match(ek):
            continue
        if not isinstance(ev, dict):
            continue
        for subk, subv in ev.items():
            if subk == "date":
                continue
            if not isinstance(subv, list):
                continue
            for i, s in enumerate(subv):
                if isinstance(s, str) and not should_skip_text(s):
                    subv[i] = tr(s)


def process_observation(observation: dict[str, Any], tr: Translator) -> None:
    for ok, ov in observation.items():
        if not OBS_SESSION_KEY.match(ok):
            continue
        if not isinstance(ov, dict):
            continue
        for _person, obs_list in ov.items():
            if not isinstance(obs_list, list):
                continue
            for inner in obs_list:
                if not isinstance(inner, list) or len(inner) < 2:
                    continue
                first = inner[0]
                # 第二项通常为 D数字:数字 引用,不翻译;只翻译首段自然语言
                if isinstance(first, str) and not should_skip_text(first):
                    inner[0] = tr(first)


def process_session_summary(session_summary: dict[str, Any], tr: Translator) -> None:
    for sk, sv in session_summary.items():
        if not SUMMARY_KEY.match(sk):
            continue
        if isinstance(sv, str) and not should_skip_text(sv):
            session_summary[sk] = tr(sv)


def count_translatable_strings(obj: Any) -> int:
    """用于进度条:预估将要调用翻译的次数。"""
    n = 0
    if isinstance(obj, list):
        for item in obj:
            n += count_translatable_strings(item)
        return n
    if not isinstance(obj, dict):
        return n

    if "qa" in obj and isinstance(obj["qa"], list):
        for q in obj["qa"]:
            if isinstance(q, dict):
                if "question" in q and q["question"] is not None and str(q["question"]).strip():
                    n += 1
                if "answer" in q and q["answer"] is not None and str(q["answer"]).strip():
                    n += 1

    conv = obj.get("conversation")
    if isinstance(conv, dict):
        for key, val in conv.items():
            if not SESSION_LIST_KEY.match(key) or not isinstance(val, list):
                continue
            for msg in val:
                if not isinstance(msg, dict):
                    continue
                for field in ("text", "blip_caption", "query"):
                    if field in msg and msg[field] is not None and str(msg[field]).strip():
                        n += 1

    es = obj.get("event_summary")
    if isinstance(es, dict):
        for ek, ev in es.items():
            if not EVENTS_SESSION_KEY.match(ek) or not isinstance(ev, dict):
                continue
            for subk, subv in ev.items():
                if subk == "date" or not isinstance(subv, list):
                    continue
                for s in subv:
                    if isinstance(s, str) and s.strip():
                        n += 1

    obs = obj.get("observation")
    if isinstance(obs, dict):
        for ok, ov in obs.items():
            if not OBS_SESSION_KEY.match(ok) or not isinstance(ov, dict):
                continue
            for _p, obs_list in ov.items():
                if not isinstance(obs_list, list):
                    continue
                for inner in obs_list:
                    if isinstance(inner, list) and inner and isinstance(inner[0], str) and inner[0].strip():
                        n += 1

    ss = obj.get("session_summary")
    if isinstance(ss, dict):
        for sk, sv in ss.items():
            if SUMMARY_KEY.match(sk) and isinstance(sv, str) and sv.strip():
                n += 1

    return n


def process_sample(sample: dict[str, Any], tr: Translator) -> None:
    if "qa" in sample and isinstance(sample["qa"], list):
        process_qa(sample["qa"], tr)
    if "conversation" in sample and isinstance(sample["conversation"], dict):
        process_conversation(sample["conversation"], tr)
    if "event_summary" in sample and isinstance(sample["event_summary"], dict):
        process_event_summary(sample["event_summary"], tr)
    if "observation" in sample and isinstance(sample["observation"], dict):
        process_observation(sample["observation"], tr)
    if "session_summary" in sample and isinstance(sample["session_summary"], dict):
        process_session_summary(sample["session_summary"], tr)


def parse_args() -> argparse.Namespace:
    p = argparse.ArgumentParser(description="将 locomo JSON 翻译为简体中文(OpenAI 兼容 API + httpx)")
    p.add_argument("input", type=str, help="输入 JSON 路径,例如 locomo10.json")
    p.add_argument(
        "-o",
        "--output",
        type=str,
        default=None,
        help="输出路径(默认:在输入文件名后加 _zh,如 locomo10_zh.json)",
    )
    p.add_argument(
        "--base-url",
        type=str,
        default=os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1"),
        help="API 根地址,默认 https://api.openai.com/v1;最终请求 {base-url}/chat/completions",
    )
    p.add_argument(
        "--api-url",
        type=str,
        default=os.environ.get("OPENAI_COMPAT_CHAT_URL"),
        help="若设置则直接使用该完整 chat completions URL,忽略 --base-url",
    )
    p.add_argument(
        "--api-key",
        type=str,
        default=os.environ.get("OPENAI_API_KEY", ""),
        help="Bearer Token(也可环境变量 OPENAI_API_KEY)",
    )
    p.add_argument(
        "--model",
        type=str,
        default=os.environ.get("OPENAI_MODEL", "gpt-4o-mini"),
        help="模型名称",
    )
    p.add_argument("--timeout", type=float, default=120.0, help="单次请求超时(秒)")
    p.add_argument("--max-retries", type=int, default=3, help="失败重试次数")
    p.add_argument(
        "--delay",
        type=float,
        default=0.0,
        metavar="SEC",
        help="每次翻译请求之后休眠秒数(限流用,默认 0)",
    )
    p.add_argument("--no-progress", action="store_true", help="不显示 tqdm 进度条")
    return p.parse_args()


def main() -> None:
    args = parse_args()
    if not args.api_key:
        print("错误:请通过 --api-key 或环境变量 OPENAI_API_KEY 提供密钥。", file=sys.stderr)
        sys.exit(1)

    in_path = os.path.abspath(args.input)
    if args.output:
        out_path = os.path.abspath(args.output)
    else:
        root, ext = os.path.splitext(in_path)
        out_path = f"{root}_zh{ext or '.json'}"

    with open(in_path, "r", encoding="utf-8") as f:
        data = json.load(f)

    if not isinstance(data, list):
        print("错误:顶层 JSON 应为数组(与 locomo10.json 一致)。", file=sys.stderr)
        sys.exit(1)

    total_calls = sum(count_translatable_strings(s) for s in data)
    print(f"预计翻译 API 调用次数: {total_calls}", file=sys.stderr)

    url = build_chat_url(args.base_url, args.api_url)
    progress = None
    if tqdm and not args.no_progress and total_calls > 0:
        progress = tqdm(total=total_calls, unit="call", desc="Translating")

    with httpx.Client() as client:
        tr = Translator(
            client=client,
            url=url,
            api_key=args.api_key,
            model=args.model,
            timeout=args.timeout,
            max_retries=args.max_retries,
            delay_s=args.delay,
            progress=progress,
        )
        for sample in data:
            if isinstance(sample, dict):
                process_sample(sample, tr)

    if progress is not None:
        progress.close()

    with open(out_path, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)
        f.write("\n")

    print(f"已写入: {out_path}", file=sys.stderr)


if __name__ == "__main__":
    main()

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions