diff --git a/milestones/README.md b/milestones/README.md index 084a0e3..99099c9 100644 --- a/milestones/README.md +++ b/milestones/README.md @@ -5,10 +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:** **[scene-spec-layout-gaps.md](scene-spec-layout-gaps.md)** — -scene-spec layout gaps must not coerce YAML bools to 1.0. +**Active:** **[grok-stt-words-list.md](grok-stt-words-list.md)** — +Grok STT `words` / `segments` must be JSON arrays of objects. **Shipped:** +- **[scene-spec-layout-gaps.md](scene-spec-layout-gaps.md)** — + scene-spec layout gaps must not coerce YAML bools to 1.0 + (#148). - **[scene-spec-bool-numerics.md](scene-spec-bool-numerics.md)** — scene-spec `wait_word` / `run_time` / sizes must not coerce YAML bools (#147). diff --git a/milestones/grok-stt-words-list.md b/milestones/grok-stt-words-list.md new file mode 100644 index 0000000..16d9b41 --- /dev/null +++ b/milestones/grok-stt-words-list.md @@ -0,0 +1,49 @@ +# Milestone: Grok STT words must be a JSON array of objects + +**Status:** Active +**PR:** [#149](https://github.com/jmjava/documentation-generator/pull/149) +**Depends on:** `milestones/scene-spec-layout-gaps.md` (PR #148), +`milestones/grok-stt-start-end.md` (PR #146), +`milestones/timing-inner-lists.md` (PR #127) + +## Problem + +PR #146 typed word `start` / `end`. The container still does: + +```python +raw_words = parsed.get("words") or [] +for w in raw_words: + if not isinstance(w, dict): + continue +``` + +A string `"hello"` iterates characters (all skipped) and writes +**empty** `words` into `timing.json`. A bool / object `words` either +raises `TypeError` uncaught or skips every row. Non-object items are +dropped, so token indices shift. + +`segments` that is not a list currently **synthesizes** a single +segment instead of failing. + +## Goal + +Present `words` / `segments` must be JSON arrays. Items must be JSON +objects. Missing / null `words` still means no word stream. Empty +`segments: []` still synthesizes from words. + +## Done when + +- [x] Non-list `words` / `segments` raise `AIError` +- [x] Non-object `words[]` / `segments[]` raise `AIError` +- [x] Missing `words` still maps an empty list +- [x] `ruff check src/ tests/` +- [x] `pytest tests/` (825 passed, 1 skipped) +- [x] `docgen benchmark` (no clock change; meets baseline) + +## Out of scope + +- OpenAI whisper-1 SDK path +- Requiring `end >= start` +- Empty-token Grok word rows (`word: ""`) are still skipped; remaining + token indices stay dense +- Issue #56 (label → `wait_word` semantic matching) diff --git a/milestones/scene-spec-layout-gaps.md b/milestones/scene-spec-layout-gaps.md index e401b36..595d116 100644 --- a/milestones/scene-spec-layout-gaps.md +++ b/milestones/scene-spec-layout-gaps.md @@ -1,6 +1,6 @@ # Milestone: scene-spec layout gaps must be YAML numbers -**Status:** Active +**Status:** Shipped **PR:** [#148](https://github.com/jmjava/documentation-generator/pull/148) **Depends on:** `milestones/scene-spec-bool-numerics.md` (PR #147) diff --git a/src/docgen/ai_client.py b/src/docgen/ai_client.py index 238a19b..eba3eb1 100644 --- a/src/docgen/ai_client.py +++ b/src/docgen/ai_client.py @@ -658,11 +658,19 @@ def _grok_stt(audio_path: Path, settings: AISettings) -> dict[str, Any]: except json.JSONDecodeError as exc: raise AIError(f"xAI STT returned non-JSON: {data[:200]!r}") from exc text = str(parsed.get("text") or "") - raw_words = parsed.get("words") or [] + raw_words = parsed.get("words") + if raw_words is None: + raw_words = [] + elif not isinstance(raw_words, list): + raise AIError( + f"xAI STT words must be a JSON array, not {type(raw_words).__name__}" + ) words: list[dict[str, Any]] = [] - for w in raw_words: + for i, w in enumerate(raw_words): if not isinstance(w, dict): - continue + raise AIError( + f"xAI STT words[{i}] must be a JSON object, not {type(w).__name__}" + ) token = str(w.get("word") or w.get("text") or "").strip() if not token: continue @@ -679,22 +687,33 @@ def _grok_stt(audio_path: Path, settings: AISettings) -> dict[str, Any]: else: duration = _stt_json_number(raw_dur, label="duration") segments = parsed.get("segments") - if not isinstance(segments, list) or not segments: + if segments is None: + segments = [] + elif not isinstance(segments, list): + raise AIError( + f"xAI STT segments must be a JSON array, not {type(segments).__name__}" + ) + if not segments: segments = ( [{"start": words[0]["start"], "end": words[-1]["end"], "text": text}] if words else [{"start": 0.0, "end": duration, "text": text}] ) else: - segments = [ - { - "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 - if isinstance(s, dict) - ] + typed_segments: list[dict[str, Any]] = [] + for i, s in enumerate(segments): + if not isinstance(s, dict): + raise AIError( + f"xAI STT segments[{i}] must be a JSON object, not {type(s).__name__}" + ) + typed_segments.append( + { + "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 ""), + } + ) + segments = typed_segments return {"text": text, "segments": segments, "words": words} diff --git a/tests/test_ai_client.py b/tests/test_ai_client.py index 00b1cc3..7569994 100644 --- a/tests/test_ai_client.py +++ b/tests/test_ai_client.py @@ -308,6 +308,66 @@ def test_grok_stt_bool_duration_raises(tmp_path: Path, monkeypatch: pytest.Monke transcribe_audio(mp3, cfg=cfg) +def test_grok_stt_string_words_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": "hello"}).encode() + with patch("docgen.ai_client._http_with_retries", return_value=body): + with pytest.raises(AIError, match="words must be a JSON array"): + transcribe_audio(mp3, cfg=cfg) + + +def test_grok_stt_non_object_word_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": 0.0, "end": 0.4}, "world"], + } + ).encode() + with patch("docgen.ai_client._http_with_retries", return_value=body): + with pytest.raises(AIError, match=r"words\[1\] must be a JSON object"): + transcribe_audio(mp3, cfg=cfg) + + +def test_grok_stt_object_segments_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": 0.0, "end": 0.4}], + "segments": {"start": 0.0, "end": 0.4, "text": "Hello"}, + } + ).encode() + with patch("docgen.ai_client._http_with_retries", return_value=body): + with pytest.raises(AIError, match="segments must be a JSON array"): + transcribe_audio(mp3, cfg=cfg) + + +def test_grok_stt_missing_words_still_ok(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": 1.2}).encode() + with patch("docgen.ai_client._http_with_retries", return_value=body): + result = transcribe_audio(mp3, cfg=cfg) + assert result["words"] == [] + assert result["segments"][0]["end"] == 1.2 + + def test_unknown_provider_raises() -> None: with pytest.raises(ValueError, match="Unknown AI provider"): from docgen.ai_client import normalize_provider