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
9 changes: 7 additions & 2 deletions src/video_transcript_api/api/services/llm_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,9 @@ def _resolve_chapters_timeline_segments(
This-round structured dialogs are preferred *inside* the coordinator after
calibration. Here we only resolve what llm_ops can see before process():

1. Explicit llm_task["timeline_segments"] (layered handoff / tests)
1. Explicit llm_task["timeline_segments"] (layered handoff / tests); the
real source kind recorded at seed-resolve time travels in
llm_task["timeline_segments_kind"] (legacy callers default "segments")
2. Cached llm_processed.json dialogs
3. Cached raw segments (get_cache["segments"] or load_segments)
4. None → coordinator/processor returns SKIPPED_NO_TIMELINE
Expand All @@ -109,7 +111,10 @@ def _resolve_chapters_timeline_segments(
"""
task_segments = llm_task.get("timeline_segments")
if isinstance(task_segments, list) and task_segments:
return task_segments, "segments"
# The kind label must match the seed's actual source (issue #227):
# transcription records it at resolve time; fall back to "segments"
# for legacy callers/tests that pass a bare list.
return task_segments, llm_task.get("timeline_segments_kind") or "segments"

if platform and media_id:
get_cache = getattr(cache_manager, "get_cache", None)
Expand Down
6 changes: 6 additions & 0 deletions src/video_transcript_api/api/services/transcription.py
Original file line number Diff line number Diff line change
Expand Up @@ -1699,19 +1699,23 @@ def _fail_task_and_notify(
# need backfill: prefer cached dialogs / segments so coordinator
# does not depend on re-running calibration for timeline.
timeline_segments_seed = None
timeline_segments_kind = None
if need_chapters:
processed = cache_data.get("llm_processed") or {}
if isinstance(processed, dict) and isinstance(
processed.get("dialogs"), list
) and processed["dialogs"]:
timeline_segments_seed = processed["dialogs"]
timeline_segments_kind = "cached_dialogs"
else:
file_path = cache_data.get("file_path")
if file_path:
try:
from ...transcriber.segments import load_segments

timeline_segments_seed = load_segments(file_path)
if timeline_segments_seed:
timeline_segments_kind = "segments"
except Exception as segs_exc:
logger.warning(
f"load_segments for chapters handoff failed: {segs_exc}"
Expand Down Expand Up @@ -1748,6 +1752,8 @@ def _fail_task_and_notify(
}
if timeline_segments_seed is not None:
handoff_payload["timeline_segments"] = timeline_segments_seed
if timeline_segments_kind:
handoff_payload["timeline_segments_kind"] = timeline_segments_kind

handoff_failure = _handoff_to_llm_stage(
task_id,
Expand Down
49 changes: 49 additions & 0 deletions tests/integration/test_layered_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -997,6 +997,55 @@ def test_chapters_seed_uses_load_segments_isomorphic_with_notes_reader(
seed_segments = queued[0].get("timeline_segments")
assert seed_segments == expected_disk_segments
assert compute_notes_anchor_fingerprint(seed_segments) == expected_fingerprint
# load_segments seed must also carry its real kind through the handoff.
assert queued[0].get("timeline_segments_kind") == "segments"

def test_chapters_seed_dialogs_handoff_carries_real_kind(
self, monkeypatch, patch_runtime, tmp_path
):
"""Issue #227 last broken link: when the chapters seed handed off via
llm_task["timeline_segments"] comes from cached structured dialogs,
the real kind must travel with it (timeline_segments_kind) instead of
being relabeled "segments" by llm_ops branch 1. The notes reader
reproduces the anchor from the kind label, so a wrong label makes it
read the wrong file and fail the fingerprint check (production:
task_61b56024, 518 dialogs vs 556 segments)."""
cache_dir = tmp_path / "cache_dialogs"
cache_dir.mkdir(parents=True)
dialogs = [
{"speaker": "A", "start_time": 0.0, "end_time": 1.5, "text": "d1"},
{"speaker": "B", "start_time": 1.5, "end_time": 3.0, "text": "d2"},
]
(cache_dir / "llm_processed.json").write_text(
json.dumps({"dialogs": dialogs}), encoding="utf-8"
)
expected_fingerprint = compute_notes_anchor_fingerprint(dialogs)

cache_data = {
**BASE_CACHE_DATA,
"file_path": str(cache_dir),
"llm_calibrated": "calibrated text",
"llm_summary": "summary text",
"llm_processed": {"dialogs": dialogs},
"llm_status": {
"calibration_status": CalibrationStatus.FULL,
"summary_status": SummaryStatus.GENERATED,
"chapters_status": ChaptersStatus.FAILED,
},
}

result, queued = _run(
monkeypatch, patch_runtime, cache_data,
{"calibrate": False, "summarize": False, "chapters": True,
"infer_speaker_names": False},
)

assert result["status"] == "success"
assert len(queued) == 1
seed_segments = queued[0].get("timeline_segments")
assert seed_segments == dialogs
assert queued[0].get("timeline_segments_kind") == "cached_dialogs"
assert compute_notes_anchor_fingerprint(seed_segments) == expected_fingerprint



Expand Down
19 changes: 19 additions & 0 deletions tests/unit/test_chapters_pipeline_wiring.py
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,25 @@ def test_prefers_task_timeline_segments(self, monkeypatch):
assert segs[0]["text"] == "a"
mock_cm.get_cache.assert_not_called()

def test_task_timeline_segments_kind_passthrough(self, monkeypatch):
"""Issue #227 last broken link: the kind recorded by transcription at
seed-resolve time must survive the llm_task handoff instead of being
relabeled "segments" by branch 1."""
mock_cm = MagicMock()
monkeypatch.setattr(llm_ops, "cache_manager", mock_cm)
segs, kind = llm_ops._resolve_chapters_timeline_segments(
llm_task={
"timeline_segments": [{"text": "d0", "start_time": 0}],
"timeline_segments_kind": "cached_dialogs",
},
platform="youtube",
media_id="m",
use_speaker_recognition=False,
)
assert kind == "cached_dialogs"
assert segs[0]["text"] == "d0"
mock_cm.get_cache.assert_not_called()

def test_uses_cached_dialogs(self, monkeypatch):
mock_cm = MagicMock()
mock_cm.get_cache.return_value = {
Expand Down
Loading