From 0404e04c6eccbcf15bd998322e43f3e237301b1f Mon Sep 17 00:00:00 2001 From: John Menke Date: Sun, 1 Nov 2026 14:13:34 -0500 Subject: [PATCH 1/2] Refuse inverted or empty OpenAI speech-to-text words. OpenAI word and segment timings skipped the ordered-interval contract already required for Grok. --- src/docgen/ai_client.py | 24 +++++++++++++++---- tests/test_ai_client.py | 52 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/src/docgen/ai_client.py b/src/docgen/ai_client.py index b17b2ff..65a9f62 100644 --- a/src/docgen/ai_client.py +++ b/src/docgen/ai_client.py @@ -822,18 +822,32 @@ def _grok_stt_segment(s: Any, index: int) -> dict[str, Any]: return {"start": start, "end": end, "text": str(s.get("text") or "")} +def _require_openai_word_token(word: Any, start: float, end: float) -> str: + """OpenAI words use the same non-empty, ordered-interval contract as Grok.""" + token = str(word or "").strip() + if not token: + raise AIError("STT words[].word must be a non-empty token") + _stt_ordered_interval(start, end, label="words[]") + return token + + def _openai_stt_word(w: Any) -> dict[str, Any]: + start = _stt_json_number(_stt_field(w, "start"), label="words[].start") + end = _stt_json_number(_stt_field(w, "end"), label="words[].end") return { - "start": _stt_json_number(_stt_field(w, "start"), label="words[].start"), - "end": _stt_json_number(_stt_field(w, "end"), label="words[].end"), - "word": _stt_field(w, "word"), + "start": start, + "end": end, + "word": _require_openai_word_token(_stt_field(w, "word"), start, end), } def _openai_stt_segment(s: Any) -> dict[str, Any]: + start = _stt_json_number(_stt_field(s, "start"), label="segments[].start") + end = _stt_json_number(_stt_field(s, "end"), label="segments[].end") + _stt_ordered_interval(start, end, label="segments[]") return { - "start": _stt_json_number(_stt_field(s, "start"), label="segments[].start"), - "end": _stt_json_number(_stt_field(s, "end"), label="segments[].end"), + "start": start, + "end": end, "text": _stt_field(s, "text"), } diff --git a/tests/test_ai_client.py b/tests/test_ai_client.py index 1e8c930..830b82c 100644 --- a/tests/test_ai_client.py +++ b/tests/test_ai_client.py @@ -479,6 +479,58 @@ def test_openai_whisper_bool_word_start_raises( transcribe_audio(mp3, cfg=cfg) +def test_openai_whisper_inverted_word_is_refused( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, clear_ai_env: None +) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "sk-openai") + cfg = _cfg(tmp_path, {}) + mp3 = tmp_path / "n.mp3" + mp3.write_bytes(b"fake-mp3") + fake = MagicMock() + fake.audio.transcriptions.create.return_value = _openai_whisper_result( + text="Hi", + words=[{"start": 0.4, "end": 0.1, "word": "Hi"}], + ) + with patch("docgen.ai_client.openai_client", return_value=fake): + with pytest.raises(AIError, match=r"words\[\] end must be >= start"): + transcribe_audio(mp3, cfg=cfg) + + +def test_openai_whisper_empty_word_is_refused( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, clear_ai_env: None +) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "sk-openai") + cfg = _cfg(tmp_path, {}) + mp3 = tmp_path / "n.mp3" + mp3.write_bytes(b"fake-mp3") + fake = MagicMock() + fake.audio.transcriptions.create.return_value = _openai_whisper_result( + text="Hi", + words=[{"start": 0.0, "end": 0.2, "word": " "}], + ) + with patch("docgen.ai_client.openai_client", return_value=fake): + with pytest.raises(AIError, match="non-empty token"): + transcribe_audio(mp3, cfg=cfg) + + +def test_openai_whisper_inverted_segment_is_refused( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, clear_ai_env: None +) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "sk-openai") + cfg = _cfg(tmp_path, {}) + mp3 = tmp_path / "n.mp3" + mp3.write_bytes(b"fake-mp3") + fake = MagicMock() + fake.audio.transcriptions.create.return_value = _openai_whisper_result( + text="Hi", + words=[{"start": 0.0, "end": 0.2, "word": "Hi"}], + segments=[{"start": 0.5, "end": 0.1, "text": "Hi"}], + ) + with patch("docgen.ai_client.openai_client", return_value=fake): + with pytest.raises(AIError, match=r"segments\[\] end must be >= start"): + transcribe_audio(mp3, cfg=cfg) + + def test_openai_whisper_maps_typed_words( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, clear_ai_env: None ) -> None: From 17e7e40a7f1917a9920d667869aa9ec9902de2db Mon Sep 17 00:00:00 2001 From: John Menke Date: Sun, 1 Nov 2026 14:15:17 -0500 Subject: [PATCH 2/2] Keep the new speech-to-text tests inside the vulture baseline. The fixture still runs, without adding unused clear_ai_env parameters. Co-authored-by: Cursor --- tests/test_ai_client.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_ai_client.py b/tests/test_ai_client.py index 830b82c..6049ec2 100644 --- a/tests/test_ai_client.py +++ b/tests/test_ai_client.py @@ -479,8 +479,9 @@ def test_openai_whisper_bool_word_start_raises( transcribe_audio(mp3, cfg=cfg) +@pytest.mark.usefixtures("clear_ai_env") def test_openai_whisper_inverted_word_is_refused( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, clear_ai_env: None + tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("OPENAI_API_KEY", "sk-openai") cfg = _cfg(tmp_path, {}) @@ -496,8 +497,9 @@ def test_openai_whisper_inverted_word_is_refused( transcribe_audio(mp3, cfg=cfg) +@pytest.mark.usefixtures("clear_ai_env") def test_openai_whisper_empty_word_is_refused( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, clear_ai_env: None + tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("OPENAI_API_KEY", "sk-openai") cfg = _cfg(tmp_path, {}) @@ -513,8 +515,9 @@ def test_openai_whisper_empty_word_is_refused( transcribe_audio(mp3, cfg=cfg) +@pytest.mark.usefixtures("clear_ai_env") def test_openai_whisper_inverted_segment_is_refused( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, clear_ai_env: None + tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("OPENAI_API_KEY", "sk-openai") cfg = _cfg(tmp_path, {})