From 9be85e152ed877e1a2f6a1928140d8c186cb5be9 Mon Sep 17 00:00:00 2001 From: zj1123581321 Date: Sun, 11 Oct 2026 00:48:21 +0800 Subject: [PATCH] =?UTF-8?q?chapters=20=E7=A7=8D=E5=AD=90=E7=BB=8F=20llm=5F?= =?UTF-8?q?task=20=E4=BA=A4=E6=8E=A5=E6=97=B6=E9=80=8F=E4=BC=A0=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=20kind=EF=BC=88=E4=BF=AE=E5=88=86=E6=94=AF=201=20?= =?UTF-8?q?=E7=A1=AC=E7=BC=96=E7=A0=81=E6=A0=87=E7=AD=BE=EF=BC=8C#227?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Executor: glm-pi Agent-Model: glm-5.3-flash Agent-Effort: high Dispatch-Id: dlg-20261010-161744-8c9581 Task-Id: VideoTranscriptAPI-20261015-07 --- .../api/services/llm_ops.py | 9 +++- .../api/services/transcription.py | 6 +++ tests/integration/test_layered_cache.py | 49 +++++++++++++++++++ tests/unit/test_chapters_pipeline_wiring.py | 19 +++++++ 4 files changed, 81 insertions(+), 2 deletions(-) diff --git a/src/video_transcript_api/api/services/llm_ops.py b/src/video_transcript_api/api/services/llm_ops.py index ad91049e..7e934afe 100644 --- a/src/video_transcript_api/api/services/llm_ops.py +++ b/src/video_transcript_api/api/services/llm_ops.py @@ -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 @@ -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) diff --git a/src/video_transcript_api/api/services/transcription.py b/src/video_transcript_api/api/services/transcription.py index 42c30a7e..5e7448d9 100644 --- a/src/video_transcript_api/api/services/transcription.py +++ b/src/video_transcript_api/api/services/transcription.py @@ -1699,12 +1699,14 @@ 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: @@ -1712,6 +1714,8 @@ def _fail_task_and_notify( 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}" @@ -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, diff --git a/tests/integration/test_layered_cache.py b/tests/integration/test_layered_cache.py index 5d4e7a00..0a867e6a 100644 --- a/tests/integration/test_layered_cache.py +++ b/tests/integration/test_layered_cache.py @@ -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 diff --git a/tests/unit/test_chapters_pipeline_wiring.py b/tests/unit/test_chapters_pipeline_wiring.py index 7e0cd690..a4fac705 100644 --- a/tests/unit/test_chapters_pipeline_wiring.py +++ b/tests/unit/test_chapters_pipeline_wiring.py @@ -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 = {