Skip to content

Commit 9daa522

Browse files
authored
Merge pull request #81 from jmjava/cursor/wizard-narration-paths-2ccd
Fail closed on wizard missing sources and corrupt timing.json
2 parents e4b01e1 + 254ecc1 commit 9daa522

5 files changed

Lines changed: 162 additions & 16 deletions

File tree

‎milestones/README.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ repositories that install `docgen` and maintain their own demo bundle. The
55
library no longer ships an in-repo dogfood; consumers are the integration test
66
of record.
77

8-
**Active:** **[validate-fail-closed.md](validate-fail-closed.md)** — validate
9-
and remaining CLI skips must not pass listed segments with no narration,
10-
untimed paced specs, or dropped context files.
8+
**Active:** **[wizard-narration-paths.md](wizard-narration-paths.md)** — wizard
9+
generate-narration must not drop missing sources; timestamps must not wipe
10+
corrupt `timing.json`.
1111

1212
**Shipped:**
13+
- **[validate-fail-closed.md](validate-fail-closed.md)** — validate
14+
narration/timing skips, empty Manim lists, missing context paths.
1315
- **[scene-compile-pace.md](scene-compile-pace.md)** — paced `scene-compile`
1416
requires timing words; `yaml-generate` keeps committed Manim rows.
1517
- **[timestamps-fail-closed.md](timestamps-fail-closed.md)** — timestamps,
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Milestone — Wizard narration paths and timing.json
2+
3+
**Goal:** Wizard generate-narration must not call the LLM with empty or
4+
dropped source files, and per-segment timestamps must not wipe
5+
`timing.json` when the file is corrupt.
6+
7+
**Branch / PR:** `cursor/wizard-narration-paths-2ccd`
8+
9+
Follows **[validate-fail-closed.md](validate-fail-closed.md)** (#80).
10+
11+
## Shipped
12+
13+
- [x] `/api/generate-narration` returns 400 when a listed `source_path`
14+
is missing (same contract as hint-save)
15+
- [x] Generate mode requires at least one source file; revise may omit sources
16+
- [x] `generate_narration_via_llm` generate mode rejects empty `source_texts`
17+
- [x] Wizard timestamps step raises on invalid `timing.json` instead of
18+
replacing it with `{}` (which dropped other stems)
19+
20+
## Not this milestone
21+
22+
- GET `/api/narration` empty 200 for a new segment (authoring UI)
23+
- Archived Playwright / Embabel / slides

‎src/docgen/wizard.py‎

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,11 @@ def generate_narration_via_llm(
319319
"that satisfy the revision notes over a full rewrite."
320320
)
321321
else:
322+
if not source_texts:
323+
raise ValueError(
324+
"generate mode requires source documentation — pick files in the wizard "
325+
"or set narration_from_source.context.paths"
326+
)
322327
user_parts = [
323328
f"Write a narration script focused on: {focus}.",
324329
"Do not mention segment numbers, file stems, ordinals, or any 'segment NN' phrasing.",
@@ -558,6 +563,12 @@ def api_generate_narration():
558563
except Exception:
559564
topic_label = None
560565

566+
effective_mode = str(mode or "generate").strip().lower()
567+
if effective_mode not in ("generate", "revise"):
568+
effective_mode = "generate"
569+
if effective_mode == "generate" and current_narration.strip() and revision_notes.strip():
570+
effective_mode = "revise"
571+
561572
root = cfg.repo_root if cfg else Path.cwd()
562573
wiz = cfg.wizard_config if cfg else {}
563574

@@ -587,10 +598,19 @@ def api_generate_narration():
587598
fpath = (root / rel_s).resolve()
588599
if not _is_under_root(fpath, root):
589600
return jsonify({"error": f"path escapes repo_root: {rel_s}"}), 400
590-
if fpath.exists() and fpath.is_file():
591-
source_texts.append(
592-
f"## File: {rel_s}\n{fpath.read_text(encoding='utf-8', errors='replace')}"
601+
if not fpath.is_file():
602+
return jsonify({"error": f"file not found: {rel_s}"}), 400
603+
source_texts.append(
604+
f"## File: {rel_s}\n{fpath.read_text(encoding='utf-8', errors='replace')}"
605+
)
606+
607+
if effective_mode != "revise" and not source_texts:
608+
return jsonify({
609+
"error": (
610+
"generate mode requires source files — pick files in the wizard "
611+
"or set narration_from_source.context.paths / hint wiring"
593612
)
613+
}), 400
594614

595615
try:
596616
narration = generate_narration_via_llm(
@@ -602,7 +622,7 @@ def api_generate_narration():
602622
revision_notes=revision_notes,
603623
topic_label=topic_label,
604624
current_narration=current_narration,
605-
mode=mode,
625+
mode=effective_mode,
606626
cfg=cfg,
607627
)
608628
except ValueError as exc:
@@ -615,12 +635,6 @@ def api_generate_narration():
615635
out = narration_dir / f"{segment_name}.md"
616636
out.write_text(narration + "\n", encoding="utf-8")
617637

618-
effective_mode = str(mode or "generate").strip().lower()
619-
if effective_mode not in ("generate", "revise"):
620-
effective_mode = "generate"
621-
if effective_mode == "generate" and current_narration.strip() and revision_notes.strip():
622-
effective_mode = "revise"
623-
624638
return jsonify({
625639
"narration": narration,
626640
"path": str(out),
@@ -856,10 +870,14 @@ def _run_segment_step(cfg: Any, step: str, segment_id: str) -> dict[str, Any]:
856870
if out.is_file():
857871
try:
858872
timing = _json.loads(out.read_text(encoding="utf-8"))
859-
except _json.JSONDecodeError:
860-
timing = {}
873+
except _json.JSONDecodeError as exc:
874+
raise RuntimeError(
875+
f"{out.name} is not valid JSON; fix or delete it before timestamps"
876+
) from exc
861877
if not isinstance(timing, dict):
862-
timing = {}
878+
raise RuntimeError(
879+
f"{out.name} root must be a JSON object, not {type(timing).__name__}"
880+
)
863881
timing[mp3.stem] = block
864882
out.parent.mkdir(parents=True, exist_ok=True)
865883
out.write_text(

‎tests/test_asset_graph.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,34 @@ def test_api_run_from_cascades_mocked(tmp_path: Path, monkeypatch: pytest.Monkey
198198
assert called == ["timestamps", "scene-retime", "manim", "compose", "validate"]
199199

200200

201+
def test_api_timestamps_rejects_corrupt_timing_json(
202+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
203+
) -> None:
204+
cfg = _bundle(tmp_path)
205+
(cfg.audio_dir / "01-demo.mp3").write_bytes(b"x")
206+
(cfg.animations_dir / "timing.json").write_text("{not-json", encoding="utf-8")
207+
app = create_app(cfg)
208+
client = app.test_client()
209+
210+
from docgen import timestamps
211+
212+
monkeypatch.setattr(
213+
timestamps.TimestampExtractor,
214+
"resolve_engine",
215+
lambda self, e: "local",
216+
)
217+
monkeypatch.setattr(
218+
timestamps.TimestampExtractor,
219+
"extract_local",
220+
lambda self, mp3: {"words": [{"word": "hi", "start": 0, "end": 0.1}]},
221+
)
222+
res = client.post("/api/run/timestamps/01")
223+
assert res.status_code == 500
224+
err = res.get_json()["error"]
225+
assert "not valid JSON" in err
226+
assert (cfg.animations_dir / "timing.json").read_text(encoding="utf-8") == "{not-json"
227+
228+
201229
def test_revise_mode_includes_current_narration(monkeypatch: pytest.MonkeyPatch) -> None:
202230
captured: dict = {}
203231

‎tests/test_wizard.py‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Tests for docgen.wizard file scanning and tree building."""
22

3+
import pytest
34

45
from docgen.wizard import scan_md_files, build_file_tree
56

@@ -114,3 +115,77 @@ def test_generate_narration_rejects_escaped_source_path(tmp_path):
114115
)
115116
assert resp.status_code == 400
116117
assert "invalid path" in resp.get_json()["error"]
118+
119+
120+
def test_generate_narration_rejects_missing_source_path(tmp_path):
121+
from docgen.config import Config
122+
from docgen.wizard import create_app
123+
124+
repo = tmp_path / "proj"
125+
repo.mkdir()
126+
yaml_path = repo / "docgen.yaml"
127+
yaml_path.write_text(
128+
"repo_root: .\nsegments:\n default: ['01']\n all: ['01']\n",
129+
encoding="utf-8",
130+
)
131+
cfg = Config.from_yaml(yaml_path)
132+
client = create_app(cfg).test_client()
133+
resp = client.post(
134+
"/api/generate-narration",
135+
json={
136+
"segment_id": "01",
137+
"segment_name": "01-intro",
138+
"source_paths": ["missing.md"],
139+
"guidance": "x",
140+
},
141+
)
142+
assert resp.status_code == 400
143+
assert "file not found" in resp.get_json()["error"]
144+
145+
146+
def test_generate_narration_requires_sources_in_generate_mode(tmp_path, monkeypatch):
147+
from docgen.config import Config
148+
from docgen.wizard import create_app
149+
150+
repo = tmp_path / "proj"
151+
repo.mkdir()
152+
yaml_path = repo / "docgen.yaml"
153+
yaml_path.write_text(
154+
"repo_root: .\nsegments:\n default: ['01']\n all: ['01']\n",
155+
encoding="utf-8",
156+
)
157+
cfg = Config.from_yaml(yaml_path)
158+
called = {"n": 0}
159+
160+
def boom(**_kwargs):
161+
called["n"] += 1
162+
return "should not run"
163+
164+
monkeypatch.setattr("docgen.wizard.generate_narration_via_llm", boom)
165+
client = create_app(cfg).test_client()
166+
resp = client.post(
167+
"/api/generate-narration",
168+
json={
169+
"segment_id": "01",
170+
"segment_name": "01-intro",
171+
"mode": "generate",
172+
"source_paths": [],
173+
},
174+
)
175+
assert resp.status_code == 400
176+
assert "source" in resp.get_json()["error"]
177+
assert called["n"] == 0
178+
179+
180+
def test_generate_narration_via_llm_generate_requires_sources() -> None:
181+
from docgen.wizard import generate_narration_via_llm
182+
183+
with pytest.raises(ValueError, match="source documentation"):
184+
generate_narration_via_llm(
185+
source_texts=[],
186+
guidance="",
187+
system_prompt="x",
188+
model="gpt-4o",
189+
segment_name="01",
190+
mode="generate",
191+
)

0 commit comments

Comments
 (0)