Skip to content

Commit 3616731

Browse files
committed
fix(i18n): make Klingon best effort
1 parent 5c3b71a commit 3616731

14 files changed

Lines changed: 478 additions & 97 deletions

.github/workflows/locale-sync.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ name: Locale Sync
1616
# them, or that no locale carries — so a hand translation survives exactly
1717
# when its PR also repinned the baseline (scripts/update_locale_baseline.py,
1818
# which is what tells the bot the changed English is already covered).
19+
# Klingon (`tlh`) is an explicit best-effort/manual locale. The planner skips
20+
# it, so it consumes no Gemini quota and cannot make a run partial; generated
21+
# projections use its valid hand-authored strings and English fallback for
22+
# missing ones. `tlh`-specific drift and validation report warnings only. All
23+
# other locales, English sources, and shared pipeline failures remain strict.
1924
#
2025
# Verification is three layers. (1) Every returned string is validated
2126
# before it is written (placeholder, markup, formatting-tag and panel-link

AGENTS.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -805,6 +805,16 @@ language code (`cs`, `de`, `eo`, `es`, `fr`, `it`, `ko`, `nl`, `pl`, `ru`, `sv`,
805805
`src/ha_mcp/settings_ui/locales/<code>.json`,
806806
`custom_components/ha_mcp_tools/translations/<code>.json`, and
807807
`homeassistant-addon{,-dev}/translations/<code>.yaml`.
808+
809+
`tlh` is the deliberate best-effort exception. It is a hand-maintained novelty
810+
locale, remains available on all four surfaces when its files are valid, and
811+
uses English per-key fallback when they are incomplete. It is excluded from
812+
automatic translation planning so it consumes no model quota, and any
813+
`tlh`-specific catalog, completeness, literal-parity, registration, or
814+
generated-drift problem is reported as a warning rather than blocking CI or
815+
locale-sync. Every other locale and every shared, English-side pipeline failure
816+
remain hard failures.
817+
808818
That list of codes is itself pinned by
809819
`test_agents_md_lists_every_shipped_locale`: adding a language means adding its
810820
code here, in the same PR, or the suite goes red. To add a language, add the

scripts/generate_locales.py

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@
2121
A locale that lacks a key falls back to English, mirroring the settings UI's
2222
own per-key fallback, so every generated catalog is structurally complete.
2323
24+
Best-effort locales are still generated when their canonical catalog is valid.
25+
If one is invalid, generation uses an empty override map (therefore English
26+
fallback), and ``--check`` reports any best-effort output drift as a warning
27+
instead of returning failure.
28+
2429
``FEATURE_META`` (the settings UI's English fallback for feature rows, and
2530
the row order) is generated from ``en.json``'s ``features.*`` keys between
2631
marker comments in ``settings.js``.
@@ -43,6 +48,7 @@
4348
import yaml # type: ignore[import-untyped]
4449

4550
from ha_mcp.settings_ui._i18n import _validate_string_map
51+
from ha_mcp.settings_ui._locale_policy import is_best_effort_locale
4652

4753
REPO_ROOT = Path(__file__).resolve().parents[1]
4854
LOCALES_DIR = REPO_ROOT / "src" / "ha_mcp" / "settings_ui" / "locales"
@@ -67,10 +73,22 @@ def load_catalogs() -> dict[str, dict[str, str]]:
6773
"""
6874
catalogs: dict[str, dict[str, str]] = {}
6975
for path in sorted(LOCALES_DIR.glob("*.json")):
70-
data = json.loads(path.read_text(encoding="utf-8"))
71-
catalogs[path.stem] = _validate_string_map(
72-
data.get("messages"), context=f"{path.name}.messages"
73-
)
76+
try:
77+
data = json.loads(path.read_text(encoding="utf-8"))
78+
if not isinstance(data, dict):
79+
raise ValueError(f"{path.name} must contain a JSON object")
80+
catalogs[path.stem] = _validate_string_map(
81+
data.get("messages"), context=f"{path.name}.messages"
82+
)
83+
except (OSError, json.JSONDecodeError, ValueError) as exc:
84+
if not is_best_effort_locale(path.stem):
85+
raise
86+
print(
87+
f"::warning file={path}::Using English fallback for best-effort "
88+
f"locale {path.stem} because its catalog is invalid: {exc}",
89+
file=sys.stderr,
90+
)
91+
catalogs[path.stem] = {}
7492
if "en" not in catalogs:
7593
raise SystemExit(f"no en.json in {LOCALES_DIR}")
7694
return catalogs
@@ -211,13 +229,39 @@ def generated_files() -> dict[Path, str]:
211229
return outputs
212230

213231

232+
def _committed_text(path: Path) -> str:
233+
"""Read generated output, tolerating corrupt best-effort locale bytes."""
234+
if not path.exists():
235+
return ""
236+
try:
237+
return path.read_text(encoding="utf-8")
238+
except UnicodeError as exc:
239+
if not is_best_effort_locale(path.stem):
240+
raise
241+
relative = path.relative_to(REPO_ROOT)
242+
print(
243+
f"::warning file={relative}::best-effort locale output is not "
244+
f"valid UTF-8 and will be treated as stale: {exc}",
245+
file=sys.stderr,
246+
)
247+
return ""
248+
249+
214250
def check() -> int:
215251
"""Exit 1 naming every derived file that no longer matches the canon."""
216252
stale: list[str] = []
217253
for path, content in generated_files().items():
218-
committed = path.read_text(encoding="utf-8") if path.exists() else ""
254+
committed = _committed_text(path)
219255
if committed != content:
220-
stale.append(str(path.relative_to(REPO_ROOT)))
256+
relative = str(path.relative_to(REPO_ROOT))
257+
if is_best_effort_locale(path.stem):
258+
print(
259+
f"::warning file={relative}::best-effort locale output is "
260+
"out of sync; run python scripts/generate_locales.py to refresh it",
261+
file=sys.stderr,
262+
)
263+
else:
264+
stale.append(relative)
221265
diff = difflib.unified_diff(
222266
committed.splitlines()[:20],
223267
content.splitlines()[:20],
@@ -239,7 +283,7 @@ def check() -> int:
239283
def write() -> int:
240284
changed = 0
241285
for path, content in generated_files().items():
242-
if not path.exists() or path.read_text(encoding="utf-8") != content:
286+
if _committed_text(path) != content:
243287
path.write_text(content, encoding="utf-8", newline="\n")
244288
changed += 1
245289
print(f"wrote {path.relative_to(REPO_ROOT)}")

scripts/translate_locales.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@
1717
from the tool definitions via ``scripts/extract_tools.py``)
1818
- ``custom_components/ha_mcp_tools/translations/<code>.json``
1919
20+
Locales listed in ``BEST_EFFORT_LOCALES`` are deliberately excluded from both
21+
authored surfaces. They remain hand-editable and use the normal English
22+
fallback without consuming translation quota or making this job fail.
23+
2024
The engine is one function (``_call_gemini``): the Gemini API free tier,
2125
keyed by ``GEMINI_API_KEY``, with ``GEMINI_MODEL`` / ``GEMINI_API_URL``
2226
overrides for any Gemini-compatible endpoint. The fallback when the engine is
@@ -78,6 +82,7 @@
7882
_PLACEHOLDER_RE,
7983
_TAG_LIKE_RE,
8084
)
85+
from ha_mcp.settings_ui._locale_policy import is_best_effort_locale # noqa: E402
8186

8287
LOCALES_DIR = REPO_ROOT / "src" / "ha_mcp" / "settings_ui" / "locales"
8388
COMPONENT_DIR = REPO_ROOT / "custom_components" / "ha_mcp_tools" / "translations"
@@ -241,7 +246,11 @@ def _changed_keys(module: Any) -> dict[str, set[str]]:
241246

242247

243248
def _target_locales() -> list[str]:
244-
return sorted(p.stem for p in LOCALES_DIR.glob("*.json") if p.stem != "en")
249+
return sorted(
250+
p.stem
251+
for p in LOCALES_DIR.glob("*.json")
252+
if p.stem != "en" and not is_best_effort_locale(p.stem)
253+
)
245254

246255

247256
def _flatten(value: Any, prefix: str = "") -> dict[str, str]:
@@ -389,7 +398,7 @@ def _plan_component(plan: Plan, changed: dict[str, set[str]]) -> None:
389398
changed_component = changed.get(COMPONENT_SURFACE, set())
390399
progress = _progress_load()
391400
for path in sorted(COMPONENT_DIR.glob("*.json")):
392-
if path.stem == "en":
401+
if path.stem == "en" or is_best_effort_locale(path.stem):
393402
continue
394403
flat = _flatten(_load_json(path))
395404
loc_changed = {

src/ha_mcp/settings_ui/AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ Any reachable HA instance works for `HOMEASSISTANT_URL` / `HOMEASSISTANT_TOKEN`.
3838
- `settings.js` — the client script, injected into `<script>__HA_MCP_JS__</script>`. Not served as a separate asset.
3939
- `settings.css` — the stylesheet, injected into `<style>__HA_MCP_CSS__</style>`.
4040
- `_i18n.py` and `locales/*.json` — auto-discovered translation catalogs. English is the per-key fallback for `messages`. Adding a language is not a single-file change: it ships on all four translated surfaces or not at all. Its `tool_groups` and `tools` sections must end up exact — the post-merge locale-sync workflow fills and verifies them, so a near-empty catalog may merge, though not a `meta`-only one: the `Decision` and `PredicateOp` words, the sentence those words are interpolated into, and one reader-addressing key are all checked ungated against the shipped catalogs — and `en.json` is the wrong starting point because it carries both of those empty by design. See `locales/README.md` for the procedure and the repository-root `AGENTS.md` § Translations for the rules CI enforces. The wheel, sdist and binary declarations match the locale directory by pattern, so a new catalog needs no packaging edit — but keep those patterns intact when touching packaging.
41+
- `tlh` is the only best-effort locale: it is excluded from machine translation and its locale-specific validation is warning-only. Invalid Klingon is skipped or falls back to English; every other locale remains strict. Keep this exception centralized in `_locale_policy.py`.
4142

4243
## Gotchas (read before editing)
4344

src/ha_mcp/settings_ui/_i18n.py

Lines changed: 74 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,16 @@
88
from __future__ import annotations
99

1010
import json
11+
import logging
1112
import re
1213
from collections import Counter
1314
from pathlib import Path
1415
from typing import Any
1516

17+
from ._locale_policy import is_best_effort_locale
18+
19+
_LOGGER = logging.getLogger(__name__)
20+
1621
DEFAULT_LOCALE = "en"
1722
LOCALE_COOKIE = "ha_mcp_locale"
1823
LOCALES_DIR = Path(__file__).parent / "locales"
@@ -87,6 +92,48 @@ def _validate_tools(value: Any, *, context: str) -> dict[str, dict[str, str]]:
8792
return result
8893

8994

95+
def _load_catalog_file(path: Path) -> dict[str, Any]:
96+
"""Load and validate one catalog without coupling it to its siblings."""
97+
try:
98+
raw = json.loads(path.read_text(encoding="utf-8"))
99+
except (OSError, json.JSONDecodeError) as exc:
100+
raise ImportError(f"Invalid settings UI locale catalog: {path}") from exc
101+
if not isinstance(raw, dict):
102+
raise ValueError(f"Locale catalog {path} must contain a JSON object")
103+
104+
meta = raw.get("meta")
105+
if not isinstance(meta, dict):
106+
raise ValueError(f"Locale catalog {path} must define a meta object")
107+
native_name = meta.get("native_name")
108+
direction = meta.get("dir", "ltr")
109+
if not isinstance(native_name, str) or not native_name.strip():
110+
raise ValueError(f"Locale catalog {path} needs meta.native_name")
111+
if direction not in ("ltr", "rtl"):
112+
raise ValueError(f"Locale catalog {path} meta.dir must be ltr or rtl")
113+
114+
unknown_sections = set(raw) - {"meta", "messages", "tool_groups", "tools"}
115+
if unknown_sections:
116+
raise ValueError(
117+
f"Locale catalog {path} has unsupported sections: "
118+
f"{sorted(unknown_sections)}"
119+
)
120+
121+
return {
122+
"meta": {"native_name": native_name, "dir": direction},
123+
"messages": _validate_string_map(
124+
raw.get("messages"), context=f"{path.name}.messages"
125+
),
126+
"tool_groups": _validate_string_map(
127+
raw.get("tool_groups"), context=f"{path.name}.tool_groups"
128+
),
129+
"tools": _validate_tools(raw.get("tools"), context=f"{path.name}.tools"),
130+
}
131+
132+
133+
def _warn_best_effort_catalog(locale: str, path: Path, exc: Exception) -> None:
134+
_LOGGER.warning("Skipping best-effort locale %s from %s: %s", locale, path, exc)
135+
136+
90137
def load_catalogs(
91138
directory: Path = LOCALES_DIR, settings_html: Path = _SETTINGS_HTML
92139
) -> dict[str, dict[str, Any]]:
@@ -106,47 +153,38 @@ def load_catalogs(
106153
for path in paths:
107154
locale = path.stem.lower().replace("_", "-")
108155
try:
109-
raw = json.loads(path.read_text(encoding="utf-8"))
110-
except (OSError, json.JSONDecodeError) as exc:
111-
raise ImportError(f"Invalid settings UI locale catalog: {path}") from exc
112-
if not isinstance(raw, dict):
113-
raise ValueError(f"Locale catalog {path} must contain a JSON object")
114-
115-
meta = raw.get("meta")
116-
if not isinstance(meta, dict):
117-
raise ValueError(f"Locale catalog {path} must define a meta object")
118-
native_name = meta.get("native_name")
119-
direction = meta.get("dir", "ltr")
120-
if not isinstance(native_name, str) or not native_name.strip():
121-
raise ValueError(f"Locale catalog {path} needs meta.native_name")
122-
if direction not in ("ltr", "rtl"):
123-
raise ValueError(f"Locale catalog {path} meta.dir must be ltr or rtl")
124-
125-
unknown_sections = set(raw) - {"meta", "messages", "tool_groups", "tools"}
126-
if unknown_sections:
127-
raise ValueError(
128-
f"Locale catalog {path} has unsupported sections: "
129-
f"{sorted(unknown_sections)}"
130-
)
131-
132-
catalogs[locale] = {
133-
"meta": {"native_name": native_name, "dir": direction},
134-
"messages": _validate_string_map(
135-
raw.get("messages"), context=f"{path.name}.messages"
136-
),
137-
"tool_groups": _validate_string_map(
138-
raw.get("tool_groups"), context=f"{path.name}.tool_groups"
139-
),
140-
"tools": _validate_tools(raw.get("tools"), context=f"{path.name}.tools"),
141-
}
156+
catalogs[locale] = _load_catalog_file(path)
157+
except (ImportError, ValueError) as exc:
158+
if not is_best_effort_locale(locale):
159+
raise
160+
_warn_best_effort_catalog(locale, path, exc)
142161

143162
if DEFAULT_LOCALE not in catalogs:
144163
raise ImportError(
145164
f"The settings UI requires {DEFAULT_LOCALE}.json in {directory}"
146165
)
147-
_validate_placeholder_parity(catalogs)
148-
_validate_inline_markup(catalogs)
149-
_validate_panel_links(catalogs, settings_html)
166+
167+
strict_catalogs = {
168+
locale: catalog
169+
for locale, catalog in catalogs.items()
170+
if not is_best_effort_locale(locale)
171+
}
172+
_validate_placeholder_parity(strict_catalogs)
173+
_validate_inline_markup(strict_catalogs)
174+
_validate_panel_links(strict_catalogs, settings_html)
175+
176+
for locale in sorted(set(catalogs) - set(strict_catalogs)):
177+
candidate = {
178+
DEFAULT_LOCALE: catalogs[DEFAULT_LOCALE],
179+
locale: catalogs[locale],
180+
}
181+
try:
182+
_validate_placeholder_parity(candidate)
183+
_validate_inline_markup(candidate)
184+
_validate_panel_links(candidate, settings_html)
185+
except ValueError as exc:
186+
_warn_best_effort_catalog(locale, directory / f"{locale}.json", exc)
187+
del catalogs[locale]
150188
return catalogs
151189

152190

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""Maintenance policy for intentionally best-effort locale catalogs."""
2+
3+
from __future__ import annotations
4+
5+
# Klingon is a novelty translation maintained by hand. It must never consume
6+
# machine-translation quota or block delivery for supported localizations.
7+
BEST_EFFORT_LOCALES = frozenset({"tlh"})
8+
9+
10+
def is_best_effort_locale(locale: str) -> bool:
11+
"""Return whether ``locale`` is isolated from hard localization gates."""
12+
normalized = locale.lower().replace("_", "-")
13+
return normalized in BEST_EFFORT_LOCALES

src/ha_mcp/settings_ui/locales/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,15 @@ Home Assistant language code names every file:
2424
- `homeassistant-addon/translations/<code>.yaml` (generated)
2525
- `homeassistant-addon-dev/translations/<code>.yaml` (generated)
2626

27+
Klingon (`tlh`) is the one best-effort exception. Its catalogs may be edited
28+
manually and still ship, but the automatic translation planner never queues
29+
them. Missing strings use English fallback; invalid or stale Klingon catalogs
30+
produce warnings instead of failing CI or the daily locale sync. The runtime
31+
loader skips an invalid Klingon settings catalog, and the generator can project
32+
English into its add-on catalogs, so Klingon cannot prevent any other locale
33+
from loading or updating. All other language codes remain subject to every
34+
hard gate below.
35+
2736
Add the two authored catalogs, then run `python scripts/generate_locales.py`
2837
and merge: the post-merge `locale-sync.yml` workflow machine-fills every string
2938
over its next daily runs. The component catalog may start as an empty object;

0 commit comments

Comments
 (0)