Files
news/tests/test_report_builder.py
simon 2f2428aa9a 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 前一日 用例
2026-08-05 08:34:02 +08:00

226 lines
8.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""日报结构化组装单元测试(_build_report_data,纯逻辑)。"""
from __future__ import annotations
import json
from datetime import date
import pytest
from scheduler.reporter import _build_report_data
def _fake_event(title: str, importance: int, event_type: str = "其他",
sentiment: str = "neutral", source_id: str = "cls",
url: str = "https://x.com/1") -> dict:
return {
"title": title,
"url": url,
"source_id": source_id,
"event": {
"stock_codes": [],
"company_names": [],
"industries": [],
"sentiment": sentiment,
"importance": importance,
"event_type": event_type,
"summary": f"{title}的摘要",
},
}
class TestBuildReportData:
def test_sections_and_ranks(self) -> None:
news = {
"total": 2, "hi_threshold": 4,
"high": [_fake_event("新闻A", 5), _fake_event("新闻B", 4)],
"sentiments": {"neutral": 2}, "importances": {5: 1, 4: 1},
"event_types": {"其他": 2},
}
cninfo = {
"total": 1, "hi_threshold": 2,
"high": [_fake_event("公告C", 3, event_type="公告")],
"by_day": {"07月10日": 1}, "announcement": 1, "research": 0, "irm": 0,
}
pipeline = {"raw_total": 100, "proc": 90}
xwlb = {"items": [_fake_event("联播D", 4, event_type="新闻联播", source_id="xwlb")],
"date": "07月10日"}
r = _build_report_data(news, cninfo, pipeline, "AI摘要", "20260710", xwlb=xwlb)
assert r.report_date == date(2026, 7, 10)
assert r.report_type == "finance"
assert r.file_name == ""
assert r.ai_summary == "AI摘要"
assert [(e.section, e.rank) for e in r.events] == [
("news", 1), ("news", 2), ("cninfo", 1), ("xwlb", 1),
]
assert r.events[0].source == "cls"
assert r.events[3].source == "xwlb"
def test_stats_snapshot(self) -> None:
news = {"total": 1, "hi_threshold": 4, "high": [], "sentiments": {},
"importances": {}, "event_types": {}}
cninfo = {"total": 0, "hi_threshold": 0, "high": [], "by_day": {},
"announcement": 0, "research": 0, "irm": 0}
r = _build_report_data(news, cninfo, {"raw_total": 100}, "s", "20260710")
# stats 可 JSON 序列化(入库时 json.dumps
json.dumps(r.stats, ensure_ascii=False)
assert r.stats["pipeline"] == {"raw_total": 100}
assert r.stats["news"]["total"] == 1
assert "xwlb" not in r.stats
def test_title_truncated(self) -> None:
news = {"total": 1, "hi_threshold": 4,
"high": [_fake_event("长" * 600, 4)], "sentiments": {},
"importances": {}, "event_types": {}}
cninfo = {"total": 0, "hi_threshold": 0, "high": [], "by_day": {},
"announcement": 0, "research": 0, "irm": 0}
r = _build_report_data(news, cninfo, {}, "s", "20260710")
assert len(r.events[0].title) == 512
def test_empty_events(self) -> None:
news = {"total": 0, "hi_threshold": 0, "high": [], "sentiments": {},
"importances": {}, "event_types": {}}
cninfo = {"total": 0, "hi_threshold": 0, "high": [], "by_day": {},
"announcement": 0, "research": 0, "irm": 0}
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