The production directory oldbox calls "deal-hunter-es" declares target_language "es-ES", its glossary says Spanish, and its 32-chapter run finished - and all 3,038 translated paragraphs are in Chinese. It is a second Chinese draft filed under a Spanish name. The cause is in the pipeline: the drafter's instructions are hard-coded to Simplified Chinese in three places (SYSTEM_DRAFTER, draft_prompt, SYSTEM_CRITIC), so the glossary's target language was ignored. No non-Chinese translation has ever been produced by that pipeline. The danger at this end is that the rows would be stored under "es", the review lane would look populated, and a reviewer would approve Chinese as Spanish. So the ingest now checks the script of the delivered text against the declared language, and refuses the delivery before writing a single row. The check is about the script, not the language: it cannot tell Spanish from Portuguese, but it can tell Chinese from Spanish, which is the mistake that actually happens. Japanese needs its kana and Korean its Hangul, and a Chinese delivery carrying kana is a Japanese translation filed in the wrong lane. An unknown language is not checked - there is no expectation to check it against. Proven on the real data: the es delivery is refused with "the text is mostly Han characters, so it is not Spanish", and the 4,300-row Chinese delivery still imports unchanged. 19 new tests. Suite: 909 passed, 8 failed (the same 8 pre-existing failures).
263 lines
9.3 KiB
Python
263 lines
9.3 KiB
Python
# Copyright 2026 Umbra Tools (umbratools.com)
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
"""The set of languages this system knows about, in one place.
|
|
|
|
Two groups, and the split is a hard capability boundary, not a preference:
|
|
|
|
* **Voiceable** - the ten languages Qwen3-TTS can synthesise. The model's own
|
|
``codec_language_id`` keys are the authority for the name string it expects.
|
|
* **Gap** - languages we can tag and store but cannot speak. Recording the tag
|
|
is useful (it excludes the line from translation and explains why a hole
|
|
exists); emitting audio for it would be wrong, so the render is refused.
|
|
|
|
Spec 31 section 5. ``/v1/tts`` validation, the export filter and the review UI
|
|
all read this module, so they cannot disagree about what is supported.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
|
|
# Where a source-language tag came from. Kept as plain strings rather than an
|
|
# enum so the value round-trips through JSON and SQLite without conversion.
|
|
ORIGIN_UNKNOWN = "unknown"
|
|
ORIGIN_UMBRATRANS = "umbratrans"
|
|
ORIGIN_OPERATOR = "operator"
|
|
ORIGINS: tuple[str, ...] = (ORIGIN_UNKNOWN, ORIGIN_UMBRATRANS, ORIGIN_OPERATOR)
|
|
|
|
|
|
# The script a language is written in. A delivery that claims one language
|
|
# and arrives written in another is not a translation of that language - it is
|
|
# the exact "declared value disagrees with the measured value" defect class
|
|
# that the release work already had to fix for audio padding.
|
|
SCRIPT_LATIN = "latin"
|
|
SCRIPT_CYRILLIC = "cyrillic"
|
|
SCRIPT_HAN = "han"
|
|
SCRIPT_KANA = "kana"
|
|
SCRIPT_HANGUL = "hangul"
|
|
SCRIPT_ARABIC = "arabic"
|
|
SCRIPT_DEVANAGARI = "devanagari"
|
|
|
|
SCRIPT_NAMES: dict[str, str] = {
|
|
SCRIPT_LATIN: "Latin script",
|
|
SCRIPT_CYRILLIC: "Cyrillic script",
|
|
SCRIPT_HAN: "Han characters",
|
|
SCRIPT_KANA: "Japanese kana",
|
|
SCRIPT_HANGUL: "Hangul",
|
|
SCRIPT_ARABIC: "Arabic script",
|
|
SCRIPT_DEVANAGARI: "Devanagari",
|
|
}
|
|
|
|
# The Unicode blocks that count as each script. Latin is split so that the
|
|
# accented forms are counted with it rather than falling through.
|
|
_SCRIPT_RANGES: tuple[tuple[str, int, int], ...] = (
|
|
(SCRIPT_LATIN, 0x0041, 0x005A),
|
|
(SCRIPT_LATIN, 0x0061, 0x007A),
|
|
(SCRIPT_LATIN, 0x00C0, 0x024F),
|
|
(SCRIPT_CYRILLIC, 0x0400, 0x04FF),
|
|
(SCRIPT_HAN, 0x3400, 0x4DBF),
|
|
(SCRIPT_HAN, 0x4E00, 0x9FFF),
|
|
(SCRIPT_HAN, 0xF900, 0xFAFF),
|
|
(SCRIPT_KANA, 0x3040, 0x309F),
|
|
(SCRIPT_KANA, 0x30A0, 0x30FF),
|
|
(SCRIPT_KANA, 0x31F0, 0x31FF),
|
|
(SCRIPT_HANGUL, 0x1100, 0x11FF),
|
|
(SCRIPT_HANGUL, 0x3130, 0x318F),
|
|
(SCRIPT_HANGUL, 0xAC00, 0xD7AF),
|
|
(SCRIPT_ARABIC, 0x0600, 0x06FF),
|
|
(SCRIPT_ARABIC, 0x0750, 0x077F),
|
|
(SCRIPT_DEVANAGARI, 0x0900, 0x097F),
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Language:
|
|
"""One language. ``tts_name`` is None when it cannot be voiced."""
|
|
|
|
code: str
|
|
name: str
|
|
tts_name: str | None = None
|
|
# The scripts this language is normally written in. A delivery written
|
|
# mostly in something else is a defect, not a translation.
|
|
scripts: tuple[str, ...] = (SCRIPT_LATIN,)
|
|
# A script that has to be present for the text to be this language at all:
|
|
# Japanese without kana is Chinese, and Korean without Hangul is Chinese.
|
|
required_script: str | None = None
|
|
# Scripts that would mean the delivery is a different language. Chinese
|
|
# text carrying kana is a Japanese translation filed under the wrong lane.
|
|
forbidden_scripts: tuple[str, ...] = ()
|
|
|
|
|
|
# The ten Qwen3-TTS languages. ``tts_name`` is copied from the model's own
|
|
# codec_language_id keys via scripts/render_lang_test.py, which is the working
|
|
# reference - do not retype these by hand.
|
|
QWEN_LANGUAGES: tuple[Language, ...] = (
|
|
Language("en", "English", "english"),
|
|
Language(
|
|
"zh",
|
|
"Chinese",
|
|
"chinese",
|
|
scripts=(SCRIPT_HAN,),
|
|
forbidden_scripts=(SCRIPT_KANA, SCRIPT_HANGUL),
|
|
),
|
|
Language(
|
|
"ja",
|
|
"Japanese",
|
|
"japanese",
|
|
scripts=(SCRIPT_HAN, SCRIPT_KANA),
|
|
required_script=SCRIPT_KANA,
|
|
),
|
|
Language(
|
|
"ko",
|
|
"Korean",
|
|
"korean",
|
|
scripts=(SCRIPT_HANGUL,),
|
|
required_script=SCRIPT_HANGUL,
|
|
),
|
|
Language("de", "German", "german"),
|
|
Language("es", "Spanish", "spanish"),
|
|
Language("fr", "French", "french"),
|
|
Language("ru", "Russian", "russian", scripts=(SCRIPT_CYRILLIC,)),
|
|
Language("it", "Italian", "italian"),
|
|
Language("pt", "Portuguese", "portuguese"),
|
|
)
|
|
|
|
# Taggable, storable, and deliberately not speakable (spec 31 section 4.3).
|
|
GAP_LANGUAGES: tuple[Language, ...] = (
|
|
Language("nl", "Dutch"),
|
|
Language("no", "Norwegian"),
|
|
Language("pl", "Polish"),
|
|
Language("ar", "Arabic", None, scripts=(SCRIPT_ARABIC,)),
|
|
Language("hi", "Hindi", None, scripts=(SCRIPT_DEVANAGARI,)),
|
|
)
|
|
|
|
ALL_LANGUAGES: tuple[Language, ...] = QWEN_LANGUAGES + GAP_LANGUAGES
|
|
|
|
_BY_CODE: dict[str, Language] = {lang.code: lang for lang in ALL_LANGUAGES}
|
|
|
|
# Accepts a code ('DE'), a name ('German') or the model string ('german').
|
|
_BY_ALIAS: dict[str, Language] = {}
|
|
for _lang in ALL_LANGUAGES:
|
|
_BY_ALIAS[_lang.code] = _lang
|
|
_BY_ALIAS[_lang.name.casefold()] = _lang
|
|
if _lang.tts_name is not None:
|
|
_BY_ALIAS[_lang.tts_name] = _lang
|
|
|
|
|
|
def by_code(value: str | None) -> Language | None:
|
|
"""Look up by code, display name or model string. None when unknown."""
|
|
if not value:
|
|
return None
|
|
return _BY_ALIAS.get(str(value).strip().casefold())
|
|
|
|
|
|
def normalize(value: str | None) -> str | None:
|
|
"""Return the canonical lowercase code for a code or name, else None.
|
|
|
|
``None`` and empty input return ``None``. An unrecognised value also
|
|
returns ``None`` rather than raising: a language we do not know is a
|
|
finding to record, not a crash (spec 31 section 8).
|
|
"""
|
|
lang = by_code(value)
|
|
return lang.code if lang is not None else None
|
|
|
|
|
|
def is_known(value: str | None) -> bool:
|
|
"""True when we recognise the language at all (voiceable or gap)."""
|
|
return by_code(value) is not None
|
|
|
|
|
|
def is_voiceable(value: str | None) -> bool:
|
|
"""True when Qwen3-TTS can synthesise this language."""
|
|
lang = by_code(value)
|
|
return lang is not None and lang.tts_name is not None
|
|
|
|
|
|
def tts_name(value: str | None) -> str | None:
|
|
"""The string to hand the TTS server, or None when not voiceable."""
|
|
lang = by_code(value)
|
|
return lang.tts_name if lang is not None else None
|
|
|
|
|
|
def script_counts(text: str) -> dict[str, int]:
|
|
"""How many letters of *text* belong to each script.
|
|
|
|
Only letters count. Punctuation, digits and whitespace say nothing about
|
|
which language a passage is written in, and a name kept in English inside
|
|
a Chinese paragraph must not pull the count towards Latin.
|
|
"""
|
|
counts: dict[str, int] = {}
|
|
for ch in text:
|
|
point = ord(ch)
|
|
for script, low, high in _SCRIPT_RANGES:
|
|
if low <= point <= high:
|
|
counts[script] = counts.get(script, 0) + 1
|
|
break
|
|
return counts
|
|
|
|
|
|
def script_mismatch(code: str | None, text: str) -> str | None:
|
|
"""Why *text* cannot be a translation into *code*, or None when it can.
|
|
|
|
A delivery that claims Spanish and arrives written in Chinese is not a
|
|
Spanish translation. It is the "declared value disagrees with the measured
|
|
value" defect class, and it has to be caught at the boundary: once the
|
|
rows are in the database, the review lane looks populated and a reviewer
|
|
approves the wrong thing.
|
|
|
|
The check is deliberately about the *script*, not the language. It cannot
|
|
tell Spanish from Portuguese, but it can tell Chinese from Spanish, which
|
|
is the mistake that actually happens - a pipeline configured for one
|
|
language that silently produces another.
|
|
"""
|
|
language = by_code(code)
|
|
if language is None:
|
|
# An unknown language has no script expectation to check against.
|
|
return None
|
|
|
|
counts = script_counts(text)
|
|
total = sum(counts.values())
|
|
if total == 0:
|
|
return None
|
|
|
|
for forbidden in language.forbidden_scripts:
|
|
if counts.get(forbidden, 0) / total >= 0.10:
|
|
return f"the text is written in {SCRIPT_NAMES[forbidden]}, so it is not {language.name}"
|
|
|
|
if language.required_script and not counts.get(language.required_script):
|
|
return (
|
|
f"the text has no {SCRIPT_NAMES[language.required_script]}, "
|
|
f"so it is not {language.name}"
|
|
)
|
|
|
|
acceptable = sum(counts.get(script, 0) for script in language.scripts)
|
|
if acceptable / total < 0.5:
|
|
dominant = max(counts, key=lambda key: counts[key])
|
|
return f"the text is mostly {SCRIPT_NAMES[dominant]}, so it is not {language.name}"
|
|
return None
|
|
|
|
|
|
def language_name(value: str | None) -> str:
|
|
"""Display name for a code. Falls back to the raw value, then 'unknown'."""
|
|
lang = by_code(value)
|
|
if lang is not None:
|
|
return lang.name
|
|
return str(value) if value else "unknown"
|
|
|
|
|
|
def refusal_reason(value: str | None) -> str | None:
|
|
"""Why this language cannot be voiced, or None when it can.
|
|
|
|
The wording is used verbatim in the review UI, so a refused render always
|
|
says *why* rather than leaving an unexplained gap (spec 31 section 11).
|
|
"""
|
|
if not value:
|
|
return None
|
|
lang = by_code(value)
|
|
if lang is None:
|
|
return f"unrecognised language {value!r} - no voice available"
|
|
if lang.tts_name is None:
|
|
return f"no Qwen3-TTS voice for {lang.code!r} ({lang.name})"
|
|
return None
|