"""安全每日一题 — which language the question is asked in. ## The bug this exists for The bank was Chinese-only. Not "mostly Chinese": the three reader-visible strings (prompt, option text, explanation) lived in flat columns holding one language, so an English-reading employee opening 安全每日一题 was shown a Chinese question, four Chinese options, and — after answering — a Chinese explanation of why they were wrong. Every other employee surface in Helix had already been taught to speak three languages (``app.core.locale``, ``secaw_materials.localized``, the notification template catalog); the daily quiz simply had nowhere to put the other two, and the leaderboard it feeds counted those employees' guesses as if they had understood the question. ## The shape ``SecQuizQuestion.localized`` carries the non-baseline languages; the flat columns stay the Chinese baseline. See the column's docstring for why Chinese is not folded in with the others. Two invariants make the grader safe to leave untouched: 1. **Option keys are the contract.** ``correct_key`` names option keys (``"A"``, ``"ABD"``, ``"T"``), and the reader picks from whichever list we rendered. A block whose key set differs from the baseline's is therefore not a translation of this question — it is a different question — and :func:`block_for` refuses it, falling back rather than grading somebody against options they never saw. The write path applies the same rule in :func:`validate_block` so the refusal is normally an editor-time error message, not a silent runtime fallback. 2. **A block may omit ``options`` entirely.** Boolean (对/错) questions store language-neutral ``True`` / ``False`` text and the label the employee sees comes from ``t('quiz.boolean.*')`` in the frontend, so there is nothing to translate; such a block inherits the baseline list. Same for a block that only wants to fix an explanation. ## What the reader is told Every rendered question reports ``lang`` (what actually rendered) and ``is_fallback_lang`` (whether that is what they asked for). An employee reading the English UI who gets a Chinese question because nobody has translated it yet sees a notice saying so — the same contract ``secaw_materials`` reads already keep. A wrong-language question with no explanation of why is indistinguishable from a broken page. """ from __future__ import annotations from typing import Any from app.core.locale import DEFAULT_LANG, Lang, normalize_lang, resolve_lang #: The three languages the quiz can be asked in. QUIZ_LANGS: tuple[Lang, ...] = ("zh", "en", "ja") #: Chinese is the baseline — the flat columns — and the fallback chain #: terminates there, so every question is answerable in some language. BASELINE_LANG: Lang = "zh" #: Per-language resolution order. Mirrors ``app.core.locale.FALLBACK_CHAIN`` #: (ja → en → zh) so a Japanese reader gets the English version of a #: question that has no Japanese copy rather than a Chinese one. #: Duplicated rather than imported for the reason #: ``security_awareness.catalog.LANG_FALLBACK`` gives: that table is about #: *recipient* language (which language do we speak to this person in), #: this one is about *content* fallback (which stored block do we render). LANG_FALLBACK: dict[Lang, tuple[Lang, ...]] = { "zh": ("zh",), "en": ("en", "zh"), "ja": ("ja", "en", "zh"), } #: Longest translated string we accept. The baseline columns are TEXT, so #: this is not a storage limit — it is a guard on one JSON blob growing #: without bound because nothing else in the row constrains it. MAX_PROMPT_LEN = 2_000 MAX_OPTION_LEN = 500 MAX_EXPLANATION_LEN = 2_000 class LocalizationError(ValueError): """A localized block that cannot be stored. Message is user-facing.""" def normalize_quiz_lang(raw: str | None) -> Lang: """Any locale spelling → one of :data:`QUIZ_LANGS`, never ``None``. Thin wrapper over ``core.locale.normalize_lang`` so callers get the baseline instead of ``None`` for an unusable value — a read path has to render *something*. """ return normalize_lang(raw) or DEFAULT_LANG def reader_lang( person: Any = None, *, explicit: str | None = None, header: str | None = None, ) -> Lang: """The language to ask ``person`` in. Precedence, and each step exists for a caller that has nothing better: 1. ``explicit`` — the ``?lang=`` the frontend sends from its active i18next language. It is what the reader is *looking at*, and a reader who just flipped the switcher means it. 2. ``header`` — ``Accept-Language``, for a client that has no switcher of its own. 3. ``resolve_lang(person)`` — the person's stored ``preferred_language`` (then country, then contract entity). This is the branch the MCP surface and any future notification card land on, where there is no browser to ask; falling back to the Chinese baseline there would have re-created the original bug on every non-browser surface. """ for candidate in (explicit, header): resolved = normalize_lang(candidate) if resolved is not None: return resolved return resolve_lang(person) if person is not None else DEFAULT_LANG def _clean_options(raw: Any, *, baseline_keys: set[str]) -> list[dict[str, str]] | None: """Validated option list from a stored/incoming block, or ``None``. ``None`` means "this block does not override the options" — either it omitted them (legitimate: boolean questions, explanation-only blocks) or it carried something unusable, which is treated the same way on the READ path so a malformed row degrades to the baseline instead of raising in an employee's face. The write path calls :func:`validate_block`, which raises instead. """ if not isinstance(raw, list) or not raw: return None out: list[dict[str, str]] = [] for item in raw: if not isinstance(item, dict): return None key = str(item.get("key") or "").strip().upper() text = str(item.get("text") or "").strip() if not key or not text: return None out.append({"key": key, "text": text}) if {o["key"] for o in out} != baseline_keys: # Not a translation of THIS question — see the module docstring. return None return out def _baseline_keys(question: Any) -> set[str]: return { str(o.get("key") or "").strip().upper() for o in (question.options or []) if isinstance(o, dict) and str(o.get("key") or "").strip() } def block_for(question: Any, lang: Lang) -> dict[str, Any] | None: """The stored block for exactly ``lang``, or ``None`` if unusable. A block counts as present only when it has a non-empty ``prompt``: a block carrying only an explanation would render a Chinese question with an English explanation, which is worse than either language on its own. """ if lang == BASELINE_LANG: return { "prompt": question.prompt, "options": question.options or [], "explanation": question.explanation, } blocks = question.localized if isinstance(question.localized, dict) else {} block = blocks.get(lang) if not isinstance(block, dict): return None prompt = str(block.get("prompt") or "").strip() if not prompt: return None options = _clean_options(block.get("options"), baseline_keys=_baseline_keys(question)) explanation = block.get("explanation") return { "prompt": prompt, # Falls back to the baseline list, NOT to an empty one: a # question with no options is unanswerable. "options": options if options is not None else (question.options or []), "explanation": ( str(explanation).strip() if isinstance(explanation, str) and explanation.strip() else question.explanation ), } def available_langs(question: Any) -> list[Lang]: """Languages this question can actually be asked in, in catalog order. Always includes the baseline. Used by the admin console to show which questions are translated and by the coverage read the security desk cites. """ return [lang for lang in QUIZ_LANGS if block_for(question, lang) is not None] def render(question: Any, lang: Lang) -> dict[str, Any]: """Resolve ``question`` into one language, walking the fallback chain. Returns the three reader-visible strings plus the two honesty fields (``lang`` = what rendered, ``is_fallback_lang`` = it is not what you asked for). Never raises and never returns an empty prompt — the chain terminates at the baseline, which every row has. """ for candidate in LANG_FALLBACK.get(lang, (BASELINE_LANG,)): block = block_for(question, candidate) if block is not None: return { **block, "lang": candidate, "is_fallback_lang": candidate != lang, } # Unreachable for a well-formed row (the baseline always resolves); # kept so a row with an empty prompt cannot 500 the daily quiz. return { "prompt": question.prompt or "", "options": question.options or [], "explanation": question.explanation, "lang": BASELINE_LANG, "is_fallback_lang": lang != BASELINE_LANG, } def validate_block( raw: Any, *, lang: str, baseline_options: list[dict] | None, ) -> dict[str, Any]: """Normalise one incoming localized block, or raise. The write-path twin of :func:`block_for`: same rules, but a violation is an error the editor can read rather than a silent fallback. Only the three translatable keys survive — anything else an admin (or a future model) puts in the JSON is dropped here rather than stored and then ignored forever, which is how ``secaw_materials.mt_langs`` earned its own column. """ if lang == BASELINE_LANG: raise LocalizationError( "Chinese is the baseline and lives in prompt / options / " "explanation — it cannot be supplied as a localized block." ) if lang not in QUIZ_LANGS: raise LocalizationError(f"unsupported quiz language {lang!r}") if not isinstance(raw, dict): raise LocalizationError(f"{lang}: localized block must be an object") prompt = str(raw.get("prompt") or "").strip() if not prompt: raise LocalizationError(f"{lang}: prompt is required") if len(prompt) > MAX_PROMPT_LEN: raise LocalizationError(f"{lang}: prompt is longer than {MAX_PROMPT_LEN} characters") out: dict[str, Any] = {"prompt": prompt} baseline_keys = { str(o.get("key") or "").strip().upper() for o in (baseline_options or []) if isinstance(o, dict) and str(o.get("key") or "").strip() } raw_options = raw.get("options") if raw_options: if not isinstance(raw_options, list): raise LocalizationError(f"{lang}: options must be a list") options: list[dict[str, str]] = [] for item in raw_options: if not isinstance(item, dict): raise LocalizationError(f"{lang}: each option must be an object") key = str(item.get("key") or "").strip().upper() text = str(item.get("text") or "").strip() if not key or not text: raise LocalizationError(f"{lang}: every option needs a key and text") if len(text) > MAX_OPTION_LEN: raise LocalizationError( f"{lang}: option {key} is longer than {MAX_OPTION_LEN} characters" ) options.append({"key": key, "text": text}) if {o["key"] for o in options} != baseline_keys: raise LocalizationError( f"{lang}: option keys {sorted({o['key'] for o in options})} do not match " f"the question's keys {sorted(baseline_keys)} — the correct answer is " "recorded as option keys, so a translation must keep them identical." ) out["options"] = options explanation = raw.get("explanation") if isinstance(explanation, str) and explanation.strip(): if len(explanation.strip()) > MAX_EXPLANATION_LEN: raise LocalizationError( f"{lang}: explanation is longer than {MAX_EXPLANATION_LEN} characters" ) out["explanation"] = explanation.strip() return out def validate_localized( raw: Any, *, baseline_options: list[dict] | None, ) -> dict[str, Any]: """Normalise a whole ``{lang: block}`` payload, or raise. An empty mapping is legal and means "this question has no translations" — that is how an admin removes one. """ if raw is None: return {} if not isinstance(raw, dict): raise LocalizationError("localized must be an object keyed by language") out: dict[str, Any] = {} for lang, block in raw.items(): normalized_lang = normalize_lang(str(lang)) if normalized_lang is None: raise LocalizationError(f"unsupported quiz language {lang!r}") out[normalized_lang] = validate_block( block, lang=normalized_lang, baseline_options=baseline_options, ) return out __all__ = [ "BASELINE_LANG", "LANG_FALLBACK", "MAX_EXPLANATION_LEN", "MAX_OPTION_LEN", "MAX_PROMPT_LEN", "QUIZ_LANGS", "LocalizationError", "available_langs", "block_for", "normalize_quiz_lang", "reader_lang", "render", "validate_block", "validate_localized", ]