Skip to content

Commit 9300497

Browse files
committed
fix(models): enforce Hinglish output contract
1 parent 2fb4c10 commit 9300497

8 files changed

Lines changed: 87 additions & 12 deletions

File tree

app/models/whisper_cpp.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
import tempfile
55
from pathlib import Path
66

7+
from app import scripts
78
from app.catalog import CatalogModel
8-
from app.errors import EngineUnavailableError, TranscriptionProcessError
9+
from app.errors import EngineUnavailableError, LanguageUnsupportedError, TranscriptionProcessError
910
from app.models.base import EngineHealth, TranscriptionOptions
1011
from app.models.warmup import prefetch_model_paths
1112

@@ -35,16 +36,38 @@ async def transcribe(self, audio_path: Path, options: TranscriptionOptions) -> s
3536
await self._require_ready()
3637
with tempfile.TemporaryDirectory(prefix="vocagateway-transcript-") as temporary:
3738
output_stem = Path(temporary) / "result"
38-
language = self.catalog_model.decoder_language_code if self.catalog_model else None
3939
arguments = _build_arguments(
4040
self.binary,
4141
self.model,
4242
audio_path,
4343
output_stem,
44-
language or options.language,
44+
self._decoder_language(options.language),
4545
)
4646
await _execute_whisper_cpp(arguments)
47-
return _read_output_text(output_stem.with_suffix(".txt"))
47+
transcript = _read_output_text(output_stem.with_suffix(".txt"))
48+
self._require_fixed_output_script(transcript)
49+
return transcript
50+
51+
def _decoder_language(self, requested: str) -> str:
52+
model = self.catalog_model
53+
if model is None or model.decoder_language_code is None:
54+
return requested
55+
if requested != "auto" and requested not in model.language_codes:
56+
supported = ", ".join(model.language_codes)
57+
raise LanguageUnsupportedError(
58+
f"The selected model supports only {supported}; choose that output mode or Auto."
59+
)
60+
return model.decoder_language_code
61+
62+
def _require_fixed_output_script(self, transcript: str) -> None:
63+
model = self.catalog_model
64+
if model is None or model.decoder_language_code is None or len(model.language_codes) != 1:
65+
return
66+
output_language = model.language_codes[0]
67+
if not scripts.transcript_matches_language(transcript, output_language):
68+
raise LanguageUnsupportedError(
69+
f"The model did not produce the required {output_language} writing system."
70+
)
4871

4972
async def _require_ready(self) -> None:
5073
health = await self.health()

app/routes/transcriptions.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
from app import audio, context, errors, schemas
1515

16-
_LANGUAGE_PATTERN = re.compile(r"^[A-Za-z_-]+$|^auto$")
16+
_LANGUAGE_PATTERN = re.compile(r"^(?:[A-Za-z-]+|hinglish_roman)$")
1717
_TRUTHY = frozenset(("true", "1", "yes"))
1818
_MAX_LANGUAGE_LENGTH = 20
1919
_READ_CHUNK_BYTES = 65_536
@@ -129,7 +129,7 @@ def language(cls, language: str | None) -> str:
129129
raise errors.APIProblem(
130130
status.HTTP_422_UNPROCESSABLE_CONTENT,
131131
"invalid_language",
132-
"Language must be auto or a language tag.",
132+
"Language must be auto, a language tag, or hinglish_roman.",
133133
)
134134
return language_value
135135

app/schemas.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ class CreateSessionRequest(BaseModel):
2222
language: str = Field(
2323
default=AUTO_ENGINE,
2424
max_length=MAXIMUM_LANGUAGE_TAG_LENGTH,
25-
pattern=r"^[A-Za-z_-]+$|^auto$",
25+
pattern=r"^(?:[A-Za-z-]+|hinglish_roman)$",
2626
)
2727
style: Literal[
2828
"raw",

app/scripts.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,10 @@
176176
# code-switching keeps far more of the base script than that — Hinglish rarely
177177
# drops below a third — so this sits well below any real transcript.
178178
_MINIMUM_EXPECTED_SHARE = 0.15
179+
# A fixed-script output mode is a stronger promise than an ordinary spoken
180+
# language. Code-switching is valid Hindi, but any non-Latin letter means the
181+
# Roman Hinglish contract was not met.
182+
_STRICT_SCRIPT_LANGUAGES = frozenset(("hinglish_roman",))
179183

180184

181185
def _script_of(character: str) -> str:
@@ -202,6 +206,8 @@ def transcript_matches_language(text: str, language: str) -> bool:
202206
with no letters at all. Otherwise the expected script has to account for at
203207
least `_MINIMUM_EXPECTED_SHARE` of the letters, which admits code-switching
204208
while rejecting text transliterated into another writing system entirely.
209+
Fixed-script output contracts require every letter to use their declared
210+
script.
205211
"""
206212
if language == "auto":
207213
return True
@@ -212,4 +218,5 @@ def transcript_matches_language(text: str, language: str) -> bool:
212218
if not letters:
213219
return True
214220
matching = sum(1 for character in letters if _script_of(character) in expected)
215-
return matching / len(letters) >= _MINIMUM_EXPECTED_SHARE
221+
minimum_share = 1.0 if language.lower() in _STRICT_SCRIPT_LANGUAGES else _MINIMUM_EXPECTED_SHARE
222+
return matching / len(letters) >= minimum_share

tests/test_api.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ def test_session_schema_accepts_roman_hinglish_output_contract() -> None:
5858
request = CreateSessionRequest(client_session_id=uuid4(), language="hinglish_roman")
5959
assert request.language == "hinglish_roman"
6060

61+
with pytest.raises(ValueError):
62+
CreateSessionRequest(client_session_id=uuid4(), language="not_a_language")
63+
6164

6265
async def test_unsupported_language_is_reported_a_d68a4(
6366
settings: Settings, authorization: dict[str, str], audio_bytes: bytes

tests/test_scripts.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ def test_uncertain_cases_pass_rather_than_fail() -> None:
5858
def test_roman_hinglish_uses_the_latin_script_contract() -> None:
5959
assert transcript_matches_language("Aaj mujhe office jaana hai", "hinglish_roman") is True
6060
assert transcript_matches_language("आज मुझे जाना है", "hinglish_roman") is False
61+
assert transcript_matches_language("Aaj office में meeting hai", "hinglish_roman") is False
6162

6263

6364
@pytest.mark.parametrize(

tests/test_transcriptions.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ async def transcribe(self, audio_path: Path, options: TranscriptionOptions) -> s
5353

5454
def test_audio_form_accepts_roman_hinglish_output_contract() -> None:
5555
assert _AudioForm.language("hinglish_roman") == "hinglish_roman"
56+
assert _AudioForm._invalid_language("not_a_language") is True
5657

5758

5859
async def test_transcriptions_require_a_bearer_token(

tests/test_whisper_cpp.py

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import pytest
66

77
from app.catalog import CatalogModel
8-
from app.errors import EngineUnavailableError, TranscriptionProcessError
8+
from app.errors import EngineUnavailableError, LanguageUnsupportedError, TranscriptionProcessError
99
from app.models.base import TranscriptionOptions
1010
from app.models.whisper_cpp import WhisperCppEngine
1111

@@ -124,13 +124,53 @@ async def test_transcribe_uses_catalog_decoder_language_for_output_contract(
124124
decoder_language_code="hi",
125125
)
126126

127-
await WhisperCppEngine(binary, model, catalog_model).transcribe(
128-
audio, TranscriptionOptions("hinglish_roman", RAW_STYLE)
129-
)
127+
engine = WhisperCppEngine(binary, model, catalog_model)
128+
await engine.transcribe(audio, TranscriptionOptions("hinglish_roman", RAW_STYLE))
130129

131130
recorded = (tmp_path / "whisper-cli.args").read_text(encoding="utf-8").splitlines()
132131
assert recorded[recorded.index("-l") + 1] == "hi"
133132

133+
with pytest.raises(LanguageUnsupportedError, match="only hinglish_roman"):
134+
await engine.transcribe(audio, TranscriptionOptions("en", RAW_STYLE))
135+
136+
137+
async def test_fixed_output_contract_rejects_devanagari_leakage(tmp_path: Path) -> None:
138+
binary = tmp_path / WHISPER_BINARY_NAME
139+
_write_binary(
140+
binary,
141+
r"""#!/bin/sh
142+
of=""
143+
while [ "$#" -gt 0 ]; do
144+
case "$1" in
145+
-of) of="$2"; shift 2 ;;
146+
*) shift ;;
147+
esac
148+
done
149+
printf '%s' "Aaj office में meeting hai" > "$of.txt"
150+
""",
151+
)
152+
model = tmp_path / MODEL_FILE_NAME
153+
model.write_bytes(MODEL_BYTES)
154+
audio = tmp_path / AUDIO_FILE_NAME
155+
audio.write_bytes(AUDIO_BYTES)
156+
catalog_model = CatalogModel(
157+
id="whisper.cpp:hinglish",
158+
engine="whisper.cpp",
159+
key=MODEL_FILE_NAME,
160+
label="Hinglish",
161+
size_bytes=1,
162+
languages="Hindi + English, Roman script",
163+
quality="Experimental",
164+
minimum_ram_gb=4,
165+
language_codes=("hinglish_roman",),
166+
decoder_language_code="hi",
167+
)
168+
169+
with pytest.raises(LanguageUnsupportedError, match="required hinglish_roman"):
170+
await WhisperCppEngine(binary, model, catalog_model).transcribe(
171+
audio, TranscriptionOptions("auto", RAW_STYLE)
172+
)
173+
134174

135175
async def test_transcribe_raises_when_the_engine_aaaa(tmp_path: Path) -> None:
136176
engine = WhisperCppEngine(tmp_path / "missing-cli", tmp_path / "missing-model.bin")

0 commit comments

Comments
 (0)