From 6718bae08bb57e6bbeb8bff9ffc63d921126fcfb Mon Sep 17 00:00:00 2001 From: Fire162 Date: Tue, 6 Oct 2026 03:56:47 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=A9=BA=E8=BE=93=E5=85=A5=E6=97=B6?= =?UTF-8?q?=E8=B7=B3=E8=BF=87=20ThreadPoolExecutor=20=E9=81=BF=E5=85=8D=20?= =?UTF-8?q?max=5Fworkers=3D0=20=E5=B4=A9=E6=BA=83=20(#179)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../llm/processors/notes_processor.py | 9 + .../llm/processors/plain_text_processor.py | 9 +- .../llm/processors/speaker_aware_processor.py | 17 ++ tests/README.md | 3 + tests/unit/test_empty_input_processors.py | 225 ++++++++++++++++++ 5 files changed, 261 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_empty_input_processors.py diff --git a/src/video_transcript_api/llm/processors/notes_processor.py b/src/video_transcript_api/llm/processors/notes_processor.py index a4db9a67..2b8022a4 100644 --- a/src/video_transcript_api/llm/processors/notes_processor.py +++ b/src/video_transcript_api/llm/processors/notes_processor.py @@ -548,6 +548,15 @@ def call_notes(user_prompt: str) -> str: chapter_notes = retry_notes return f"{_format_chapter_heading(chapter)}\n{chapter_notes.strip()}" + if not mapping.slices: + return NotesResult( + text=None, + status=NotesStatus.FAILED, + error=mapping.error or "no chapter slices available", + fingerprint=mapping.current_fingerprint, + chapter_count=0, + ) + max_workers = min(len(mapping.slices), self.config.notes_concurrency) with ThreadPoolExecutor(max_workers=max_workers) as executor: future_positions = { diff --git a/src/video_transcript_api/llm/processors/plain_text_processor.py b/src/video_transcript_api/llm/processors/plain_text_processor.py index 281bf568..a4a65ae5 100644 --- a/src/video_transcript_api/llm/processors/plain_text_processor.py +++ b/src/video_transcript_api/llm/processors/plain_text_processor.py @@ -124,7 +124,7 @@ def process( segments = self.segmenter.segment(text) logger.debug(f"Text segmented: {len(segments)} segments") else: - segments = [text] + segments = [text] if text.strip() else [] logger.debug("Text length below threshold, no segmentation") # 步骤3: 分段校对 @@ -155,7 +155,9 @@ def process( # 上面局部变量 calibrated_segments(校对后文本列表)混淆——此处在赋值前先读取计数。 calibrated_segment_count = total_segments - fallback_segments - if fallback_segments == 0 and low_quality_segments == 0: + if total_segments == 0: + calibration_status = CalibrationStatus.NONE + elif fallback_segments == 0 and low_quality_segments == 0: calibration_status = CalibrationStatus.FULL elif calibrated_segment_count == 0: calibration_status = CalibrationStatus.NONE @@ -204,6 +206,9 @@ def _calibrate_segments( 仍采用 LLM 候选文本;"fallback": 最终采用了原文格式化,即 _fallback_plain_text 或异常兜底路径返回了 _format_plain_text(original)) """ + if not segments: + return [], [] + model = selected_models["calibrate_model"] if selected_models else self.config.calibrate_model reasoning_effort = selected_models.get("calibrate_reasoning_effort") if selected_models else self.config.calibrate_reasoning_effort diff --git a/src/video_transcript_api/llm/processors/speaker_aware_processor.py b/src/video_transcript_api/llm/processors/speaker_aware_processor.py index 1ceded3f..09987bed 100644 --- a/src/video_transcript_api/llm/processors/speaker_aware_processor.py +++ b/src/video_transcript_api/llm/processors/speaker_aware_processor.py @@ -741,6 +741,23 @@ def _calibrate_chunks( - calibrated_chunks: 校对后的分块列表(包含成功+降级的混合结果) - calibration_stats: 校准统计 {total_chunks, success_count, fallback_count, failed_count} """ + if not chunks: + return [], { + "total_chunks": 0, + "success_count": 0, + "partial_count": 0, + "fallback_count": 0, + "failed_count": 0, + "dialog_counts": { + "applied": 0, + "kept_original": 0, + "unknown_id": 0, + "duplicate_id": 0, + "malformed": 0, + }, + "calibration_status": CalibrationStatus.NONE, + } + model = selected_models["calibrate_model"] if selected_models else self.config.calibrate_model reasoning_effort = selected_models.get("calibrate_reasoning_effort") if selected_models else self.config.calibrate_reasoning_effort diff --git a/tests/README.md b/tests/README.md index f2bae164..e4302d9d 100644 --- a/tests/README.md +++ b/tests/README.md @@ -165,6 +165,9 @@ CapsWriter 层(`test_generic_path_duration_reaches_transcriber`);修复不 ffprobe / ffmpeg、无预置 fixture、没有 skip 分支。拒绝路径的完整矩阵仍由上一节那个 需要真实 ffprobe 的文件覆盖。 +LLM 处理器空输入守卫(#179):`tests/unit/test_empty_input_processors.py`。 +锁的不变量:`PlainTextProcessor._calibrate_segments`、`SpeakerAwareProcessor._calibrate_chunks` 与 `NotesProcessor.process` 在输入分段/分块列表为空时直接返回与空输入同构的空结果,不启动 `ThreadPoolExecutor(max_workers=0)`,不发起 LLM 调用;端到端空文本/空对话校对返回诚实状态 `calibration_status=none` 且不抛 `ValueError`。 + ## 并发压测 `scripts/perf/concurrent_load.py` 会提交本地 API 任务,并使用真实抖音和 B 站 diff --git a/tests/unit/test_empty_input_processors.py b/tests/unit/test_empty_input_processors.py new file mode 100644 index 00000000..a7412eae --- /dev/null +++ b/tests/unit/test_empty_input_processors.py @@ -0,0 +1,225 @@ +"""Regression tests for Issue #179: ThreadPoolExecutor(max_workers=0) crashes on empty input. + +Ensures that: +1. PlainTextProcessor._calibrate_segments([]) returns ([], []) directly without + launching a ThreadPoolExecutor or issuing LLM calls. +2. PlainTextProcessor.process(text="") completes cleanly with 0 segments and + honest calibration_status=none. +3. SpeakerAwareProcessor._calibrate_chunks(chunks=[]) returns empty chunks and + zeroed calibration stats without launching a ThreadPoolExecutor. +4. SpeakerAwareProcessor.process(dialogs=[]) completes cleanly with empty output + and honest calibration_status=none. +5. NotesProcessor with empty chapter slices returns a failed NotesResult rather + than crashing on max_workers=0. +""" + +from unittest.mock import MagicMock, Mock +import pytest + +from video_transcript_api.llm.processors.plain_text_processor import PlainTextProcessor +from video_transcript_api.llm.processors.speaker_aware_processor import ( + SpeakerAwareProcessor, +) +from video_transcript_api.llm.processors.notes_processor import ( + NotesProcessor, + NotesStatus, +) +from video_transcript_api.llm.core.config import LLMConfig +from video_transcript_api.llm.core.key_info_extractor import KeyInfo +from video_transcript_api.utils.llm_status import CalibrationStatus + + +@pytest.fixture +def mock_config(): + config = Mock(spec=LLMConfig) + config.enable_threshold = 5000 + config.min_calibrate_ratio = 0.8 + config.concurrent_workers = 10 + config.calibration_concurrent_limit = 10 + config.segment_size = 2000 + config.max_segment_size = 3000 + config.calibrate_model = "mock-model" + config.calibrate_reasoning_effort = "medium" + config.notes_model = "mock-notes-model" + config.notes_reasoning_effort = "medium" + config.notes_concurrency = 4 + config.segmentation_pass_ratio = 0.7 + config.segmentation_force_retry_ratio = 0.5 + config.segmentation_fallback_strategy = "best_quality" + config.segmentation_validation_enabled = False + config.paragraphization_target_chars = 400 + config.paragraphization_hard_max_chars = 600 + config.paragraphization_pause_threshold_seconds = 2.0 + config.plain_structured_preferred_chunk_length = 2000 + config.plain_structured_max_chunk_length = 3000 + return config + + +class TestEmptyInputProcessors: + def test_plain_text_calibrate_segments_empty_input(self, mock_config): + llm_client = Mock() + processor = PlainTextProcessor( + config=mock_config, + llm_client=llm_client, + key_info_extractor=Mock(), + quality_validator=Mock(), + ) + + calibrated_segments, segment_statuses = processor._calibrate_segments( + segments=[], + key_info=Mock(spec=KeyInfo), + title="Empty Test", + description="", + selected_models=None, + ) + + assert calibrated_segments == [] + assert segment_statuses == [] + llm_client.call.assert_not_called() + + def test_plain_text_process_empty_text(self, mock_config): + llm_client = Mock() + key_info_mock = Mock(spec=KeyInfo) + key_info_mock.to_dict.return_value = {} + key_info_mock.format_for_prompt.return_value = "" + key_info_extractor = Mock() + key_info_extractor.extract.return_value = key_info_mock + + processor = PlainTextProcessor( + config=mock_config, + llm_client=llm_client, + key_info_extractor=key_info_extractor, + quality_validator=Mock(), + ) + + result = processor.process( + text="", + title="Empty Recording", + author="Author", + description="", + platform="test", + media_id="m1", + ) + + assert result["calibrated_text"] == "" + stats = result["stats"] + assert stats["original_length"] == 0 + assert stats["calibrated_length"] == 0 + assert stats["segment_count"] == 0 + assert stats["total_segments"] == 0 + assert stats["calibrated_segments"] == 0 + assert stats["fallback_segments"] == 0 + assert stats["calibration_status"] == CalibrationStatus.NONE + llm_client.call.assert_not_called() + + def test_speaker_aware_calibrate_chunks_empty_input(self, mock_config): + llm_client = Mock() + processor = SpeakerAwareProcessor( + config=mock_config, + llm_client=llm_client, + key_info_extractor=Mock(), + speaker_inferencer=Mock(), + quality_validator=Mock(), + ) + + chunks, stats = processor._calibrate_chunks( + chunks=[], + original_chunks=[], + key_info=Mock(spec=KeyInfo), + speaker_mapping={}, + title="Empty Test", + description="", + selected_models=None, + ) + + assert chunks == [] + assert stats["total_chunks"] == 0 + assert stats["success_count"] == 0 + assert stats["failed_count"] == 0 + assert stats["calibration_status"] == CalibrationStatus.NONE + llm_client.call.assert_not_called() + + def test_speaker_aware_process_empty_dialogs(self, mock_config): + llm_client = Mock() + key_info_mock = Mock(spec=KeyInfo) + key_info_mock.to_dict.return_value = {} + key_info_mock.format_for_prompt.return_value = "" + key_info_extractor = Mock() + key_info_extractor.extract.return_value = key_info_mock + + processor = SpeakerAwareProcessor( + config=mock_config, + llm_client=llm_client, + key_info_extractor=key_info_extractor, + speaker_inferencer=Mock(), + quality_validator=Mock(), + ) + + result = processor.process( + dialogs=[], + title="12s Silent Recording", + author="Author", + description="", + platform="test", + media_id="m2", + ) + + assert result["calibrated_text"] == "" + assert result["structured_data"]["dialogs"] == [] + stats = result["stats"] + assert stats["original_length"] == 0 + assert stats["calibrated_length"] == 0 + assert stats["dialog_count"] == 0 + assert stats["calibration_stats"]["total_chunks"] == 0 + assert stats["calibration_stats"]["calibration_status"] == CalibrationStatus.NONE + llm_client.call.assert_not_called() + + def test_notes_processor_empty_slices_returns_failed_result(self, mock_config): + llm_client = Mock() + processor = NotesProcessor(llm_client, mock_config) + + result = processor.process( + chapters={"chapters": [], "source": {"fingerprint": "abc"}}, + source_segments=[{"text": "some text", "start_time": 0.0, "end_time": 1.0}], + selected_models=None, + ) + + assert result.status == NotesStatus.FAILED + assert result.text is None + assert result.chapter_count == 0 + llm_client.call.assert_not_called() + + def test_coordinator_handles_empty_string_and_empty_list(self, tmp_path): + from video_transcript_api.llm.coordinator import LLMCoordinator + + cfg = { + "llm": { + "api_key": "test-key", + "base_url": "http://localhost", + "calibrate_model": "mock-model", + "summary_model": "mock-model", + } + } + coord = LLMCoordinator(cfg, cache_dir=str(tmp_path)) + + res_str = coord.process( + content="", + title="Empty String", + author="Author", + description="", + skip_summary=True, + skip_chapters=True, + ) + assert res_str["calibrated_text"] == "" + assert res_str["stats"]["calibration_status"] == CalibrationStatus.NONE + + res_list = coord.process( + content=[], + title="Empty List", + author="Author", + description="", + skip_summary=True, + skip_chapters=True, + ) + assert res_list["calibrated_text"] == "" + assert res_list["stats"]["calibration_status"] == CalibrationStatus.NONE