From 68e2107720f527abdcaff21ef7cb29a5212aa393 Mon Sep 17 00:00:00 2001 From: zj1123581321 Date: Mon, 24 Aug 2026 17:18:46 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E6=80=BB=E7=BB=93=E5=B1=82?= =?UTF-8?q?=E7=AF=87=E5=B9=85=E9=A2=84=E7=AE=97=E6=B2=BB=E7=90=86=EF=BC=88?= =?UTF-8?q?prompt=20=E6=94=B6=E6=95=9B=20+=20=E5=9B=9B=E5=B1=82=E9=98=B2?= =?UTF-8?q?=E7=BA=BF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 建立预算函数为唯一事实源,prompt 注入篇幅目标、LLM call 加 max_tokens、 超硬顶压缩重试一次并 accept 落盘,新增 /api/audit/summary-ratio 监控端点, 修复 skipped_short 误导性统计展示。 Co-authored-by: Cursor Agent-Executor: cursor Agent-Model: composer-2.5 Agent-Effort: unknown Dispatch-Id: dlg-20260824-085655-4fbb2d Task-Id: VideoTranscriptAPI-20260824-02 --- config/config.example.jsonc | 16 + src/video_transcript_api/api/routes/audit.py | 45 +++ .../api/services/summary_ratio_stats.py | 200 ++++++++++++ src/video_transcript_api/llm/core/config.py | 8 + .../llm/core/llm_client.py | 2 + .../llm/core/summary_budget.py | 98 ++++++ src/video_transcript_api/llm/llm.py | 10 +- .../llm/processors/summary_processor.py | 94 +++++- .../llm/prompts/__init__.py | 69 ++-- src/web/templates/transcript.html | 8 +- tests/llm/test_llm_max_tokens.py | 88 +++++ tests/llm/test_summary_budget.py | 68 ++++ tests/llm/test_summary_processor.py | 305 ++++++++++-------- tests/unit/test_summary_ratio_stats.py | 123 +++++++ .../web/test_transcript_disabled_states.py | 2 +- .../test_transcript_skipped_short_stats.py | 54 ++++ 16 files changed, 985 insertions(+), 205 deletions(-) create mode 100644 src/video_transcript_api/api/services/summary_ratio_stats.py create mode 100644 src/video_transcript_api/llm/core/summary_budget.py create mode 100644 tests/llm/test_llm_max_tokens.py create mode 100644 tests/llm/test_summary_budget.py create mode 100644 tests/unit/test_summary_ratio_stats.py create mode 100644 tests/unit/web/test_transcript_skipped_short_stats.py diff --git a/config/config.example.jsonc b/config/config.example.jsonc index 7f12c9d9..8e02cf5a 100644 --- a/config/config.example.jsonc +++ b/config/config.example.jsonc @@ -215,6 +215,22 @@ "min_summary_threshold": 500, // 文本长度低于此值时跳过总结 "min_calibrate_ratio": 0.8, // 校对后文本长度/原文长度的最小比例,低于则视为异常 + // ------------------------------------------------------ + // 总结篇幅预算(prompt / max_tokens / 后验硬顶共用同一曲线) + // ------------------------------------------------------ + "summary_budget": { + "s_target_min": 500, + "s_target_max": 3000, + "s_hard_cap_max": 4500, + "m_target_min": 2000, + "m_target_max": 4000, + "m_hard_cap": 5000, + "l_target_min": 4000, + "l_target_max": 6000, + "l_hard_cap": 8000, + "max_tokens_multiplier": 1.5 + }, + // ------------------------------------------------------ // 内容审查降级(llm-compat content_fallbacks) // 当主模型因内容审查拒绝时,自动切换到 fallback 模型 diff --git a/src/video_transcript_api/api/routes/audit.py b/src/video_transcript_api/api/routes/audit.py index b74f8f34..3179b3a5 100644 --- a/src/video_transcript_api/api/routes/audit.py +++ b/src/video_transcript_api/api/routes/audit.py @@ -6,6 +6,7 @@ import asyncio import sqlite3 +from pathlib import Path from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query @@ -13,12 +14,15 @@ from ..context import ( get_audit_logger, get_cache_manager, + get_config, get_logger, get_usage_recorder, get_user_manager, lazy_resource, ) +from ..services.summary_ratio_stats import compute_summary_ratio_stats from ..services.transcription import TranscribeResponse, verify_token +from ...llm.core.summary_budget import SummaryBudgetConfig from ..services.view_token_resolver import ViewTokenResolver from ...utils.llm_status import SummaryStatus @@ -166,6 +170,47 @@ async def get_audit_stats(days: int = 30, user_info: dict = Depends(verify_token raise HTTPException(status_code=500, detail=f"获取统计信息失败: {exc}") +@router.get("/summary-ratio") +async def get_summary_ratio_stats( + days: int = Query(30, ge=1, le=365, description="统计时间窗口(天)"), + user_info: dict = Depends(verify_token), +): + """按 S/M/L 分带返回总结相对原文的长度比例监控指标。""" + can_view_global = ( + not user_manager.is_multi_user_mode() or user_info.get("is_legacy", False) + ) + if not can_view_global: + raise HTTPException(status_code=403, detail="无权访问全局总结比例统计") + + try: + app_config = get_config() + storage = app_config.get("storage", {}) + audit_db_path = storage.get("audit_db") + cache_manager = get_cache_manager() + cache_db_path = str(Path(cache_manager.cache_dir) / "cache.db") + cache_root = Path(cache_manager.cache_dir) + budget_config = SummaryBudgetConfig.from_dict( + (app_config.get("llm") or {}).get("summary_budget") + ) + + data = await asyncio.to_thread( + compute_summary_ratio_stats, + audit_db_path=audit_db_path, + cache_db_path=cache_db_path, + cache_root=cache_root, + days=days, + budget_config=budget_config, + ) + return TranscribeResponse( + code=200, + message="获取总结比例统计成功", + data=data, + ) + except Exception as exc: + logger.exception("获取总结比例统计异常: %s", exc) + raise HTTPException(status_code=500, detail=f"获取总结比例统计失败: {exc}") + + @router.get("/calls") async def get_audit_calls( limit: int = Query(100, ge=1, le=10000, description="返回记录数量限制"), diff --git a/src/video_transcript_api/api/services/summary_ratio_stats.py b/src/video_transcript_api/api/services/summary_ratio_stats.py new file mode 100644 index 00000000..78af2140 --- /dev/null +++ b/src/video_transcript_api/api/services/summary_ratio_stats.py @@ -0,0 +1,200 @@ +"""Compute summary-to-original length ratio stats for audit monitoring.""" + +from __future__ import annotations + +import json +import sqlite3 +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from ...transcriber import FunASRSpeakerClient +from ...utils.logging import setup_logger +from ...llm.core.summary_budget import ( + classify_original_length_band, + compute_summary_budget, + SummaryBudgetConfig, +) + +logger = setup_logger(__name__) + +_BAND_ORDER = ("S", "M", "L") + + +def _median(values: List[float]) -> float: + if not values: + return 0.0 + ordered = sorted(values) + mid = len(ordered) // 2 + if len(ordered) % 2: + return ordered[mid] + return (ordered[mid - 1] + ordered[mid]) / 2.0 + + +def _p90(values: List[float]) -> float: + if not values: + return 0.0 + ordered = sorted(values) + index = int(0.9 * (len(ordered) - 1)) + return ordered[index] + + +def _read_original_length(cache_dir: Path) -> Optional[int]: + funasr_file = cache_dir / "transcript_funasr.json" + capswriter_file = cache_dir / "transcript_capswriter.txt" + try: + if funasr_file.exists(): + with funasr_file.open("r", encoding="utf-8") as handle: + funasr_data = json.load(handle) + client = FunASRSpeakerClient() + transcript_text = client.format_transcript_with_speakers(funasr_data) + return len(transcript_text) + if capswriter_file.exists(): + with capswriter_file.open("r", encoding="utf-8") as handle: + return len(handle.read()) + except Exception as exc: + logger.warning(f"Failed to read original transcript length from {cache_dir}: {exc}") + return None + + +def _read_summary_length(cache_dir: Path) -> Optional[int]: + summary_file = cache_dir / "llm_summary.txt" + if not summary_file.exists(): + return None + try: + with summary_file.open("r", encoding="utf-8") as handle: + return len(handle.read()) + except OSError as exc: + logger.warning(f"Failed to read summary length from {cache_dir}: {exc}") + return None + + +def _aggregate_band(ratios: List[float], over_100: int, over_hardcap: int) -> Dict[str, Any]: + return { + "n": len(ratios), + "median_ratio": round(_median(ratios), 4) if ratios else 0.0, + "p90_ratio": round(_p90(ratios), 4) if ratios else 0.0, + "over_100pct": over_100, + "over_hardcap": over_hardcap, + } + + +def compute_summary_ratio_stats( + *, + audit_db_path: str, + cache_db_path: str, + cache_root: Path, + days: int, + budget_config: Optional[SummaryBudgetConfig] = None, +) -> Dict[str, Any]: + """Join audit snapshots to cache artifacts and compute S/M/L band ratios.""" + budget_config = budget_config or SummaryBudgetConfig() + cutoff = (datetime.now() - timedelta(days=max(1, days))).strftime("%Y-%m-%d %H:%M:%S") + + audit_conn = sqlite3.connect(f"file:{audit_db_path}?mode=ro", uri=True) + audit_conn.row_factory = sqlite3.Row + cache_conn = sqlite3.connect(f"file:{cache_db_path}?mode=ro", uri=True) + cache_conn.row_factory = sqlite3.Row + + try: + snapshot_rows = audit_conn.execute( + """ + SELECT task_id, platform, summary_status, archived_at + FROM task_audit_snapshots + WHERE summary_status = 'generated' + AND status = 'success' + AND COALESCE(content_expired, 0) = 0 + AND archived_at >= ? + """, + (cutoff,), + ).fetchall() + + band_ratios: Dict[str, List[float]] = {band: [] for band in _BAND_ORDER} + band_over_100: Dict[str, int] = {band: 0 for band in _BAND_ORDER} + band_over_hardcap: Dict[str, int] = {band: 0 for band in _BAND_ORDER} + skipped = 0 + + for row in snapshot_rows: + task_id = row["task_id"] + platform = row["platform"] + task_row = cache_conn.execute( + """ + SELECT platform, media_id, use_speaker_recognition + FROM task_status + WHERE task_id = ? + """, + (task_id,), + ).fetchone() + if not task_row: + skipped += 1 + continue + + resolved_platform = task_row["platform"] or platform + media_id = task_row["media_id"] + if not resolved_platform or not media_id: + skipped += 1 + continue + + use_speaker = bool(task_row["use_speaker_recognition"]) + cache_row = cache_conn.execute( + """ + SELECT files_loc FROM video_cache + WHERE platform = ? AND media_id = ? + ORDER BY use_speaker_recognition DESC, updated_at DESC + LIMIT 1 + """, + (resolved_platform, media_id), + ).fetchone() + if use_speaker: + cache_row = cache_conn.execute( + """ + SELECT files_loc FROM video_cache + WHERE platform = ? AND media_id = ? AND use_speaker_recognition = 1 + ORDER BY updated_at DESC + LIMIT 1 + """, + (resolved_platform, media_id), + ).fetchone() or cache_row + + if not cache_row: + skipped += 1 + continue + + cache_dir = cache_root / Path(cache_row["files_loc"]) + original_length = _read_original_length(cache_dir) + summary_length = _read_summary_length(cache_dir) + if original_length is None or summary_length is None or original_length <= 0: + skipped += 1 + continue + + band = classify_original_length_band(original_length) + if band not in band_ratios: + skipped += 1 + continue + + ratio = summary_length / original_length + band_ratios[band].append(ratio) + if ratio > 1.0: + band_over_100[band] += 1 + hard_cap = compute_summary_budget(original_length, budget_config).hard_cap + if summary_length > hard_cap: + band_over_hardcap[band] += 1 + + bands = { + band: _aggregate_band( + band_ratios[band], + band_over_100[band], + band_over_hardcap[band], + ) + for band in _BAND_ORDER + } + return { + "days": days, + "cutoff": cutoff, + "bands": bands, + "skipped_tasks": skipped, + "sampled_tasks": sum(bands[b]["n"] for b in _BAND_ORDER), + } + finally: + audit_conn.close() + cache_conn.close() diff --git a/src/video_transcript_api/llm/core/config.py b/src/video_transcript_api/llm/core/config.py index ff8792f8..2590e7eb 100644 --- a/src/video_transcript_api/llm/core/config.py +++ b/src/video_transcript_api/llm/core/config.py @@ -3,6 +3,8 @@ from dataclasses import dataclass, field from typing import Dict, List, Optional, Union +from .summary_budget import SummaryBudgetConfig + @dataclass class LLMConfig: @@ -129,6 +131,9 @@ class LLMConfig: notes_reasoning_effort: Optional[str] = None notes_concurrency: int = 10 + # Summary output budget (prompt + max_tokens + post-hoc cap share one curve). + summary_budget: SummaryBudgetConfig = field(default_factory=SummaryBudgetConfig) + @classmethod def from_dict(cls, config_dict: dict) -> "LLMConfig": """从配置字典创建 LLMConfig 实例 @@ -321,6 +326,9 @@ def from_dict(cls, config_dict: dict) -> "LLMConfig": ) ), notes_concurrency=llm_config.get("notes_concurrency", 10), + summary_budget=SummaryBudgetConfig.from_dict( + llm_config.get("summary_budget") + ), ) def get_models(self) -> dict: diff --git a/src/video_transcript_api/llm/core/llm_client.py b/src/video_transcript_api/llm/core/llm_client.py index 1287169f..2d7b93d5 100644 --- a/src/video_transcript_api/llm/core/llm_client.py +++ b/src/video_transcript_api/llm/core/llm_client.py @@ -52,6 +52,7 @@ def call( reasoning_effort: Optional[str] = None, task_type: str = "unknown", force_json_mode: Optional[str] = None, + max_tokens: Optional[int] = None, ) -> LLMResponse: """调用 LLM API @@ -93,6 +94,7 @@ def call( system_prompt=system_prompt, config=self.config, force_json_mode=force_json_mode, + max_tokens=max_tokens, ) if isinstance(result, StructuredResult): diff --git a/src/video_transcript_api/llm/core/summary_budget.py b/src/video_transcript_api/llm/core/summary_budget.py new file mode 100644 index 00000000..416918df --- /dev/null +++ b/src/video_transcript_api/llm/core/summary_budget.py @@ -0,0 +1,98 @@ +"""Summary output budget: single source of truth for prompt, max_tokens, and post-hoc checks.""" + +from dataclasses import dataclass +from typing import Any, Dict, Optional + + +@dataclass(frozen=True) +class SummaryBudget: + """Budget envelope for one summary generation call.""" + + target_min: int + target_max: int + hard_cap: int + max_tokens: int + + +@dataclass(frozen=True) +class SummaryBudgetConfig: + """Configurable band parameters; defaults match production locked decision #4.""" + + s_target_min: int = 500 + s_target_max: int = 3000 + s_hard_cap_max: int = 4500 + m_target_min: int = 2000 + m_target_max: int = 4000 + m_hard_cap: int = 5000 + l_target_min: int = 4000 + l_target_max: int = 6000 + l_hard_cap: int = 8000 + max_tokens_multiplier: float = 1.5 + + @classmethod + def from_dict(cls, data: Optional[Dict[str, Any]]) -> "SummaryBudgetConfig": + if not data: + return cls() + return cls( + s_target_min=int(data.get("s_target_min", 500)), + s_target_max=int(data.get("s_target_max", 3000)), + s_hard_cap_max=int(data.get("s_hard_cap_max", 4500)), + m_target_min=int(data.get("m_target_min", 2000)), + m_target_max=int(data.get("m_target_max", 4000)), + m_hard_cap=int(data.get("m_hard_cap", 5000)), + l_target_min=int(data.get("l_target_min", 4000)), + l_target_max=int(data.get("l_target_max", 6000)), + l_hard_cap=int(data.get("l_hard_cap", 8000)), + max_tokens_multiplier=float(data.get("max_tokens_multiplier", 1.5)), + ) + + +def compute_summary_budget( + original_length: int, + config: Optional[SummaryBudgetConfig] = None, +) -> SummaryBudget: + """Compute summary budget from calibrated transcript length L (characters). + + Bands (locked decision #4): + S: 800 <= L < 8000 -> target 500-3000, hard cap min(2*L, 4500) + M: 8000 <= L < 30000 -> target 2000-4000, hard cap 5000 + L: L >= 30000 -> target 4000-6000, hard cap 8000 + + For L < 800 (possible when min_summary_threshold < 800), S-band targets apply + with the same hard-cap formula as S. + """ + cfg = config or SummaryBudgetConfig() + length = max(0, int(original_length)) + + if length >= 30000: + target_min = cfg.l_target_min + target_max = cfg.l_target_max + hard_cap = cfg.l_hard_cap + elif length >= 8000: + target_min = cfg.m_target_min + target_max = cfg.m_target_max + hard_cap = cfg.m_hard_cap + else: + target_min = cfg.s_target_min + target_max = cfg.s_target_max + hard_cap = min(2 * length, cfg.s_hard_cap_max) + + max_tokens = int(hard_cap * cfg.max_tokens_multiplier) + return SummaryBudget( + target_min=target_min, + target_max=target_max, + hard_cap=hard_cap, + max_tokens=max_tokens, + ) + + +def classify_original_length_band(original_length: int) -> str: + """Return S/M/L band label for monitoring (same L thresholds as compute_summary_budget).""" + length = max(0, int(original_length)) + if length >= 30000: + return "L" + if length >= 8000: + return "M" + if length >= 800: + return "S" + return "below_S" diff --git a/src/video_transcript_api/llm/llm.py b/src/video_transcript_api/llm/llm.py index 374f6f34..152434c0 100644 --- a/src/video_transcript_api/llm/llm.py +++ b/src/video_transcript_api/llm/llm.py @@ -415,6 +415,7 @@ def _call_with_text_output( reasoning_effort: Optional[str], task_type: str, config: Optional[Dict[str, Any]] = None, + max_tokens: Optional[int] = None, ) -> str: """纯文本输出调用(通过 llm-compat SyncLLMClient) @@ -450,7 +451,10 @@ def _call_with_text_output( ) start_time = time.time() - result = client.chat(model, messages, reasoning_effort=effort) + chat_kwargs: Dict[str, Any] = {} + if max_tokens is not None: + chat_kwargs["max_tokens"] = max_tokens + result = client.chat(model, messages, reasoning_effort=effort, **chat_kwargs) # 记录 usage 快照供 llm_client.call() 落审计库(token 用量审计,参见 usage_context.py) _record_chat_usage(model, result) content = str(result) @@ -643,6 +647,7 @@ def call_llm_api( config: Optional[Dict[str, Any]] = None, system_prompt: str = "You are a helpful assistant.", force_json_mode: Optional[str] = None, + max_tokens: Optional[int] = None, ) -> str: ... @@ -657,6 +662,7 @@ def call_llm_api( config: Optional[Dict[str, Any]] = None, system_prompt: str = "You are a helpful assistant.", force_json_mode: Optional[str] = None, + max_tokens: Optional[int] = None, ) -> StructuredResult: ... @@ -675,6 +681,7 @@ def call_llm_api( base_url: Optional[str] = None, max_retries: Optional[int] = None, retry_delay: Optional[int] = None, + max_tokens: Optional[int] = None, ) -> Union[str, StructuredResult]: """调用 LLM API(通过 llm-compat SyncLLMClient) @@ -698,6 +705,7 @@ def call_llm_api( reasoning_effort=reasoning_effort, task_type=task_type, config=config if config is not None else _default_config, + max_tokens=max_tokens, ) effective_config = config if config is not None else _default_config diff --git a/src/video_transcript_api/llm/processors/summary_processor.py b/src/video_transcript_api/llm/processors/summary_processor.py index 2bab5486..2ea71691 100644 --- a/src/video_transcript_api/llm/processors/summary_processor.py +++ b/src/video_transcript_api/llm/processors/summary_processor.py @@ -1,4 +1,4 @@ -"""内容总结处理器""" +"""Content summary processor with budget-aware generation and compression retry.""" from dataclasses import dataclass from typing import Dict, Optional @@ -7,6 +7,7 @@ from ...utils.llm_status import SummaryStatus from ..core.config import LLMConfig from ..core.llm_client import LLMClient +from ..core.summary_budget import compute_summary_budget from ..prompts import ( SUMMARY_SYSTEM_PROMPT_SINGLE_SPEAKER, SUMMARY_SYSTEM_PROMPT_MULTI_SPEAKER, @@ -15,6 +16,11 @@ logger = setup_logger(__name__) +_SUMMARY_BUDGET_RETRY_SUFFIX = ( + "警告:你上一次的输出字数为 {actual_len} 字,超过了篇幅上限 {hard_cap} 字。" + "请在保留全部信息点的前提下压缩篇幅,不得超过 {hard_cap} 字。" +) + @dataclass(frozen=True) class SummaryResult: @@ -91,7 +97,9 @@ def process( ) return SummaryResult(text=None, status=SummaryStatus.SKIPPED_SHORT) - logger.info(f"Generating summary for text (length: {len(text)}, speaker_count: {speaker_count})") + logger.info( + f"Generating summary for text (length: {len(text)}, speaker_count: {speaker_count})" + ) try: # 步骤 2: 选择模型 @@ -99,7 +107,7 @@ def process( model = selected_models.get("summary_model", self.config.summary_model) reasoning_effort = selected_models.get( "summary_reasoning_effort", - self.config.summary_reasoning_effort + self.config.summary_reasoning_effort, ) else: model = self.config.summary_model @@ -108,39 +116,97 @@ def process( # 步骤 3: 选择 System Prompt system_prompt = self._select_system_prompt(speaker_count) - # 步骤 4: 构建 User Prompt + # 步骤 4: 篇幅预算(prompt / max_tokens / 后验校验共用) + budget = compute_summary_budget(len(text), self.config.summary_budget) + user_prompt = build_summary_user_prompt( transcript=text, video_title=title, author=author, description=description, + budget_target_min=budget.target_min, + budget_target_max=budget.target_max, + budget_hard_cap=budget.hard_cap, ) - # 步骤 5: 调用 LLM - response = self.llm_client.call( + first_text = self._call_summary_llm( model=model, system_prompt=system_prompt, user_prompt=user_prompt, reasoning_effort=reasoning_effort, - task_type="summary", # 标识为总结任务(用于日志追踪和监控) + max_tokens=budget.max_tokens, ) - summary_text = response.text - - # 步骤 6: 验证结果 - if not summary_text or len(summary_text) < 50: + if not first_text or len(first_text) < 50: logger.warning( - f"Summary too short or empty: {len(summary_text) if summary_text else 0} chars" + f"Summary too short or empty: {len(first_text) if first_text else 0} chars" ) return SummaryResult(text=None, status=SummaryStatus.FAILED) - logger.info(f"Summary generated successfully (length: {len(summary_text)})") - return SummaryResult(text=summary_text, status=SummaryStatus.GENERATED) + if len(first_text) <= budget.hard_cap: + logger.info(f"Summary generated successfully (length: {len(first_text)})") + return SummaryResult(text=first_text, status=SummaryStatus.GENERATED) + + retry_suffix = _SUMMARY_BUDGET_RETRY_SUFFIX.format( + actual_len=len(first_text), + hard_cap=budget.hard_cap, + ) + retry_prompt = f"{user_prompt}\n\n{retry_suffix}" + retry_text: Optional[str] = None + try: + retry_text = self._call_summary_llm( + model=model, + system_prompt=system_prompt, + user_prompt=retry_prompt, + reasoning_effort=reasoning_effort, + max_tokens=budget.max_tokens, + ) + except Exception as retry_exc: + logger.warning( + f"Summary compression retry failed, keeping first answer: {retry_exc}" + ) + + if retry_text and len(retry_text) >= 50 and len(retry_text) <= budget.hard_cap: + logger.info( + f"Summary generated after compression retry (length: {len(retry_text)})" + ) + return SummaryResult(text=retry_text, status=SummaryStatus.GENERATED) + + candidates = [first_text] + if retry_text and len(retry_text) >= 50: + candidates.append(retry_text) + final_text = min(candidates, key=len) + + logger.warning( + f"summary_over_budget_accepted: first={len(first_text)} " + f"retry={len(retry_text) if retry_text else 0} " + f"hard_cap={budget.hard_cap} accepted={len(final_text)}" + ) + return SummaryResult(text=final_text, status=SummaryStatus.GENERATED) except Exception as e: logger.error(f"Summary generation failed: {e}", exc_info=True) return SummaryResult(text=None, status=SummaryStatus.FAILED) + def _call_summary_llm( + self, + *, + model: str, + system_prompt: str, + user_prompt: str, + reasoning_effort: Optional[str], + max_tokens: int, + ) -> str: + response = self.llm_client.call( + model=model, + system_prompt=system_prompt, + user_prompt=user_prompt, + reasoning_effort=reasoning_effort, + task_type="summary", + max_tokens=max_tokens, + ) + return response.text + def _select_system_prompt(self, speaker_count: int) -> str: """根据说话人数量选择 System Prompt diff --git a/src/video_transcript_api/llm/prompts/__init__.py b/src/video_transcript_api/llm/prompts/__init__.py index 3014f104..0731f6c2 100644 --- a/src/video_transcript_api/llm/prompts/__init__.py +++ b/src/video_transcript_api/llm/prompts/__init__.py @@ -9,6 +9,8 @@ 3. 动态内容放在消息末尾,最大化前缀缓存命中 """ +from typing import Optional + # ============================================================ # 校对任务 Prompt 模板 # ============================================================ @@ -163,7 +165,7 @@ def build_calibrate_user_prompt( ### 2. 主题详述 识别并详细展开内容中的各个主题,要求: -- 每个主题作为一个小节,详细展开内容(每个小节不少于500字) +- 每个主题作为一个小节,充分展开该主题的全部信息点,篇幅与该主题的信息量相称,服从总篇幅预算 - 让读者不需要二次查看原内容就能了解详情 - 若出现方法/框架/流程,将其重写为条理清晰的步骤或段落 - 若有关键数字、定义、原话,请如实保留核心词,并在括号内补充注释 @@ -171,7 +173,7 @@ def build_calibrate_user_prompt( - 如果文本中有Speaker标识,请尝试根据内容推测具体姓名或身份,无法推测则保留Speaker[x]的格式 ### 3. 核心观点与洞察 -- 提炼内容中的核心观点和重要结论(每点150字以上) +- 提炼内容中的核心观点和重要结论,每点充分展开论证,篇幅与信息量相称 - 使用 markdown 格式来提升观点可读性 - 识别论述中的关键主张和论证逻辑 - 总结主要论点和支撑论据 @@ -192,27 +194,9 @@ def build_calibrate_user_prompt( - 技术教程、操作指南、科普讲解 - 娱乐内容、日常对话、闲聊杂谈 -### 5. 框架与心智模型 - -⚠️ **本章节默认不生成。** - -仅当方法论/框架是**内容的核心价值**时才生成: -1. 作者/嘉宾**有意识地分享**一套做事方法、思维方式或经验总结 -2. 这些方法是内容想要传达的**主要信息**,而非完成某任务的附带步骤 - -❌ **以下情况直接跳过,无需任何说明**: -- 评测/对比的"做法"——这是完成评测的步骤,不是内容要教授的方法论 -- 叙事的"结构"——故事的组织方式不等于思维模型 -- 纯信息汇总——罗列事实不构成方法论 - -✅ **应该生成的情况**: -- 采访/播客中嘉宾分享的工作方法、成功经验 -- 教程中传授的系统性思维方式 -- 作者明确提出并命名的框架模型 - ## 风格要求 -- 永远不要高度浓缩!要充分展开所有细节 +- 目标是读者只读总结即可获得内容的绝大部分信息量;篇幅为信息服务——允许为结构与澄清适度超出原文,禁止为凑字数而扩写、注水或堆砌格式 - 不新增事实;若出现含混表述,请保持原意并注明不确定性 - **只能使用中文书写,禁止添加任何常见的英文翻译或解释** - 如果有缩写,可以使用括号适当解释 @@ -233,7 +217,7 @@ def build_calibrate_user_prompt( ### 2. 主题详述 识别并详细展开内容中的各个主题,要求: -- 每个主题作为一个小节,详细展开内容(每个小节不少于500字) +- 每个主题作为一个小节,充分展开该主题的全部信息点,篇幅与该主题的信息量相称,服从总篇幅预算 - 让读者不需要二次查看原内容就能了解详情 - 若出现方法/框架/流程,将其重写为条理清晰的步骤或段落 - 若有关键数字、定义、原话,请如实保留核心词,并在括号内补充注释 @@ -242,7 +226,7 @@ def build_calibrate_user_prompt( - 如果能推测出Speaker的真实姓名或身份,请使用推测的姓名,无法推测则保留Speaker[x] ### 3. 核心观点与洞察 -- 提炼内容中的核心观点和重要结论(每点150字以上) +- 提炼内容中的核心观点和重要结论,每点充分展开论证,篇幅与信息量相称 - 使用 markdown 格式来提升观点可读性 - 识别对话中达成的共识或分歧点 - 总结主要论点和支撑论据 @@ -263,27 +247,9 @@ def build_calibrate_user_prompt( - 技术教程、操作指南、科普讲解 - 娱乐内容、日常对话、闲聊杂谈 -### 5. 框架与心智模型 - -⚠️ **本章节默认不生成。** - -仅当方法论/框架是**内容的核心价值**时才生成: -1. 说话人**有意识地分享**一套做事方法、思维方式或经验总结 -2. 这些方法是对话想要传达的**主要信息**,而非完成某任务的附带步骤 - -❌ **以下情况直接跳过,无需任何说明**: -- 评测/对比的"做法"——这是完成评测的步骤,不是内容要教授的方法论 -- 叙事的"结构"——故事的组织方式不等于思维模型 -- 纯信息汇总——罗列事实不构成方法论 - -✅ **应该生成的情况**: -- 采访/播客中嘉宾分享的工作方法、成功经验 -- 教程中传授的系统性思维方式 -- 说话人明确提出并命名的框架模型 - ## 风格要求 -- 永远不要高度浓缩!要充分展开所有细节 +- 目标是读者只读总结即可获得内容的绝大部分信息量;篇幅为信息服务——允许为结构与澄清适度超出原文,禁止为凑字数而扩写、注水或堆砌格式 - 不新增事实;若出现含混表述,请保持原意并注明不确定性 - **只能使用中文书写,禁止添加任何常见的英文翻译或解释** - 以 Markdown 语法来强化全文的结构,提升可读性 @@ -295,7 +261,10 @@ def build_summary_user_prompt( transcript: str, video_title: str = "", author: str = "", - description: str = "" + description: str = "", + budget_target_min: Optional[int] = None, + budget_target_max: Optional[int] = None, + budget_hard_cap: Optional[int] = None, ) -> str: """ 构建总结任务的 User Prompt @@ -305,12 +274,26 @@ def build_summary_user_prompt( video_title: 视频标题 author: 作者/频道 description: 视频描述 + budget_target_min: 篇幅预算下限(字符) + budget_target_max: 篇幅预算上限(字符) + budget_hard_cap: 篇幅硬顶(字符) Returns: User prompt 字符串 """ parts = [] + if ( + budget_target_min is not None + and budget_target_max is not None + and budget_hard_cap is not None + ): + parts.append( + f"**篇幅预算**:总长 {budget_target_min}–{budget_target_max} 字," + f"不得超过 {budget_hard_cap} 字。" + ) + parts.append("") + # 辅助信息(动态部分) if video_title or author or description: parts.append("**内容辅助信息**:") diff --git a/src/web/templates/transcript.html b/src/web/templates/transcript.html index 1f3ab95e..10dba725 100644 --- a/src/web/templates/transcript.html +++ b/src/web/templates/transcript.html @@ -358,7 +358,7 @@ {% set notes_length = stats.get('notes_length', 0) if stats else 0 %} {% set summary_percentage = (summary_length / original_length * 100)|round(1) if original_length > 0 and summary_length > 0 else 0 %} {% set has_readable_notes = notes_html is defined and notes_html %} - {% set show_summary_stats = original_length > 0 and summary_length > 0 and summary_length != calibrated_length %} + {% set show_summary_stats = original_length > 0 and summary_length > 0 and summary_length != calibrated_length and summary_state != 'skipped_short' %} {% if original_length > 0 %}
📊 @@ -366,7 +366,9 @@ 原始转录 {{ "{:,}".format(original_length) }} 字 {% if calibrated_length > 0 %} | 校对文本 {{ "{:,}".format(calibrated_length) }} 字{% endif %} - {% if show_summary_stats %} | 内容总结 {{ "{:,}".format(summary_length) }} 字(占原文 {{ "%.1f"|format(summary_percentage) }}%){% endif %} + {% if summary_state == 'skipped_short' and summary_length > 0 %} + | 原文过短未生成总结(以下为校对后全文) + {% elif show_summary_stats %} | 内容总结 {{ "{:,}".format(summary_length) }} 字(占原文 {{ "%.1f"|format(summary_percentage) }}%){% endif %} {% if notes_length > 0 %} | 详细笔记 {{ "{:,}".format(notes_length) }} 字{% endif %}
@@ -524,7 +526,7 @@

📝 内容总结

{% endif %} {% elif summary_state == 'skipped_short' %} -

该任务未生成总结(原始文本过短,未达到生成总结的长度阈值)。

+

原文过短未生成总结(以下为校对后全文)。

{% elif summary_state == 'disabled' %}

该任务未启用内容总结。

{% else %} diff --git a/tests/llm/test_llm_max_tokens.py b/tests/llm/test_llm_max_tokens.py new file mode 100644 index 00000000..ec0f35a1 --- /dev/null +++ b/tests/llm/test_llm_max_tokens.py @@ -0,0 +1,88 @@ +"""Tests for max_tokens propagation into llm-compat chat payload.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from video_transcript_api.llm.llm import call_llm_api, set_default_config + + +class _FakeChatResult: + def __init__(self, content: str): + self._content = content + self.fallback_from = None + self.model = "test-model" + + def __str__(self) -> str: + return self._content + + +@pytest.fixture(autouse=True) +def _init_llm_client(): + config = { + "llm": { + "api_key": "test-key", + "base_url": "https://api.test.com/v1", + "text_output": {"max_retries": 0}, + } + } + set_default_config(config) + yield + + +class TestMaxTokensPayload: + @patch("video_transcript_api.llm.llm.get_sync_client") + def test_none_max_tokens_omits_field(self, mock_get_client): + client = MagicMock() + client.chat.return_value = _FakeChatResult("ok") + mock_get_client.return_value = client + + call_llm_api( + model="test-model", + prompt="user", + system_prompt="system", + task_type="summary", + ) + + _, kwargs = client.chat.call_args + assert "max_tokens" not in kwargs + + @patch("video_transcript_api.llm.llm.get_sync_client") + def test_max_tokens_forwarded_when_set(self, mock_get_client): + client = MagicMock() + client.chat.return_value = _FakeChatResult("ok") + mock_get_client.return_value = client + + call_llm_api( + model="test-model", + prompt="user", + system_prompt="system", + task_type="summary", + max_tokens=6750, + ) + + _, kwargs = client.chat.call_args + assert kwargs.get("max_tokens") == 6750 + + @patch("video_transcript_api.llm.core.llm_client.call_llm_api") + def test_llm_client_forwards_max_tokens(self, mock_call): + from video_transcript_api.llm.core.llm_client import LLMClient + + mock_call.return_value = "text" + client = LLMClient(api_key="k", base_url="http://test") + client.call( + model="m", + system_prompt="s", + user_prompt="u", + max_tokens=12000, + ) + assert mock_call.call_args.kwargs.get("max_tokens") == 12000 + + @patch("video_transcript_api.llm.core.llm_client.call_llm_api") + def test_llm_client_none_max_tokens(self, mock_call): + from video_transcript_api.llm.core.llm_client import LLMClient + + mock_call.return_value = "text" + client = LLMClient(api_key="k", base_url="http://test") + client.call(model="m", system_prompt="s", user_prompt="u") + assert mock_call.call_args.kwargs.get("max_tokens") is None diff --git a/tests/llm/test_summary_budget.py b/tests/llm/test_summary_budget.py new file mode 100644 index 00000000..07d841d4 --- /dev/null +++ b/tests/llm/test_summary_budget.py @@ -0,0 +1,68 @@ +"""Tests for summary budget curve and band boundaries.""" + +import unittest + +from video_transcript_api.llm.core.summary_budget import ( + classify_original_length_band, + compute_summary_budget, + SummaryBudgetConfig, +) + + +class TestSummaryBudgetBands(unittest.TestCase): + def test_s_band_midpoint(self): + budget = compute_summary_budget(4000) + self.assertEqual(budget.target_min, 500) + self.assertEqual(budget.target_max, 3000) + self.assertEqual(budget.hard_cap, 4500) + self.assertEqual(budget.max_tokens, 6750) + + def test_m_band_midpoint(self): + budget = compute_summary_budget(15000) + self.assertEqual(budget.target_min, 2000) + self.assertEqual(budget.target_max, 4000) + self.assertEqual(budget.hard_cap, 5000) + self.assertEqual(budget.max_tokens, 7500) + + def test_l_band(self): + budget = compute_summary_budget(50000) + self.assertEqual(budget.target_min, 4000) + self.assertEqual(budget.target_max, 6000) + self.assertEqual(budget.hard_cap, 8000) + self.assertEqual(budget.max_tokens, 12000) + + def test_boundary_799_vs_800(self): + low = compute_summary_budget(799) + high = compute_summary_budget(800) + self.assertEqual(low.hard_cap, min(2 * 799, 4500)) + self.assertEqual(high.hard_cap, min(2 * 800, 4500)) + self.assertEqual(low.hard_cap, 1598) + self.assertEqual(high.hard_cap, 1600) + self.assertEqual(classify_original_length_band(799), "below_S") + self.assertEqual(classify_original_length_band(800), "S") + + def test_boundary_7999_vs_8000(self): + s_band = compute_summary_budget(7999) + m_band = compute_summary_budget(8000) + self.assertEqual(s_band.hard_cap, 4500) + self.assertEqual(m_band.hard_cap, 5000) + self.assertEqual(classify_original_length_band(7999), "S") + self.assertEqual(classify_original_length_band(8000), "M") + + def test_boundary_29999_vs_30000(self): + m_band = compute_summary_budget(29999) + l_band = compute_summary_budget(30000) + self.assertEqual(m_band.hard_cap, 5000) + self.assertEqual(l_band.hard_cap, 8000) + self.assertEqual(classify_original_length_band(29999), "M") + self.assertEqual(classify_original_length_band(30000), "L") + + def test_config_override(self): + cfg = SummaryBudgetConfig(s_hard_cap_max=4000, max_tokens_multiplier=2.0) + budget = compute_summary_budget(2000, cfg) + self.assertEqual(budget.hard_cap, 4000) + self.assertEqual(budget.max_tokens, 8000) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/llm/test_summary_processor.py b/tests/llm/test_summary_processor.py index 29ef1b79..e9bb2778 100644 --- a/tests/llm/test_summary_processor.py +++ b/tests/llm/test_summary_processor.py @@ -2,155 +2,174 @@ import unittest from unittest.mock import Mock, patch + +from video_transcript_api.llm.processors import summary_processor as summary_processor_module from video_transcript_api.llm.processors.summary_processor import SummaryProcessor from video_transcript_api.llm.core.config import LLMConfig +from video_transcript_api.llm.core.llm_client import LLMResponse from video_transcript_api.utils.llm_status import SummaryStatus +processor_module_logger = summary_processor_module.logger + + +def _long_text() -> str: + return "This is a very long text segment. " * 60 # ~2160 chars + + +def _make_processor() -> SummaryProcessor: + config = LLMConfig( + api_key="test_key", + base_url="http://test.api.com", + calibrate_model="test-model", + summary_model="test-summary-model", + min_summary_threshold=500, + ) + return SummaryProcessor(llm_client=Mock(), config=config) + class TestSummaryProcessor(unittest.TestCase): - """Test SummaryProcessor functionality""" - - def setUp(self): - """Set up test configuration""" - # Create minimal config - self.config = LLMConfig( - api_key="test_key", - base_url="http://test.api.com", - calibrate_model="test-model", - summary_model="test-summary-model", - min_summary_threshold=500, - ) - - self.llm_client = Mock() - - self.processor = SummaryProcessor( - llm_client=self.llm_client, - config=self.config, - ) - - def test_short_text_returns_none(self): - """Test short text skips summary generation (status=SKIPPED_SHORT, not a failure)""" - result = self.processor.process( - text="This is a very short text.", # < 500 chars - title="Test Title", - ) - self.assertIsNone(result.text) - self.assertEqual(result.status, SummaryStatus.SKIPPED_SHORT) - - @patch('video_transcript_api.llm.core.llm_client.LLMClient.call') - def test_long_text_generates_summary(self, mock_call): - """Test long text generates summary""" - # Mock LLM response - mock_response = Mock() - mock_response.text = "This is the generated summary. " * 10 # > 50 chars - self.llm_client.call = Mock(return_value=mock_response) - - result = self.processor.process( - text="This is a very long text... " * 100, # > 500 chars - title="Test Title", - ) - - self.assertIsNotNone(result.text) - self.assertIn("summary", result.text.lower()) - self.assertEqual(result.status, SummaryStatus.GENERATED) - - def test_single_speaker_prompt_selection(self): - """Test single speaker prompt selection""" - single_prompt = self.processor._select_system_prompt(speaker_count=0) - multi_prompt = self.processor._select_system_prompt(speaker_count=2) - - # Verify prompt is not empty - self.assertTrue(len(single_prompt) > 100) - - # Verify single and multi prompts are different - self.assertNotEqual(single_prompt, multi_prompt) - - def test_multi_speaker_prompt_selection(self): - """Test multi-speaker prompt selection""" - single_prompt = self.processor._select_system_prompt(speaker_count=0) - multi_prompt = self.processor._select_system_prompt(speaker_count=2) - - # Verify prompt is not empty - self.assertTrue(len(multi_prompt) > 100) - - # Verify single and multi prompts are different - self.assertNotEqual(single_prompt, multi_prompt) - - @patch('video_transcript_api.llm.core.llm_client.LLMClient.call') - def test_task_type_parameter(self, mock_call): - """Test task_type parameter is correctly passed""" - # Mock LLM response - mock_response = Mock() - mock_response.text = "This is the generated summary. " * 10 - self.llm_client.call = Mock(return_value=mock_response) - - # Call processor - self.processor.process( - text="This is a very long text... " * 100, - title="Test Title", - ) - - # Verify task_type parameter - self.llm_client.call.assert_called_once() - call_kwargs = self.llm_client.call.call_args[1] - self.assertEqual(call_kwargs.get("task_type"), "summary") - - @patch('video_transcript_api.llm.core.llm_client.LLMClient.call') - def test_summary_too_short_returns_none(self, mock_call): - """Test summary generation returns status=FAILED if result too short (not SKIPPED_SHORT)""" - # Mock LLM response with very short text - mock_response = Mock() - mock_response.text = "Short" # < 50 chars - self.llm_client.call = Mock(return_value=mock_response) - - result = self.processor.process( - text="This is a very long text... " * 100, - title="Test Title", - ) - - self.assertIsNone(result.text) - self.assertEqual(result.status, SummaryStatus.FAILED) - - @patch('video_transcript_api.llm.core.llm_client.LLMClient.call') - def test_exception_handling(self, mock_call): - """Test exception handling returns status=FAILED gracefully (no raise)""" - # Mock LLM call to raise exception - self.llm_client.call = Mock(side_effect=Exception("Test error")) - - result = self.processor.process( - text="This is a very long text... " * 100, - title="Test Title", - ) - - # Should return a FAILED SummaryResult instead of raising exception - self.assertIsNone(result.text) - self.assertEqual(result.status, SummaryStatus.FAILED) - - @patch('video_transcript_api.llm.core.llm_client.LLMClient.call') - def test_selected_models_parameter(self, mock_call): - """Test selected_models parameter overrides config""" - # Mock LLM response - mock_response = Mock() - mock_response.text = "This is the generated summary. " * 10 - self.llm_client.call = Mock(return_value=mock_response) - - # Call with selected_models - selected_models = { - "summary_model": "risk-model", - "summary_reasoning_effort": "high", - } - - self.processor.process( - text="This is a very long text... " * 100, - title="Test Title", - selected_models=selected_models, - ) - - # Verify model parameter - call_kwargs = self.llm_client.call.call_args[1] - self.assertEqual(call_kwargs.get("model"), "risk-model") - self.assertEqual(call_kwargs.get("reasoning_effort"), "high") + def setUp(self): + self.processor_module_logger = processor_module_logger + self.processor = _make_processor() + self.long_text = _long_text() + self.mock_call = self.processor.llm_client.call + + def test_short_text_returns_none(self): + result = self.processor.process( + text="This is a very short text.", + title="Test Title", + ) + self.assertIsNone(result.text) + self.assertEqual(result.status, SummaryStatus.SKIPPED_SHORT) + + def test_single_speaker_prompt_selection(self): + single_prompt = self.processor._select_system_prompt(speaker_count=0) + multi_prompt = self.processor._select_system_prompt(speaker_count=2) + self.assertTrue(len(single_prompt) > 100) + self.assertNotEqual(single_prompt, multi_prompt) + + def test_within_hard_cap_single_call(self): + self.mock_call.return_value = LLMResponse(text="generated summary text. " * 20) + + result = self.processor.process(text=self.long_text, title="Test Title") + + self.assertEqual(result.status, SummaryStatus.GENERATED) + self.assertEqual(self.mock_call.call_count, 1) + self.assertIn("max_tokens", self.mock_call.call_args.kwargs) + + def test_over_hard_cap_retry_success(self): + hard_cap = min(2 * len(self.long_text), 4500) + self.mock_call.side_effect = [ + LLMResponse(text="x" * (hard_cap + 100)), + LLMResponse(text="y" * (hard_cap - 50)), + ] + + result = self.processor.process(text=self.long_text, title="Test Title") + + self.assertEqual(result.status, SummaryStatus.GENERATED) + self.assertEqual(self.mock_call.call_count, 2) + self.assertEqual(len(result.text), hard_cap - 50) + + def test_over_hard_cap_accept_shortest_with_warning(self): + hard_cap = min(2 * len(self.long_text), 4500) + first = "a" * (hard_cap + 200) + retry = "b" * (hard_cap + 50) + self.mock_call.side_effect = [ + LLMResponse(text=first), + LLMResponse(text=retry), + ] + + with patch.object(self.processor_module_logger, "warning") as warning_mock: + result = self.processor.process(text=self.long_text, title="Test Title") + + self.assertEqual(result.status, SummaryStatus.GENERATED) + self.assertEqual(len(result.text), len(retry)) + joined = " ".join(str(call.args[0]) for call in warning_mock.call_args_list) + self.assertIn("summary_over_budget_accepted", joined) + + def test_over_hard_cap_retry_failure_keeps_first(self): + hard_cap = min(2 * len(self.long_text), 4500) + first = "c" * (hard_cap + 300) + self.mock_call.side_effect = [ + LLMResponse(text=first), + Exception("retry failed"), + ] + + with patch.object(self.processor_module_logger, "warning") as warning_mock: + result = self.processor.process(text=self.long_text, title="Test Title") + + self.assertEqual(result.status, SummaryStatus.GENERATED) + self.assertEqual(result.text, first) + joined = " ".join(str(call.args[0]) for call in warning_mock.call_args_list) + self.assertIn("summary_over_budget_accepted", joined) + + def test_summary_too_short_returns_failed(self): + self.mock_call.return_value = LLMResponse(text="Short") + + result = self.processor.process(text=self.long_text, title="Test Title") + + self.assertIsNone(result.text) + self.assertEqual(result.status, SummaryStatus.FAILED) + + def test_exception_handling(self): + self.mock_call.side_effect = Exception("Test error") + + result = self.processor.process(text=self.long_text, title="Test Title") + + self.assertIsNone(result.text) + self.assertEqual(result.status, SummaryStatus.FAILED) + + def test_task_type_and_model_override(self): + self.mock_call.return_value = LLMResponse(text="generated summary text. " * 20) + selected_models = { + "summary_model": "risk-model", + "summary_reasoning_effort": "high", + } + + self.processor.process( + text=self.long_text, + title="Test Title", + selected_models=selected_models, + ) + + kwargs = self.mock_call.call_args.kwargs + self.assertEqual(kwargs.get("task_type"), "summary") + self.assertEqual(kwargs.get("model"), "risk-model") + self.assertEqual(kwargs.get("reasoning_effort"), "high") + + +class TestSummaryPromptContent(unittest.TestCase): + def test_prompts_removed_expansion_language(self): + from video_transcript_api.llm.prompts import ( + SUMMARY_SYSTEM_PROMPT_MULTI_SPEAKER, + SUMMARY_SYSTEM_PROMPT_SINGLE_SPEAKER, + ) + + for prompt in ( + SUMMARY_SYSTEM_PROMPT_SINGLE_SPEAKER, + SUMMARY_SYSTEM_PROMPT_MULTI_SPEAKER, + ): + self.assertNotIn("不少于500字", prompt) + self.assertNotIn("150字以上", prompt) + self.assertNotIn("永远不要高度浓缩", prompt) + self.assertNotIn("框架与心智模型", prompt) + self.assertIn("概述", prompt) + self.assertIn("主题详述", prompt) + self.assertIn("核心观点与洞察", prompt) + self.assertIn("逻辑分析", prompt) + self.assertIn("默认不生成", prompt) + + def test_user_prompt_injects_budget_line(self): + from video_transcript_api.llm.prompts import build_summary_user_prompt + + prompt = build_summary_user_prompt( + transcript="body", + budget_target_min=500, + budget_target_max=3000, + budget_hard_cap=4500, + ) + self.assertTrue(prompt.startswith("**篇幅预算**:总长 500–3000 字,不得超过 4500 字。")) if __name__ == "__main__": - unittest.main() + unittest.main() diff --git a/tests/unit/test_summary_ratio_stats.py b/tests/unit/test_summary_ratio_stats.py new file mode 100644 index 00000000..f6342ce3 --- /dev/null +++ b/tests/unit/test_summary_ratio_stats.py @@ -0,0 +1,123 @@ +"""Tests for summary ratio monitoring service.""" + +import json +from pathlib import Path + +import pytest + +from video_transcript_api.api.services.summary_ratio_stats import compute_summary_ratio_stats +from video_transcript_api.cache.cache_manager import CacheManager +from video_transcript_api.utils.logging.audit_logger import AuditLogger +from video_transcript_api.utils.llm_status import SummaryStatus + + +def _seed_task( + tmp_path: Path, + *, + task_id: str, + media_id: str, + original_text: str, + summary_text: str, + original_length_band: int, +): + cache_root = tmp_path / "cache" + audit_path = tmp_path / "audit.db" + cm = CacheManager(cache_dir=str(cache_root)) + audit = AuditLogger(str(audit_path)) + + cm.save_cache( + platform="youtube", + url=f"https://www.youtube.com/watch?v={media_id}", + media_id=media_id, + use_speaker_recognition=False, + transcript_data=original_text, + transcript_type="capswriter", + title="Title", + author="Author", + description="Desc", + ) + cm.save_llm_result( + platform="youtube", + media_id=media_id, + use_speaker_recognition=False, + llm_type="summary", + content=summary_text, + ) + task = cm.create_task( + url=f"https://www.youtube.com/watch?v={media_id}", + use_speaker_recognition=False, + platform="youtube", + media_id=media_id, + ) + created_task_id = task["task_id"] + cm.update_task_status(created_task_id, "success", summary_status=SummaryStatus.GENERATED) + + audit.archive_task_snapshot( + { + "task_id": created_task_id, + "view_token": task["view_token"], + "title": "Title", + "author": "Author", + "platform": "youtube", + "status": "success", + "summary_status": SummaryStatus.GENERATED, + "submitted_by": "user-1", + "completed_at": "2026-08-24 12:00:00", + } + ) + + # Pad original transcript file to target band without changing summary ratio much + artifact_dir = Path(cm.get_cache("youtube", media_id, False)["file_path"]) + transcript_path = artifact_dir / "transcript_capswriter.txt" + padded = original_text + ("x" * max(0, original_length_band - len(original_text))) + transcript_path.write_text(padded, encoding="utf-8") + + audit.close() + cm.close() + return created_task_id, cache_root, audit_path + + +def test_compute_summary_ratio_stats_fixture(tmp_path): + _seed_task( + tmp_path, + task_id="t1", + media_id="vid-s", + original_text="original transcript text for ratio", + summary_text="summary " * 200, + original_length_band=2000, + ) + + cache_root = tmp_path / "cache" + audit_path = tmp_path / "audit.db" + cache_db = cache_root / "cache.db" + + result = compute_summary_ratio_stats( + audit_db_path=str(audit_path), + cache_db_path=str(cache_db), + cache_root=cache_root, + days=30, + ) + + assert result["bands"]["S"]["n"] == 1 + assert result["bands"]["S"]["median_ratio"] > 0 + assert result["sampled_tasks"] == 1 + + +def test_over_hardcap_counted(tmp_path): + _seed_task( + tmp_path, + task_id="t2", + media_id="vid-hardcap", + original_text="short", + summary_text="x" * 5000, + original_length_band=2000, + ) + + cache_root = tmp_path / "cache" + result = compute_summary_ratio_stats( + audit_db_path=str(tmp_path / "audit.db"), + cache_db_path=str(cache_root / "cache.db"), + cache_root=cache_root, + days=30, + ) + assert result["bands"]["S"]["over_hardcap"] == 1 diff --git a/tests/unit/web/test_transcript_disabled_states.py b/tests/unit/web/test_transcript_disabled_states.py index 3ef84dd1..c382dde6 100644 --- a/tests/unit/web/test_transcript_disabled_states.py +++ b/tests/unit/web/test_transcript_disabled_states.py @@ -79,7 +79,7 @@ def test_summary_failed_state_unaffected(self): def test_summary_skipped_short_state_unaffected(self): html = _render(summary_html=None, summary_state="skipped_short") - assert "原始文本过短" in html + assert "原文过短未生成总结" in html assert "该任务未启用内容总结" not in html diff --git a/tests/unit/web/test_transcript_skipped_short_stats.py b/tests/unit/web/test_transcript_skipped_short_stats.py new file mode 100644 index 00000000..49be6efe --- /dev/null +++ b/tests/unit/web/test_transcript_skipped_short_stats.py @@ -0,0 +1,54 @@ +"""Template tests for skipped_short summary stats display.""" + +from pathlib import Path + +import jinja2 + +PROJECT_ROOT = Path(__file__).resolve().parents[3] +TEMPLATES_DIR = PROJECT_ROOT / "src" / "web" / "templates" + + +def _render(**overrides) -> str: + env = jinja2.Environment( + loader=jinja2.FileSystemLoader(str(TEMPLATES_DIR)), + autoescape=True, + ) + ctx = { + "title": "Sample", + "author": "Author", + "url": "https://example.com", + "created_at_display": "2026-08-24", + "platform": "youtube", + "summary_html": None, + "summary_state": "skipped_short", + "calibrated_html": "

Body

", + "use_speaker_recognition": False, + "view_token": "token", + "stats": { + "original_length": 600, + "calibrated_length": 500, + "summary_length": 500, + }, + "llm_config": None, + } + ctx.update(overrides) + return env.get_template("transcript.html").render(**ctx) + + +def test_skipped_short_stats_line_neutral_message(): + html = _render() + assert "原文过短未生成总结(以下为校对后全文)" in html + assert "占原文" not in html + + +def test_generated_summary_stats_still_show_ratio(): + html = _render( + summary_state="generated", + summary_html="

Summary

", + stats={ + "original_length": 5000, + "calibrated_length": 4800, + "summary_length": 3000, + }, + ) + assert "占原文" in html From 79f5c97582f80e2fe185325c9ff8014c28ce0876 Mon Sep 17 00:00:00 2001 From: zj1123581321 Date: Mon, 24 Aug 2026 17:50:17 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20OCR=20=E5=88=86=E8=AF=8A=207=20?= =?UTF-8?q?=E9=A1=B9=EF=BC=88=E9=A2=84=E7=AE=97=20clamp=E3=80=81=E7=9B=91?= =?UTF-8?q?=E6=8E=A7=E5=8F=98=E4=BD=93/=E8=B7=AF=E5=BE=84=E3=80=81?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E8=A1=A5=E5=85=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor Agent-Executor: cursor Agent-Model: composer-2.5 Agent-Effort: unknown Dispatch-Id: dlg-20260824-094037-24901c Task-Id: VideoTranscriptAPI-20260824-02 --- src/video_transcript_api/api/routes/audit.py | 2 +- .../api/services/summary_ratio_stats.py | 75 +++++++++++++------ .../llm/core/summary_budget.py | 3 + src/web/templates/transcript.html | 5 +- tests/llm/test_summary_budget.py | 8 +- tests/llm/test_summary_processor.py | 21 +++++- 6 files changed, 83 insertions(+), 31 deletions(-) diff --git a/src/video_transcript_api/api/routes/audit.py b/src/video_transcript_api/api/routes/audit.py index 3179b3a5..056d3415 100644 --- a/src/video_transcript_api/api/routes/audit.py +++ b/src/video_transcript_api/api/routes/audit.py @@ -187,7 +187,7 @@ async def get_summary_ratio_stats( storage = app_config.get("storage", {}) audit_db_path = storage.get("audit_db") cache_manager = get_cache_manager() - cache_db_path = str(Path(cache_manager.cache_dir) / "cache.db") + cache_db_path = str(cache_manager.db_path) cache_root = Path(cache_manager.cache_dir) budget_config = SummaryBudgetConfig.from_dict( (app_config.get("llm") or {}).get("summary_budget") diff --git a/src/video_transcript_api/api/services/summary_ratio_stats.py b/src/video_transcript_api/api/services/summary_ratio_stats.py index 78af2140..3eeacfef 100644 --- a/src/video_transcript_api/api/services/summary_ratio_stats.py +++ b/src/video_transcript_api/api/services/summary_ratio_stats.py @@ -69,6 +69,48 @@ def _read_summary_length(cache_dir: Path) -> Optional[int]: return None +def _resolve_cache_dir( + cache_conn: sqlite3.Connection, + cache_root: Path, + *, + cache_id: Optional[int], + platform: str, + media_id: str, + use_speaker_recognition: bool, +) -> Optional[Path]: + """Resolve artifact directory for the task's actual cache variant.""" + cache_root_resolved = cache_root.resolve() + cache_row = None + + if cache_id is not None: + cache_row = cache_conn.execute( + "SELECT files_loc FROM video_cache WHERE id = ?", + (cache_id,), + ).fetchone() + + if not cache_row: + cache_row = cache_conn.execute( + """ + SELECT files_loc FROM video_cache + WHERE platform = ? AND media_id = ? AND use_speaker_recognition = ? + ORDER BY updated_at DESC + LIMIT 1 + """, + (platform, media_id, 1 if use_speaker_recognition else 0), + ).fetchone() + + if not cache_row: + return None + + cache_dir = (cache_root / Path(cache_row["files_loc"])).resolve() + if not cache_dir.is_relative_to(cache_root_resolved): + logger.warning( + f"files_loc escapes cache_root, skipping: {cache_row['files_loc']}" + ) + return None + return cache_dir + + def _aggregate_band(ratios: List[float], over_100: int, over_hardcap: int) -> Dict[str, Any]: return { "n": len(ratios), @@ -119,7 +161,7 @@ def compute_summary_ratio_stats( platform = row["platform"] task_row = cache_conn.execute( """ - SELECT platform, media_id, use_speaker_recognition + SELECT platform, media_id, use_speaker_recognition, cache_id FROM task_status WHERE task_id = ? """, @@ -136,31 +178,18 @@ def compute_summary_ratio_stats( continue use_speaker = bool(task_row["use_speaker_recognition"]) - cache_row = cache_conn.execute( - """ - SELECT files_loc FROM video_cache - WHERE platform = ? AND media_id = ? - ORDER BY use_speaker_recognition DESC, updated_at DESC - LIMIT 1 - """, - (resolved_platform, media_id), - ).fetchone() - if use_speaker: - cache_row = cache_conn.execute( - """ - SELECT files_loc FROM video_cache - WHERE platform = ? AND media_id = ? AND use_speaker_recognition = 1 - ORDER BY updated_at DESC - LIMIT 1 - """, - (resolved_platform, media_id), - ).fetchone() or cache_row - - if not cache_row: + cache_dir = _resolve_cache_dir( + cache_conn, + cache_root, + cache_id=task_row["cache_id"], + platform=resolved_platform, + media_id=media_id, + use_speaker_recognition=use_speaker, + ) + if not cache_dir: skipped += 1 continue - cache_dir = cache_root / Path(cache_row["files_loc"]) original_length = _read_original_length(cache_dir) summary_length = _read_summary_length(cache_dir) if original_length is None or summary_length is None or original_length <= 0: diff --git a/src/video_transcript_api/llm/core/summary_budget.py b/src/video_transcript_api/llm/core/summary_budget.py index 416918df..f3055805 100644 --- a/src/video_transcript_api/llm/core/summary_budget.py +++ b/src/video_transcript_api/llm/core/summary_budget.py @@ -77,6 +77,9 @@ def compute_summary_budget( target_max = cfg.s_target_max hard_cap = min(2 * length, cfg.s_hard_cap_max) + # Prompt 注入的 target_max 不得超过 hard_cap(S 带 L∈[800,1500) 时 2L < s_target_max)。 + target_max = min(target_max, hard_cap) + max_tokens = int(hard_cap * cfg.max_tokens_multiplier) return SummaryBudget( target_min=target_min, diff --git a/src/web/templates/transcript.html b/src/web/templates/transcript.html index 10dba725..6a8ebfe1 100644 --- a/src/web/templates/transcript.html +++ b/src/web/templates/transcript.html @@ -365,10 +365,7 @@ 转录统计 原始转录 {{ "{:,}".format(original_length) }} 字 - {% if calibrated_length > 0 %} | 校对文本 {{ "{:,}".format(calibrated_length) }} 字{% endif %} - {% if summary_state == 'skipped_short' and summary_length > 0 %} - | 原文过短未生成总结(以下为校对后全文) - {% elif show_summary_stats %} | 内容总结 {{ "{:,}".format(summary_length) }} 字(占原文 {{ "%.1f"|format(summary_percentage) }}%){% endif %} + {% if calibrated_length > 0 %} | 校对文本 {{ "{:,}".format(calibrated_length) }} 字{% endif %}{% if summary_state == 'skipped_short' and summary_length > 0 %} | 原文过短未生成总结(以下为校对后全文){% elif show_summary_stats %} | 内容总结 {{ "{:,}".format(summary_length) }} 字(占原文 {{ "%.1f"|format(summary_percentage) }}%){% endif %} {% if notes_length > 0 %} | 详细笔记 {{ "{:,}".format(notes_length) }} 字{% endif %} diff --git a/tests/llm/test_summary_budget.py b/tests/llm/test_summary_budget.py index 07d841d4..6dbe82d0 100644 --- a/tests/llm/test_summary_budget.py +++ b/tests/llm/test_summary_budget.py @@ -34,13 +34,17 @@ def test_l_band(self): def test_boundary_799_vs_800(self): low = compute_summary_budget(799) high = compute_summary_budget(800) - self.assertEqual(low.hard_cap, min(2 * 799, 4500)) - self.assertEqual(high.hard_cap, min(2 * 800, 4500)) self.assertEqual(low.hard_cap, 1598) self.assertEqual(high.hard_cap, 1600) self.assertEqual(classify_original_length_band(799), "below_S") self.assertEqual(classify_original_length_band(800), "S") + def test_s_band_low_length_clamps_target_max_to_hard_cap(self): + budget = compute_summary_budget(1000) + self.assertEqual(budget.hard_cap, 2000) + self.assertEqual(budget.target_max, 2000) + self.assertEqual(budget.target_min, 500) + def test_boundary_7999_vs_8000(self): s_band = compute_summary_budget(7999) m_band = compute_summary_budget(8000) diff --git a/tests/llm/test_summary_processor.py b/tests/llm/test_summary_processor.py index e9bb2778..306652c4 100644 --- a/tests/llm/test_summary_processor.py +++ b/tests/llm/test_summary_processor.py @@ -64,11 +64,30 @@ def test_over_hard_cap_retry_success(self): LLMResponse(text="y" * (hard_cap - 50)), ] - result = self.processor.process(text=self.long_text, title="Test Title") + with patch.object(self.processor_module_logger, "warning") as warning_mock: + result = self.processor.process(text=self.long_text, title="Test Title") self.assertEqual(result.status, SummaryStatus.GENERATED) self.assertEqual(self.mock_call.call_count, 2) self.assertEqual(len(result.text), hard_cap - 50) + joined = " ".join(str(call.args[0]) for call in warning_mock.call_args_list) + self.assertNotIn("summary_over_budget_accepted", joined) + + def test_over_hard_cap_retry_too_short_falls_back_to_first(self): + hard_cap = min(2 * len(self.long_text), 4500) + first = "d" * (hard_cap + 100) + self.mock_call.side_effect = [ + LLMResponse(text=first), + LLMResponse(text="tiny"), + ] + + with patch.object(self.processor_module_logger, "warning") as warning_mock: + result = self.processor.process(text=self.long_text, title="Test Title") + + self.assertEqual(result.status, SummaryStatus.GENERATED) + self.assertEqual(result.text, first) + joined = " ".join(str(call.args[0]) for call in warning_mock.call_args_list) + self.assertIn("summary_over_budget_accepted", joined) def test_over_hard_cap_accept_shortest_with_warning(self): hard_cap = min(2 * len(self.long_text), 4500)