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
8 changes: 5 additions & 3 deletions milestones/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ repositories that install `docgen` and maintain their own demo bundle. The
library no longer ships an in-repo dogfood; consumers are the integration test
of record.

**Active:** **[validate-stream-probe.md](validate-stream-probe.md)** —
validate stream/drift checks must not trust ffprobe stdout when the
probe exits non-zero.
**Active:** **[grok-stt-start-end.md](grok-stt-start-end.md)** —
Grok STT word/segment `start` / `end` must be JSON numbers, not bools.

**Shipped:**
- **[validate-stream-probe.md](validate-stream-probe.md)** —
validate stream/drift checks must not trust ffprobe stdout when the
probe exits non-zero (#145).
- **[whisper-prompt-caps.md](whisper-prompt-caps.md)** —
timing-enrichment whisper count caps must not coerce bools to 1
(#144).
Expand Down
45 changes: 45 additions & 0 deletions milestones/grok-stt-start-end.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Milestone: Grok STT start/end must be JSON numbers

**Status:** Active
**PR:** [#146](https://github.com/jmjava/documentation-generator/pull/146)
**Depends on:** `milestones/validate-stream-probe.md` (PR #145),
`milestones/timing-start-end.md` (PR #134),
`milestones/tts-empty-audio.md`

## Problem

`load_bundle_timing` / Manim loaders already require JSON-number `start` /
`end` (#134). Grok STT ingest still does:

```python
"start": float(w.get("start") or 0.0),
"end": float(w.get("end") or 0.0),
```

`bool` is a subclass of `int`: `start: true` becomes **1.0**. Missing /
null `start` waits at **0.0** and dumps the board. `duration: true`
becomes **1.0** for the no-words segment fallback.

That corrupt payload is what `docgen timestamps --engine whisper`
writes into `timing.json` when `ai.provider` is grok.

## Goal

Present word / segment `start` / `end` and present `duration` must be
JSON numbers (not bool). Missing `duration` still falls back to the last
word end. Explicit `start: 0` / `duration: 0` stay 0.

## Done when

- [x] Bool / missing / string word `start` raises `AIError`
- [x] `start: 0.0` still maps to `0.0`
- [x] Present `duration: true` raises `AIError`
- [x] `ruff check src/ tests/`
- [x] `pytest tests/` (812 passed, 1 skipped)
- [x] `docgen benchmark` (no clock change; meets baseline)

## Out of scope

- OpenAI whisper-1 SDK path (typed `.start` / `.end` attributes)
- Requiring `end >= start`
- `words` / `segments` list typing beyond skipping non-object rows
2 changes: 1 addition & 1 deletion milestones/validate-stream-probe.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Milestone: validate stream/drift probes must honor ffprobe returncode

**Status:** Active
**Status:** Shipped
**PR:** [#145](https://github.com/jmjava/documentation-generator/pull/145)
**Depends on:** `milestones/whisper-prompt-caps.md` (PR #144),
`milestones/pages-ffprobe-returncode.md` (PR #143),
Expand Down
24 changes: 19 additions & 5 deletions src/docgen/ai_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -632,6 +632,16 @@ def _grok_tts(
output_path.write_bytes(body)


def _stt_json_number(value: Any, *, label: str) -> float:
"""Require a JSON number so bools/strings do not become fake timestamps."""
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise AIError(
f"xAI STT {label} must be a JSON number, not {type(value).__name__} "
f"({value!r})"
)
return float(value)


def _grok_stt(audio_path: Path, settings: AISettings) -> dict[str, Any]:
if not settings.api_key:
raise AIError(f"xAI STT needs an API key. {settings.auth_help()}")
Expand All @@ -658,12 +668,16 @@ def _grok_stt(audio_path: Path, settings: AISettings) -> dict[str, Any]:
continue
words.append(
{
"start": float(w.get("start") or 0.0),
"end": float(w.get("end") or 0.0),
"start": _stt_json_number(w.get("start"), label="words[].start"),
"end": _stt_json_number(w.get("end"), label="words[].end"),
"word": token,
}
)
duration = float(parsed.get("duration") or (words[-1]["end"] if words else 0.0))
raw_dur = parsed.get("duration")
if raw_dur is None:
duration = words[-1]["end"] if words else 0.0
else:
duration = _stt_json_number(raw_dur, label="duration")
segments = parsed.get("segments")
if not isinstance(segments, list) or not segments:
segments = (
Expand All @@ -674,8 +688,8 @@ def _grok_stt(audio_path: Path, settings: AISettings) -> dict[str, Any]:
else:
segments = [
{
"start": float(s.get("start") or 0.0),
"end": float(s.get("end") or 0.0),
"start": _stt_json_number(s.get("start"), label="segments[].start"),
"end": _stt_json_number(s.get("end"), label="segments[].end"),
"text": str(s.get("text") or ""),
}
for s in segments
Expand Down
70 changes: 70 additions & 0 deletions tests/test_ai_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,76 @@ def test_grok_stt_maps_words(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) ->
assert result["segments"][0]["text"] == "Hello world"


def test_grok_stt_zero_start_is_not_replaced(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("DOCGEN_AI_PROVIDER", "grok")
monkeypatch.setenv("XAI_API_KEY", "xai-test")
cfg = _cfg(tmp_path, {})
mp3 = tmp_path / "n.mp3"
mp3.write_bytes(b"fake-mp3")
body = json.dumps(
{
"text": "Hello",
"words": [{"text": "Hello", "start": 0.0, "end": 0.4}],
}
).encode()
with patch("docgen.ai_client._http_with_retries", return_value=body):
result = transcribe_audio(mp3, cfg=cfg)
assert result["words"][0]["start"] == 0.0
assert result["words"][0]["end"] == 0.4


def test_grok_stt_bool_start_raises(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("DOCGEN_AI_PROVIDER", "grok")
monkeypatch.setenv("XAI_API_KEY", "xai-test")
cfg = _cfg(tmp_path, {})
mp3 = tmp_path / "n.mp3"
mp3.write_bytes(b"fake-mp3")
body = json.dumps(
{
"text": "Hello",
"words": [{"text": "Hello", "start": True, "end": 0.4}],
}
).encode()
with patch("docgen.ai_client._http_with_retries", return_value=body):
with pytest.raises(AIError, match="words\\[\\]\\.start must be a JSON number"):
transcribe_audio(mp3, cfg=cfg)


def test_grok_stt_missing_start_raises(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("DOCGEN_AI_PROVIDER", "grok")
monkeypatch.setenv("XAI_API_KEY", "xai-test")
cfg = _cfg(tmp_path, {})
mp3 = tmp_path / "n.mp3"
mp3.write_bytes(b"fake-mp3")
body = json.dumps(
{
"text": "Hello",
"words": [{"text": "Hello", "end": 0.4}],
}
).encode()
with patch("docgen.ai_client._http_with_retries", return_value=body):
with pytest.raises(AIError, match="words\\[\\]\\.start must be a JSON number"):
transcribe_audio(mp3, cfg=cfg)


def test_grok_stt_bool_duration_raises(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("DOCGEN_AI_PROVIDER", "grok")
monkeypatch.setenv("XAI_API_KEY", "xai-test")
cfg = _cfg(tmp_path, {})
mp3 = tmp_path / "n.mp3"
mp3.write_bytes(b"fake-mp3")
body = json.dumps(
{
"text": "Hello",
"duration": True,
"words": [{"text": "Hello", "start": 0.0, "end": 0.4}],
}
).encode()
with patch("docgen.ai_client._http_with_retries", return_value=body):
with pytest.raises(AIError, match="duration must be a JSON number"):
transcribe_audio(mp3, cfg=cfg)


def test_unknown_provider_raises() -> None:
with pytest.raises(ValueError, match="Unknown AI provider"):
from docgen.ai_client import normalize_provider
Expand Down