"""Recipient language resolution — the single source of truth for "which language do we speak to this person in". Every outbound surface (Feishu card, email, in-app bell, error copy) needs the same answer, and before this module there were **six** independent predicates that disagreed with each other: =============================================== ============== ============== site ``None`` → ``"chinese"`` → =============================================== ============== ============== ``notifications/service.py::_is_en`` zh zh ``notifications/email_layout.py::_is_en`` zh zh ``hr_lifecycle/hire_day_service.py`` (day-90) **en** **en** ✗ ``google_workspace/account_provisioner.py`` **en** **en** ✗ ``cmdb/server_owner_confirmation_service.py`` zh zh ``cmdb/dns_request_service.py`` zh zh =============================================== ============== ============== The two ``✗`` rows were live bugs, because production really does contain free-text locale values: rows seeded from SuccessFactors' ``defaultLocale`` carry ``english`` / ``chinese`` / ``中文`` rather than BCP-47 tags (documented on the frontend at ``web/src/stores/auth.ts``). A ``startswith("zh")`` test reads ``"chinese"`` as *not Chinese*, so Chinese-preferring employees were being sent the **English Google Workspace password email** and the English Day-90 survey while every other surface spoke to them in Chinese. ``normalize_lang`` understands those legacy spellings; ``resolve_lang`` applies one documented fallback chain when the preference is missing entirely. Callers should never re-implement either. """ from __future__ import annotations from collections.abc import Mapping from typing import Literal, Protocol Lang = Literal["zh", "en", "ja"] #: Ordered degradation ladder per language — a surface that lacks copy in the #: recipient's language falls back along this chain rather than to a blank or #: to an arbitrary default. Japanese degrades to English (closer register for a #: JP-based reader than Chinese); English degrades to Chinese only because the #: Chinese variant is the unsuffixed baseline every template is guaranteed to #: have. FALLBACK_CHAIN: dict[Lang, tuple[Lang, ...]] = { "zh": ("zh",), "en": ("en", "zh"), "ja": ("ja", "en", "zh"), } #: Language spoken to a person carrying no usable signal at all. Chinese is the #: baseline because it is both the majority language of the workforce and the #: behaviour every notification surface already had (``_is_en(None) is False``); #: keeping it means this refactor is a bug-fix, not a mass re-targeting of #: everyone's notification language. DEFAULT_LANG: Lang = "zh" # Free-text spellings observed in production ``preferred_language`` values plus # the obvious human variants. Matched as substrings *after* the BCP-47 prefix # test, so a well-formed tag never reaches them. _ZH_TOKENS = ("chinese", "中文", "简体", "繁體", "繁体", "mandarin", "汉语", "漢語") _JA_TOKENS = ("japanese", "日本", "にほん", "ニホン", "nihongo") _EN_TOKENS = ("english", "英语", "英語", "eng") #: Country → language, for when no explicit preference is stored. #: #: Keyed on the LOWERCASED raw value and accepting alpha-2, alpha-3 and the #: free-text spellings, because ``IdentityPerson.country`` carries all three: #: the column doc says "typically a 2-letter ISO-3166 code (e.g. CN, US)", #: while some rows resolve to alpha-3. An alpha-3-only table silently sent #: every ``CN`` / ``HK`` / ``TW`` employee to English — a mass re-targeting #: rather than the bug-fix this module claims to be. ``working_hours`` #: ``_COUNTRY_TO_TZ`` and ``phone.to_e164`` both already accept both shapes; #: this was the only consumer that did not. _COUNTRY_LANG: dict[str, Lang] = { # Chinese-reading regions. "cn": "zh", "chn": "zh", "china": "zh", "中国": "zh", "hk": "zh", "hkg": "zh", "hong kong": "zh", "香港": "zh", "tw": "zh", "twn": "zh", "taiwan": "zh", "台湾": "zh", "mo": "zh", "mac": "zh", "macao": "zh", "macau": "zh", # Japan. "jp": "ja", "jpn": "ja", "japan": "ja", "日本": "ja", } class _PersonLike(Protocol): """Structural view of the columns ``resolve_lang`` reads. Typed as a Protocol rather than importing ``IdentityPerson`` so this module stays free of domain imports (it is used from ``app.core`` and from every domain; a concrete import would create a cycle). """ preferred_language: str | None country: str | None is_china_employee: bool def normalize_lang(raw: str | None) -> Lang | None: """Map any stored locale spelling onto one of the three languages Helix speaks, or ``None`` when the value carries no usable signal. Handles BCP-47 tags in either separator style (``zh-CN`` / ``zh_CN``), bare language codes, casing noise, and the free-text values that SuccessFactors seeded into production (``english`` / ``chinese`` / ``中文``). >>> normalize_lang("zh-CN"), normalize_lang("ZH_cn"), normalize_lang("中文") ('zh', 'zh', 'zh') >>> normalize_lang("english"), normalize_lang("ja_JP"), normalize_lang("") ('en', 'ja', None) """ if not raw: return None norm = raw.strip().lower().replace("_", "-") if not norm: return None # Well-formed tag / bare code first — cheapest and unambiguous. if norm.startswith("zh"): return "zh" if norm.startswith("ja"): return "ja" if norm.startswith("en"): return "en" # Legacy free text. Order matters: check the CJK-native spellings before # the Latin ones so "日本語 (Japanese)" resolves to ja rather than en. for token in _JA_TOKENS: if token in norm: return "ja" for token in _ZH_TOKENS: if token in norm: return "zh" for token in _EN_TOKENS: if token in norm: return "en" return None def resolve_lang(person: _PersonLike | None) -> Lang: """The language to address ``person`` in, via a documented fallback chain. 1. **Explicit preference** — ``preferred_language``, normalised. Always wins; a person who set their language gets it. 2. **Country** — ``country`` comes from the SF location master in a mix of alpha-2, alpha-3 and free text, so a Japan-based hire with no stored preference gets Japanese rather than the Chinese baseline (previously they got Chinese, which nobody would call correct). Greater-China regions (CN / HK / TW / MO) map to Chinese. 3. **Labour-contract entity** — ``is_china_employee`` is the flag HR already uses to pick welcome-letter language. Checked BEFORE the English fallback so a China-contract employee stationed abroad keeps Chinese. 4. **Baseline** — :data:`DEFAULT_LANG`. Never raises and never returns ``None``: a missing/partial person object degrades to the baseline, because a notification in the wrong language still beats an exception on the send path. """ if person is None: return DEFAULT_LANG explicit = normalize_lang(getattr(person, "preferred_language", None)) if explicit is not None: return explicit country = (getattr(person, "country", None) or "").strip().lower() by_country = _COUNTRY_LANG.get(country) if by_country is not None: return by_country # Labour-contract entity is checked BEFORE falling back to English, so a # China-contract employee posted abroad (country = SGP, is_china_employee # = True) keeps Chinese. Checking it after the country fallback made this # branch unreachable in practice, since ``country`` is nearly always set. if getattr(person, "is_china_employee", False): return "zh" # ``country`` is set but implies no CJK language → a non-CN/JP office, # where English is the working language. if country: return "en" return DEFAULT_LANG def is_en(person_or_lang: _PersonLike | str | None) -> bool: """Back-compat shim for the ``is_en`` boolean that older call sites thread through their renderers. Accepts either a person or a raw locale string so a call site can be migrated without also refactoring how it obtains the value. Japanese maps to ``True`` here — the legacy two-way surfaces have no Japanese copy, so English is the correct degradation for them (see :data:`FALLBACK_CHAIN`). New code should branch on :func:`resolve_lang` directly instead, so it can offer real Japanese copy. """ if isinstance(person_or_lang, str) or person_or_lang is None: lang = normalize_lang(person_or_lang) or DEFAULT_LANG else: lang = resolve_lang(person_or_lang) return lang in ("en", "ja") #: 判「这句话是用哪种语言写的」时,各类字符的取值范围。 #: #: 假名(平假名 + 片假名)只出现在日语里,是三者中唯一无歧义的信号 —— 所以它 #: 一旦出现就压过其余。汉字中日共用,拉丁字母则几乎每种语言的技术词汇里都有 #: (「VPN 连不上」「GitHub ID を変更したい」),所以那两类只能比**分量**, #: 不能比**有没有**。 _KANA_RANGES = ((0x3041, 0x309F), (0x30A1, 0x30FF)) _HAN_RANGES = ((0x3400, 0x4DBF), (0x4E00, 0x9FFF), (0xF900, 0xFAFF)) #: 一个汉字顶几个拉丁字母。中文表达同一件事所用的字符数大约是英文的三分之一到 #: 二分之一,所以直接比字符数会让任何一句混着英文技术名词的中文都被判成英文。 #: 取 2 是保守值:它让「VPN 连不上」(3 汉字 vs 3 字母)判中文,而 #: 「How do I connect to 飞书?」(2 汉字 vs 18 字母)仍然判英文 —— 后者正是 #: 「不要因为出现了一个中文名词就改用中文回答」那条要求要保住的例子。 _HAN_WEIGHT = 2 #: 判成英文所需的最少字母数。低于它的多半是一个产品名或一句应答词 #: (``OK`` / ``VPN`` / ``hi``),把它们当成「这个人改用英文了」会让一段中文 #: 对话因为一句「ok」整个翻译过去。够不着这个门槛时返回 ``None``,由调用方 #: 沿用上一句的语言或这个人的偏好 —— 那两者都比一次猜测可靠。 _MIN_LATIN_LETTERS = 4 def detect_text_lang(text: str) -> Lang | None: """这句话是用哪种语言写的?判不出来返回 ``None``。 **只用于对话回复**,不用于通知、卡片文案或任何异步产物 —— 那些的收件人 不一定是刚才说话的人,而 :func:`resolve_lang` 才是「这个人读哪种语言」的 唯一权威(ADR 2026-08-09)。这个函数回答的是另一个问题:**这一句是用什么 语言问的**。一个把 Helix 语言设成中文的同事临时用英文问一句,应该得到一句 英文回答;而这不改变他的偏好,也不改变他收到的通知。 判法与两处易错点: 1. **假名出现即日语。** 日语句子必然带假名,而假名不出现在中文里。 2. **汉字与拉丁字母比分量,不比有无。** 每个汉字按 :data:`_HAN_WEIGHT` 个 字母计。只看「有没有汉字」会让「How do I connect to 飞书?」判成中文。 3. **太短的纯拉丁串不算数。** ``OK`` / ``VPN`` 这种够不着门槛,返回 ``None`` —— 一段中文对话不该因为一句「ok」整个翻过去。 >>> detect_text_lang("VPN 连不上,怎么排查?") 'zh' >>> detect_text_lang("How do I connect to 飞书?") 'en' >>> detect_text_lang("GitHub ID を変更したい") 'ja' >>> detect_text_lang("OK") is None True """ if not text: return None kana = han = latin = 0 for ch in text: code = ord(ch) if any(lo <= code <= hi for lo, hi in _KANA_RANGES): kana += 1 elif any(lo <= code <= hi for lo, hi in _HAN_RANGES): han += 1 elif ch.isascii() and ch.isalpha(): latin += 1 # 假名压过一切,但仍然要够得着分量 —— 一句英文里嵌一个「ー」长音符号 # (片假名区)不该把整句判成日语。 if kana and (kana + han) * _HAN_WEIGHT >= latin: return "ja" if han * _HAN_WEIGHT > latin: return "zh" if latin >= _MIN_LATIN_LETTERS: return "en" # 汉字有一点、但比不过拉丁字母,而拉丁字母又够不着门槛(例如「VPN 慢」)。 # 与其在两个弱信号之间猜,不如让调用方沿用已知的语言。 return None def suffixed(field: str, lang: Lang) -> str: """Column/key name carrying ``lang``'s variant of ``field``. Chinese is the unsuffixed baseline (``subject``), the others take a suffix (``subject_en`` / ``subject_ja``) — matching how the notification template catalog and the ``*_zh``/``*_en`` DB column pairs are already spelled. """ return field if lang == "zh" else f"{field}_{lang}" def pick_localized( source: Mapping[str, object], field: str, lang: Lang, ) -> str | None: """Pick ``field``'s value for ``lang`` from a mapping of suffixed variants, Typed as ``Mapping`` rather than ``dict`` because ``dict`` is invariant in its value type: a caller holding the natural ``dict[str, str]`` template could not pass it to a ``dict[str, object]`` parameter. walking :data:`FALLBACK_CHAIN` until a non-empty string is found. Returns ``None`` when no variant exists in any language, so the caller can decide how to degrade (fall through to an envelope value, skip a channel, …) rather than shipping an empty string to a user. """ for candidate in FALLBACK_CHAIN[lang]: value = source.get(suffixed(field, candidate)) if isinstance(value, str) and value.strip(): return value return None def pick_variant(variants: Mapping[str, object], lang: Lang) -> str | None: """Pick ``lang``'s value from a mapping keyed by BARE language codes. The sibling :func:`pick_localized` reads suffixed template columns (``body_text`` / ``body_text_en`` / ``body_text_ja``); this reads the flat ``{"zh": …, "en": …, "ja": …}`` shape used by inline localized values such as notification card chrome. Both walk the same :data:`FALLBACK_CHAIN`, so a value that is missing one language degrades identically wherever it appears. Returns ``None`` when no language has a non-empty value. """ for candidate in FALLBACK_CHAIN[lang]: value = variants.get(candidate) if isinstance(value, str) and value.strip(): return value return None __all__ = [ "DEFAULT_LANG", "FALLBACK_CHAIN", "Lang", "detect_text_lang", "is_en", "normalize_lang", "pick_localized", "pick_variant", "resolve_lang", "suffixed", ]