Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions config/config.example.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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",

// ------------------------------------------------------
// 通用配置
// ------------------------------------------------------
Expand Down
21 changes: 21 additions & 0 deletions src/video_transcript_api/llm/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 实例
Expand Down Expand Up @@ -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:
Expand All @@ -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,
}
36 changes: 23 additions & 13 deletions src/video_transcript_api/llm/processors/share_card_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 契约严格校验模型输出

失败语义:不向调用方抛出任何异常;生成失败或输出不合规时返回
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
)
49 changes: 39 additions & 10 deletions tests/llm/test_share_card_processor.py
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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))

Expand All @@ -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):
Expand Down
36 changes: 36 additions & 0 deletions tests/unit/test_llm_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
7 changes: 6 additions & 1 deletion tests/unit/test_llm_config_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 做回退。"
),
}


Expand Down Expand Up @@ -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()
Expand Down
Loading