Transkript-Editor Phase 2 Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Extend the V1 transcript editor with Speaker-Re-Labelling, Merge/Split-Segments, Word-Level-Edits, Diff-View, and an Undo-Stack.
Architecture: Four new pure-Python modules (transcript_segment_ops.py, transcript_word_ops.py, transcript_history.py, transcript_diff.py) extend the existing transcript_editor.py. The WebGUI grows by ~8 routes (HTMX endpoints returning segment partials) and 3 templates. Every mutating route triggers snapshot() first, so all changes are undoable. SRT/TXT siblings are regenerated on every save; meta+render phases are invalidated downstream.
Tech Stack: Python 3.12, FastAPI/Starlette 1.0+, Jinja2, HTMX, pytest, uv-managed venv at .venv/, difflib (stdlib) for diff-view.
File Structure¶
Create:
- transcript_segment_ops.py — merge_segment, split_segment, change_speaker, bulk_rename_speaker
- transcript_word_ops.py — load_words_flat, save_word_edits
- transcript_history.py — snapshot, undo_last, list_history, cleanup_snapshots, SNAPSHOT_CAP = 20
- transcript_diff.py — compute_segment_diff (word-level via difflib.SequenceMatcher)
- webgui/templates/run_edit_words.html — per-word editor
- webgui/templates/run_diff.html — diff view
- webgui/templates/_partials/segment_editor.html — HTMX target for re-renders
- webgui/templates/_partials/history_dropdown.html — undo + history list
- webgui/templates/_partials/speaker_bulk_form.html — bulk-rename form
- tests/test_transcript_segment_ops.py (~13 tests)
- tests/test_transcript_word_ops.py (~6)
- tests/test_transcript_history.py (~8)
- tests/test_transcript_diff.py (~5)
- tests/test_webgui_phase2_routes.py (~12)
- tests/fixtures/sample-transcript-edited.whisperx.json — fixture for diff tests (has _edited, _speaker_edited, _merged_from, history entries)
Modify:
- webgui/app.py — add imports + 8 new routes
- webgui/templates/run_edit.html — add top-bar (Diff/Words/Undo links), bulk-rename form, replace segment-block markup with {% include "_partials/segment_editor.html" %}
- AGENTS.md — document Phase 2 surface area
Do not modify:
- transcript_editor.py (V1 functions stay; new code goes in new modules)
- pipeline.py, transcribe.py, anything in the pipeline-execution layer
Conventions Reminder¶
- TemplateResponse: Starlette 1.0+ signature →
templates.TemplateResponse(request, name, context)(request as first positional) - Tests:
VIRTUAL_ENV=$PWD/.venv .venv/bin/pytest tests/ -v - Encoding: UTF-8, JSON with
ensure_ascii=False - Commits: Conventional (
feat(transcript-editor-phase2): …), Co-Authored-By footer - Style: Module docstring at top; type hints throughout
Setup¶
- [ ] Step 0.1: Verify baseline
Expected: 97 passed. If lower, stop and report.
- [ ] Step 0.2: Ensure feature branch
Expected: clean working tree, on feat/transcript-editor-phase2 branch. If not, switch:
Task 1: change_speaker¶
Files:
- Create: transcript_segment_ops.py
- Create: tests/test_transcript_segment_ops.py
- [ ] Step 1.1: Write failing test
Create tests/test_transcript_segment_ops.py:
"""Tests for transcript_segment_ops — merge, split, speaker change, bulk rename."""
import json
import shutil
from pathlib import Path
import pytest
@pytest.fixture
def sample_run(tmp_path, fixtures_dir):
"""Copy sample-transcript.whisperx.json into a fresh output dir."""
stem = "ep01"
run_dir = tmp_path / stem
run_dir.mkdir()
src = fixtures_dir / "sample-transcript.whisperx.json"
dst = run_dir / f"{stem}.whisperx.json"
shutil.copy(src, dst)
return {"dir": run_dir, "stem": stem, "json_path": dst}
def test_change_speaker_updates_segment(sample_run):
from transcript_segment_ops import change_speaker
change_speaker(str(sample_run["json_path"]), 0, "Anna")
data = json.loads(sample_run["json_path"].read_text(encoding="utf-8"))
assert data["segments"][0]["speaker"] == "Anna"
assert data["segments"][0]["_speaker_edited"] is True
def test_change_speaker_noop_when_same(sample_run):
from transcript_segment_ops import change_speaker
original = json.loads(sample_run["json_path"].read_text(encoding="utf-8"))
original_speaker = original["segments"][1]["speaker"]
change_speaker(str(sample_run["json_path"]), 1, original_speaker)
data = json.loads(sample_run["json_path"].read_text(encoding="utf-8"))
# _speaker_edited should NOT be set when value is unchanged
assert data["segments"][1].get("_speaker_edited", False) is False
def test_change_speaker_raises_on_empty(sample_run):
from transcript_segment_ops import change_speaker
with pytest.raises(ValueError, match="speaker"):
change_speaker(str(sample_run["json_path"]), 0, "")
- [ ] Step 1.2: Run, verify fail
Expected: 3 FAIL (ImportError).
- [ ] Step 1.3: Implement
Create transcript_segment_ops.py:
"""Segment-level operations: merge, split, change_speaker, bulk_rename_speaker.
All operations work on the WhisperX JSON in-place and regenerate SRT/TXT
siblings via transcript_editor.regenerate_srt_txt.
"""
import json
from pathlib import Path
from transcript_editor import regenerate_srt_txt
def _load(json_path: str) -> dict:
return json.loads(Path(json_path).read_text(encoding="utf-8"))
def _save(json_path: str, data: dict) -> None:
Path(json_path).write_text(
json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8"
)
def change_speaker(json_path: str, segment_index: int, new_speaker: str) -> None:
"""Change segment speaker; sets _speaker_edited flag if value changed.
Raises ValueError on empty new_speaker or out-of-range index.
"""
if not new_speaker or not new_speaker.strip():
raise ValueError("speaker name must be non-empty")
if len(new_speaker) > 64:
raise ValueError("speaker name too long (max 64 chars)")
data = _load(json_path)
segments = data.get("segments", [])
if segment_index < 0 or segment_index >= len(segments):
raise ValueError(f"segment_index {segment_index} out of range")
seg = segments[segment_index]
if seg.get("speaker") == new_speaker:
return # no-op
seg["speaker"] = new_speaker
seg["_speaker_edited"] = True
_save(json_path, data)
regenerate_srt_txt(json_path)
- [ ] Step 1.4: Verify pass
Expected: 3 PASS.
- [ ] Step 1.5: Commit
git add transcript_segment_ops.py tests/test_transcript_segment_ops.py
git commit -m "$(cat <<'EOF'
feat(transcript-editor-phase2): add change_speaker — per-segment + flag
Sets _speaker_edited only when the value actually changes. Empty names
and >64 chars raise ValueError. SRT/TXT regenerated after change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 2: bulk_rename_speaker¶
Files:
- Modify: transcript_segment_ops.py
- Modify: tests/test_transcript_segment_ops.py
- [ ] Step 2.1: Write failing tests
Append:
def test_bulk_rename_speaker_renames_all_matching(sample_run):
from transcript_segment_ops import bulk_rename_speaker
# Fixture has SPEAKER_00 (segs 0, 2) and SPEAKER_01 (seg 1)
count = bulk_rename_speaker(str(sample_run["json_path"]), "SPEAKER_00", "Anna")
assert count == 2
data = json.loads(sample_run["json_path"].read_text(encoding="utf-8"))
assert data["segments"][0]["speaker"] == "Anna"
assert data["segments"][1]["speaker"] == "SPEAKER_01"
assert data["segments"][2]["speaker"] == "Anna"
def test_bulk_rename_speaker_does_not_set_flag(sample_run):
from transcript_segment_ops import bulk_rename_speaker
bulk_rename_speaker(str(sample_run["json_path"]), "SPEAKER_00", "Anna")
data = json.loads(sample_run["json_path"].read_text(encoding="utf-8"))
# Bulk-rename is a display change, not a diarization fix → no _speaker_edited
for seg in data["segments"]:
if seg["speaker"] == "Anna":
assert seg.get("_speaker_edited", False) is False
def test_bulk_rename_speaker_raises_on_same_names(sample_run):
from transcript_segment_ops import bulk_rename_speaker
with pytest.raises(ValueError, match="differ"):
bulk_rename_speaker(str(sample_run["json_path"]), "SPEAKER_00", "SPEAKER_00")
def test_bulk_rename_speaker_returns_zero_when_no_match(sample_run):
from transcript_segment_ops import bulk_rename_speaker
count = bulk_rename_speaker(str(sample_run["json_path"]), "SPEAKER_99", "Whoever")
assert count == 0
- [ ] Step 2.2: Verify fail
Expected: 4 FAIL.
- [ ] Step 2.3: Implement
Append to transcript_segment_ops.py:
def bulk_rename_speaker(json_path: str, old_name: str, new_name: str) -> int:
"""Rename every segment whose speaker == old_name to new_name.
Does NOT set _speaker_edited (this is a display rename, not a diarization fix).
Returns the count of renamed segments.
"""
if not old_name or not new_name:
raise ValueError("both names must be non-empty")
if old_name == new_name:
raise ValueError("old and new names must differ")
if len(new_name) > 64:
raise ValueError("new name too long (max 64 chars)")
data = _load(json_path)
count = 0
for seg in data.get("segments", []):
if seg.get("speaker") == old_name:
seg["speaker"] = new_name
count += 1
if count > 0:
_save(json_path, data)
regenerate_srt_txt(json_path)
return count
- [ ] Step 2.4: Verify pass
Expected: 7 PASS.
- [ ] Step 2.5: Commit
git add transcript_segment_ops.py tests/test_transcript_segment_ops.py
git commit -m "$(cat <<'EOF'
feat(transcript-editor-phase2): add bulk_rename_speaker
Renames all segments matching old_name → new_name. Returns count.
Intentionally does NOT set _speaker_edited (display-rename only,
not a diarization correction).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 3: merge_segment¶
Files:
- Modify: transcript_segment_ops.py
- Modify: tests/test_transcript_segment_ops.py
- [ ] Step 3.1: Write failing tests
Append:
def test_merge_segment_combines_text_and_words(sample_run):
from transcript_segment_ops import merge_segment
original = json.loads(sample_run["json_path"].read_text(encoding="utf-8"))
original_text_0 = original["segments"][0]["text"]
original_text_1 = original["segments"][1]["text"]
words_0_len = len(original["segments"][0]["words"])
words_1_len = len(original["segments"][1]["words"])
merge_segment(str(sample_run["json_path"]), 0)
data = json.loads(sample_run["json_path"].read_text(encoding="utf-8"))
assert len(data["segments"]) == 2 # was 3
assert data["segments"][0]["text"] == original_text_0 + " " + original_text_1
assert len(data["segments"][0]["words"]) == words_0_len + words_1_len
def test_merge_segment_extends_end_time(sample_run):
from transcript_segment_ops import merge_segment
original = json.loads(sample_run["json_path"].read_text(encoding="utf-8"))
original_start_0 = original["segments"][0]["start"]
original_end_1 = original["segments"][1]["end"]
merge_segment(str(sample_run["json_path"]), 0)
data = json.loads(sample_run["json_path"].read_text(encoding="utf-8"))
assert data["segments"][0]["start"] == original_start_0
assert data["segments"][0]["end"] == original_end_1
def test_merge_segment_sets_merged_from_flag(sample_run):
from transcript_segment_ops import merge_segment
merge_segment(str(sample_run["json_path"]), 0)
data = json.loads(sample_run["json_path"].read_text(encoding="utf-8"))
assert data["segments"][0]["_merged_from"] == [0, 1]
def test_merge_segment_raises_when_no_next(sample_run):
from transcript_segment_ops import merge_segment
# last segment (index 2) has no successor
with pytest.raises(ValueError, match="no next"):
merge_segment(str(sample_run["json_path"]), 2)
- [ ] Step 3.2: Verify fail
Expected: 4 FAIL.
- [ ] Step 3.3: Implement
Append to transcript_segment_ops.py:
def merge_segment(json_path: str, segment_index: int) -> None:
"""Merge segments[index] with segments[index+1] in-place.
The combined text uses single-space join. words are concatenated.
Resulting segment has start=current.start, end=next.end,
speaker=current.speaker. Sets _merged_from = [index, index+1].
Clears _split_from (mutually exclusive with merged_from).
Raises ValueError if index has no successor.
"""
data = _load(json_path)
segments = data.get("segments", [])
if segment_index < 0 or segment_index >= len(segments) - 1:
raise ValueError(f"segment {segment_index}: no next segment to merge with")
curr = segments[segment_index]
nxt = segments[segment_index + 1]
merged = {
"start": curr["start"],
"end": nxt["end"],
"text": curr["text"] + " " + nxt["text"],
"speaker": curr.get("speaker", "SPEAKER_00"),
"words": curr.get("words", []) + nxt.get("words", []),
"_merged_from": [segment_index, segment_index + 1],
}
# Carry over edit flags from curr (preserves "user touched this" history)
for flag in ("_edited", "_speaker_edited"):
if curr.get(flag):
merged[flag] = True
segments[segment_index:segment_index + 2] = [merged]
_save(json_path, data)
regenerate_srt_txt(json_path)
- [ ] Step 3.4: Verify pass
Expected: 11 PASS.
- [ ] Step 3.5: Commit
git add transcript_segment_ops.py tests/test_transcript_segment_ops.py
git commit -m "$(cat <<'EOF'
feat(transcript-editor-phase2): add merge_segment
Combines segments[i] and segments[i+1]. Single-space text join, word
list concatenation, time extension to next.end. _merged_from is a
list of the two original indices (pre-merge). Carries over _edited
and _speaker_edited from the current segment.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 4: split_segment¶
Files:
- Modify: transcript_segment_ops.py
- Modify: tests/test_transcript_segment_ops.py
- [ ] Step 4.1: Write failing tests
Append:
def test_split_segment_creates_two_segments(sample_run):
from transcript_segment_ops import split_segment
# Segment 1 text: "Let's dive into this autopoiesis thing."
# Split at position 16 (after "Let's dive into ")
split_segment(str(sample_run["json_path"]), 1, 16)
data = json.loads(sample_run["json_path"].read_text(encoding="utf-8"))
assert len(data["segments"]) == 4 # was 3
assert data["segments"][1]["text"].strip() == "Let's dive into"
assert data["segments"][2]["text"].strip() == "this autopoiesis thing."
def test_split_segment_interpolates_times(sample_run):
from transcript_segment_ops import split_segment
original = json.loads(sample_run["json_path"].read_text(encoding="utf-8"))
seg_1 = original["segments"][1]
original_start = seg_1["start"]
original_end = seg_1["end"]
# Split at char position equal to half the text length (no words to refine to)
text_len = len(seg_1["text"])
split_segment(str(sample_run["json_path"]), 1, text_len // 2)
data = json.loads(sample_run["json_path"].read_text(encoding="utf-8"))
# The two new segments' boundary should be somewhere between original start and end
assert data["segments"][1]["start"] == original_start
assert data["segments"][2]["end"] == original_end
assert data["segments"][1]["end"] == data["segments"][2]["start"]
assert original_start < data["segments"][1]["end"] < original_end
def test_split_segment_sets_split_from_flag(sample_run):
from transcript_segment_ops import split_segment
split_segment(str(sample_run["json_path"]), 1, 16)
data = json.loads(sample_run["json_path"].read_text(encoding="utf-8"))
assert data["segments"][1]["_split_from"] == 1
assert data["segments"][2]["_split_from"] == 1
def test_split_segment_raises_at_text_boundary_zero(sample_run):
from transcript_segment_ops import split_segment
with pytest.raises(ValueError, match="position"):
split_segment(str(sample_run["json_path"]), 1, 0)
def test_split_segment_raises_at_text_boundary_end(sample_run):
from transcript_segment_ops import split_segment
data = json.loads(sample_run["json_path"].read_text(encoding="utf-8"))
text_len = len(data["segments"][1]["text"])
with pytest.raises(ValueError, match="position"):
split_segment(str(sample_run["json_path"]), 1, text_len)
def test_split_segment_splits_words_by_time(sample_run):
from transcript_segment_ops import split_segment
# Segment 0 has words [All(0.04-0.1), right.(0.12-0.24)]
# Split at position 4 (after "All ")
split_segment(str(sample_run["json_path"]), 0, 4)
data = json.loads(sample_run["json_path"].read_text(encoding="utf-8"))
# First new segment should have only the first word
assert len(data["segments"][0]["words"]) == 1
assert data["segments"][0]["words"][0]["word"] == "All"
# Second new segment should have the second word
assert len(data["segments"][1]["words"]) == 1
assert data["segments"][1]["words"][0]["word"] == "right."
- [ ] Step 4.2: Verify fail
Expected: 6 FAIL.
- [ ] Step 4.3: Implement
Append to transcript_segment_ops.py:
def split_segment(json_path: str, segment_index: int, char_position: int) -> None:
"""Split segments[index] into two at char_position.
text_left = text[:char_position].rstrip()
text_right = text[char_position:].lstrip()
Both must be non-empty → ValueError otherwise.
Time split: linear interpolation by char_position / len(text).
If words list is present, refine split-time to the start of the first
word whose start >= interpolated time (avoids splitting mid-word).
Both new segments inherit _split_from = original_index.
"""
data = _load(json_path)
segments = data.get("segments", [])
if segment_index < 0 or segment_index >= len(segments):
raise ValueError(f"segment_index {segment_index} out of range")
seg = segments[segment_index]
text = seg["text"]
if char_position <= 0 or char_position >= len(text):
raise ValueError(
f"char_position {char_position} must be between 1 and {len(text) - 1}"
)
text_left = text[:char_position].rstrip()
text_right = text[char_position:].lstrip()
if not text_left or not text_right:
raise ValueError(
"split position would create empty segment after whitespace trim"
)
start = seg["start"]
end = seg["end"]
# Linear interpolation by char-position ratio
split_time = start + (end - start) * (char_position / len(text))
# Word-aware refinement if words exist
words = seg.get("words", [])
if words:
# First word whose start >= split_time becomes the right segment's first word
for w in words:
if w.get("start", start) >= split_time:
split_time = w["start"]
break
words_left = [w for w in words if w.get("start", start) < split_time]
words_right = [w for w in words if w.get("start", start) >= split_time]
left = {
"start": start, "end": split_time, "text": text_left,
"speaker": seg.get("speaker", "SPEAKER_00"),
"words": words_left,
"_split_from": segment_index,
}
right = {
"start": split_time, "end": end, "text": text_right,
"speaker": seg.get("speaker", "SPEAKER_00"),
"words": words_right,
"_split_from": segment_index,
}
# Carry edit flags onto the left half (preserves "I edited this" history)
for flag in ("_edited", "_speaker_edited"):
if seg.get(flag):
left[flag] = True
segments[segment_index:segment_index + 1] = [left, right]
_save(json_path, data)
regenerate_srt_txt(json_path)
- [ ] Step 4.4: Verify pass
Expected: 17 PASS.
- [ ] Step 4.5: Sanity-check existing tests
Expected: 114 PASS (97 existing + 17 new).
- [ ] Step 4.6: Commit
git add transcript_segment_ops.py tests/test_transcript_segment_ops.py
git commit -m "$(cat <<'EOF'
feat(transcript-editor-phase2): add split_segment
Splits a segment at a char position. Time is linearly interpolated and
refined to a word boundary when words[] is present. Both halves get
_split_from = original_index. Empty halves after whitespace trim raise
ValueError.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 5: snapshot + cleanup_snapshots¶
Files:
- Create: transcript_history.py
- Create: tests/test_transcript_history.py
- [ ] Step 5.1: Write failing tests
Create tests/test_transcript_history.py:
"""Tests for transcript_history — snapshot, undo_last, cleanup."""
import json
import shutil
import time
from pathlib import Path
import pytest
@pytest.fixture
def sample_run(tmp_path, fixtures_dir):
stem = "ep01"
run_dir = tmp_path / stem
run_dir.mkdir()
src = fixtures_dir / "sample-transcript.whisperx.json"
dst = run_dir / f"{stem}.whisperx.json"
shutil.copy(src, dst)
return {"dir": run_dir, "stem": stem, "json_path": dst}
def test_snapshot_creates_file(sample_run):
from transcript_history import snapshot
snap_path = snapshot(str(sample_run["json_path"]), action="edit_text", metric="1 segment edited")
assert Path(snap_path).exists()
# File should be a sibling under snapshots/
assert "snapshots" in str(snap_path)
def test_snapshot_content_matches_pre_mutation(sample_run):
from transcript_history import snapshot
pre_content = sample_run["json_path"].read_text(encoding="utf-8")
snap_path = snapshot(str(sample_run["json_path"]), action="edit_text", metric="x")
# Snapshot file must contain the PRE-mutation state (caller hasn't mutated yet)
assert Path(snap_path).read_text(encoding="utf-8") == pre_content
def test_snapshot_appends_history_entry(sample_run):
from transcript_history import snapshot
snapshot(str(sample_run["json_path"]), action="edit_text", metric="1 segment edited")
data = json.loads(sample_run["json_path"].read_text(encoding="utf-8"))
history = data.get("_history", [])
assert len(history) == 1
assert history[0]["action"] == "edit_text"
assert history[0]["metric"] == "1 segment edited"
assert "ts" in history[0]
assert "snapshot" in history[0]
def test_snapshot_history_path_is_relative(sample_run):
from transcript_history import snapshot
snap_path = snapshot(str(sample_run["json_path"]), action="merge", metric="0+1")
data = json.loads(sample_run["json_path"].read_text(encoding="utf-8"))
# Stored path should be relative to output dir: "snapshots/<ts>.json"
rel = data["_history"][-1]["snapshot"]
assert rel.startswith("snapshots/")
assert rel.endswith(".json")
def test_cleanup_snapshots_keeps_cap(sample_run, monkeypatch):
from transcript_history import snapshot, cleanup_snapshots, SNAPSHOT_CAP
# Force a small cap for the test
monkeypatch.setattr("transcript_history.SNAPSHOT_CAP", 3)
for i in range(5):
snapshot(str(sample_run["json_path"]), action="x", metric=f"{i}")
time.sleep(0.01) # ensure ts ordering
cleanup_snapshots(str(sample_run["json_path"]))
data = json.loads(sample_run["json_path"].read_text(encoding="utf-8"))
# Only 3 history entries should remain
assert len(data["_history"]) == 3
# Corresponding snapshot files: also 3
snap_dir = sample_run["dir"] / "snapshots"
assert len(list(snap_dir.glob("*.json"))) == 3
- [ ] Step 5.2: Verify fail
Expected: 5 FAIL.
- [ ] Step 5.3: Implement
Create transcript_history.py:
"""Snapshots + undo stack for transcript edits.
Each mutating operation calls snapshot() BEFORE the mutation, capturing
the pre-mutation JSON. _history[] in the main JSON references each
snapshot. cleanup_snapshots enforces SNAPSHOT_CAP.
"""
import json
import shutil
import time
from datetime import datetime, timezone
from pathlib import Path
SNAPSHOT_CAP = 20
def _now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _load(json_path: str) -> dict:
return json.loads(Path(json_path).read_text(encoding="utf-8"))
def _save(json_path: str, data: dict) -> None:
Path(json_path).write_text(
json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8"
)
def snapshot(json_path: str, action: str, metric: str) -> str:
"""Save a pre-mutation snapshot and append a _history entry.
Returns absolute path to the snapshot file.
Snapshot is written BEFORE the caller mutates json_path, so it reflects
the state to revert TO if undone.
"""
path = Path(json_path)
snap_dir = path.parent / "snapshots"
snap_dir.mkdir(exist_ok=True)
# Use nanosecond-precision timestamp so rapid successive snapshots don't collide
ts = int(time.time() * 1000)
snap_file = snap_dir / f"{ts}.json"
# Ensure unique filename if same ms-tick
while snap_file.exists():
ts += 1
snap_file = snap_dir / f"{ts}.json"
shutil.copy2(path, snap_file)
data = _load(json_path)
data.setdefault("_history", []).append({
"ts": _now_iso(),
"action": action,
"metric": metric,
"snapshot": f"snapshots/{snap_file.name}",
})
_save(json_path, data)
return str(snap_file)
def cleanup_snapshots(json_path: str) -> int:
"""Delete oldest snapshots beyond SNAPSHOT_CAP. Returns count deleted."""
data = _load(json_path)
history = data.get("_history", [])
if len(history) <= SNAPSHOT_CAP:
return 0
excess = len(history) - SNAPSHOT_CAP
to_remove = history[:excess]
parent = Path(json_path).parent
deleted = 0
for entry in to_remove:
snap_rel = entry.get("snapshot", "")
if snap_rel:
snap_path = parent / snap_rel
if snap_path.exists():
snap_path.unlink()
deleted += 1
data["_history"] = history[excess:]
_save(json_path, data)
return deleted
- [ ] Step 5.4: Verify pass
Expected: 5 PASS.
- [ ] Step 5.5: Commit
git add transcript_history.py tests/test_transcript_history.py
git commit -m "$(cat <<'EOF'
feat(transcript-editor-phase2): add snapshot + cleanup_snapshots
Each snapshot copies the pre-mutation JSON to output/{stem}/snapshots/
and appends a _history entry with action, metric, ts, snapshot path.
cleanup_snapshots enforces SNAPSHOT_CAP=20 by deleting oldest first.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 6: undo_last + list_history¶
Files:
- Modify: transcript_history.py
- Modify: tests/test_transcript_history.py
- [ ] Step 6.1: Write failing tests
Append:
def test_undo_last_restores_pre_mutation_state(sample_run):
from transcript_history import snapshot, undo_last
pre_content = sample_run["json_path"].read_text(encoding="utf-8")
snapshot(str(sample_run["json_path"]), action="edit_text", metric="1 changed")
# Simulate a mutation: alter segment 0's text
data = json.loads(sample_run["json_path"].read_text(encoding="utf-8"))
data["segments"][0]["text"] = "CHANGED."
sample_run["json_path"].write_text(json.dumps(data, indent=2), encoding="utf-8")
entry = undo_last(str(sample_run["json_path"]))
assert entry is not None
assert entry["action"] == "edit_text"
# Content should match pre-snapshot state
restored = json.loads(sample_run["json_path"].read_text(encoding="utf-8"))
assert restored["segments"][0]["text"] != "CHANGED."
def test_undo_last_deletes_snapshot_file(sample_run):
from transcript_history import snapshot, undo_last
snap_path = snapshot(str(sample_run["json_path"]), action="x", metric="y")
assert Path(snap_path).exists()
undo_last(str(sample_run["json_path"]))
assert not Path(snap_path).exists()
def test_undo_last_returns_none_when_empty(sample_run):
from transcript_history import undo_last
entry = undo_last(str(sample_run["json_path"]))
assert entry is None
def test_list_history_returns_entries_newest_last(sample_run):
from transcript_history import snapshot, list_history
snapshot(str(sample_run["json_path"]), action="a", metric="1")
time.sleep(0.01)
snapshot(str(sample_run["json_path"]), action="b", metric="2")
entries = list_history(str(sample_run["json_path"]))
assert len(entries) == 2
assert entries[0]["action"] == "a"
assert entries[1]["action"] == "b"
- [ ] Step 6.2: Verify fail
Expected: 4 FAIL.
- [ ] Step 6.3: Implement
Append to transcript_history.py:
def undo_last(json_path: str) -> dict | None:
"""Restore the latest snapshot and pop the matching _history entry.
Returns the popped history entry, or None if _history is empty.
Snapshot file is deleted after restoration.
"""
data = _load(json_path)
history = data.get("_history", [])
if not history:
return None
entry = history[-1]
parent = Path(json_path).parent
snap_rel = entry.get("snapshot", "")
if not snap_rel:
# Defensive: history without snapshot can't be undone — pop anyway
history.pop()
_save(json_path, data)
return entry
snap_path = parent / snap_rel
if snap_path.exists():
# Restore: copy snapshot content over current file
shutil.copy2(snap_path, json_path)
snap_path.unlink()
# Reload (file was just overwritten by snapshot content) and pop history
data = _load(json_path)
if data.get("_history"):
data["_history"].pop()
_save(json_path, data)
return entry
def list_history(json_path: str) -> list[dict]:
"""Return _history entries (oldest first). Empty list if absent."""
if not Path(json_path).exists():
return []
data = _load(json_path)
return list(data.get("_history", []))
- [ ] Step 6.4: Verify pass
Expected: 9 PASS.
Note: After undo_last restores from snapshot, the snapshot file's _history is the same as what was just popped — we must not double-pop. The implementation above reloads then pops. Verify that test 1 still passes (it should — the snapshot reflects the state BEFORE the snapshot+_history append, so the popped state has no history entry to pop).
Actually re-read: snapshot() writes the snapshot file with copy2(path, snap_file) BEFORE appending to _history. So the snapshot file content lacks the new _history entry. After undo restores from snapshot, _history doesn't include the just-popped entry. So data["_history"].pop() on the restored content would pop an EARLIER entry — wrong!
Fix the implementation: after restoring snapshot content, do NOT pop again. The snapshot file already reflects the pre-edit state which lacks the corresponding history entry.
Replace undo_last body with:
def undo_last(json_path: str) -> dict | None:
"""Restore the latest snapshot and pop the matching _history entry.
Returns the popped history entry, or None if _history is empty.
Snapshot file is deleted after restoration.
Note on history bookkeeping: snapshot() writes the file BEFORE appending
to _history. So the snapshot content lacks the just-appended history
entry. After restoring from snapshot, no further pop is needed.
"""
data = _load(json_path)
history = data.get("_history", [])
if not history:
return None
entry = history[-1]
parent = Path(json_path).parent
snap_rel = entry.get("snapshot", "")
if not snap_rel:
history.pop()
_save(json_path, data)
return entry
snap_path = parent / snap_rel
if snap_path.exists():
shutil.copy2(snap_path, json_path) # restores pre-mutation state
snap_path.unlink()
else:
# Snapshot missing — fall back to popping history entry only
history.pop()
_save(json_path, data)
return entry
- [ ] Step 6.5: Re-run tests
Expected: 9 PASS. If test_undo_last_restores_pre_mutation_state fails, debug the snapshot-vs-history ordering (see note above).
- [ ] Step 6.6: Commit
git add transcript_history.py tests/test_transcript_history.py
git commit -m "$(cat <<'EOF'
feat(transcript-editor-phase2): add undo_last + list_history
undo_last restores the snapshot file (which captures pre-mutation
state) and deletes it; no extra _history pop needed because the
snapshot was written before the corresponding append. list_history
exposes the entries for the UI dropdown.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 7: load_words_flat + save_word_edits¶
Files:
- Create: transcript_word_ops.py
- Create: tests/test_transcript_word_ops.py
- [ ] Step 7.1: Write failing tests
Create tests/test_transcript_word_ops.py:
"""Tests for transcript_word_ops — load_words_flat + save_word_edits."""
import json
import shutil
from pathlib import Path
import pytest
@pytest.fixture
def sample_run(tmp_path, fixtures_dir):
stem = "ep01"
run_dir = tmp_path / stem
run_dir.mkdir()
src = fixtures_dir / "sample-transcript.whisperx.json"
dst = run_dir / f"{stem}.whisperx.json"
shutil.copy(src, dst)
return {"json_path": dst}
def test_load_words_flat_returns_list_with_segment_index(sample_run):
from transcript_word_ops import load_words_flat
flat = load_words_flat(str(sample_run["json_path"]))
# Sample fixture has words in seg 0 (2 words), seg 1 (1 word), seg 2 (0 words)
# Total: 3 word entries
assert len(flat) == 3
assert flat[0]["word"] == "All"
assert flat[0]["segment_index"] == 0
assert flat[2]["segment_index"] == 1
def test_load_words_flat_for_specific_segment(sample_run):
from transcript_word_ops import load_words_flat
flat = load_words_flat(str(sample_run["json_path"]), segment_index=0)
assert len(flat) == 2
assert all(w["segment_index"] == 0 for w in flat)
def test_save_word_edits_updates_word_strings(sample_run):
from transcript_word_ops import save_word_edits
# Seg 0 has 2 words: "All", "right."
save_word_edits(str(sample_run["json_path"]), segment_index=0,
new_words=["Alle", "richtig."])
data = json.loads(sample_run["json_path"].read_text(encoding="utf-8"))
words = data["segments"][0]["words"]
assert words[0]["word"] == "Alle"
assert words[1]["word"] == "richtig."
def test_save_word_edits_sets_word_edited_flag(sample_run):
from transcript_word_ops import save_word_edits
save_word_edits(str(sample_run["json_path"]), segment_index=0,
new_words=["Alle", "right."]) # only first word changed
data = json.loads(sample_run["json_path"].read_text(encoding="utf-8"))
words = data["segments"][0]["words"]
assert words[0]["_edited"] is True
assert words[1].get("_edited", False) is False
def test_save_word_edits_rebuilds_segment_text(sample_run):
from transcript_word_ops import save_word_edits
save_word_edits(str(sample_run["json_path"]), segment_index=0,
new_words=["Alle", "richtig."])
data = json.loads(sample_run["json_path"].read_text(encoding="utf-8"))
assert data["segments"][0]["text"] == "Alle richtig."
def test_save_word_edits_does_not_set_segment_edited(sample_run):
from transcript_word_ops import save_word_edits
save_word_edits(str(sample_run["json_path"]), segment_index=0,
new_words=["Alle", "richtig."])
data = json.loads(sample_run["json_path"].read_text(encoding="utf-8"))
# Word-level edits don't set segment._edited (separate tracking)
assert data["segments"][0].get("_edited", False) is False
def test_save_word_edits_raises_on_length_mismatch(sample_run):
from transcript_word_ops import save_word_edits
with pytest.raises(ValueError, match="length"):
save_word_edits(str(sample_run["json_path"]), segment_index=0,
new_words=["only one"])
- [ ] Step 7.2: Verify fail
Expected: 7 FAIL.
- [ ] Step 7.3: Implement
Create transcript_word_ops.py:
"""Word-level edit operations.
V1 does not re-align word timings against audio. After a word edit, the
word's start/end remain at the pre-edit values; segment.text is
rebuilt from the (possibly updated) word list.
"""
import json
from pathlib import Path
from transcript_editor import regenerate_srt_txt
def _load(json_path: str) -> dict:
return json.loads(Path(json_path).read_text(encoding="utf-8"))
def _save(json_path: str, data: dict) -> None:
Path(json_path).write_text(
json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8"
)
def load_words_flat(json_path: str, segment_index: int | None = None) -> list[dict]:
"""Return a flat list of {word, start, end, score, _edited, segment_index, word_index}.
If segment_index is given, returns only words for that segment.
"""
data = _load(json_path)
out: list[dict] = []
for seg_i, seg in enumerate(data.get("segments", [])):
if segment_index is not None and seg_i != segment_index:
continue
for word_i, w in enumerate(seg.get("words", [])):
out.append({
**w,
"segment_index": seg_i,
"word_index": word_i,
})
return out
def save_word_edits(json_path: str, segment_index: int, new_words: list[str]) -> dict:
"""Update word strings within a single segment.
Sets _edited: true on each word whose string changed. Rebuilds the
segment.text via " ".join(words). Does NOT set segment._edited
(word-level tracking is separate).
Returns: {"edited_count": int, "total_words": int}.
Raises ValueError on length mismatch or out-of-range index.
"""
data = _load(json_path)
segments = data.get("segments", [])
if segment_index < 0 or segment_index >= len(segments):
raise ValueError(f"segment_index {segment_index} out of range")
seg = segments[segment_index]
words = seg.get("words", [])
if len(new_words) != len(words):
raise ValueError(
f"new_words length {len(new_words)} != words length {len(words)}"
)
edited_count = 0
for word, new_str in zip(words, new_words):
if word.get("word") != new_str:
word["word"] = new_str
word["_edited"] = True
edited_count += 1
if edited_count > 0:
# Rebuild segment.text from words
seg["text"] = " ".join(w.get("word", "") for w in words)
_save(json_path, data)
if edited_count > 0:
regenerate_srt_txt(json_path)
return {"edited_count": edited_count, "total_words": len(words)}
- [ ] Step 7.4: Verify pass
Expected: 7 PASS.
- [ ] Step 7.5: Commit
git add transcript_word_ops.py tests/test_transcript_word_ops.py
git commit -m "$(cat <<'EOF'
feat(transcript-editor-phase2): add word-level edits (no re-align)
load_words_flat exposes per-word rows for the UI. save_word_edits
updates word strings, sets per-word _edited flags, and rebuilds the
parent segment.text. Word timings are not re-aligned — users who
want fresh timings can re-run the Transcribe phase.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 8: compute_segment_diff¶
Files:
- Create: transcript_diff.py
- Create: tests/test_transcript_diff.py
- [ ] Step 8.1: Write failing tests
Create tests/test_transcript_diff.py:
"""Tests for transcript_diff — segment-level diff vs .original.json."""
import json
import shutil
from pathlib import Path
import pytest
@pytest.fixture
def edited_run(tmp_path, fixtures_dir):
"""A run dir with both .whisperx.json (edited) and .whisperx.original.json."""
stem = "ep01"
run_dir = tmp_path / stem
run_dir.mkdir()
src = fixtures_dir / "sample-transcript.whisperx.json"
# Original copy
shutil.copy(src, run_dir / f"{stem}.whisperx.original.json")
# Current copy (will be mutated below)
dst = run_dir / f"{stem}.whisperx.json"
shutil.copy(src, dst)
return {"json_path": dst, "original_path": run_dir / f"{stem}.whisperx.original.json"}
@pytest.fixture
def unedited_run(tmp_path, fixtures_dir):
"""A run dir with .whisperx.json only (no .original.json)."""
stem = "ep01"
run_dir = tmp_path / stem
run_dir.mkdir()
shutil.copy(fixtures_dir / "sample-transcript.whisperx.json",
run_dir / f"{stem}.whisperx.json")
return {"json_path": run_dir / f"{stem}.whisperx.json"}
def test_compute_segment_diff_returns_empty_when_no_original(unedited_run):
from transcript_diff import compute_segment_diff
diffs = compute_segment_diff(str(unedited_run["json_path"]))
assert diffs == []
def test_compute_segment_diff_marks_text_changes(edited_run):
from transcript_diff import compute_segment_diff
# Mutate current: change segment 0 text
data = json.loads(edited_run["json_path"].read_text(encoding="utf-8"))
data["segments"][0]["text"] = "Hello and good morning."
data["segments"][0]["_edited"] = True
edited_run["json_path"].write_text(json.dumps(data, indent=2), encoding="utf-8")
diffs = compute_segment_diff(str(edited_run["json_path"]))
# Diff entries for ALL segments (or at least segment 0)
assert len(diffs) >= 1
seg_0 = [d for d in diffs if d["current_index"] == 0][0]
assert seg_0["text_changed"] is True
assert "text_diff" in seg_0
def test_compute_segment_diff_detects_speaker_change(edited_run):
from transcript_diff import compute_segment_diff
data = json.loads(edited_run["json_path"].read_text(encoding="utf-8"))
data["segments"][0]["speaker"] = "Anna"
data["segments"][0]["_speaker_edited"] = True
edited_run["json_path"].write_text(json.dumps(data, indent=2), encoding="utf-8")
diffs = compute_segment_diff(str(edited_run["json_path"]))
seg_0 = [d for d in diffs if d["current_index"] == 0][0]
assert seg_0["speaker_changed"] is True
assert seg_0["original_speaker"] == "SPEAKER_00"
assert seg_0["current_speaker"] == "Anna"
def test_compute_segment_diff_handles_merged_segments(edited_run):
from transcript_diff import compute_segment_diff
data = json.loads(edited_run["json_path"].read_text(encoding="utf-8"))
# Simulate merge of segments 0+1 → produces 2 segments instead of 3
seg_0 = data["segments"][0]
seg_1 = data["segments"][1]
merged = {
"start": seg_0["start"],
"end": seg_1["end"],
"text": seg_0["text"] + " " + seg_1["text"],
"speaker": seg_0["speaker"],
"words": seg_0["words"] + seg_1["words"],
"_merged_from": [0, 1],
}
data["segments"] = [merged] + data["segments"][2:]
edited_run["json_path"].write_text(json.dumps(data, indent=2), encoding="utf-8")
diffs = compute_segment_diff(str(edited_run["json_path"]))
merged_entry = [d for d in diffs if d["current_index"] == 0][0]
assert merged_entry["original_indices"] == [0, 1]
assert merged_entry["merge_or_split"] == "merged"
def test_compute_segment_diff_handles_split_segments(edited_run):
from transcript_diff import compute_segment_diff
data = json.loads(edited_run["json_path"].read_text(encoding="utf-8"))
# Simulate split of segment 1 → into two halves, both _split_from=1
seg_1 = data["segments"][1]
mid = (seg_1["start"] + seg_1["end"]) / 2
left = {"start": seg_1["start"], "end": mid, "text": "Left half.",
"speaker": seg_1["speaker"], "words": [], "_split_from": 1}
right = {"start": mid, "end": seg_1["end"], "text": "Right half.",
"speaker": seg_1["speaker"], "words": [], "_split_from": 1}
data["segments"] = [data["segments"][0], left, right, data["segments"][2]]
edited_run["json_path"].write_text(json.dumps(data, indent=2), encoding="utf-8")
diffs = compute_segment_diff(str(edited_run["json_path"]))
split_entries = [d for d in diffs if d.get("merge_or_split") == "split"]
assert len(split_entries) == 2
assert all(s["original_indices"] == [1] for s in split_entries)
- [ ] Step 8.2: Verify fail
Expected: 5 FAIL.
- [ ] Step 8.3: Implement
Create transcript_diff.py:
"""Diff transcript vs. .original.json snapshot.
Pure function — read-only. Produces a per-current-segment list with
text-diff tags, speaker-change flag, and merge/split origin metadata.
"""
import difflib
import json
from pathlib import Path
def _backup_path_for(json_path: Path) -> Path:
return json_path.with_name(json_path.stem + ".original.json")
def _load(json_path: str) -> dict:
return json.loads(Path(json_path).read_text(encoding="utf-8"))
def compute_segment_diff(json_path: str) -> list[dict]:
"""Return per-current-segment diff entries.
If .original.json doesn't exist, returns []. Otherwise each entry has:
- current_index: int
- original_indices: list[int] — derived from _split_from / _merged_from
or [current_index] when no metadata is present
- text_changed: bool
- text_diff: list[tuple(tag, original_word, current_word)] # via SequenceMatcher
- speaker_changed: bool
- original_speaker: str
- current_speaker: str
- merge_or_split: "merged" | "split" | None
"""
cur_path = Path(json_path)
orig_path = _backup_path_for(cur_path)
if not orig_path.exists():
return []
current = _load(json_path)
original = _load(str(orig_path))
cur_segs = current.get("segments", [])
orig_segs = original.get("segments", [])
out: list[dict] = []
for i, seg in enumerate(cur_segs):
if "_merged_from" in seg:
orig_indices = list(seg["_merged_from"])
merge_or_split = "merged"
elif "_split_from" in seg:
orig_indices = [int(seg["_split_from"])]
merge_or_split = "split"
else:
orig_indices = [i] if i < len(orig_segs) else []
merge_or_split = None
# Concat original text(s) for comparison
original_text = " ".join(
orig_segs[oi]["text"] for oi in orig_indices if 0 <= oi < len(orig_segs)
).strip()
current_text = seg.get("text", "").strip()
text_changed = original_text != current_text
# Word-level diff via SequenceMatcher
orig_words = original_text.split()
cur_words = current_text.split()
matcher = difflib.SequenceMatcher(a=orig_words, b=cur_words)
text_diff: list[tuple] = []
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
text_diff.append((tag, " ".join(orig_words[i1:i2]), " ".join(cur_words[j1:j2])))
# Speaker compare
original_speaker = (
orig_segs[orig_indices[0]].get("speaker", "")
if orig_indices and 0 <= orig_indices[0] < len(orig_segs)
else ""
)
current_speaker = seg.get("speaker", "")
speaker_changed = original_speaker != current_speaker
out.append({
"current_index": i,
"original_indices": orig_indices,
"text_changed": text_changed,
"text_diff": text_diff,
"speaker_changed": speaker_changed,
"original_speaker": original_speaker,
"current_speaker": current_speaker,
"merge_or_split": merge_or_split,
})
return out
- [ ] Step 8.4: Verify pass
Expected: 5 PASS.
- [ ] Step 8.5: Full sweep
Expected: 135 PASS (97 + 17 segment ops + 9 history + 7 word + 5 diff).
- [ ] Step 8.6: Commit
git add transcript_diff.py tests/test_transcript_diff.py
git commit -m "$(cat <<'EOF'
feat(transcript-editor-phase2): add compute_segment_diff
Per-current-segment diff vs .original.json. Uses _merged_from /
_split_from to match against original indices. Word-level diff via
difflib.SequenceMatcher. Empty list when no .original.json exists.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 9: Route — POST /runs/{stem}/edit/speaker¶
Files:
- Modify: webgui/app.py
- Create: tests/test_webgui_phase2_routes.py
- Create: webgui/templates/_partials/segment_editor.html
- [ ] Step 9.1: Write failing tests
Create tests/test_webgui_phase2_routes.py:
"""Phase 2 route tests — speaker, bulk-rename, merge, split, undo, words, diff."""
import json
import shutil
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def client():
from webgui.app import app
return TestClient(app)
@pytest.fixture
def populated_run(tmp_path, fixtures_dir, monkeypatch):
"""One stem 'ep01' with transcript JSON + minimal run-state."""
out = tmp_path / "output"
ep_dir = out / "ep01"
ep_dir.mkdir(parents=True)
shutil.copy(fixtures_dir / "sample-transcript.whisperx.json",
ep_dir / "ep01.whisperx.json")
state = {
"schema_version": 1, "stem": "ep01",
"audio": str(fixtures_dir / "sample.m4a"),
"phases": {
"transcribe": {"status": "done"}, "meta": {"status": "done"},
"render": {"status": "done"}, "upload": {"status": "skipped"},
},
"config": {},
}
(ep_dir / "run-state.json").write_text(json.dumps(state, indent=2), encoding="utf-8")
from webgui import app as app_mod
monkeypatch.setattr(app_mod, "OUTPUT_ROOT", out)
return out
def test_post_speaker_change_updates_segment(client, populated_run):
r = client.post("/runs/ep01/edit/speaker",
data={"segment_index": "0", "speaker": "Anna"})
assert r.status_code == 200
json_path = populated_run / "ep01" / "ep01.whisperx.json"
data = json.loads(json_path.read_text(encoding="utf-8"))
assert data["segments"][0]["speaker"] == "Anna"
assert data["segments"][0]["_speaker_edited"] is True
def test_post_speaker_change_returns_segment_partial(client, populated_run):
r = client.post("/runs/ep01/edit/speaker",
data={"segment_index": "0", "speaker": "Anna"})
assert r.status_code == 200
# Returns HTML partial — should contain the segment textarea + speaker dropdown
assert "segment_text_0" in r.text
assert "Anna" in r.text
- [ ] Step 9.2: Verify fail
Expected: 2 FAIL.
- [ ] Step 9.3: Create the segment_editor.html partial
Create webgui/templates/_partials/segment_editor.html:
{# Re-rendered after every HTMX edit. Receives: seg, loop_index, speakers (distinct list) #}
<div class="edit-segment {% if seg.get('_edited') %}is-edited{% endif %}"
id="segment-{{ loop_index }}">
<div class="edit-segment-header mono">
<span class="time">{{ "%02d:%02d"|format((seg.start|int)//60, (seg.start|int)%60) }}</span>
<form hx-post="/runs/{{ stem }}/edit/speaker"
hx-target="#segment-{{ loop_index }}"
hx-swap="outerHTML"
style="display:inline;">
<input type="hidden" name="segment_index" value="{{ loop_index }}">
<select name="speaker" class="speaker-select" onchange="this.form.requestSubmit()">
{% for spk in speakers %}
<option value="{{ spk }}" {% if spk == seg.speaker %}selected{% endif %}>{{ spk }}</option>
{% endfor %}
<option value="{{ seg.speaker }}" {% if seg.speaker not in speakers %}selected{% endif %}>{{ seg.speaker }}</option>
</select>
</form>
{% if seg.get('_edited') %}<span class="edit-badge">★ edited</span>{% endif %}
{% if seg.get('_speaker_edited') %}<span class="edit-badge">☆ speaker</span>{% endif %}
{% if seg.get('_merged_from') %}<span class="edit-badge">⇄ merged</span>{% endif %}
{% if seg.get('_split_from') is not none %}<span class="edit-badge">⇋ split</span>{% endif %}
</div>
<textarea name="segment_text_{{ loop_index }}" class="edit-textarea" rows="2">{{ seg.text }}</textarea>
<input type="hidden" name="original_text_{{ loop_index }}" value="{{ seg.text }}">
<div class="row" style="gap:8px; margin-top:6px;">
<form hx-post="/runs/{{ stem }}/edit/merge"
hx-target="#edit-form-segments"
hx-swap="innerHTML"
style="display:inline;">
<input type="hidden" name="segment_index" value="{{ loop_index }}">
<button type="submit" class="btn btn-ghost btn-sm">⇄ Merge with next</button>
</form>
<form hx-post="/runs/{{ stem }}/edit/split"
hx-target="#edit-form-segments"
hx-swap="innerHTML"
style="display:inline;"
onsubmit="this.querySelector('[name=char_position]').value = document.querySelector('[name=segment_text_{{ loop_index }}]').selectionStart;">
<input type="hidden" name="segment_index" value="{{ loop_index }}">
<input type="hidden" name="char_position" value="0">
<button type="submit" class="btn btn-ghost btn-sm">⇋ Split at cursor</button>
</form>
</div>
</div>
- [ ] Step 9.4: Add the route
In webgui/app.py, add imports near the top:
from transcript_editor import load_segments, save_edits, invalidate_downstream, has_been_edited
from transcript_segment_ops import change_speaker, bulk_rename_speaker, merge_segment, split_segment
from transcript_word_ops import load_words_flat, save_word_edits
from transcript_history import snapshot, undo_last, list_history, cleanup_snapshots
from transcript_diff import compute_segment_diff
(Only the first line was V1; replace it with the full block above.)
Add helper just below the existing helpers:
def _distinct_speakers(json_path: Path) -> list[str]:
if not json_path.exists():
return []
data = json.loads(json_path.read_text(encoding="utf-8"))
return sorted({seg.get("speaker", "") for seg in data.get("segments", []) if seg.get("speaker")})
Add the speaker-change route:
@app.post("/runs/{stem}/edit/speaker", response_class=HTMLResponse)
async def run_edit_speaker(stem: str, request: Request):
json_path = OUTPUT_ROOT / stem / f"{stem}.whisperx.json"
if not json_path.exists():
raise HTTPException(status_code=404, detail="Transcript not found")
form = await request.form()
try:
segment_index = int(form.get("segment_index", "-1"))
except ValueError:
raise HTTPException(status_code=400, detail="segment_index must be int")
new_speaker = form.get("speaker", "").strip()
if not new_speaker:
raise HTTPException(status_code=400, detail="speaker required")
snapshot(str(json_path), action="edit_speaker", metric=f"segment {segment_index} → {new_speaker}")
try:
change_speaker(str(json_path), segment_index, new_speaker)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
invalidate_downstream(str(OUTPUT_ROOT / stem / "run-state.json"))
cleanup_snapshots(str(json_path))
segments = load_segments(str(json_path))
speakers = _distinct_speakers(json_path)
seg = segments[segment_index]
return templates.TemplateResponse(
request, "_partials/segment_editor.html",
{"stem": stem, "seg": seg, "loop_index": segment_index, "speakers": speakers},
)
- [ ] Step 9.5: Verify pass
Expected: 2 PASS.
- [ ] Step 9.6: Commit
git add webgui/app.py webgui/templates/_partials/segment_editor.html tests/test_webgui_phase2_routes.py
git commit -m "$(cat <<'EOF'
feat(transcript-editor-phase2): POST /runs/{stem}/edit/speaker
HTMX endpoint: changes one segment's speaker, snapshots first,
invalidates downstream phases, and re-renders the segment partial.
segment_editor.html is the canonical HTMX target for segment changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 10: Route — POST /runs/{stem}/edit/bulk-rename¶
Files:
- Modify: webgui/app.py
- Modify: tests/test_webgui_phase2_routes.py
- [ ] Step 10.1: Write failing tests
Append:
def test_post_bulk_rename_renames_all_matching(client, populated_run):
r = client.post("/runs/ep01/edit/bulk-rename",
data={"old_name": "SPEAKER_00", "new_name": "Anna"},
follow_redirects=False)
assert r.status_code == 303
json_path = populated_run / "ep01" / "ep01.whisperx.json"
data = json.loads(json_path.read_text(encoding="utf-8"))
speakers = [s["speaker"] for s in data["segments"]]
assert "Anna" in speakers
assert "SPEAKER_00" not in speakers
def test_post_bulk_rename_400_on_same_names(client, populated_run):
r = client.post("/runs/ep01/edit/bulk-rename",
data={"old_name": "SPEAKER_00", "new_name": "SPEAKER_00"})
assert r.status_code == 400
- [ ] Step 10.2: Verify fail
Expected: 2 FAIL.
- [ ] Step 10.3: Implement
Add to webgui/app.py:
@app.post("/runs/{stem}/edit/bulk-rename")
async def run_edit_bulk_rename(stem: str, request: Request):
json_path = OUTPUT_ROOT / stem / f"{stem}.whisperx.json"
if not json_path.exists():
raise HTTPException(status_code=404, detail="Transcript not found")
form = await request.form()
old_name = (form.get("old_name") or "").strip()
new_name = (form.get("new_name") or "").strip()
if not old_name or not new_name:
raise HTTPException(status_code=400, detail="old_name and new_name required")
snapshot(str(json_path), action="bulk_rename",
metric=f"{old_name} → {new_name}")
try:
count = bulk_rename_speaker(str(json_path), old_name, new_name)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
invalidate_downstream(str(OUTPUT_ROOT / stem / "run-state.json"))
cleanup_snapshots(str(json_path))
return RedirectResponse(url=f"/runs/{stem}/edit", status_code=303)
- [ ] Step 10.4: Verify pass
Expected: 4 PASS.
- [ ] Step 10.5: Commit
git add webgui/app.py tests/test_webgui_phase2_routes.py
git commit -m "$(cat <<'EOF'
feat(transcript-editor-phase2): POST /edit/bulk-rename
Classic form-redirect endpoint (303 → /runs/{stem}/edit). Snapshots
before, invalidates downstream after. Empty or same names → 400.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 11: Routes — POST /edit/merge + POST /edit/split¶
Files:
- Modify: webgui/app.py
- Modify: tests/test_webgui_phase2_routes.py
- [ ] Step 11.1: Write failing tests
Append:
def test_post_merge_combines_segments(client, populated_run):
r = client.post("/runs/ep01/edit/merge", data={"segment_index": "0"})
assert r.status_code == 200
json_path = populated_run / "ep01" / "ep01.whisperx.json"
data = json.loads(json_path.read_text(encoding="utf-8"))
assert len(data["segments"]) == 2 # was 3
def test_post_merge_returns_all_segments_partial(client, populated_run):
r = client.post("/runs/ep01/edit/merge", data={"segment_index": "0"})
# Returns the full segments-list partial so HTMX can swap the whole list
assert "segment_text_0" in r.text
assert "segment_text_1" in r.text
def test_post_split_creates_two_segments(client, populated_run):
r = client.post("/runs/ep01/edit/split",
data={"segment_index": "1", "char_position": "16"})
assert r.status_code == 200
json_path = populated_run / "ep01" / "ep01.whisperx.json"
data = json.loads(json_path.read_text(encoding="utf-8"))
assert len(data["segments"]) == 4 # was 3
def test_post_split_400_on_invalid_position(client, populated_run):
r = client.post("/runs/ep01/edit/split",
data={"segment_index": "1", "char_position": "0"})
assert r.status_code == 400
- [ ] Step 11.2: Verify fail
Expected: 4 FAIL.
- [ ] Step 11.3: Add a helper that renders the full segments-list partial
In webgui/app.py, add helper:
def _render_segments_partial(request: Request, stem: str) -> HTMLResponse:
json_path = OUTPUT_ROOT / stem / f"{stem}.whisperx.json"
segments = load_segments(str(json_path))
speakers = _distinct_speakers(json_path)
return templates.TemplateResponse(
request, "_partials/segments_list.html",
{"stem": stem, "segments": segments, "speakers": speakers},
)
Create webgui/templates/_partials/segments_list.html:
Note: inside the loop, loop.index0 provides the index — but the partial expects loop_index. Add a wrapper {% with loop_index = loop.index0 %}…{% endwith %} or pass loop_index=loop.index0 via include … with (Jinja2). The cleanest:
{% for seg in segments %}
{% set loop_index = loop.index0 %}
{% include "_partials/segment_editor.html" %}
{% endfor %}
{% set %} inside a loop has loop-scope by default in Jinja2 — verify in the test pass step.
- [ ] Step 11.4: Add merge + split routes
Append to webgui/app.py:
@app.post("/runs/{stem}/edit/merge", response_class=HTMLResponse)
async def run_edit_merge(stem: str, request: Request):
json_path = OUTPUT_ROOT / stem / f"{stem}.whisperx.json"
if not json_path.exists():
raise HTTPException(status_code=404, detail="Transcript not found")
form = await request.form()
try:
segment_index = int(form.get("segment_index", "-1"))
except ValueError:
raise HTTPException(status_code=400, detail="segment_index must be int")
snapshot(str(json_path), action="merge",
metric=f"segments {segment_index}+{segment_index + 1}")
try:
merge_segment(str(json_path), segment_index)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
invalidate_downstream(str(OUTPUT_ROOT / stem / "run-state.json"))
cleanup_snapshots(str(json_path))
return _render_segments_partial(request, stem)
@app.post("/runs/{stem}/edit/split", response_class=HTMLResponse)
async def run_edit_split(stem: str, request: Request):
json_path = OUTPUT_ROOT / stem / f"{stem}.whisperx.json"
if not json_path.exists():
raise HTTPException(status_code=404, detail="Transcript not found")
form = await request.form()
try:
segment_index = int(form.get("segment_index", "-1"))
char_position = int(form.get("char_position", "0"))
except ValueError:
raise HTTPException(status_code=400, detail="indices must be int")
snapshot(str(json_path), action="split",
metric=f"segment {segment_index} at char {char_position}")
try:
split_segment(str(json_path), segment_index, char_position)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
invalidate_downstream(str(OUTPUT_ROOT / stem / "run-state.json"))
cleanup_snapshots(str(json_path))
return _render_segments_partial(request, stem)
- [ ] Step 11.5: Verify pass
Expected: 8 PASS.
- [ ] Step 11.6: Commit
git add webgui/app.py webgui/templates/_partials/segments_list.html tests/test_webgui_phase2_routes.py
git commit -m "$(cat <<'EOF'
feat(transcript-editor-phase2): POST /edit/merge + /edit/split
Both HTMX endpoints return the full segments_list.html partial since
merge/split change segment count (a single-segment swap wouldn't
suffice). Snapshot → mutate → invalidate downstream → cleanup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 12: Route — POST /runs/{stem}/edit/undo¶
Files:
- Modify: webgui/app.py
- Modify: tests/test_webgui_phase2_routes.py
- [ ] Step 12.1: Write failing tests
Append:
def test_post_undo_restores_previous_state(client, populated_run):
json_path = populated_run / "ep01" / "ep01.whisperx.json"
pre = json.loads(json_path.read_text(encoding="utf-8"))
pre_speaker_0 = pre["segments"][0]["speaker"]
# Make a change first
client.post("/runs/ep01/edit/speaker", data={"segment_index": "0", "speaker": "Anna"})
mid = json.loads(json_path.read_text(encoding="utf-8"))
assert mid["segments"][0]["speaker"] == "Anna"
# Now undo
r = client.post("/runs/ep01/edit/undo", follow_redirects=False)
assert r.status_code == 303
post_state = json.loads(json_path.read_text(encoding="utf-8"))
assert post_state["segments"][0]["speaker"] == pre_speaker_0
def test_post_undo_303_when_history_empty(client, populated_run):
# No prior edits → no history
r = client.post("/runs/ep01/edit/undo", follow_redirects=False)
# Endpoint should still redirect (no-op rather than error)
assert r.status_code == 303
- [ ] Step 12.2: Verify fail
Expected: 2 FAIL.
- [ ] Step 12.3: Implement
Append to webgui/app.py:
@app.post("/runs/{stem}/edit/undo")
async def run_edit_undo(stem: str):
json_path = OUTPUT_ROOT / stem / f"{stem}.whisperx.json"
if not json_path.exists():
raise HTTPException(status_code=404, detail="Transcript not found")
undo_last(str(json_path)) # no-op if history empty
# Note: undo restores the pre-snapshot state which already had SRT/TXT
# in sync at that point. Re-generate to be safe.
from transcript_editor import regenerate_srt_txt as _regen
if json_path.exists():
_regen(str(json_path))
# Downstream is invalidated anyway on next save; no need to invalidate here.
return RedirectResponse(url=f"/runs/{stem}/edit", status_code=303)
- [ ] Step 12.4: Verify pass
Expected: 10 PASS.
- [ ] Step 12.5: Commit
git add webgui/app.py tests/test_webgui_phase2_routes.py
git commit -m "$(cat <<'EOF'
feat(transcript-editor-phase2): POST /edit/undo
303 redirect back to /edit after restoring the latest snapshot.
Empty history is a no-op (still 303). Regenerates SRT/TXT defensively
in case any prior step left them out of sync.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 13: Routes — GET + POST /edit/words¶
Files:
- Modify: webgui/app.py
- Modify: tests/test_webgui_phase2_routes.py
- Create: webgui/templates/run_edit_words.html
- [ ] Step 13.1: Write failing tests
Append:
def test_get_words_renders_per_word_rows(client, populated_run):
r = client.get("/runs/ep01/edit/words?segment_index=0")
assert r.status_code == 200
# Sample fixture has 2 words in segment 0: "All" and "right."
assert 'name="word_0"' in r.text
assert 'name="word_1"' in r.text
assert "All" in r.text
assert "right." in r.text
def test_get_words_404_when_transcribe_missing(client, tmp_path, monkeypatch):
out = tmp_path / "output"
(out / "ghost").mkdir(parents=True)
from webgui import app as app_mod
monkeypatch.setattr(app_mod, "OUTPUT_ROOT", out)
r = client.get("/runs/ghost/edit/words?segment_index=0")
assert r.status_code == 404
def test_post_words_saves_edits(client, populated_run):
json_path = populated_run / "ep01" / "ep01.whisperx.json"
r = client.post("/runs/ep01/edit/words",
data={"segment_index": "0", "word_0": "Alle", "word_1": "richtig."},
follow_redirects=False)
assert r.status_code == 303
data = json.loads(json_path.read_text(encoding="utf-8"))
words = data["segments"][0]["words"]
assert words[0]["word"] == "Alle"
assert words[1]["word"] == "richtig."
assert data["segments"][0]["text"] == "Alle richtig."
- [ ] Step 13.2: Verify fail
Expected: 3 FAIL.
- [ ] Step 13.3: Add routes
Append to webgui/app.py:
@app.get("/runs/{stem}/edit/words", response_class=HTMLResponse)
async def run_edit_words(stem: str, request: Request, segment_index: int = 0):
json_path = OUTPUT_ROOT / stem / f"{stem}.whisperx.json"
if not json_path.exists():
raise HTTPException(status_code=404, detail="Transcript not found")
segments = load_segments(str(json_path))
if segment_index < 0 or segment_index >= len(segments):
raise HTTPException(status_code=400, detail="segment_index out of range")
seg = segments[segment_index]
words = seg.get("words", [])
return templates.TemplateResponse(
request, "run_edit_words.html",
{"stem": stem, "segment_index": segment_index,
"segment": seg, "words": words,
"total_segments": len(segments), "page_mood": "neutral"},
)
@app.post("/runs/{stem}/edit/words")
async def run_edit_words_save(stem: str, request: Request):
json_path = OUTPUT_ROOT / stem / f"{stem}.whisperx.json"
if not json_path.exists():
raise HTTPException(status_code=404, detail="Transcript not found")
form = await request.form()
try:
segment_index = int(form.get("segment_index", "-1"))
except ValueError:
raise HTTPException(status_code=400, detail="segment_index must be int")
segments = load_segments(str(json_path))
if segment_index < 0 or segment_index >= len(segments):
raise HTTPException(status_code=400, detail="segment_index out of range")
words = segments[segment_index].get("words", [])
new_words: list[str] = []
for i in range(len(words)):
v = form.get(f"word_{i}")
if v is None:
raise HTTPException(status_code=400, detail=f"Missing word_{i}")
new_words.append(v)
snapshot(str(json_path), action="edit_words",
metric=f"segment {segment_index}, {len(new_words)} words")
try:
save_word_edits(str(json_path), segment_index, new_words)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
invalidate_downstream(str(OUTPUT_ROOT / stem / "run-state.json"))
cleanup_snapshots(str(json_path))
return RedirectResponse(url=f"/runs/{stem}/edit", status_code=303)
- [ ] Step 13.4: Create the template
Create webgui/templates/run_edit_words.html:
{% extends "base.html" %}
{% block title %}Word edit · {{ stem }}{% endblock %}
{% block content %}
<header class="run-header">
<div class="left">
<div class="crumbs"><a href="/runs/{{ stem }}/edit">← Back to editor</a></div>
<h1><span class="accent-mark">⫶</span>Word edits: {{ stem }} / Segment {{ segment_index }}</h1>
<p class="muted" style="margin-top:8px; font-size: 13px;">
Edit individual words. Segment text will be rebuilt from these words on save.
Word timings are not re-aligned (run Transcribe again for fresh timings).
</p>
</div>
<div>
<form method="get" action="/runs/{{ stem }}/edit/words" style="display:inline;">
<label>Segment:
<select name="segment_index" onchange="this.form.submit()">
{% for i in range(total_segments) %}
<option value="{{ i }}" {% if i == segment_index %}selected{% endif %}>Segment {{ i }}</option>
{% endfor %}
</select>
</label>
</form>
</div>
</header>
<form method="post" action="/runs/{{ stem }}/edit/words" class="words-form">
<input type="hidden" name="segment_index" value="{{ segment_index }}">
<table class="words-table">
<thead>
<tr><th>Time</th><th>Speaker</th><th>Word</th></tr>
</thead>
<tbody>
{% for w in words %}
<tr>
<td class="mono">{{ "%.2f"|format(w.start|default(0)) }}–{{ "%.2f"|format(w.end|default(0)) }}</td>
<td>{{ segment.speaker | default("SPEAKER_00") }}</td>
<td><input type="text" name="word_{{ loop.index0 }}" value="{{ w.word | default('') }}" class="input"></td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="row" style="gap:12px; margin-top:24px;">
<button type="submit" class="btn btn-primary">Save words</button>
<a href="/runs/{{ stem }}/edit" class="btn btn-ghost">Cancel</a>
</div>
</form>
{% endblock %}
- [ ] Step 13.5: Verify pass
Expected: 13 PASS.
- [ ] Step 13.6: Commit
git add webgui/app.py webgui/templates/run_edit_words.html tests/test_webgui_phase2_routes.py
git commit -m "$(cat <<'EOF'
feat(transcript-editor-phase2): GET + POST /edit/words
Word-level editor: per-word table with read-only times + speaker
and an editable input per word. Segment-picker dropdown switches
which segment is being edited. Save rebuilds segment.text and
triggers SRT/TXT regeneration.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 14: Route — GET /runs/{stem}/diff¶
Files:
- Modify: webgui/app.py
- Modify: tests/test_webgui_phase2_routes.py
- Create: webgui/templates/run_diff.html
- [ ] Step 14.1: Write failing tests
Append:
def test_get_diff_empty_state_when_no_original(client, populated_run):
r = client.get("/runs/ep01/diff")
assert r.status_code == 200
# No original.json yet → empty state
assert "No edits yet" in r.text or "no diff" in r.text.lower()
def test_get_diff_shows_changed_segments(client, populated_run):
# Make an edit so .original.json is created
client.post("/runs/ep01/edit/speaker",
data={"segment_index": "0", "speaker": "Anna"})
# The speaker change should have triggered save_edits' backup-on-first-save
# via regenerate paths. To trigger backup creation, run a text edit:
form = {
"segment_text_0": "Hello edited.",
"original_text_0": " All right.",
"segment_text_1": "Let's dive into this autopoiesis thing.",
"original_text_1": "Let's dive into this autopoiesis thing.",
"segment_text_2": "Sounds good to me.",
"original_text_2": "Sounds good to me.",
"action": "save-return",
}
client.post("/runs/ep01/edit", data=form)
r = client.get("/runs/ep01/diff")
assert r.status_code == 200
assert "Hello edited" in r.text or "edited" in r.text.lower()
- [ ] Step 14.2: Verify fail
Expected: 2 FAIL.
- [ ] Step 14.3: Add route
Append to webgui/app.py:
@app.get("/runs/{stem}/diff", response_class=HTMLResponse)
async def run_diff_view(stem: str, request: Request):
json_path = OUTPUT_ROOT / stem / f"{stem}.whisperx.json"
if not json_path.exists():
raise HTTPException(status_code=404, detail="Transcript not found")
diffs = compute_segment_diff(str(json_path))
changed = [d for d in diffs if d["text_changed"] or d["speaker_changed"] or d["merge_or_split"]]
return templates.TemplateResponse(
request, "run_diff.html",
{"stem": stem, "diffs": diffs, "changed_count": len(changed),
"has_original": (json_path.with_name(json_path.stem + ".original.json").exists()),
"page_mood": "neutral"},
)
- [ ] Step 14.4: Create the template
Create webgui/templates/run_diff.html:
{% extends "base.html" %}
{% block title %}Diff · {{ stem }}{% endblock %}
{% block content %}
<header class="run-header">
<div class="left">
<div class="crumbs"><a href="/runs/{{ stem }}/edit">← Back to editor</a> · <a href="/runs/{{ stem }}">Run</a></div>
<h1><span class="accent-mark">≡</span>Diff: {{ stem }}</h1>
{% if has_original %}
<p class="muted" style="margin-top:8px;">{{ changed_count }} of {{ diffs|length }} segments changed.</p>
{% endif %}
</div>
</header>
{% if not has_original %}
<section class="card" style="margin-top: 24px;">
<div class="card-body">
<p class="muted">No edits yet. The diff view becomes available after the first save in the editor.</p>
</div>
</section>
{% else %}
<div class="diff-list" style="margin-top: 24px;">
{% for d in diffs %}
<div class="diff-segment {% if d.text_changed or d.speaker_changed or d.merge_or_split %}is-changed{% endif %}">
<header class="diff-segment-header">
Segment {{ d.current_index }}
{% if d.merge_or_split == "merged" %}
⇄ merged from {{ d.original_indices | join(' + ') }}
{% elif d.merge_or_split == "split" %}
⇋ split from {{ d.original_indices[0] }}
{% endif %}
{% if d.speaker_changed %}
★ speaker {{ d.original_speaker }} → {{ d.current_speaker }}
{% endif %}
</header>
<div class="diff-pair">
<div class="diff-col diff-col-original">
<div class="diff-col-label mono">Original</div>
<div class="diff-text">
{% for tag, orig, cur in d.text_diff %}
{% if tag == 'equal' %}{{ orig }} {% endif %}
{% if tag == 'delete' %}<del>{{ orig }}</del> {% endif %}
{% if tag == 'replace' %}<del>{{ orig }}</del> {% endif %}
{% endfor %}
</div>
</div>
<div class="diff-col diff-col-current">
<div class="diff-col-label mono">Current</div>
<div class="diff-text">
{% for tag, orig, cur in d.text_diff %}
{% if tag == 'equal' %}{{ cur }} {% endif %}
{% if tag == 'insert' %}<ins>{{ cur }}</ins> {% endif %}
{% if tag == 'replace' %}<ins>{{ cur }}</ins> {% endif %}
{% endfor %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% endblock %}
- [ ] Step 14.5: Verify pass
Expected: 15 PASS.
- [ ] Step 14.6: Commit
git add webgui/app.py webgui/templates/run_diff.html tests/test_webgui_phase2_routes.py
git commit -m "$(cat <<'EOF'
feat(transcript-editor-phase2): GET /diff — side-by-side diff view
Reads .original.json via transcript_diff.compute_segment_diff. Empty
state when no .original.json exists. Per-segment markup with merge/
split origin badges and <ins>/<del> word-level highlighting.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 15: Extend run_edit.html — top-bar, bulk-rename form, history dropdown¶
Files:
- Modify: webgui/templates/run_edit.html
- Create: webgui/templates/_partials/history_dropdown.html
- Create: webgui/templates/_partials/speaker_bulk_form.html
- Modify: webgui/app.py (extend run_edit GET handler to compute speakers + history)
- [ ] Step 15.1: Inspect current run_edit.html
Verify the V1 structure (header + form with {% for seg in segments %} loop + action buttons). The Phase 2 changes replace the loop body with {% include "_partials/segment_editor.html" %} so the editor uses the same partial that HTMX targets.
- [ ] Step 15.2: Update run_edit GET handler in webgui/app.py
Find the run_edit GET handler (added in V1). Update it to compute speakers + history:
@app.get("/runs/{stem}/edit", response_class=HTMLResponse)
async def run_edit(stem: str, request: Request):
json_path = OUTPUT_ROOT / stem / f"{stem}.whisperx.json"
if not json_path.exists():
raise HTTPException(status_code=404, detail="Transcript not found")
segments = load_segments(str(json_path))
backup_exists = json_path.with_name(json_path.stem + ".original.json").exists()
edited_any = has_been_edited(str(json_path))
speakers = _distinct_speakers(json_path)
history = list_history(str(json_path))
return templates.TemplateResponse(
request, "run_edit.html",
{
"stem": stem,
"segments": segments,
"backup_exists": backup_exists,
"edited_any": edited_any,
"speakers": speakers,
"history": history,
"page_mood": "neutral",
},
)
- [ ] Step 15.3: Create the history dropdown partial
Create webgui/templates/_partials/history_dropdown.html:
<details class="history-dropdown">
<summary class="btn btn-ghost btn-sm">
Undo {% if history|length > 0 %}({{ history|length }} entries){% endif %} ▾
</summary>
<div class="history-panel">
{% if not history %}
<p class="muted">No edit history yet.</p>
{% else %}
<form method="post" action="/runs/{{ stem }}/edit/undo" style="margin-bottom:8px;">
<button type="submit" class="btn btn-primary btn-sm">↶ Undo last action</button>
</form>
<ul class="history-list">
{% for entry in history|reverse %}
<li>
<span class="mono">{{ entry.ts }}</span> — {{ entry.action }}: <span class="muted">{{ entry.metric }}</span>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
</details>
- [ ] Step 15.4: Create the bulk-rename form partial
Create webgui/templates/_partials/speaker_bulk_form.html:
<form method="post" action="/runs/{{ stem }}/edit/bulk-rename" class="bulk-rename-form">
<label class="muted" style="font-size:12px;">Bulk rename speaker:</label>
<select name="old_name">
{% for spk in speakers %}
<option value="{{ spk }}">{{ spk }}</option>
{% endfor %}
</select>
→
<input type="text" name="new_name" placeholder="New name" class="input" style="width:150px;">
<button type="submit" class="btn btn-sm">Rename</button>
</form>
- [ ] Step 15.5: Rewrite run_edit.html
Overwrite webgui/templates/run_edit.html:
{% extends "base.html" %}
{% block title %}Edit · {{ stem }}{% endblock %}
{% block content %}
<header class="run-header">
<div class="left">
<div class="crumbs">
<a href="/runs/{{ stem }}">← Back to run</a> ·
<a href="/runs/{{ stem }}/diff">Diff view</a> ·
<a href="/runs/{{ stem }}/edit/words">Word view</a>
</div>
<h1><span class="accent-mark">✎</span>Edit transcript: {{ stem }}</h1>
<p class="muted" style="margin-top:8px; font-size: 13px;">
Edit text, change speakers, merge/split segments, or undo any action.
Saving invalidates Meta + Render — they will need to re-run.
</p>
{% if backup_exists %}
<p class="muted mono" style="font-size: 11px; margin-top:4px;">Original backup: ✓ saved</p>
{% endif %}
</div>
<div class="run-header-controls" style="display:flex; gap:12px; align-items:flex-start;">
{% include "_partials/history_dropdown.html" %}
</div>
</header>
<div style="margin-top:16px;">
{% include "_partials/speaker_bulk_form.html" %}
</div>
<form id="edit-form" method="post" action="/runs/{{ stem }}/edit" class="edit-form">
<div id="edit-form-segments">
{% for seg in segments %}
{% set loop_index = loop.index0 %}
{% include "_partials/segment_editor.html" %}
{% endfor %}
</div>
<div class="edit-actions" style="margin-top: 24px; display:flex; gap:12px; flex-wrap:wrap;">
<button type="submit" name="action" value="save-return" class="btn">Save & Return</button>
<button type="submit" name="action" value="save-continue" class="btn btn-primary">Save & Continue</button>
<a href="/runs/{{ stem }}" class="btn btn-ghost">Cancel</a>
</div>
</form>
{% endblock %}
- [ ] Step 15.6: Add CSS for new components
Append to webgui/static/style.css:
/* === Phase 2 — bulk-rename, history dropdown, diff, words table =========== */
.bulk-rename-form {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: color-mix(in oklab, var(--surface, #1a1a1a) 94%, var(--accent, #6ea8fe) 6%);
border-radius: var(--radius-md, 8px);
border: 1px solid var(--border, rgba(255, 255, 255, 0.06));
}
.history-dropdown summary { cursor: pointer; }
.history-panel {
position: relative;
margin-top: 8px;
padding: 12px;
background: var(--surface, #1a1a1a);
border: 1px solid var(--border, rgba(255, 255, 255, 0.08));
border-radius: var(--radius-md, 8px);
min-width: 280px;
}
.history-list {
margin: 0; padding: 0; list-style: none;
display: flex; flex-direction: column; gap: 4px;
font-size: 12px;
max-height: 240px; overflow-y: auto;
}
.speaker-select {
background: transparent;
border: 1px solid var(--border, rgba(255, 255, 255, 0.08));
border-radius: var(--radius-sm, 6px);
color: var(--accent, #6ea8fe);
font-weight: 500;
padding: 1px 6px;
font-family: inherit;
font-size: 11px;
cursor: pointer;
}
.diff-segment {
margin-top: 12px;
padding: 12px;
border-radius: var(--radius-md, 8px);
background: var(--surface, #1a1a1a);
border: 1px solid var(--border, rgba(255, 255, 255, 0.06));
}
.diff-segment.is-changed {
border-color: color-mix(in oklab, var(--accent, #6ea8fe) 30%, transparent);
}
.diff-segment-header {
font-size: 11px;
color: var(--muted, rgba(255, 255, 255, 0.55));
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 8px;
}
.diff-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.diff-col-label { font-size: 10px; color: var(--muted, rgba(255, 255, 255, 0.4)); margin-bottom: 4px; }
.diff-text del { background: color-mix(in oklab, #d04050 30%, transparent); text-decoration: line-through; }
.diff-text ins { background: color-mix(in oklab, #50d080 30%, transparent); text-decoration: none; }
.words-table { width: 100%; border-collapse: collapse; margin-top: 16px; }
.words-table th, .words-table td { padding: 6px 8px; text-align: left; border-bottom: 1px solid var(--border, rgba(255, 255, 255, 0.06)); }
.words-table thead th { font-size: 10px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted, rgba(255, 255, 255, 0.55)); }
.words-table input { width: 100%; }
.btn-sm { font-size: 11px; padding: 4px 8px; }
- [ ] Step 15.7: Add HTMX script tag to base.html (if not present)
Check webgui/templates/base.html:
If the project already vendored htmx (e.g. /static/htmx.min.js mounted, per V1 file structure), confirm it's loaded via a <script> tag in base.html. If not, add:
before the closing </head> (or wherever scripts are loaded).
- [ ] Step 15.8: Verify all tests still pass
Expected: ≥150 PASS (97 + 17 + 9 + 7 + 5 + 15 = 150).
- [ ] Step 15.9: Commit
git add webgui/templates/ webgui/static/style.css webgui/app.py
git commit -m "$(cat <<'EOF'
feat(transcript-editor-phase2): integrate editor extensions in UI
run_edit.html now uses segment_editor.html partial (HTMX-targetable),
shows the bulk-rename form, history dropdown with undo, and links
to /diff and /edit/words. CSS for new components added.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 16: AGENTS.md update¶
- [ ] Step 16.1: Edit AGENTS.md
In the "Architektur-Highlights" section, replace the Transcript-Editor bullet with a longer description covering Phase 2 features. Add to "Wo finde ich was":
| Wo lebt der Editor Phase 2? | `transcript_segment_ops.py` · `transcript_word_ops.py` · `transcript_history.py` · `transcript_diff.py` |
| Routes für Phase-2-Editor? | `POST /edit/speaker` · `POST /edit/bulk-rename` · `POST /edit/merge` · `POST /edit/split` · `POST /edit/undo` · `GET`/`POST /edit/words` · `GET /diff` |
| Spec für Phase 2? | `docs/superpowers/specs/2026-05-27-transcript-editor-phase2-design.md` |
| Plan für Phase 2? | `docs/superpowers/plans/2026-05-27-transcript-editor-phase2.md` |
In the "Phase 2 oder später"-Liste, remove the line about Phase 2 features and replace with a Phase 3 stub:
- Audio-Re-Align nach Word-Edit (Phase 3)
- Visual word-timing editor (Phase 3)
- Multi-Tab Conflict-Resolution (Phase 3)
- Find-and-Replace (Phase 3)
- Spell-Check / LLM-Suggestions (Phase 3)
- [ ] Step 16.2: No commit (AGENTS.md is .gitignored per repo convention)
This file is local-only. Skip git add.
Task 17: End-to-end manual test¶
Skip if testing only via automated suite. For thorough verification:
- [ ] Step 17.1: Start webgui
- [ ] Step 17.2: Run a small audio through the pipeline with pause-after-transcribe enabled
Editor should open. Try each Phase 2 action:
1. Change a speaker per segment — verify the dropdown updates without page reload (HTMX)
2. Bulk-rename SPEAKER_00 → Anna — verify all segments update on return
3. Merge two segments — verify count drops by 1 and combined text appears
4. Split a segment at a char position — verify count grows by 1
5. Click "Word view" — edit a word, save, verify segment.text rebuilt
6. Open Undo dropdown — verify history list is shown
7. Click "Undo last action" — verify the last change is reverted
8. Open Diff view — verify edits appear with ins/del highlighting
9. Save & Continue — verify Meta + Render run with updated transcript
- [ ] Step 17.3: Document any issues
If anything is broken, fix it as a follow-up commit on this branch.
Task 18: Merge to main¶
- [ ] Step 18.1: Final sweep
Confirm ≥150 PASS, 0 failures.
- [ ] Step 18.2: Verify branch state
- [ ] Step 18.3: Merge
git checkout main
git merge --no-ff feat/transcript-editor-phase2 -m "Merge branch 'feat/transcript-editor-phase2' — Transkript-Editor Phase 2
Adds Speaker-Re-Labelling (per-segment + bulk), Merge/Split-Segments,
Word-Level-Edits (no audio re-align), Diff-View vs .original.json,
and Undo-Stack via per-mutation snapshots.
4 new modules, 3 new templates, ~50 new tests (150 total, all passing).
Spec: docs/superpowers/specs/2026-05-27-transcript-editor-phase2-design.md
Plan: docs/superpowers/plans/2026-05-27-transcript-editor-phase2.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
- [ ] Step 18.4: Do NOT push to remote without user authorization.
Self-Review Notes¶
After writing this plan, verified:
- ✅ Spec → tasks mapping: Speaker (T1, T2, T9, T10) · Merge/Split (T3, T4, T11) · History (T5, T6, T12) · Words (T7, T13) · Diff (T8, T14) · UI integration (T15) · Docs (T16) · E2E + merge (T17-18)
- ✅ No placeholders (TBD, "appropriate", etc.)
- ✅ Type consistency: change_speaker(json_path, segment_index, new_speaker), bulk_rename_speaker(...) → int, merge_segment(json_path, segment_index), split_segment(json_path, segment_index, char_position), snapshot(json_path, action, metric) → str, undo_last(json_path) → dict | None — all used consistently across tasks
- ✅ File paths absolute and matched to current repo structure
- ✅ TDD discipline maintained per task
- ✅ Critical subtlety in Task 6 (undo_last) is called out: snapshot captures pre-history-append state, so no extra pop needed after restore
- ✅ HTMX integration documented (segment_editor.html partial as canonical target; segments_list.html for full-list swaps after merge/split)
- ✅ Plan size aligned with V1 (~2000 lines for similar scope)