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..6049ec2 100644 --- a/tests/test_ai_client.py +++ b/tests/test_ai_client.py @@ -479,6 +479,61 @@ 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 +) -> 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) + + +@pytest.mark.usefixtures("clear_ai_env") +def test_openai_whisper_empty_word_is_refused( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> 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) + + +@pytest.mark.usefixtures("clear_ai_env") +def test_openai_whisper_inverted_segment_is_refused( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> 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: