Skip to content
Open
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
7 changes: 5 additions & 2 deletions milestones/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
49 changes: 49 additions & 0 deletions milestones/grok-stt-words-list.md
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 1 addition & 1 deletion milestones/scene-spec-layout-gaps.md
Original file line number Diff line number Diff line change
@@ -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)

Expand Down
45 changes: 32 additions & 13 deletions src/docgen/ai_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}


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