diff --git a/config/config.example.jsonc b/config/config.example.jsonc index 8e02cf5a..a55062dd 100644 --- a/config/config.example.jsonc +++ b/config/config.example.jsonc @@ -216,8 +216,9 @@ "min_calibrate_ratio": 0.8, // 校对后文本长度/原文长度的最小比例,低于则视为异常 // ------------------------------------------------------ - // 总结篇幅预算(prompt / max_tokens / 后验硬顶共用同一曲线) - // ------------------------------------------------------ + // 总结篇幅预算(prompt / 后验硬顶共用同一曲线) + // max_tokens 仅对 reasoning_effort == "disabled" 的非思考模型下发 + // (hard_cap × max_tokens_multiplier);思考模型不下发 max_tokens。 "summary_budget": { "s_target_min": 500, "s_target_max": 3000, diff --git a/docs/sessions/260824-summary-trunc/reviews/summary-truncation-r1-verdict.md b/docs/sessions/260824-summary-trunc/reviews/summary-truncation-r1-verdict.md new file mode 100644 index 00000000..6ae55106 --- /dev/null +++ b/docs/sessions/260824-summary-trunc/reviews/summary-truncation-r1-verdict.md @@ -0,0 +1,99 @@ +# summary-truncation R1 独立审查 verdict + +## 审查范围与结论 + +- 审查对象:`f4ab77c4ad7d630ce2c1c1d23a5e44649f40ed74..5fd47cad624acedddfaf0f697250e79fb99f9845`。 +- 风险等级:`personal`;冻结上界为 H0 `5fd47cad624acedddfaf0f697250e79fb99f9845`。 +- 结论:`pass-with-backlog`。 +- 本轮没有发现 P1 或 P2 阻塞项;已知 backlog 与新增 P3 测试/脚本建议接受不修,不阻塞合并。 + +## Findings 分级清单 + +### P1 + +无。 + +P1 两问总判定:在真实使用方式下,没有发现会把本次截断响应静默当作新生成总结落盘、导致崩溃或造成数据丢失的触发路径;审查到的失败路径均返回明确的 `SummaryStatus.FAILED` 或不写总结文件,因此后果可接受。 + +### P2 + +无。 + +### P3 / 接受不修 + +1. OCR 标注为 low:`scripts/scan_truncated_summaries.py` 同时向 stdout 输出逐行 JSON 和制表格,若调用方把 stdout 当稳定 JSON 流解析会失败。人工判断:这是诊断脚本输出契约/易用性问题,不影响总结生成、截断判定、状态迁移或落盘;真实使用方式下不是 P1,接受为 P3 backlog。 +2. OCR 标注为 low:模板回归测试只显式覆盖 `failed` 与 `generated`,没有分别 pin `pending`、`disabled`、`skipped_short`。人工判断:生产条件已经是精确的 `summary_state == 'generated'`,其余状态均不会渲染提示;遗漏的是测试覆盖而非当前行为,违反不变式 5 的测试锁定强度但不违反实现行为,接受为 P3 backlog。 +3. 任务卡已知 backlog 保留原分诊:扫描脚本不能区分思考 token 成功调用与真截断;`usage_missing=True` 路径缺测试 pin;“重试未截断但小于 50 字”路径缺测试 pin。它们不在本轮重复升级,均不阻塞。 + +上述 P3 的两问答案均为:真实使用下可能触发,但后果是诊断可读性或测试保险不足,不会把截断总结作为本轮 `GENERATED` 结果保存,因而不达到 personal 风险等级 P1 红线。 + +## 五条不变式核验 + +1. **预算参数**:`resolve_summary_max_tokens()` 只在 `reasoning_effort == 'disabled'` 时返回 `budget.max_tokens`;其他形态返回 `None`。`llm.py` 的文本调用只有在值非 `None` 时才把 `max_tokens` 放进 provider 请求。S/M/L 预算和 1.5 系数未改。 +2. **截断代理判定**:`SummaryProcessor._is_truncated()` 先要求已下发 `sent_max_tokens`,再拒绝 `usage is None`、`usage_missing` 或缺失 `completion_tokens`,最后使用 `completion_tokens >= sent_max_tokens`,等值会判截断。 +3. **失败终态与落盘**:首次截断只进入一次 `_retry_after_truncation()`;重试仍截断、过短或异常时返回 `SummaryResult(text=None, status=SummaryStatus.FAILED)`。`_save_llm_results()` 只有 `summary_status == GENERATED` 且文本非空才写总结文件;FAILED 分支明确跳过总结文件,不会把截断文本作为 `GENERATED` 保存。超预算接受分支只接收已通过截断守卫、仍有完整文本的结果。 +4. **usage 桥接审计契约**:`LLMClient.call()` 用 `peek_chat_result_usage()` 构造响应 usage,`finally` 仍由 `_record_usage()` 使用 `pop_chat_result_usage()`;后者继续对全部快照求和落库。多快照手工探针确认:响应给截断判定使用最后一个已知 completion 值,而审计记录 prompt/completion/total 仍分别对全部快照求和。 +5. **浓缩提示**:模板条件已经是 `show_summary_stats and summary_percentage <= 20 and summary_state == 'generated'`;失败态的模板测试确认不会出现“内容高度浓缩”提示,generated 态仍保留提示。 + +## 降层审查三问 + +### 1. FAILED 写入前的不可逆动作 + +这里的 FAILED 是 `SummaryStatus.FAILED`,不是任务总状态 `TaskStatus.FAILED`:总结失败仍可伴随校对成功,任务总流程可以成功结束,但总结状态如实为 failed。 + +在 `SummaryProcessor` 返回 FAILED 之前,最多已经发生:首次总结 LLM 外部调用、一次语义重试调用,以及每次 `LLMClient.call()` 的 usage 审计记录;这些是必要且不可逆的外部调用/审计动作。截断文本仍只在内存中,不在此阶段落盘。 + +随后 `_save_llm_results()` 会按既有产物流程撤销旧 `llm_status.json`、保存校对产物,并在总结分支跳过 `llm_summary.txt`,最后写入 `summary_status=failed`。总结失败路径本身不发送“总结成功”通知;成功通知只在任务 success CAS 成功后执行,并由 `skip_summary`/失败状态走校对文本和“生成失败”文案。没有看到截断总结文本先通知后失败的旁路。 + +### 2. 截断守卫值在真实部署形态下是否可靠 + +`max_tokens` 是本地计算的 hard cap × 1.5,仅 disabled 形态下下发;`completion_tokens` 来自 llm-compat 返回的 `ChatResult.usage`,由 usage_context 在每次真实 provider 往返后追加快照。一次调用内多次 provider 往返时,响应 usage 使用最终已知快照的 completion 值,审计仍保留全部快照的总和。 + +守卫对 usage 缺失、网关不上报或多快照中任一快照缺失采取保守不判截断,避免误报;代价是无法识别该类环境下的真截断。该取舍符合冻结不变式 2,但它不是 provider `finish_reason` 的强保证,生产网关若丢失 usage 会产生漏检而不是把正常结果误判失败。 + +### 3. 防线覆盖“写入”还是“行为”,是否存在旁路 + +防线覆盖两层:处理器层在检测到截断后不返回文本,状态层在 `_save_llm_results()` 只允许 GENERATED 写总结文件。审查了协调器结果适配、缓存保存分支、查看页状态解析、缓存命中通知路径:新一次处理没有另一条可以绕过 `SummaryProcessor` 直接把响应文本写入 `llm_summary.txt` 的旁路;缓存读取路径只读取既有产物,并受 `summary_status` 约束,不会把本次 FAILED 响应重新当成 generated。 + +仍需注意 usage 缺失时按锁定决策“不判截断”,因此不可观测 provider 的不完整文本在信息论上无法被这条代理守卫识别;这属于已锁定的保守取舍,不作为本轮 P1 重开。 + +## 熵增审查 + +逐项回答“第二个消费者是谁 / 单消费者是否必要”: + +| 新增抽象 | 第二个生产消费者 | 单消费者必要性判断 | +| --- | --- | --- | +| `LLMUsage` | 无;当前由 `SummaryProcessor._is_truncated()` 消费,测试只是假实现 | 必要。它是 `LLMClient` 到处理器的类型化 usage 边界,避免把快照桥接细节泄漏到处理器;删除会破坏截断判定所需的响应元数据。 +| `should_send_max_tokens` | 无;由 `resolve_summary_max_tokens()` 调用 | 必要但范围窄。它把冻结决策“仅 disabled 下发”作为可单测的命名策略,避免在调用方重复比较 `reasoning_effort`。 +| `resolve_summary_max_tokens` | 无;当前由 `SummaryProcessor.process()` 调用 | 必要但范围窄。它隔离 `SummaryBudget` 与 provider 参数的 `None` 语义,预算计算不需要知道 reasoning policy。 +| `peek_chat_result_usage` | 无;当前由 `LLMClient.call()` 调用 | 必要。它与既有 `pop_chat_result_usage()` 分工,响应读取不清空槽,才能同时满足新截断判定和不改变审计求和契约。 +| `_SUMMARY_TRUNCATION_RETRY_SUFFIX` | 无;当前仅截断重试使用 | 必要。它集中保存一次截断重试的完整提示,避免把语义重试文案和普通超预算重试混写;当前没有新增配置或状态。 + +这些抽象均有当前行为或测试契约上的单消费者必要性,没有发现只为未来复用而引入的包装层、fallback 或额外状态;本维度不产生 P2 finding。 + +## OCR 对照与人工分诊 + +OCR 前置扫描真实返回 `status=reviewed`、`profile=minimax`、`coverage=complete`。工具标注不是本仓库最终严重度,逐条核对如下: + +| 工具标注 | 本仓判定 | 两问答案 | +| --- | --- | --- | +| medium:表格打印用 `or ''` 会抹掉 0 | 反驳。当前表格列不包含 OCR 所称的 `prompt_tokens`/`total_tokens`/`duration_ms`,且默认筛选 completion token 已高于阈值;不构成当前 diff 的核心缺陷 | 不适用 P1;真实默认运行不触发,后果不可达到数据丢失/静默错结果/崩溃 | +| low:JSON 行后再输出 TSV 表 | 接受为 P3 backlog;是脚本 stdout 契约问题,不影响生产总结行为 | 可能影响把 stdout 当 JSON 流的诊断调用方,但后果可接受,非 P1 | +| low:缺 pending/disabled/skipped_short 模板测试 | 接受为 P3 backlog;条件本身精确屏蔽所有非 generated 状态 | 真实模板行为正确,缺的是保险测试,不会造成截断文本落盘 | +| high:截断失败测试缺 `call_count == 2` | 反驳。测试的第一响应带 `completion_tokens == max_tokens`;若跳过重试,结果会走 GENERATED 而不是 FAILED,现有状态断言已经能使回归变红 | 不触发 P1;工具建议是冗余断言 | +| low:重试测试的 `max_tokens` 局部变量未被使用 | 反驳。OCR 引用的代码与 H0 不一致;当前相关测试确实使用该变量构造 usage,不能据此落 finding | 不触发 P1;事实前提不成立 | + +## 运行证据 + +- `cd /home/zlx/projects/personal/VideoTranscriptAPI-worktrees/summary-trunc && uv run pytest tests/llm tests/unit -q` + - exit code: `0` + - 末行:`-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html` + - 结果进度到 `[100%]`;仅有既存依赖弃用告警及少量测试函数返回非 `None` 告警。 +- `PYTHONPATH=src uv run python` 手工不变式探针:exit code `0`,输出 `manual invariants passed`;覆盖多快照响应 usage、审计全量求和、`>=` 等值截断、`None`/usage 缺失不判截断。 +- `git diff --check f4ab77c4ad7d630ce2c1c1d23a5e44649f40ed74 5fd47cad624acedddfaf0f697250e79fb99f9845`:exit code `0`。 + +## 交付物与提交证据 + +本轮只新增本 verdict 文件,未修改源码、测试或配置。提交证据在完成提交后补录于执行器 report.md;本文件路径满足: + +`docs/sessions/260824-summary-trunc/reviews/summary-truncation-r1-verdict.md` diff --git a/scripts/scan_truncated_summaries.py b/scripts/scan_truncated_summaries.py new file mode 100644 index 00000000..465061af --- /dev/null +++ b/scripts/scan_truncated_summaries.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Scan audit.db for suspected truncated summary LLM calls. + +Heuristic (pre-finish_reason): stage=summary, completion_tokens >= threshold, +created after cutoff. Joins task_audit_snapshots for view_token/title when present. + +Usage: + uv run python scripts/scan_truncated_summaries.py [--db PATH] [--since ISO] [--min-tokens N] +""" + +from __future__ import annotations + +import argparse +import json +import sqlite3 +import sys +from pathlib import Path + + +DEFAULT_DB = Path(__file__).resolve().parents[1] / "data" / "audit.db" +DEFAULT_SINCE = "2026-08-24 18:00:00" +DEFAULT_MIN_COMPLETION_TOKENS = 2400 + + +def _resolve_db_path(path: str | None) -> Path: + if path: + return Path(path) + repo_db = DEFAULT_DB + if repo_db.exists(): + return repo_db + fallback = Path(__file__).resolve().parents[2] / "VideoTranscriptAPI" / "data" / "audit.db" + return fallback + + +def scan_truncated_summaries( + db_path: Path, + *, + since: str, + min_completion_tokens: int, +) -> list[dict]: + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + try: + cursor = conn.execute( + """ + SELECT + u.id, + u.task_id, + u.model, + u.prompt_tokens, + u.completion_tokens, + u.total_tokens, + u.duration_ms, + u.usage_missing, + u.created_at, + s.view_token, + s.title, + s.platform + FROM llm_usage u + LEFT JOIN task_audit_snapshots s ON s.task_id = u.task_id + WHERE u.stage = 'summary' + AND u.created_at >= ? + AND u.completion_tokens >= ? + ORDER BY u.created_at DESC, u.id DESC + """, + (since, min_completion_tokens), + ) + return [dict(row) for row in cursor.fetchall()] + finally: + conn.close() + + +def _print_table(rows: list[dict]) -> None: + if not rows: + print("No suspected truncated summary calls found.") + return + + headers = ( + "id", + "created_at", + "task_id", + "view_token", + "model", + "completion_tokens", + "title", + ) + print("\t".join(headers)) + for row in rows: + print( + "\t".join( + str(row.get(column, "") or "") + for column in headers + ) + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--db", + default=None, + help=f"Path to audit.db (default: {DEFAULT_DB} or main repo fallback)", + ) + parser.add_argument( + "--since", + default=DEFAULT_SINCE, + help=f"Only rows with created_at >= this timestamp (default: {DEFAULT_SINCE})", + ) + parser.add_argument( + "--min-tokens", + type=int, + default=DEFAULT_MIN_COMPLETION_TOKENS, + help=( + "Minimum completion_tokens to flag (default: " + f"{DEFAULT_MIN_COMPLETION_TOKENS}, S-band min max_tokens)" + ), + ) + args = parser.parse_args(argv) + + db_path = _resolve_db_path(args.db) + if not db_path.exists(): + print(f"audit.db not found: {db_path}", file=sys.stderr) + return 1 + + rows = scan_truncated_summaries( + db_path, + since=args.since, + min_completion_tokens=args.min_tokens, + ) + + for row in rows: + print(json.dumps(row, ensure_ascii=False)) + + _print_table(rows) + print(f"Total rows: {len(rows)}", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/video_transcript_api/llm/core/llm_client.py b/src/video_transcript_api/llm/core/llm_client.py index 2d7b93d5..1a7048fb 100644 --- a/src/video_transcript_api/llm/core/llm_client.py +++ b/src/video_transcript_api/llm/core/llm_client.py @@ -5,7 +5,7 @@ """ import time -from typing import Dict, Optional +from typing import Dict, Optional, Tuple from dataclasses import dataclass from ...utils.logging import setup_logger @@ -18,16 +18,52 @@ TimeoutError as LLMTimeoutError, TruncationError, ) -from .usage_context import get_context, pop_chat_result_usage +from .usage_context import ( + ChatUsageSnapshot, + get_context, + peek_chat_result_usage, + pop_chat_result_usage, +) logger = setup_logger(__name__) +@dataclass +class LLMUsage: + """Token usage snapshot for a single LLMClient.call() invocation.""" + + prompt_tokens: Optional[int] = None + completion_tokens: Optional[int] = None + total_tokens: Optional[int] = None + usage_missing: bool = True + + +def _usage_from_snapshots( + snapshots: Tuple[ChatUsageSnapshot, ...], +) -> Optional[LLMUsage]: + """Build response usage from bridge snapshots without clearing the slot.""" + if not snapshots: + return None + + known = [snapshot for snapshot in snapshots if not snapshot.usage_missing] + if not known: + return LLMUsage(usage_missing=True) + + last = known[-1] + return LLMUsage( + prompt_tokens=sum(snapshot.prompt_tokens or 0 for snapshot in known), + completion_tokens=last.completion_tokens, + total_tokens=sum(snapshot.total_tokens or 0 for snapshot in known), + usage_missing=len(known) < len(snapshots), + ) + + @dataclass class LLMResponse: """LLM 响应数据类""" text: str structured_output: Optional[Dict] = None + usage: Optional[LLMUsage] = None class LLMClient: @@ -97,15 +133,18 @@ def call( max_tokens=max_tokens, ) + usage = _usage_from_snapshots(peek_chat_result_usage()) + if isinstance(result, StructuredResult): if not result.success: raise LLMCallError(f"Structured output failed: {result.error}") return LLMResponse( text="", structured_output=result.data or {}, + usage=usage, ) else: - return LLMResponse(text=result) + return LLMResponse(text=result, usage=usage) except LLMCallError: raise diff --git a/src/video_transcript_api/llm/core/summary_budget.py b/src/video_transcript_api/llm/core/summary_budget.py index f3055805..55f6055d 100644 --- a/src/video_transcript_api/llm/core/summary_budget.py +++ b/src/video_transcript_api/llm/core/summary_budget.py @@ -89,6 +89,26 @@ def compute_summary_budget( ) +def should_send_max_tokens(reasoning_effort: Optional[str]) -> bool: + """Whether to send max_tokens on summary LLM calls. + + Only non-reasoning models (reasoning_effort == \"disabled\") receive an + explicit token ceiling. Reasoning models consume hidden reasoning tokens + against the same completion budget, so max_tokens must not be sent. + """ + return reasoning_effort == "disabled" + + +def resolve_summary_max_tokens( + budget: SummaryBudget, + reasoning_effort: Optional[str], +) -> Optional[int]: + """Return max_tokens for a summary call, or None for reasoning models.""" + if should_send_max_tokens(reasoning_effort): + return budget.max_tokens + return None + + 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)) diff --git a/src/video_transcript_api/llm/core/usage_context.py b/src/video_transcript_api/llm/core/usage_context.py index 8fa79c1f..c73453fb 100644 --- a/src/video_transcript_api/llm/core/usage_context.py +++ b/src/video_transcript_api/llm/core/usage_context.py @@ -171,6 +171,16 @@ def record_chat_result_usage(*, model: str, usage: Any) -> None: _chat_usage_log.set(_chat_usage_log.get() + (snapshot,)) +def peek_chat_result_usage() -> Tuple[ChatUsageSnapshot, ...]: + """Read accumulated usage snapshots without clearing the bridge slot. + + Used by ``LLMClient.call()`` to attach per-call usage to ``LLMResponse`` + while preserving the existing ``pop_chat_result_usage()`` contract for + ``_record_usage()`` in the same call's ``finally`` block. + """ + return _chat_usage_log.get() + + def pop_chat_result_usage() -> Tuple[ChatUsageSnapshot, ...]: """读取并清空本次 call_llm_api() 调用内累积的全部 usage 快照。 diff --git a/src/video_transcript_api/llm/processors/summary_processor.py b/src/video_transcript_api/llm/processors/summary_processor.py index 2ea71691..cac213cf 100644 --- a/src/video_transcript_api/llm/processors/summary_processor.py +++ b/src/video_transcript_api/llm/processors/summary_processor.py @@ -6,8 +6,12 @@ from ...utils.logging import setup_logger 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 ..core.llm_client import LLMClient, LLMResponse, LLMUsage +from ..core.summary_budget import ( + SummaryBudget, + compute_summary_budget, + resolve_summary_max_tokens, +) from ..prompts import ( SUMMARY_SYSTEM_PROMPT_SINGLE_SPEAKER, SUMMARY_SYSTEM_PROMPT_MULTI_SPEAKER, @@ -21,6 +25,11 @@ "请在保留全部信息点的前提下压缩篇幅,不得超过 {hard_cap} 字。" ) +_SUMMARY_TRUNCATION_RETRY_SUFFIX = ( + "警告:你上一次的输出可能因 token 预算限制被截断(未完成)。" + "请在保留全部信息点的前提下重新生成完整总结,不得超过 {hard_cap} 字。" +) + @dataclass(frozen=True) class SummaryResult: @@ -118,6 +127,7 @@ def process( # 步骤 4: 篇幅预算(prompt / max_tokens / 后验校验共用) budget = compute_summary_budget(len(text), self.config.summary_budget) + sent_max_tokens = resolve_summary_max_tokens(budget, reasoning_effort) user_prompt = build_summary_user_prompt( transcript=text, @@ -129,38 +139,61 @@ def process( budget_hard_cap=budget.hard_cap, ) - first_text = self._call_summary_llm( + first_response = self._call_summary_llm( model=model, system_prompt=system_prompt, user_prompt=user_prompt, reasoning_effort=reasoning_effort, - max_tokens=budget.max_tokens, + max_tokens=sent_max_tokens, ) + working_text = first_response.text - if not first_text or len(first_text) < 50: + if self._is_truncated(sent_max_tokens, first_response.usage): + retry_text = self._retry_after_truncation( + model=model, + system_prompt=system_prompt, + user_prompt=user_prompt, + reasoning_effort=reasoning_effort, + sent_max_tokens=sent_max_tokens, + budget=budget, + ) + if retry_text is None: + logger.warning( + "summary_truncated_failed: first truncated and retry did not recover" + ) + return SummaryResult(text=None, status=SummaryStatus.FAILED) + working_text = retry_text + + if not working_text or len(working_text) < 50: logger.warning( - f"Summary too short or empty: {len(first_text) if first_text else 0} chars" + f"Summary too short or empty: {len(working_text) if working_text else 0} chars" ) return SummaryResult(text=None, status=SummaryStatus.FAILED) - 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) + if len(working_text) <= budget.hard_cap: + logger.info(f"Summary generated successfully (length: {len(working_text)})") + return SummaryResult(text=working_text, status=SummaryStatus.GENERATED) retry_suffix = _SUMMARY_BUDGET_RETRY_SUFFIX.format( - actual_len=len(first_text), + actual_len=len(working_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( + retry_response = self._call_summary_llm( model=model, system_prompt=system_prompt, user_prompt=retry_prompt, reasoning_effort=reasoning_effort, - max_tokens=budget.max_tokens, + max_tokens=sent_max_tokens, ) + if self._is_truncated(sent_max_tokens, retry_response.usage): + logger.warning( + "Summary compression retry truncated, keeping first answer" + ) + else: + retry_text = retry_response.text except Exception as retry_exc: logger.warning( f"Summary compression retry failed, keeping first answer: {retry_exc}" @@ -172,13 +205,13 @@ def process( ) return SummaryResult(text=retry_text, status=SummaryStatus.GENERATED) - candidates = [first_text] + candidates = [working_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"summary_over_budget_accepted: first={len(working_text)} " f"retry={len(retry_text) if retry_text else 0} " f"hard_cap={budget.hard_cap} accepted={len(final_text)}" ) @@ -188,6 +221,51 @@ def process( logger.error(f"Summary generation failed: {e}", exc_info=True) return SummaryResult(text=None, status=SummaryStatus.FAILED) + @staticmethod + def _is_truncated( + sent_max_tokens: Optional[int], + usage: Optional[LLMUsage], + ) -> bool: + """Detect token-budget truncation before llm-compat exposes finish_reason.""" + if sent_max_tokens is None: + return False + if usage is None or usage.usage_missing: + return False + if usage.completion_tokens is None: + return False + return usage.completion_tokens >= sent_max_tokens + + def _retry_after_truncation( + self, + *, + model: str, + system_prompt: str, + user_prompt: str, + reasoning_effort: Optional[str], + sent_max_tokens: Optional[int], + budget: SummaryBudget, + ) -> Optional[str]: + """Retry once after truncation; truncated text is never returned.""" + retry_suffix = _SUMMARY_TRUNCATION_RETRY_SUFFIX.format(hard_cap=budget.hard_cap) + retry_prompt = f"{user_prompt}\n\n{retry_suffix}" + try: + retry_response = self._call_summary_llm( + model=model, + system_prompt=system_prompt, + user_prompt=retry_prompt, + reasoning_effort=reasoning_effort, + max_tokens=sent_max_tokens, + ) + except Exception as retry_exc: + logger.warning(f"Summary truncation retry failed: {retry_exc}") + return None + + if self._is_truncated(sent_max_tokens, retry_response.usage): + return None + if not retry_response.text or len(retry_response.text) < 50: + return None + return retry_response.text + def _call_summary_llm( self, *, @@ -195,9 +273,9 @@ def _call_summary_llm( system_prompt: str, user_prompt: str, reasoning_effort: Optional[str], - max_tokens: int, - ) -> str: - response = self.llm_client.call( + max_tokens: Optional[int], + ) -> LLMResponse: + return self.llm_client.call( model=model, system_prompt=system_prompt, user_prompt=user_prompt, @@ -205,7 +283,6 @@ def _call_summary_llm( 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/web/templates/transcript.html b/src/web/templates/transcript.html index 6a8ebfe1..bf1337a9 100644 --- a/src/web/templates/transcript.html +++ b/src/web/templates/transcript.html @@ -369,7 +369,7 @@ {% if notes_length > 0 %} | 详细笔记 {{ "{:,}".format(notes_length) }} 字{% endif %} - {% if show_summary_stats and summary_percentage <= 20 %} + {% if show_summary_stats and summary_percentage <= 20 and summary_state == 'generated' %}
First summary paragraph.
", + "summary_state": "generated", + "calibrated_html": "Calibrated body.
", + "view_token": "test-view-token-123", + "stats": { + "original_length": 100, + "calibrated_length": 80, + "summary_length": 20, + }, + "llm_config": None, + } + context.update(overrides) + return context + + +def _render(**overrides) -> str: + return _jinja_env().get_template("transcript.html").render(**_base_context(**overrides)) + + +class TestSummaryCondensationHintStates: + def test_failed_summary_state_omits_condensation_hint(self): + html = _render( + summary_html=None, + summary_state="failed", + stats={ + "original_length": 100, + "calibrated_length": 80, + "summary_length": 20, + }, + ) + assert "总结生成失败" in html + assert "内容高度浓缩,可能省略较多细节。" not in html + + def test_generated_summary_state_keeps_condensation_hint(self): + html = _render( + summary_state="generated", + stats={ + "original_length": 100, + "calibrated_length": 80, + "summary_length": 20, + }, + ) + assert "内容高度浓缩,可能省略较多细节。" in html diff --git a/tests/unit/web/test_transcript_summary_share.py b/tests/unit/web/test_transcript_summary_share.py index 39c39ab7..fb624063 100644 --- a/tests/unit/web/test_transcript_summary_share.py +++ b/tests/unit/web/test_transcript_summary_share.py @@ -26,6 +26,7 @@ def _base_context(**overrides) -> dict: "created_at_display": "2026-07-11 10:00", "platform": "youtube", "summary_html": "First summary paragraph.
Second paragraph.
", + "summary_state": "generated", "calibrated_html": "Calibrated body.
", "view_token": "test-view-token-123", "stats": {