fix: 日报摘要可靠性(去模型兜底+重试) 与取数逻辑优化
- llm/client: 移除内置默认模型兜底(deepseek-chat/qwen-plus), 模型必须显式配置否则报错 - reporter._llm_call: 指数退避重试(LLM_RETRY_TIMES 默认3 / LLM_RETRY_BACKOFF_SEC 默认2s) - pipeline report: report_date 改为当天(原昨天+回溯3天) - reporter._collect_news_events: 读当天+前一天目录, publish_time 30h 回溯(NEWS_LOOKBACK_HOURS=30), 统一时区 - reporter._collect_xwlb: 固定取 day_str 前一日(已播出联播), source_date 同步 - 公告/调研/互动保持近15日设置(CNINFO_DAYS_BACK), 不受 30h 影响 - 测试: 新增 30h回溯/带时区/重试/模型缺失/xwlb 前一日 用例
This commit is contained in:
+15
-2
@@ -322,21 +322,24 @@ async def test_extract_event_async_retries(fake_config: LLMConfig) -> None:
|
||||
def test_load_llm_config_deepseek_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("LLM_PROVIDER", "deepseek")
|
||||
monkeypatch.setenv("DEEPSEEK_API_KEY", "sk-test-deepseek")
|
||||
monkeypatch.setenv("DEEPSEEK_MODEL", "deepseek-v4-flash")
|
||||
monkeypatch.delenv("LLM_MODEL", raising=False)
|
||||
cfg = load_llm_config()
|
||||
assert cfg.provider == "deepseek"
|
||||
assert cfg.api_key == "sk-test-deepseek"
|
||||
assert cfg.model.startswith("deepseek")
|
||||
assert cfg.model == "deepseek-v4-flash"
|
||||
assert "deepseek" in cfg.base_url
|
||||
|
||||
|
||||
def test_load_llm_config_qwen_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("LLM_PROVIDER", "qwen")
|
||||
monkeypatch.setenv("DASHSCOPE_API_KEY", "sk-test-qwen")
|
||||
monkeypatch.setenv("QWEN_MODEL", "qwen-plus")
|
||||
monkeypatch.delenv("LLM_MODEL", raising=False)
|
||||
cfg = load_llm_config()
|
||||
assert cfg.provider == "qwen"
|
||||
assert cfg.api_key == "sk-test-qwen"
|
||||
assert cfg.model == "qwen-plus"
|
||||
assert "dashscope" in cfg.base_url or "aliyuncs" in cfg.base_url
|
||||
|
||||
|
||||
@@ -347,5 +350,15 @@ def test_load_llm_config_unknown_provider_raises(monkeypatch: pytest.MonkeyPatch
|
||||
|
||||
def test_load_llm_config_missing_key_raises(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False)
|
||||
with pytest.raises(ValueError):
|
||||
monkeypatch.setenv("DEEPSEEK_MODEL", "deepseek-v4-flash")
|
||||
with pytest.raises(ValueError, match="API key"):
|
||||
load_llm_config(provider="deepseek")
|
||||
|
||||
|
||||
def test_load_llm_config_missing_model_raises(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""去掉内置默认模型后:未显式配置模型必须报错(不再回退 deepseek-chat)。"""
|
||||
monkeypatch.setenv("DEEPSEEK_API_KEY", "sk-test")
|
||||
monkeypatch.delenv("DEEPSEEK_MODEL", raising=False)
|
||||
monkeypatch.delenv("LLM_MODEL", raising=False)
|
||||
with pytest.raises(ValueError, match="模型"):
|
||||
load_llm_config(provider="deepseek")
|
||||
|
||||
@@ -5,6 +5,8 @@ from __future__ import annotations
|
||||
import json
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from scheduler.reporter import _build_report_data
|
||||
|
||||
|
||||
@@ -85,3 +87,139 @@ class TestBuildReportData:
|
||||
r = _build_report_data(news, cninfo, {}, None, "20260710")
|
||||
assert r.events == []
|
||||
assert r.ai_summary is None
|
||||
|
||||
|
||||
class TestLlmCallRetry:
|
||||
"""_llm_call 重试逻辑(纯逻辑,mock client)。"""
|
||||
|
||||
@staticmethod
|
||||
def _fake_client(failures: int):
|
||||
"""构造 mock client:前 failures 次抛 ConnectionError,之后成功。"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
n = {"count": 0}
|
||||
|
||||
class Completions:
|
||||
def create(self, **kwargs):
|
||||
n["count"] += 1
|
||||
if n["count"] <= failures:
|
||||
raise ConnectionError("transient")
|
||||
return SimpleNamespace(
|
||||
choices=[SimpleNamespace(
|
||||
message=SimpleNamespace(content="今日要点摘要"),
|
||||
finish_reason="stop",
|
||||
)]
|
||||
)
|
||||
|
||||
return SimpleNamespace(chat=SimpleNamespace(completions=Completions())), n
|
||||
|
||||
def test_success_first_try(self) -> None:
|
||||
from scheduler.reporter import _llm_call
|
||||
client, n = self._fake_client(0)
|
||||
out = _llm_call(client, "deepseek-v4-flash", "p")
|
||||
assert out == "今日要点摘要"
|
||||
assert n["count"] == 1
|
||||
|
||||
def test_retry_then_success(self, monkeypatch) -> None:
|
||||
import scheduler.reporter as rep
|
||||
monkeypatch.setattr(rep, "_LLM_RETRY_TIMES", 3)
|
||||
monkeypatch.setattr(rep, "_LLM_RETRY_BACKOFF_SEC", 0.01)
|
||||
client, n = self._fake_client(2) # 前 2 次失败,第 3 次成功
|
||||
out = rep._llm_call(client, "deepseek-v4-flash", "p")
|
||||
assert out == "今日要点摘要"
|
||||
assert n["count"] == 3
|
||||
|
||||
def test_exhausts_retries_raises(self, monkeypatch) -> None:
|
||||
import scheduler.reporter as rep
|
||||
monkeypatch.setattr(rep, "_LLM_RETRY_TIMES", 2)
|
||||
monkeypatch.setattr(rep, "_LLM_RETRY_BACKOFF_SEC", 0.01)
|
||||
client, n = self._fake_client(99) # 一直失败
|
||||
with pytest.raises(ConnectionError):
|
||||
rep._llm_call(client, "deepseek-v4-flash", "p")
|
||||
assert n["count"] == 2 # 重试 2 次后放弃
|
||||
|
||||
|
||||
class TestCollectXwlb:
|
||||
"""_collect_xwlb 取数逻辑:应查询日报前一日(已播出的联播),并跳过内容提要。"""
|
||||
|
||||
def test_queries_previous_day_and_skips_toc(self, monkeypatch) -> None:
|
||||
import json as _json
|
||||
import urllib.request
|
||||
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
def fake_urlopen(req, timeout=15): # noqa: ARG001
|
||||
captured["url"] = req.full_url
|
||||
|
||||
class Resp:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return _json.dumps({"data": {"news": [
|
||||
{"daily_sub_id": 1, "news_title": "内容提要", "news_days": "2026-08-04", "news_improve": "开场白"},
|
||||
{"daily_sub_id": 2, "news_title": "联播要闻A", "news_days": "2026-08-04", "news_improve": "正文A"},
|
||||
]}}).encode("utf-8")
|
||||
|
||||
return Resp()
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
|
||||
from scheduler.reporter import _collect_xwlb
|
||||
|
||||
result = _collect_xwlb("20260805")
|
||||
# 查询的是前一日(20260804)而非当日
|
||||
assert "start_date=20260804" in captured["url"]
|
||||
assert "end_date=20260804" in captured["url"]
|
||||
# 跳过第 1 条内容提要
|
||||
assert len(result["items"]) == 1
|
||||
assert result["items"][0]["title"] == "联播要闻A"
|
||||
assert result["source_date"] == "20260804"
|
||||
assert result["date"] == "08月04日"
|
||||
|
||||
|
||||
class TestCollectNewsEventsLookback:
|
||||
"""_collect_news_events 30 小时回溯逻辑。"""
|
||||
|
||||
def test_filters_30h_and_excludes_cninfo(self, monkeypatch) -> None:
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import scheduler.reporter as rep
|
||||
|
||||
now = datetime.now().astimezone()
|
||||
|
||||
def fake_load(day_str: str) -> list[dict]: # noqa: ARG001
|
||||
def ev(title: str, hours_ago: float | None, source: str = "cls",
|
||||
importance: int = 5, aware: bool = False) -> dict:
|
||||
pt = None
|
||||
if hours_ago is not None:
|
||||
t = now - timedelta(hours=hours_ago)
|
||||
pt = t.isoformat() if not aware else t.astimezone().isoformat()
|
||||
return {
|
||||
"title": title, "url": "u", "source_id": source,
|
||||
"publish_time": pt,
|
||||
"event": {"importance": importance, "sentiment": "neutral",
|
||||
"event_type": "其他", "summary": "s"},
|
||||
}
|
||||
|
||||
return [
|
||||
ev("窗口内新闻", 10),
|
||||
ev("窗口内新闻带时区", 12, aware=True),
|
||||
ev("窗口外旧闻", 40),
|
||||
ev("无时间戳", None),
|
||||
ev("公告排除", 5, source="cninfo", importance=2),
|
||||
]
|
||||
|
||||
monkeypatch.setattr(rep, "_load_events_from_dir", fake_load)
|
||||
result = rep._collect_news_events("20260805")
|
||||
|
||||
# 两个日期目录各返回 5 条(共 10): 旧闻×2、公告×2 被滤, 保留 6 条
|
||||
assert result["total"] == 6
|
||||
titles = {e["title"] for e in result["high"]}
|
||||
assert "窗口内新闻" in titles
|
||||
assert "窗口内新闻带时区" in titles
|
||||
assert "无时间戳" in titles
|
||||
assert "窗口外旧闻" not in titles
|
||||
assert "公告排除" not in titles
|
||||
|
||||
Reference in New Issue
Block a user