++
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user