Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"name": "_auto_993f1c39697248b4a2c07b3edec98972",
"mainConversationId": "993f1c39697248b4a2c07b3edec98972",
"workspacePath": "d:\\repos\\CodeWiki-CN",
"createdAt": "2026-09-07T06:45:44.764Z",
"options": {
"workspacePath": "d:\\repos\\CodeWiki-CN",
"isAutoTeam": true
},
"isAutoTeam": true,
"members": [
{
"memberId": "distill-i18n-session@_auto_993f1c39697248b4a2c07b3edec98972",
"name": "distill-i18n-session",
"role": "补蒸馏产品维护积压对话",
"sessionId": "5d0ba5a19c1f4ee4a4cc1839fede0cc0"
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[]
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
[
{
"id": "msg-1788763916029-3mnijm",
"from": "distill-i18n-session",
"to": "team-lead",
"type": "message",
"content": "「产品维护」任务积压蒸馏已完成(Mode C,走 distilled_file 侧通道)。\n\n## 数量\n- 处理对话:1 条(`conv-@d-repos-CodeWiki-CN-codewiki-mcp-prompts.py-代码里的prompt的titl`,52 轮,MCP prompt 国际化设计会话)\n- 新建草稿笔记:**8 条**(全部已 ingested 到 `repowiki/notes/`,status=draft,待你与用户确认)\n- 去重抑制/合并:0 抑制、0 合并。首轮 submit 返回 `conflicts_pending=6`,我逐个读了候选笔记,按 Doctrine「related≠same,拿不准就不合并」全部判为 `dedup_action=store`(候选看似主题相近,实为不同触发场景/不同实例,已在笔记正文加「与既有知识的区别」小节说明理由)。二次 submit 后 `conflicts_pending=0`。\n- 落盘任务记忆:`memories_written=7`\n- raw 已归档到 `repowiki/conversations/`(未删除,keep_raw=false)\n- `skill_hint`:本次 submit 未返回。\n\n## 8 条待确认草稿\n1. `notes/2026-09-07-mcp-层中文返回文本-i18n-方案定案yaml-双文件全量一次性全做.md` — decision,P92。定案:YAML 双文件全量(zh 源 + en 覆盖)、一次性全做、无运行时回退靠发版前 key 集测试暴露、范围含落盘产物、顺带去硬编码漂移。\n2. `notes/2026-09-07-mcp-promptslist-与-promptsget-无语言协商参数语言只能在-server-进程启动时确定.md` — architecture,P88。MCP SDK 类型定义核对结论:协议无语言协商通道,语言只能进程启动期定一次;推荐 MCP 配置 `env: {\"CODEWIKI_LANG\":\"en\"}`,勿改 args。\n3. `notes/2026-09-07-prompt-正文是逻辑模板混合体不能整块搬进-yaml-语料.md` — pitfall,P86。`_prompt_*` 是条件分支 + f-string 占位符 + JSON 示例转义的「逻辑+模板」混合体,英文版是约 1250 行创作而非查表翻译。\n4. `notes/2026-09-07-hatchling-wheel-artifacts-不含新增包内资源新增-localesyaml-需同步改-pyproj.md` — pitfall,P85。`locales/*.yaml` 不自动进 wheel,须改 artifacts。\n5. `notes/2026-09-07-硬编码中文常量承载可派生数据必然漂移i18n-集中化时应顺带根除.md` — lesson,P82。catalog 15 条 vs 注册表 22 条、instructions 声称 11 个 prompt、`tool_count: 49` 手写。\n6. `notes/2026-09-07-i18n-语言来源优先级configjson-lang-codewiki-lang-env-系统-locale-zh且不.md` — decision,P85。config.json `lang` > `CODEWIKI_LANG` > 系统 locale > zh;不能放 `repowiki/schema.yaml`(server 启动期无 repo 上下文)。\n7. `notes/2026-09-07-server-在-serverpy-模块顶层构造语言初始化必须早于它.md` — pitfall,P80。`Server(...)` 在 import 期构造,语言初始化必须更早。\n8. `notes/2026-09-07-存量中文落盘产物不追溯重写语言策略只作用于新生成整体重写路径.md` — decision,P80。已存在的 schema.yaml 绝不动。\n\n建议展示顺序:先 2、3(架构/工作量认知),再 1、6、8(决策三件套),最后 4、5、7(工程坑)。\n\n## 落盘的 7 条任务记忆要点(可直接用)\n- 会话定案了 MCP 层 i18n 全套方案(YAML 双文件全量、一次全做、无回退靠测试、范围含落盘产物)。\n- 审计结论:工具 schema 与错误消息全英文,中文集中在 prompts.py(正文 ~1250 行 + 元数据 ~100 行)、resources.py catalog ~92 行、server.py instructions 57 行、工具层散点,合计 ~1500 行。\n- 语言来源优先级 + 用户切换方式(MCP 配置 env 注入)。\n- M1 基建已落地:`codewiki/mcp/i18n.py`、`locales/zh.yaml`、`locales/en.yaml` 已建;`server.py` 已接线(i18n 导入 + Server 构造前初始化语言 + instructions 从语料取);`pyproject.toml` wheel artifacts 已加 locales。\n- `tests/test_i18n.py` 已存在(key 集一致性 / 占位符一致性 / 缺 key 哨兵 / 语言解析优先级四组断言),尚未实跑验证。\n- 待办清单(按依赖顺序):prompts 元数据接入 → 22 个 `_prompt_*` 正文函数重写 + 英文创作(最大工作量)→ resources catalog 派生化 → 工具层散点 → 落盘产物(agents_md / wiki_index / reading_guide / schema_generator)→ `tool_count` 运行时计数 + instructions 11→22 修正。\n- 风险:英文正文初稿为 Agent 创作,建议合入前做一次英文可读性审校。\n\n未执行任何 confirm_note / skill_creator;除 `repowiki/raw/` 下两个临时 JSON(工具已自动删除)外未改动其他文件。",
"timestamp": "2026-09-07T06:51:56.029Z",
"read": true
}
]
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
- [第 7 篇:记忆/经验分层提取——自生长的团队知识库](https://mp.weixin.qq.com/s/s253xe5LiUmgdfDo3XxAbg)(2026-08)
- [第 8 篇:四维代码评审——让踩过的坑自动变成 CHECKLIST](https://mp.weixin.qq.com/s/wH_mjG5IL-0qo_qDFpODuw)(2026-08)
- [第 9 篇:多仓Harness集中式管理方案](https://mp.weixin.qq.com/s/pA1CsLSAIqbeVqFV4-kVQQ)(2026-09)
- [第 10 篇:如何让AI写的Wiki可信——项目知识全生命周期管理](https://mp.weixin.qq.com/s/OdESdERtINqYTBkIIVFWlQ)(2026-09)



Expand Down
2 changes: 1 addition & 1 deletion codewiki/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
and an MCP server for IDE-driven documentation generation.
"""

__version__ = "5.7.0"
__version__ = "5.8.0"
__author__ = "CodeWiki Contributors"
__license__ = "MIT"

Expand Down
266 changes: 266 additions & 0 deletions codewiki/mcp/i18n.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
"""Two-locale message catalog for text returned to agents.

Why this exists
---------------
Prompt titles/descriptions, the session instructions block, resource
metadata/bodies, tool-facing hints and on-disk artifacts were hard-coded in
Chinese. MCP has no per-request language negotiation, so the language is
resolved **once per server process** (each IDE session spawns its own stdio
process, which is fine in practice).

Design decisions (product-maintenance, 2026-09):
- YAML resource files under ``locales/``: ``zh.yaml`` is the source of truth,
``en.yaml`` must fully cover it.
- **No runtime fallback for missing keys.** A missing key surfaces as an
``[missing-i18n-key ...]`` sentinel plus a warning; a pre-release key-set
test (``tests/test_i18n.py``) guarantees the catalogs stay in sync, so a
missing key at runtime is a bug that ships only if the test was skipped.
- Language resolution order:
``~/.codewiki/config.json`` ``lang`` field > ``$CODEWIKI_LANG`` env >
OS locale (``zh*`` -> zh, anything else -> en) > ``zh``.
An explicit-but-invalid value logs a warning and falls back to ``zh``.
(This is not the same as the "no fallback for missing translation keys"
rule above: it only governs how an unset/invalid language preference is
defaulted.)

Usage
-----
from codewiki.mcp import i18n
i18n.init_lang() # once, at process start (idempotent)
i18n.t("prompts.init_wiki.title")
i18n.t("server.instructions")
i18n.t("prompts.init_wiki.step", repo_path=repo)

Text blocks that contain no placeholders can be fetched with ``t(key)``.
Blocks with placeholders must be fetched with ``t(key, **vars)``: the
template syntax is ``str.format``, so literal ``{``/``}`` inside a template
are written ``{{``/``}}`` (JSON examples, PowerShell snippets etc.).
"""

from __future__ import annotations

import json
import locale as _locale_mod
import logging
import os
import threading
from pathlib import Path
from typing import Any, Optional

import yaml

logger = logging.getLogger(__name__)

DEFAULT_LANG = "zh"
SUPPORTED = ("zh", "en")
MISSING_PREFIX = "[missing-i18n-key]"

_LOCALES_DIR = Path(__file__).resolve().parent / "locales"
_DEFAULT_CONFIG_PATH = Path.home() / ".codewiki" / "config.json"

_lock = threading.RLock()
_catalogs: dict[str, dict[str, Any]] = {}
_lang: str = DEFAULT_LANG


# ---------------------------------------------------------------------------
# Catalog loading
# ---------------------------------------------------------------------------


def _catalog_path(lang: str) -> Path:
return _LOCALES_DIR / f"{lang}.yaml"


def _load_catalog(lang: str) -> dict[str, Any]:
"""Load one catalog file; never raises (missing/broken file -> empty)."""
path = _catalog_path(lang)
try:
with open(path, encoding="utf-8") as fh:
data = yaml.safe_load(fh)
if isinstance(data, dict):
return data
logger.error("i18n catalog %s is not a mapping; treating as empty", path)
return {}
except FileNotFoundError:
logger.error("i18n catalog not found: %s", path)
return {}
except Exception as exc: # noqa: BLE001 - catalog must never crash the server
logger.error("failed to load i18n catalog %s: %s", path, exc)
return {}


def _ensure_loaded() -> None:
"""Load zh + en catalogs once (lazy, thread-safe)."""
if not _catalogs:
with _lock:
if not _catalogs:
for lang in SUPPORTED:
_catalogs[lang] = _load_catalog(lang)


def _lookup(key: str, lang: Optional[str] = None) -> Optional[str]:
"""Resolve a dotted key against a catalog; None when absent."""
_ensure_loaded()
lang = lang or _lang
node: Any = _catalogs.get(lang, {})
for part in key.split("."):
if not isinstance(node, dict) or part not in node:
return None
node = node[part]
return node if isinstance(node, str) else None


# ---------------------------------------------------------------------------
# Language resolution
# ---------------------------------------------------------------------------


def _read_config_lang(config_path: Path) -> str:
"""Read the ``lang`` field from a CodeWiki config.json ('' when absent)."""
try:
with open(config_path, encoding="utf-8") as fh:
data = json.load(fh)
value = data.get("lang")
if isinstance(value, str):
return value.strip().lower()
except FileNotFoundError:
pass
except Exception as exc: # noqa: BLE001 - bad config must not crash startup
logger.warning("could not read language from %s: %s", config_path, exc)
return ""


def _system_locale_code() -> str:
"""Return the current locale language code ('' when undetectable)."""
try:
code, _ = _locale_mod.getlocale()
if code:
return code
except Exception: # noqa: BLE001
pass
# Some minimal environments return (None, None) from getlocale().
lang = os.environ.get("LANG") or os.environ.get("LC_ALL") or ""
return lang.split(".")[0].replace("_", "-")


def resolve_lang(
config_path: Optional[Path] = None,
env_value: Optional[str] = None,
locale_code: Optional[str] = None,
) -> str:
"""Resolve the process language.

Resolution order (see module docstring): config file ``lang`` field >
``$CODEWIKI_LANG`` env > OS locale > ``zh``.

Arguments are injectable for tests; ``None`` means "read from the real
environment".
"""
# 1) explicit config file value
cfg_lang = _read_config_lang(config_path if config_path is not None else _DEFAULT_CONFIG_PATH)
if cfg_lang:
return cfg_lang if cfg_lang in SUPPORTED else _invalid("config", cfg_lang)

# 2) explicit env value
env_lang = (os.environ.get("CODEWIKI_LANG") if env_value is None else env_value) or ""
env_lang = env_lang.strip().lower()
if env_lang:
return env_lang if env_lang in SUPPORTED else _invalid("env", env_lang)

# 3) OS locale inference.
# NOTE: Python reports Windows locales by language NAME, not by ISO code —
# e.g. ('Chinese (Simplified)_China', '936') rather than ('zh_CN', ...).
# Matching only on a "zh" prefix silently turned Chinese Windows into
# English, so match both spellings.
code = _system_locale_code() if locale_code is None else (locale_code or "")
code = code.lower().replace("-", "_")
if code.startswith("zh") or "chinese" in code:
return "zh"
if code:
return "en"

# 4) default
return DEFAULT_LANG


def _invalid(source: str, value: str) -> str:
logger.warning(
"unsupported language %r from %s (expected one of %s); falling back to %r",
value,
source,
", ".join(SUPPORTED),
DEFAULT_LANG,
)
return DEFAULT_LANG


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------


def init_lang() -> str:
"""Resolve and set the process language once (idempotent)."""
global _lang
with _lock:
resolved = resolve_lang()
if resolved != _lang:
_lang = resolved
_catalogs.clear()
logger.info("i18n language set to %r", _lang)
return _lang


def set_lang(lang: str) -> str:
"""Force a language (tests / explicit override)."""
global _lang
if lang not in SUPPORTED:
raise ValueError(f"unsupported language: {lang!r} (expected one of {SUPPORTED})")
with _lock:
_lang = lang
_catalogs.clear()
return lang


def lang() -> str:
return _lang


def t(key: str, **fmt: Any) -> str:
"""Fetch a localized string by dotted key, formatting placeholders.

``t(key)`` returns the raw template; use ``t(key, **vars)`` when the
template contains ``{name}`` placeholders. Missing keys never silently
fall back — they return a ``[missing-i18n-key ...]`` sentinel (and log a
warning) so the gap is visible; ``tests/test_i18n.py`` keeps zh/en in sync
so this should not happen in a released build.
"""
text = _lookup(key)
if text is None:
logger.warning("missing i18n key %r (lang=%s)", key, _lang)
return f"{MISSING_PREFIX} {key}"
if not fmt:
return text
try:
return text.format(**fmt)
except Exception as exc: # noqa: BLE001 - surface as sentinel, never raise
logger.warning("i18n format error for key %r: %s", key, exc)
return f"{MISSING_PREFIX} {key} (format error: {exc})"


def all_keys(lang: str) -> list[str]:
"""Return every leaf key of a catalog as dotted paths (for tests)."""
_ensure_loaded()
data = _catalogs.get(lang, {})
result: list[str] = []

def walk(node: Any, prefix: str) -> None:
if isinstance(node, dict):
for part, value in node.items():
walk(value, f"{prefix}.{part}" if prefix else part)
else:
result.append(prefix)

walk(data, "")
return result
Loading
Loading