Refuse a delivery whose text is not written in the language it claims

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).
This commit is contained in:
Umbra Tools
2026-11-06 20:06:59 -08:00
parent 81915b4ec1
commit 3db6ac3b8b
4 changed files with 400 additions and 6 deletions

View File

@@ -185,6 +185,50 @@ the mechanical split, 0 partition mismatches**. The one fallback was a model
answer that did not reproduce the translated text; the content check caught it
and the paragraph was flagged rather than accepted.
### The pipeline is Chinese-only, and one production says otherwise
Measured on oldbox (2026-11-06), the second production directory is
`productions/deal-hunter-es`. It declares `target_language: es-ES`, its
glossary says `"script": "Spanish"` with Spanish style rules and the Spanish
title `Cazador de Ofertas`, and it has a complete 32-chapter run (39,083 s).
**Its text is Chinese.** All 3,038 translated paragraphs are Chinese, in the
same proportion as the Chinese production (3,034 with Han characters, 4
Latin-only). It is a second Chinese draft filed under a Spanish name - a
different Chinese wording from the `deal-hunter-zh` run (2,792 of 3,038
paragraphs differ), but Chinese.
The cause is in the pipeline, not in the run: the drafter's instructions are
**hard-coded to Simplified Chinese** in three places.
- `prompts.py` `SYSTEM_DRAFTER`: *"把英文太空冒险小说译成简体中文"* - translate the
English space adventure novel into Simplified Chinese.
- `prompts.py` `draft_prompt`: *"请把下面英文译为简体中文"* and a JSON key of
`"zh"`.
- `prompts.py` `SYSTEM_CRITIC`: *"对照英文原文和中文译文"*.
The glossary is passed into the prompt, but the *instruction* says Chinese, so
the model followed the instruction and ignored the glossary's target. The
critic is hard-coded the same way, so it checked the Chinese text and found
nothing wrong - there was nothing wrong with it, for the language it was
actually asked about.
Two consequences, both measured rather than inferred:
1. **No non-Chinese translation has ever been produced by this pipeline.** The
nine other Qwen3-TTS languages exist only as the single-pass sample files
described above.
2. **The English translation output would be wrong for any other language**,
because the analyst, the drafter and the critic are all told they are
working on Chinese.
The pipeline needs a target-language parameter before it can be run for
anything but Chinese. Until then, a production directory's name and its
`target_language` field are a *claim*, not a measurement - which is why the
delivery ingest now checks the script of the delivered text against the
declared language and refuses a delivery whose text is written in another
language (`umbraaudio/languages.py:script_mismatch`).
## Open questions
1. Which model runs Passes 1-4? This is where the local LLM question returns,

View File

@@ -0,0 +1,205 @@
# Copyright 2026 Umbra Tools (umbratools.com)
# SPDX-License-Identifier: Apache-2.0
"""Tests for the script check on a translation delivery (spec 31 section 8).
A delivery that claims one language and arrives written in another is not a
translation of that language. It is the "declared value disagrees with the
measured value" defect class, and it is checked at the boundary: once the rows
are stored, the review lane looks populated and the mistake is invisible.
The check is about the *script*, not the language. It cannot tell Spanish
from Portuguese. It can tell Chinese from Spanish, which is the mistake that
actually happens: a pipeline configured for one language that silently
produces another.
"""
from __future__ import annotations
import sqlite3
import pytest
from umbraaudio import languages as lang
from umbraaudio import translations as tr
CHINESE = "欢迎收听《Deal Hunter》有声书。我是旁白,将为您介绍几位您将听到的声音。"
SPANISH = "Bienvenido al audiolibro Deal Hunter. Soy tu narrador."
RUSSIAN = "Добро пожаловать в аудиокнигу Deal Hunter. Я ваш рассказчик."
JAPANESE = "『Deal Hunter』的有声書へようこそ。私はナレーターです。"
KOREAN = "Deal Hunter 오디오북에 오신 것을 환영합니다. 저는 내레이터입니다."
# --- the registry's own answers --------------------------------------------
def test_chinese_is_not_spanish() -> None:
reason = lang.script_mismatch("es", CHINESE)
assert reason is not None
assert "Han" in reason
assert "Spanish" in reason
def test_chinese_is_chinese() -> None:
assert lang.script_mismatch("zh", CHINESE) is None
def test_spanish_is_spanish() -> None:
assert lang.script_mismatch("es", SPANISH) is None
def test_russian_is_russian() -> None:
assert lang.script_mismatch("ru", RUSSIAN) is None
def test_latin_text_is_not_russian() -> None:
reason = lang.script_mismatch("ru", SPANISH)
assert reason is not None
assert "Latin" in reason
assert "Russian" in reason
def test_japanese_needs_kana() -> None:
assert lang.script_mismatch("ja", JAPANESE) is None
def test_chinese_text_is_not_japanese() -> None:
"""Kanji are Han, so Chinese reads as Japanese without the kana."""
reason = lang.script_mismatch("ja", CHINESE)
assert reason is not None
assert "kana" in reason
def test_japanese_text_is_not_chinese() -> None:
"""Kana in a Chinese delivery means the target is the wrong language."""
reason = lang.script_mismatch("zh", JAPANESE)
assert reason is not None
assert "Japanese" in reason or "kana" in reason
def test_korean_is_hangul() -> None:
assert lang.script_mismatch("ko", KOREAN) is None
def test_chinese_text_is_not_korean() -> None:
reason = lang.script_mismatch("ko", CHINESE)
assert reason is not None
def test_names_kept_in_english_do_not_break_the_check() -> None:
"""A Chinese paragraph keeps Deal Hunter and Umbra Audio in English."""
text = CHINESE + " Deal Hunter 是船名,Umbra Audio 是出品方。"
assert lang.script_mismatch("zh", text) is None
def test_an_unknown_language_is_not_checked() -> None:
"""There is no script expectation for a language we do not know."""
assert lang.script_mismatch("xx", CHINESE) is None
def test_empty_text_is_not_checked() -> None:
assert lang.script_mismatch("es", "") is None
assert lang.script_mismatch("es", "1234 -- ...") is None
# --- the gate at the ingest boundary ----------------------------------------
def _seed(conn: sqlite3.Connection) -> None:
conn.executescript(
"""
CREATE TABLE project (id INTEGER PRIMARY KEY, name TEXT);
CREATE TABLE chapters (id INTEGER PRIMARY KEY, chapter_index INTEGER);
CREATE TABLE utterances (
id INTEGER PRIMARY KEY,
chapter_id INTEGER,
utterance_index INTEGER,
speaker_name TEXT,
text TEXT,
context_before TEXT,
context_after TEXT,
language_code TEXT,
language_origin TEXT
);
INSERT INTO project (id, name) VALUES (1, 'Test');
INSERT INTO utterances (id, chapter_id, utterance_index, speaker_name, text) VALUES
(1, 1, 0, 'narrator', 'The ship landed.'),
(2, 1, 1, 'Kainda', 'Welcome to the ship.');
"""
)
conn.commit()
@pytest.fixture
def conn() -> sqlite3.Connection:
c = sqlite3.connect(":memory:")
c.row_factory = sqlite3.Row
_seed(c)
tr.ensure_tables(c)
yield c
c.close()
def test_a_mismatched_delivery_is_refused_and_writes_nothing(conn: sqlite3.Connection) -> None:
"""The Chinese text filed under Spanish must not reach the database."""
report = tr.import_translations(
conn,
1,
{
"target_language": "es",
"utterances": [
{"utterance_id": 1, "text": CHINESE},
{"utterance_id": 2, "text": "我是旁白。"},
],
},
)
assert report.accepted == 0
assert report.problems
assert "not Spanish" in report.problems[0]
assert conn.execute("SELECT COUNT(*) FROM utterance_translations").fetchone()[0] == 0
def test_a_matching_delivery_is_imported(conn: sqlite3.Connection) -> None:
report = tr.import_translations(
conn,
1,
{
"target_language": "es",
"utterances": [
{"utterance_id": 1, "text": SPANISH},
{"utterance_id": 2, "text": "Bienvenido a la nave."},
],
},
)
assert report.accepted == 2
assert report.problems == []
assert conn.execute("SELECT COUNT(*) FROM utterance_translations").fetchone()[0] == 2
def test_a_locale_code_is_checked_against_its_base_language(conn: sqlite3.Connection) -> None:
"""``es-ES`` is still Spanish; ``zh-Hans`` is still Chinese."""
report = tr.import_translations(
conn,
1,
{"target_language": "es-ES", "utterances": [{"utterance_id": 1, "text": CHINESE}]},
)
assert report.accepted == 0
assert report.problems
def test_a_collapse_orphan_is_not_mistaken_for_a_mismatch(conn: sqlite3.Connection) -> None:
"""A row with no text is a gap, not evidence of the wrong language."""
report = tr.import_translations(
conn,
1,
{
"target_language": "es",
"utterances": [
{"utterance_id": 1, "text": SPANISH},
{"utterance_id": 2, "voiced": False, "reason": "absorbed into 1"},
],
},
)
assert report.accepted == 2
assert report.not_voiced == 1
assert report.problems == []

View File

@@ -27,6 +27,50 @@ 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."""
@@ -34,6 +78,15 @@ class Language:
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
@@ -41,13 +94,31 @@ class Language:
# reference - do not retype these by hand.
QWEN_LANGUAGES: tuple[Language, ...] = (
Language("en", "English", "english"),
Language("zh", "Chinese", "chinese"),
Language("ja", "Japanese", "japanese"),
Language("ko", "Korean", "korean"),
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"),
Language("ru", "Russian", "russian", scripts=(SCRIPT_CYRILLIC,)),
Language("it", "Italian", "italian"),
Language("pt", "Portuguese", "portuguese"),
)
@@ -57,8 +128,8 @@ GAP_LANGUAGES: tuple[Language, ...] = (
Language("nl", "Dutch"),
Language("no", "Norwegian"),
Language("pl", "Polish"),
Language("ar", "Arabic"),
Language("hi", "Hindi"),
Language("ar", "Arabic", None, scripts=(SCRIPT_ARABIC,)),
Language("hi", "Hindi", None, scripts=(SCRIPT_DEVANAGARI,)),
)
ALL_LANGUAGES: tuple[Language, ...] = QWEN_LANGUAGES + GAP_LANGUAGES
@@ -109,6 +180,64 @@ def tts_name(value: str | None) -> str | None:
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)

View File

@@ -285,6 +285,22 @@ def import_translations(
if not isinstance(rows, list):
raise ValueError("'utterances' must be a list")
# A delivery that claims one language and arrives written in another is a
# defect, and it has to be caught before a single row is written. Once the
# rows are stored the lane looks populated, the review UI shows the text,
# and the mistake is invisible - which is how a Chinese translation ends up
# being approved as Spanish.
if target:
delivered_text = "\n".join(
str(row.get("text") or "") for row in rows if isinstance(row, dict) and row.get("text")
)
mismatch = lang.script_mismatch(target, delivered_text)
if mismatch is not None:
report.problems.append(
f"delivery declares '{target}' but {mismatch}; nothing was imported"
)
return report
for row in rows:
if not isinstance(row, dict) or "utterance_id" not in row:
report.problems.append(f"row without utterance_id: {json.dumps(row)[:80]}")