You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
330 lines
12 KiB
330 lines
12 KiB
"""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) |