From e4be5d54a68cde26709ede566e714315625ce8de Mon Sep 17 00:00:00 2001 From: zj1123581321 Date: Fri, 16 Oct 2026 16:37:54 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=BA=E5=88=86=E4=BA=AB=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E6=96=87=E6=A1=88=E5=A2=9E=E5=8A=A0=E7=8B=AC=E7=AB=8B=E5=8F=AF?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E7=9A=84=20share=5Fcard=5Fmodel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 卡片短文案不应再绑 summary 的贵模型;未配置时仍回退 summary,行为与改前一致。 Co-authored-by: Cursor Agent-Executor: cursor Agent-Model: cursor-grok-4.6-high Agent-Effort: unknown Dispatch-Id: dlg-20261016-082932-f7ad6e Task-Id: VideoTranscriptAPI-20261016-08 --- config/config.example.jsonc | 8 +++ src/video_transcript_api/llm/core/config.py | 21 ++++++++ .../llm/processors/share_card_processor.py | 36 +++++++++----- tests/llm/test_share_card_processor.py | 49 +++++++++++++++---- tests/unit/test_llm_config.py | 36 ++++++++++++++ tests/unit/test_llm_config_defaults.py | 7 ++- 6 files changed, 133 insertions(+), 24 deletions(-) diff --git a/config/config.example.jsonc b/config/config.example.jsonc index 2afcdeeb..68f3c835 100644 --- a/config/config.example.jsonc +++ b/config/config.example.jsonc @@ -218,6 +218,14 @@ "notes_reasoning_effort": "disabled", "notes_concurrency": 10, // 分章并发数上限;按实际章节数自动取较小值 + // ------------------------------------------------------ + // 分享卡片文案(可选) + // ------------------------------------------------------ + // share_card_model 未配置时自动回退 summary_model;推理强度同理回退 summary_reasoning_effort。 + // 卡片是短文案,思考无收益,建议 "disabled"。 + "share_card_model": "deepseek-v4-flash", + "share_card_reasoning_effort": "disabled", + // ------------------------------------------------------ // 通用配置 // ------------------------------------------------------ diff --git a/src/video_transcript_api/llm/core/config.py b/src/video_transcript_api/llm/core/config.py index 229006b2..b9c5f40e 100644 --- a/src/video_transcript_api/llm/core/config.py +++ b/src/video_transcript_api/llm/core/config.py @@ -141,6 +141,11 @@ class LLMConfig: # Summary output budget (prompt + max_tokens + post-hoc cap share one curve). summary_budget: SummaryBudgetConfig = field(default_factory=SummaryBudgetConfig) + # Share-card copy. Appended to preserve legacy positional construction + # of LLMConfig (same reason as the notes fields above). + share_card_model: Optional[str] = None + share_card_reasoning_effort: Optional[str] = None + @classmethod def from_dict(cls, config_dict: dict) -> "LLMConfig": """从配置字典创建 LLMConfig 实例 @@ -336,6 +341,16 @@ def from_dict(cls, config_dict: dict) -> "LLMConfig": summary_budget=SummaryBudgetConfig.from_dict( llm_config.get("summary_budget") ), + # Share-card defaults to the summary model and effort when omitted. + share_card_model=llm_config.get( + "share_card_model", llm_config["summary_model"] + ), + share_card_reasoning_effort=normalize_reasoning_effort( + llm_config.get( + "share_card_reasoning_effort", + llm_config.get("summary_reasoning_effort"), + ) + ), ) def get_models(self) -> dict: @@ -355,6 +370,12 @@ def get_models(self) -> dict: if self.notes_reasoning_effort is not None else self.summary_reasoning_effort ), + "share_card_model": self.share_card_model or self.summary_model, + "share_card_reasoning_effort": ( + self.share_card_reasoning_effort + if self.share_card_reasoning_effort is not None + else self.summary_reasoning_effort + ), "validator_model": self.validator_model, "validator_reasoning_effort": self.validator_reasoning_effort, } diff --git a/src/video_transcript_api/llm/processors/share_card_processor.py b/src/video_transcript_api/llm/processors/share_card_processor.py index 4f173b1b..67216f84 100644 --- a/src/video_transcript_api/llm/processors/share_card_processor.py +++ b/src/video_transcript_api/llm/processors/share_card_processor.py @@ -13,7 +13,7 @@ from dataclasses import dataclass from datetime import datetime, timezone -from typing import Dict, Optional +from typing import Any, Dict, Mapping, Optional, Tuple from ...utils.logging import setup_logger from ..core.config import LLMConfig @@ -98,8 +98,9 @@ class ShareCardProcessor: 职责: - 基于总结正文生成分享卡片文案(吸睛标题 + 要点) - - 模型复用 summary_model / summary_reasoning_effort 配置 - (含风控覆盖:selected_models 的覆盖逻辑与总结一致) + - 模型默认用 share_card_model / share_card_reasoning_effort, + 未配置时回退 summary 对应项(含风控覆盖:selected_models + 读 share_card_* 键,写法与 NotesProcessor._resolve_model 同构) - 按 llm_share_card.json 契约严格校验模型输出 失败语义:不向调用方抛出任何异常;生成失败或输出不合规时返回 @@ -139,16 +140,7 @@ def process( if not isinstance(summary_text, str) or not summary_text.strip(): return ShareCardResult(card=None, error="empty summary text") - # 模型选择:与 SummaryProcessor 相同的覆盖逻辑(风控覆盖优先)。 - if selected_models: - model = selected_models.get("summary_model", self.config.summary_model) - reasoning_effort = selected_models.get( - "summary_reasoning_effort", - self.config.summary_reasoning_effort, - ) - else: - model = self.config.summary_model - reasoning_effort = self.config.summary_reasoning_effort + model, reasoning_effort = self._resolve_model(selected_models) response = self.llm_client.call( model=model, @@ -194,3 +186,21 @@ def process( # 卡面要求的隔离边界:分享卡片失败绝不冒泡影响任务终态。 logger.error(f"Share card generation failed: {exc}", exc_info=True) return ShareCardResult(card=None, error=f"{type(exc).__name__}: {exc}") + + def _resolve_model( + self, + selected_models: Optional[Mapping[str, Any]], + ) -> Tuple[str, Optional[str]]: + """Resolve share-card model and effort, isomorphic to NotesProcessor.""" + default_model = self.config.share_card_model or self.config.summary_model + default_effort = ( + self.config.share_card_reasoning_effort + if self.config.share_card_reasoning_effort is not None + else self.config.summary_reasoning_effort + ) + if not selected_models: + return default_model, default_effort + return ( + selected_models.get("share_card_model", default_model) or default_model, + selected_models.get("share_card_reasoning_effort", default_effort), + ) diff --git a/tests/llm/test_share_card_processor.py b/tests/llm/test_share_card_processor.py index dafc29d7..f1834b29 100644 --- a/tests/llm/test_share_card_processor.py +++ b/tests/llm/test_share_card_processor.py @@ -1,7 +1,8 @@ """Unit tests for ShareCardProcessor (og share-card copy, card A). Covers the llm_share_card.json contract shape, contract rejections -(too-long / empty fields), exception isolation, and summary-model reuse. +(too-long / empty fields), exception isolation, and share_card_model +selection (explicit / summary fallback / selected_models override). All LLM calls are mocked; no real request is ever sent. Console output must be English only. @@ -34,11 +35,14 @@ } -def _make_processor() -> tuple[ShareCardProcessor, Mock]: +def _make_processor(extra_llm: dict | None = None) -> tuple[ShareCardProcessor, Mock]: llm_client = Mock() + llm_section = dict(CONFIG_DICT["llm"]) + if extra_llm: + llm_section.update(extra_llm) processor = ShareCardProcessor( llm_client=llm_client, - config=LLMConfig.from_dict(CONFIG_DICT), + config=LLMConfig.from_dict({"llm": llm_section}), ) return processor, llm_client @@ -82,8 +86,8 @@ def test_valid_input_produces_contract_card(self): assert parsed.tzinfo is not None def test_uses_summary_model_and_schema(self): - """The share-card call must reuse summary_model config and pass the - share-card response schema (locked decision).""" + """Unconfigured share_card_model must fall back to summary_model + and still pass the share-card response schema.""" processor, llm_client = _make_processor() llm_client.call.return_value = _structured_response(dict(VALID_CARD_DATA)) @@ -96,20 +100,45 @@ def test_uses_summary_model_and_schema(self): assert kwargs["response_schema"] is not None assert "headline" in kwargs["response_schema"]["properties"] + def test_explicit_share_card_model_is_used(self): + """Configured share_card_model / effort must reach llm_client.call.""" + processor, llm_client = _make_processor( + { + "share_card_model": "flash-card-model", + "share_card_reasoning_effort": "disabled", + } + ) + llm_client.call.return_value = _structured_response(dict(VALID_CARD_DATA)) + + processor.process(summary_text="总结正文" * 50, title="T") + + kwargs = llm_client.call.call_args.kwargs + assert kwargs["model"] == "flash-card-model" + assert kwargs["reasoning_effort"] == "disabled" + def test_selected_models_override_wins(self): - """Risk-control override (selected_models) takes priority, same as - the summary call.""" - processor, llm_client = _make_processor() + """selected_models share_card_* keys take priority over config.""" + processor, llm_client = _make_processor( + { + "share_card_model": "flash-card-model", + "share_card_reasoning_effort": "disabled", + } + ) llm_client.call.return_value = _structured_response(dict(VALID_CARD_DATA)) processor.process( summary_text="总结正文" * 50, title="T", - selected_models={"summary_model": "override-model", "summary_reasoning_effort": "high"}, + selected_models={ + "share_card_model": "override-card-model", + "share_card_reasoning_effort": "high", + "summary_model": "must-not-use-summary-key", + "summary_reasoning_effort": "medium", + }, ) kwargs = llm_client.call.call_args.kwargs - assert kwargs["model"] == "override-model" + assert kwargs["model"] == "override-card-model" assert kwargs["reasoning_effort"] == "high" def test_long_summary_input_is_truncated(self): diff --git a/tests/unit/test_llm_config.py b/tests/unit/test_llm_config.py index 55a90959..2ae41b0c 100644 --- a/tests/unit/test_llm_config.py +++ b/tests/unit/test_llm_config.py @@ -339,3 +339,39 @@ def test_notes_fields_accept_explicit_values(self): assert config.get_models()["notes_model"] == "notes" assert config.get_models()["notes_reasoning_effort"] == "high" + + def test_share_card_fields_fallback_to_summary_for_legacy_config(self): + config = LLMConfig.from_dict( + { + "llm": { + "api_key": "k", + "base_url": "u", + "calibrate_model": "calibrate", + "summary_model": "summary", + "summary_reasoning_effort": "medium", + } + } + ) + + assert config.share_card_model == "summary" + assert config.share_card_reasoning_effort == "medium" + assert config.get_models()["share_card_model"] == "summary" + assert config.get_models()["share_card_reasoning_effort"] == "medium" + + def test_share_card_fields_accept_explicit_values(self): + config = LLMConfig.from_dict( + { + "llm": { + "api_key": "k", + "base_url": "u", + "calibrate_model": "calibrate", + "summary_model": "summary", + "summary_reasoning_effort": "low", + "share_card_model": "flash-card", + "share_card_reasoning_effort": "disabled", + } + } + ) + + assert config.get_models()["share_card_model"] == "flash-card" + assert config.get_models()["share_card_reasoning_effort"] == "disabled" diff --git a/tests/unit/test_llm_config_defaults.py b/tests/unit/test_llm_config_defaults.py index b6ba1e1d..d55ae482 100644 --- a/tests/unit/test_llm_config_defaults.py +++ b/tests/unit/test_llm_config_defaults.py @@ -55,6 +55,10 @@ "dataclass 默认 None 表示「继承 summary_model」,from_dict 缺键时直接回填 " "summary_model 的字面值;get_models() 用 notes_model or summary_model 做回退。" ), + "share_card_model": ( + "dataclass 默认 None 表示「继承 summary_model」,from_dict 缺键时直接回填 " + "summary_model 的字面值;get_models() 用 share_card_model or summary_model 做回退。" + ), } @@ -112,7 +116,8 @@ def test_structured_fallback_strategy_agrees_on_both_paths(): @pytest.mark.parametrize("field_name", ["key_info_model", "speaker_model", - "validator_model", "chapters_model", "notes_model"]) + "validator_model", "chapters_model", + "notes_model", "share_card_model"]) def test_derived_model_fields_still_fall_back_through_consumers(field_name): """豁免字段本身也要有行为保证:None 必须真的能被消费端回退,而不是躺平。""" direct, from_dict = _minimal_instances()