from __future__ import annotations import os from collections.abc import Mapping from typing import Any DEFAULT_LOCALE = "zh-CN" CATALOGS: dict[str, dict[str, Any]] = { "zh-CN": { "list.joiner": "、", "plan.steps": ["理解问题", "检索信息", "整理资料"], "event.plan_created": "我已经拆好任务步骤:{steps_text}。", "event.research_blocked": "搜索工具接口已就绪,但真实搜索适配器尚未接入;本次会生成一份占位资料。", "event.completed": "我已经整理好资料。第一版搜索接口还是占位实现,后续接入真实搜索后会返回实际来源。", "artifact.title_suffix": "研究资料", "artifact.user_request": "用户请求", "artifact.label_separator": ":", "artifact.current_status": "当前状态", "artifact.null_search_line_1": "搜索工具抽象已经接入,但真实搜索适配器尚未启用。", "artifact.null_search_line_2": "这份资料用于验证 CyberVerse Task Service、LangGraph Worker、事件流和 artifact 交付链路。", "artifact.results_intro": "已检索到以下候选信息:", "worker.cancelled": "Worker 已停止执行任务。", "worker.failed": "Worker 执行失败:{error}", }, "en": { "list.joiner": ", ", "plan.steps": ["Understand the request", "Search for information", "Prepare materials"], "event.plan_created": "I have broken the task into steps: {steps_text}.", "event.research_blocked": "The search tool interface is ready, but no real search adapter is enabled yet; I will generate a placeholder artifact for this run.", "event.completed": "I have prepared the materials. Search is still using a placeholder in this first version; real sources will appear after a search adapter is connected.", "artifact.title_suffix": "Research Notes", "artifact.user_request": "User request", "artifact.label_separator": ": ", "artifact.current_status": "Current status", "artifact.null_search_line_1": "The search tool abstraction is connected, but no real search adapter is enabled yet.", "artifact.null_search_line_2": "This artifact verifies the CyberVerse Task Service, LangGraph Worker, event stream, and artifact delivery path.", "artifact.results_intro": "The following candidate information was found:", "worker.cancelled": "The worker has stopped the task.", "worker.failed": "Worker execution failed: {error}", }, "ja": { "list.joiner": "、", "plan.steps": ["依頼を理解する", "情報を検索する", "資料を整理する"], "event.plan_created": "タスクを次の手順に分解しました:{steps_text}。", "event.research_blocked": "検索ツールのインターフェースは準備済みですが、実検索アダプターはまだ有効化されていません。今回はプレースホルダー資料を生成します。", "event.completed": "資料を整理しました。初版では検索はプレースホルダー実装のため、実検索アダプター接続後に実際の出典が返ります。", "artifact.title_suffix": "調査資料", "artifact.user_request": "ユーザー依頼", "artifact.label_separator": ":", "artifact.current_status": "現在の状態", "artifact.null_search_line_1": "検索ツール抽象は接続されていますが、実検索アダプターはまだ有効化されていません。", "artifact.null_search_line_2": "この資料は CyberVerse Task Service、LangGraph Worker、イベントストリーム、artifact 配信経路を検証するためのものです。", "artifact.results_intro": "以下の候補情報が見つかりました:", "worker.cancelled": "Worker がタスクの実行を停止しました。", "worker.failed": "Worker の実行に失敗しました:{error}", }, "ko": { "list.joiner": ", ", "plan.steps": ["요청 이해", "정보 검색", "자료 정리"], "event.plan_created": "작업을 다음 단계로 나누었습니다: {steps_text}.", "event.research_blocked": "검색 도구 인터페이스는 준비되었지만 실제 검색 어댑터는 아직 연결되지 않았습니다. 이번에는 자리표시자 자료를 생성합니다.", "event.completed": "자료를 정리했습니다. 첫 버전에서는 검색이 자리표시자 구현이며, 실제 검색 어댑터를 연결한 뒤 실제 출처가 반환됩니다.", "artifact.title_suffix": "조사 자료", "artifact.user_request": "사용자 요청", "artifact.label_separator": ": ", "artifact.current_status": "현재 상태", "artifact.null_search_line_1": "검색 도구 추상화는 연결되었지만 실제 검색 어댑터는 아직 활성화되지 않았습니다.", "artifact.null_search_line_2": "이 자료는 CyberVerse Task Service, LangGraph Worker, 이벤트 스트림, artifact 전달 경로를 검증하기 위한 것입니다.", "artifact.results_intro": "다음 후보 정보를 찾았습니다:", "worker.cancelled": "Worker가 작업 실행을 중지했습니다.", "worker.failed": "Worker 실행 실패: {error}", }, } def normalize_locale(locale: str | None) -> str: raw = (locale or "").strip() if not raw: raw = os.getenv("CYBERVERSE_AGENT_LOCALE", DEFAULT_LOCALE) lowered = raw.replace("_", "-").lower() if lowered in {"zh", "zh-cn", "zh-hans", "cn"}: return "zh-CN" if lowered.startswith("en"): return "en" if lowered.startswith("ja") or lowered.startswith("jp"): return "ja" if lowered.startswith("ko") or lowered.startswith("kr"): return "ko" return DEFAULT_LOCALE class Localizer: def __init__(self, locale: str | None = None) -> None: self.locale = normalize_locale(locale) def value(self, key: str) -> Any: catalog = CATALOGS.get(self.locale, CATALOGS[DEFAULT_LOCALE]) if key in catalog: return catalog[key] return CATALOGS[DEFAULT_LOCALE][key] def text(self, key: str, **kwargs: Any) -> str: value = self.value(key) if not isinstance(value, str): raise TypeError(f"i18n key {key!r} is not a string") return value.format_map(_SafeFormat(kwargs)) def list(self, key: str) -> list[str]: value = self.value(key) if not isinstance(value, list): raise TypeError(f"i18n key {key!r} is not a list") return [str(item) for item in value] class _SafeFormat(dict[str, Any]): def __missing__(self, key: str) -> str: return "{" + key + "}" def locale_from_metadata(metadata: Mapping[str, Any] | None) -> str: if not metadata: return normalize_locale(None) value = metadata.get("locale") or metadata.get("language") return normalize_locale(str(value) if value is not None else None)