"""Tiny i18n helpers for localizing API error messages. We intentionally avoid pulling in a heavy dependency (Babel / gettext) for what is essentially a fixed dictionary of user-facing strings. Supported locales are kept in sync with the frontend's SUPPORTED_LOCALES list in `frontend/src/locales/index.js`. """ from __future__ import annotations from typing import Iterable from fastapi import Request SUPPORTED_LOCALES = ("en", "zh") DEFAULT_LOCALE = "en" # Each value is a dict keyed by locale code. English is the fallback. _MESSAGES: dict[str, dict[str, str]] = { "rules_cannot_be_empty": { "en": "Rules cannot be empty", "zh": "规则不能为空", }, "population_range": { "en": "Population must be between 2 and 50", "zh": "人口规模必须在 2 到 50 之间", }, "duration_range": { "en": "Duration must be between 1 and 365 days", "zh": "持续时间必须在 1 到 365 天之间", }, "simulation_not_found": { "en": "Simulation not found", "zh": "未找到模拟", }, "simulation_pipeline_gone": { "en": "Simulation pipeline is not live (server may have restarted). Open the report view or start a new simulation.", "zh": "模拟管线已下线(可能是服务器重启了)。请打开报告页查看已保存的状态,或重新发起模拟。", }, "simulation_interrupted_no_report": { "en": "Simulation was interrupted (server restarted). No partial report is available — please start a new simulation.", "zh": "模拟已中断(服务器重启过)。暂无部分报告可用 —— 请重新发起模拟。", }, "no_world_state": { "en": "No world state", "zh": "无世界状态", }, "simulation_not_running": { "en": "Simulation not running", "zh": "模拟未运行", }, "source_simulation_not_found": { "en": "Source simulation not found", "zh": "未找到源模拟", }, "agent_not_found": { "en": "Agent not found", "zh": "未找到智能体", }, "invalid_speed_mode": { "en": "Invalid speed mode: {mode}", "zh": "无效的速度模式:{mode}", }, "fork_state_not_found": { "en": "Fork state not found", "zh": "未找到分叉状态", }, "no_metrics_data": { "en": "No metrics data found", "zh": "未找到指标数据", }, "forecast_not_available": { "en": "Forecast service not available", "zh": "预测服务不可用", }, "report_generation_failed": { "en": "Report generation failed", "zh": "报告生成失败", }, "simulation_cancelled": { "en": "Simulation cancelled", "zh": "模拟已取消", }, "language_name": { "en": "English", "zh": "中文", }, "language_instruction": { "en": "Write all output in English.", "zh": "请用中文撰写所有输出。", }, } def parse_accept_language(header: str | None) -> str: """Pick the best supported locale from an Accept-Language header.""" if not header: return DEFAULT_LOCALE for raw in header.split(","): tag = raw.split(";")[0].strip().lower() if not tag: continue primary = tag.split("-")[0] if primary in SUPPORTED_LOCALES: return primary return DEFAULT_LOCALE def locale_from_request(request: Request) -> str: return parse_accept_language(request.headers.get("accept-language")) def translate(key: str, locale: str, /, **kwargs) -> str: """Return the localized message for `key`, falling back to English then key.""" entry = _MESSAGES.get(key) if not entry: return key template = entry.get(locale) or entry.get(DEFAULT_LOCALE) or key if kwargs: try: return template.format(**kwargs) except (KeyError, IndexError): return template return template def language_instruction(locale: str) -> str: """Return a short prompt instruction telling the LLM which language to use.""" return translate("language_instruction", locale) def language_directive(locale: str) -> tuple[str, str]: """Strong language directive to bracket a system prompt. Returns (head, tail). Place `head` at the start and `tail` at the end of the system prompt so the LLM is reminded of the language constraint at both ends. Most modern chat models follow instructions more reliably when they appear in the top section of the prompt. """ head = ( f"== LANGUAGE REQUIREMENT ==\n" + language_instruction(locale) + "\nYour entire response MUST be in the language above. " "Do NOT mix languages. If a name or technical term is originally " "in another language, transliterate or translate it; do not leave " "English fragments in a Chinese response (or vice versa)." ) tail = "[Reminder: write your entire response in the language specified above.]" return head, tail def supported_locales() -> Iterable[str]: return SUPPORTED_LOCALES # --------------------------------------------------------------------------- # Domain translations for report fields. Keys are the canonical enum strings # used in the data model; values are per-locale display strings. # --------------------------------------------------------------------------- _METRIC_LABELS = { "stability": {"en": "Stability", "zh": "稳定性"}, "prosperity": {"en": "Prosperity", "zh": "繁荣度"}, "trust": {"en": "Trust", "zh": "信任度"}, "freedom": {"en": "Freedom", "zh": "自由度"}, "conflict": {"en": "Conflict", "zh": "冲突度"}, "brand_sentiment": {"en": "Brand Sentiment", "zh": "品牌口碑"}, "purchase_intent": {"en": "Purchase Intent", "zh": "购买意向"}, "word_of_mouth": {"en": "Word of Mouth", "zh": "口碑传播"}, "churn_risk": {"en": "Churn Risk", "zh": "流失风险"}, "adoption_rate": {"en": "Adoption Rate", "zh": "采纳率"}, } _RATINGS = { "excellent": {"en": "Excellent", "zh": "极佳"}, "strong": {"en": "Strong", "zh": "良好"}, "moderate": {"en": "Moderate", "zh": "中等"}, "weak": {"en": "Weak", "zh": "偏弱"}, "critical": {"en": "Critical", "zh": "危险"}, } _TRENDS = { "up": {"en": "up", "zh": "上升"}, "down": {"en": "down", "zh": "下降"}, "flat": {"en": "flat", "zh": "平稳"}, } _VERDICTS = { "go": {"en": "Proceed", "zh": "推进"}, "caution": {"en": "Caution", "zh": "谨慎"}, "rethink": {"en": "Rethink", "zh": "重新思考"}, } _CONFIDENCE = { "high": {"en": "high", "zh": "高"}, "medium": {"en": "medium", "zh": "中"}, "low": {"en": "low", "zh": "低"}, } _SEGMENT_FUNNEL = { "aware": {"en": "Aware", "zh": "知晓"}, "interested": {"en": "Interested", "zh": "感兴趣"}, "tried": {"en": "Tried", "zh": "试用"}, "adopted": {"en": "Adopted", "zh": "采纳"}, "churned": {"en": "Churned", "zh": "流失"}, } _EMOTIONAL_STATES = { "calm": {"en": "Calm", "zh": "平静"}, "content": {"en": "Content", "zh": "满足"}, "curious": {"en": "Curious", "zh": "好奇"}, "hopeful": {"en": "Hopeful", "zh": "期盼"}, "satisfied": {"en": "Satisfied", "zh": "满意"}, "restless": {"en": "Restless", "zh": "躁动"}, "uneasy": {"en": "Uneasy", "zh": "不安"}, "confused": {"en": "Confused", "zh": "困惑"}, "frustrated": {"en": "Frustrated", "zh": "沮丧"}, "anxious": {"en": "Anxious", "zh": "焦虑"}, "angry": {"en": "Angry", "zh": "愤怒"}, "frustrated": {"en": "Frustrated", "zh": "沮丧"}, "hostile": {"en": "Hostile", "zh": "敌对"}, "fearful": {"en": "Fearful", "zh": "恐惧"}, "desperate": {"en": "Desperate", "zh": "绝望"}, "torn": {"en": "Torn", "zh": "撕裂"}, "resigned": {"en": "Resigned", "zh": "认命"}, "indifferent": {"en": "Indifferent", "zh": "漠然"}, } _SEGMENT_FALLBACK_NAMES = { # Used when the LLM never generated a proper segment (so we fall back to # the agent's role). Keys are lowercased role names. "laborer": {"en": "Laborer", "zh": "劳动者"}, "merchant": {"en": "Merchant", "zh": "商人"}, "scholar": {"en": "Scholar", "zh": "学者"}, "guard": {"en": "Guard", "zh": "守卫"}, "leader": {"en": "Leader", "zh": "领袖"}, "general": {"en": "General", "zh": "普通居民"}, "worker": {"en": "Worker", "zh": "工人"}, "farmer": {"en": "Farmer", "zh": "农夫"}, "teacher": {"en": "Teacher", "zh": "教师"}, "artist": {"en": "Artist", "zh": "艺术家"}, } def _pick(mapping: dict[str, dict[str, str]], key: str, locale: str, *, default: str | None = None) -> str: """Pick the localized string for an enum key, falling back to English.""" entry = mapping.get(key) if not entry: return default if default is not None else key if locale in entry: return entry[locale] return entry.get("en", default if default is not None else key) def metric_label(key: str, locale: str) -> str: return _pick(_METRIC_LABELS, key, locale) def rating_label(key: str, locale: str) -> str: return _pick(_RATINGS, key, locale) def trend_label(key: str, locale: str) -> str: return _pick(_TRENDS, key, locale) def verdict_label(key: str, locale: str) -> str: return _pick(_VERDICTS, key, locale, default=key.capitalize() if key else "—") def confidence_label(key: str, locale: str) -> str: return _pick(_CONFIDENCE, key, locale) def segment_funnel_label(key: str, locale: str) -> str: return _pick(_SEGMENT_FUNNEL, key, locale) def emotional_state_label(key: str, locale: str) -> str: return _pick(_EMOTIONAL_STATES, key, locale) def localize_report(report: dict, locale: str) -> dict: """Recursively translate known enum strings in a report dict to the requested locale. Unknown keys pass through unchanged. This walks the structure used by the Narrator.generate_report output and rewrites label/rating/trend/verdict/confidence/funnel fields in place. Strings not in our maps (LLM-generated headlines, summaries, action quotes, etc.) are left alone — they should already be in the user's language because of the prompt-level language directive. """ if isinstance(report, dict): out = {} for k, v in report.items(): if k == "label" and isinstance(v, str): out[k] = metric_label(v, locale) elif k == "rating" and isinstance(v, str): out[k] = rating_label(v, locale) elif k == "trend" and isinstance(v, str): out[k] = trend_label(v, locale) elif k == "verdict" and isinstance(v, str): out[k] = verdict_label(v, locale) elif k == "confidence" and isinstance(v, str): out[k] = confidence_label(v, locale) elif k == "funnel" and isinstance(v, dict): out[k] = {fk: segment_funnel_label(fv, locale) if isinstance(fv, str) else fv for fk, fv in v.items()} elif k == "emotional_state" and isinstance(v, str): out[k] = emotional_state_label(v, locale) elif k == "quality" and isinstance(v, str): out[k] = rating_label(v, locale) elif k == "name" and isinstance(v, str) and locale == "zh" and not _has_cjk(v): # segment / role fallback name like "Laborer" — translate via # the small fallback map. Strings that already contain CJK are # left alone (LLM wrote a proper Chinese name). out[k] = _SEGMENT_FALLBACK_NAMES.get(v.lower(), {}).get("zh", v) elif isinstance(v, (dict, list)): out[k] = localize_report(v, locale) else: out[k] = v return out if isinstance(report, list): return [localize_report(item, locale) for item in report] return report def _has_cjk(text: str) -> bool: return any("\u4e00" <= ch <= "\u9fff" for ch in text)