Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions src/docgen/ai_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
}

Expand Down
55 changes: 55 additions & 0 deletions tests/test_ai_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading