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
13 changes: 8 additions & 5 deletions milestones/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@ 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:** **[pipeline-fail-closed.md](pipeline-fail-closed.md)** — `generate-all`
and `yaml-generate` fail closed (preserve still/recording wiring; no silent
compose/validate skip).
**Active:** **[timestamps-fail-closed.md](timestamps-fail-closed.md)** — timestamps,
concat, Anthropic `base_url`, and remaining CLI/wizard silent-success paths.

**Shipped:** **[multi-host-ai-hardening.md](multi-host-ai-hardening.md)** — Cloud /
local Cursor / Claude Code keys, `--repo` clone cache, fail-closed chat.
**Shipped:**
- **[pipeline-fail-closed.md](pipeline-fail-closed.md)** — `generate-all` /
`yaml-generate` fail closed (preserve still/recording wiring; no silent
compose/validate skip).
- **[multi-host-ai-hardening.md](multi-host-ai-hardening.md)** — Cloud /
local Cursor / Claude Code keys, `--repo` clone cache, fail-closed chat.

**Not in this milestone:** archived slides / i18n / Playwright / Embabel
roadmaps under [archive/](archive/) (Playwright was removed from the product;
Expand Down
25 changes: 25 additions & 0 deletions milestones/timestamps-fail-closed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Milestone — Timestamps, concat, and remaining silent success

**Goal:** Timing, concat, Manim, compose CLI, and wizard steps must not
report success while dropping stems, muxing a partial concat, or ignoring
`ai.base_url` for Anthropic.

**Branch / PR:** `cursor/timestamps-fail-closed-2ccd`

Follows **[pipeline-fail-closed.md](pipeline-fail-closed.md)** (#77).

## Shipped

- [x] Anthropic chat posts to `{ai.base_url or DOCGEN_AI_BASE_URL}/v1/messages`
- [x] `timestamps extract_all` walks `segments.all` via `find_segment_asset`
(no `*.mp3` glob wipe); missing audio for a listed segment is an error
- [x] `concat` raises on unknown target, missing recordings, or ffmpeg failure
- [x] `ManimRunner.render` raises when `scenes.py` or the manim binary is missing
- [x] `docgen compose` exits non-zero when composed count is short
- [x] Wizard `/api/generate-narration` rejects paths that escape `repo_root`
- [x] Wizard manim / compose / validate steps fail closed

## Not this milestone

- Issue #56 residual quality (unpaced compile when `pace: none` is explicit)
- Archived Playwright / Embabel / slides
12 changes: 11 additions & 1 deletion src/docgen/ai_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,16 @@ def echo_ai_status(cfg: "Config | None" = None) -> None:
click.echo(format_ai_status_line(cfg=cfg), err=True)


def _anthropic_messages_url(settings: AISettings) -> str:
"""Chat completions URL for Anthropic or a compatible proxy."""
base = (settings.base_url or "https://api.anthropic.com").rstrip("/")
if base == "https://api.anthropic.com":
return ANTHROPIC_MESSAGES_URL
if base.endswith("/v1"):
return f"{base}/messages"
return f"{base}/v1/messages"


def _anthropic_chat(
*,
system_prompt: str,
Expand All @@ -566,7 +576,7 @@ def _anthropic_chat(
"messages": [{"role": "user", "content": user_message}],
}
raw = _http_json(
ANTHROPIC_MESSAGES_URL,
_anthropic_messages_url(settings),
payload,
settings=settings,
accept="application/json",
Expand Down
27 changes: 22 additions & 5 deletions src/docgen/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -441,7 +441,10 @@ def manim(ctx: click.Context, scene: str | None) -> None:

cfg = _require_config(ctx)
runner = ManimRunner(cfg)
runner.render(scene=scene)
try:
runner.render(scene=scene)
except RuntimeError as exc:
raise click.ClickException(str(exc)) from exc


@main.command()
Expand Down Expand Up @@ -485,7 +488,19 @@ def compose(
f"({', '.join(only_visual_types)})."
)
click.echo(f"=== Composing {len(target)} segments ===")
comp.compose_segments(target)
composed = comp.compose_segments(target)
mapped = [
sid
for sid in target
if isinstance(cfg.visual_map.get(sid), dict)
and str(cfg.visual_map[sid].get("type", "")).strip()
]
expected = len(mapped) if mapped else len(target)
if expected and composed < expected:
raise click.ClickException(
f"[compose] produced {composed}/{expected} segment videos "
"(missing audio or visuals)."
)


@main.command()
Expand Down Expand Up @@ -1254,11 +1269,13 @@ def clean_bundle(
@click.pass_context
def concat(ctx: click.Context, concat_name: str | None) -> None:
"""Concatenate full demo files from composed segments."""
from docgen.concat import ConcatBuilder
from docgen.concat import ConcatBuilder, ConcatError

cfg = _require_config(ctx)
builder = ConcatBuilder(cfg)
builder.build(name=concat_name)
try:
ConcatBuilder(cfg).build(name=concat_name)
except ConcatError as exc:
raise click.ClickException(str(exc)) from exc


@main.command()
Expand Down
47 changes: 35 additions & 12 deletions src/docgen/concat.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,37 +10,55 @@
from docgen.config import Config


class ConcatError(RuntimeError):
"""Raised when concat cannot build a complete output."""


class ConcatBuilder:
def __init__(self, config: Config) -> None:
self.config = config

def build(self, name: str | None = None) -> None:
concat_map = self.config.concat_map
if not concat_map:
if not isinstance(concat_map, dict) or not concat_map:
print("[concat] No concat map in config")
return

targets = {name: concat_map[name]} if name and name in concat_map else concat_map
if name:
if name not in concat_map:
raise ConcatError(
f"[concat] unknown target {name!r}; "
f"known: {', '.join(sorted(concat_map))}"
)
targets = {name: concat_map[name]}
else:
targets = concat_map
for out_name, seg_ids in targets.items():
self._build_one(out_name, seg_ids)

def _build_one(self, out_name: str, seg_ids: list[str]) -> None:
recordings_dir = self.config.recordings_dir
if not recordings_dir.exists():
print("[concat] Recordings dir not found")
return
raise ConcatError(f"[concat] recordings dir not found: {recordings_dir}")

files: list[Path] = []
missing: list[str] = []
for seg_id in seg_ids:
found = self.config.find_segment_asset(recordings_dir, seg_id, ".mp4")
found = self.config.find_segment_asset(recordings_dir, str(seg_id), ".mp4")
if found:
files.append(found)
else:
print(f"[concat] Missing recording for segment {seg_id}")

if not files:
print(f"[concat] No files to concatenate for {out_name}")
return
missing.append(str(seg_id))
if missing:
raise ConcatError(
"[concat] missing recording(s) for "
+ ", ".join(missing)
+ f" (needed for {out_name})"
)
if len(files) != len(seg_ids):
raise ConcatError(
f"[concat] {out_name}: expected {len(seg_ids)} files, got {len(files)}"
)

fname = out_name if out_name.endswith(".mp4") else f"{out_name}.mp4"
out = recordings_dir / fname
Expand All @@ -57,7 +75,12 @@ def _build_one(self, out_name: str, seg_ids: list[str]) -> None:
check=True, capture_output=True, text=True, timeout=300,
cwd=str(recordings_dir),
)
except Exception as exc:
print(f"[concat] Failed: {exc}")
except FileNotFoundError as exc:
raise ConcatError("[concat] ffmpeg not found in PATH") from exc
except subprocess.CalledProcessError as exc:
detail = (exc.stderr or exc.stdout or "")[:400]
raise ConcatError(f"[concat] ffmpeg failed: {detail}") from exc
except subprocess.TimeoutExpired as exc:
raise ConcatError("[concat] ffmpeg timed out") from exc
finally:
concat_list.unlink(missing_ok=True)
10 changes: 5 additions & 5 deletions src/docgen/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,10 @@ def resolve_segment_name(self, seg_id: str) -> str:
def find_segment_asset(self, directory: Path, seg_id: str, suffix: str) -> Path | None:
"""Resolve a per-segment file without substring glob collisions (``01`` vs ``101``).

Prefers ``segment_names[id]`` stem, then ``{id}{suffix}``, then ``{id}-*{suffix}``.
Does not use ``*{id}*``.
Prefers ``segment_names[id]`` stem. If the stem is the id itself, also
accepts ``{id}-*{suffix}``. Does not use ``*{id}*`` and does not treat
a bare ``{id}{suffix}`` as a match for a longer stem (orphan ``01.mp3``
must not satisfy segment ``01-intro``).
"""
if not directory.is_dir():
return None
Expand All @@ -69,9 +71,7 @@ def find_segment_asset(self, directory: Path, seg_id: str, suffix: str) -> Path
if exact.is_file():
return exact
if stem != sid:
by_id = directory / f"{sid}{ext}"
if by_id.is_file():
return by_id
return None
prefixed = sorted(p for p in directory.glob(f"{sid}-*{ext}") if p.is_file())
return prefixed[0] if prefixed else None

Expand Down
8 changes: 5 additions & 3 deletions src/docgen/manim_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,17 @@ def render(

scenes_file = self.config.animations_dir / "scenes.py"
if not scenes_file.exists():
print(f"[manim] scenes.py not found at {scenes_file}")
return
raise RuntimeError(f"[manim] scenes.py not found at {scenes_file}")

self._check_font()

quality_args, quality_label = self._quality_args()
manim_bin = self._resolve_manim_binary()
if not manim_bin:
return
raise RuntimeError(
"[manim] manim executable not found. "
"Install with `pip install manim` or set `manim.manim_path` in docgen.yaml."
)

font = self.config.manim_font
print(f"[manim] Rendering at {quality_label}, font={font}")
Expand Down
46 changes: 33 additions & 13 deletions src/docgen/timestamps.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
ENGINES = ("local", "whisper")


class TimestampError(RuntimeError):
"""Raised when timestamps cannot be extracted for required segments."""


class TimestampExtractor:
def __init__(self, config: Config) -> None:
self.config = config
Expand Down Expand Up @@ -77,12 +81,12 @@ def resolve_engine(self, engine: str | None = None) -> str:
return chosen

def extract_all(self, engine: str | None = None) -> None:
"""Extract timestamps for all segments and write timing.json."""
audio_dir = self.config.audio_dir
if not audio_dir.exists():
print("[timestamps] No audio directory found")
return
"""Extract timestamps for ``segments.all`` and write timing.json.

Walks configured segment ids via :meth:`Config.find_segment_asset` (no
``*.mp3`` glob). Missing audio for a listed segment is an error. With
no ``segments.all`` entries, existing ``timing.json`` is left unchanged.
"""
chosen = self.resolve_engine(engine)
print(f"[timestamps] engine: {chosen}")
if chosen == "whisper":
Expand All @@ -96,19 +100,35 @@ def extract_all(self, engine: str | None = None) -> None:
f"(offline) or OpenAI/Grok. {st.auth_help()}"
)

mp3s = sorted(audio_dir.glob("*.mp3"))
if not mp3s:
print("[timestamps] No audio/*.mp3 files found; leaving timing.json unchanged")
seg_ids = [str(s) for s in self.config.segments_all]
if not seg_ids:
print("[timestamps] segments.all is empty; leaving timing.json unchanged")
return

missing: list[str] = []
jobs: list[tuple[str, Path]] = []
for sid in seg_ids:
mp3 = self.config.find_segment_asset(self.config.audio_dir, sid, ".mp3")
if mp3 is None:
stem = self.config.resolve_segment_name(sid)
missing.append(f"{sid} ({stem}.mp3)")
else:
jobs.append((sid, mp3))
if missing:
raise TimestampError(
"[timestamps] missing audio for segment(s): "
+ ", ".join(missing)
+ ". Run `docgen tts` first."
)

timing: dict[str, Any] = {}
for mp3 in mp3s:
seg_id = mp3.stem
print(f"[timestamps] Extracting timestamps for {seg_id}")
for sid, mp3 in jobs:
key = mp3.stem
print(f"[timestamps] Extracting timestamps for {sid} ({key})")
if chosen == "whisper":
timing[seg_id] = self.extract(mp3)
timing[key] = self.extract(mp3)
else:
timing[seg_id] = self.extract_local(mp3)
timing[key] = self.extract_local(mp3)

out = self.config.animations_dir / "timing.json"
out.parent.mkdir(parents=True, exist_ok=True)
Expand Down
41 changes: 35 additions & 6 deletions src/docgen/wizard.py
Original file line number Diff line number Diff line change
Expand Up @@ -581,10 +581,15 @@ def api_generate_narration():

source_texts = []
for rel in source_paths:
fpath = root / rel
rel_s = str(rel).strip().replace("\\", "/")
if not rel_s or rel_s.startswith("/") or ".." in rel_s.split("/"):
return jsonify({"error": f"invalid path: {rel!r}"}), 400
fpath = (root / rel_s).resolve()
if not _is_under_root(fpath, root):
return jsonify({"error": f"path escapes repo_root: {rel_s}"}), 400
if fpath.exists() and fpath.is_file():
source_texts.append(
f"## File: {rel}\n{fpath.read_text(encoding='utf-8', errors='replace')}"
f"## File: {rel_s}\n{fpath.read_text(encoding='utf-8', errors='replace')}"
)

try:
Expand Down Expand Up @@ -913,21 +918,45 @@ def _run_segment_step(cfg: Any, step: str, segment_id: str) -> dict[str, Any]:

runner = ManimRunner(cfg)
vmap = cfg.visual_map.get(segment_id, {})
scene = vmap.get("scene")
if scene:
runner.render(scene=scene)
scene = None
if isinstance(vmap, dict):
scene = vmap.get("scene") or vmap.get("class")
if not scene:
raise RuntimeError(
f"no manim scene for segment {segment_id} "
"(set visual_map scene/class)"
)
runner.render(scene=str(scene))
return {"ok": True, "step": "manim", "segment": segment_id}

if step == "compose":
from docgen.compose import Composer

Composer(cfg).compose_segments([segment_id])
n = Composer(cfg).compose_segments([segment_id])
if n < 1:
raise RuntimeError(
f"compose produced no video for segment {segment_id}"
)
return {"ok": True, "step": "compose", "segment": segment_id}

if step == "validate":
from docgen.validate import Validator

report = Validator(cfg).validate_segment(segment_id)
passed = True
if isinstance(report, dict):
if "passed" in report:
passed = bool(report["passed"])
else:
checks = report.get("checks") or []
passed = all(
(not isinstance(c, dict)) or c.get("passed", True)
for c in checks
)
if not passed:
raise RuntimeError(
f"validate failed for segment {segment_id}"
)
return {
"ok": True,
"step": "validate",
Expand Down
Loading