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:
2026-08-05 08:34:02 +08:00
parent 366e60e8a9
commit 2f2428aa9a
7 changed files with 293 additions and 70 deletions
+4 -28
View File
@@ -74,39 +74,15 @@ def run_step(name: str, date_str: str) -> StepResult:
返回: StepResult。
"""
# report 步骤:内部函数,不走子进程
# 日报默认统计"昨天"的数据(因为今天的数据由当天的定时任务处理)。
# 如果昨天没有事件数据,向前回溯最多 3 天,取最近有数据的日期
# 日报按当天日期生成: 新闻由 _collect_news_events 回溯过去 30 小时,
# xwlb 由 _collect_xwlb 固定取前一日(已播出)联播
if name == "report":
started = datetime.now()
try:
from datetime import timedelta # noqa: E402
from pathlib import Path # noqa: E402
from .reporter import generate_report # noqa: E402
# 向前回溯找最近有事件数据的日期(最多回溯 3 天)
report_date: str | None = None
for offset in range(1, 4):
candidate = (date.today() - timedelta(days=offset)).strftime("%Y%m%d")
ev_dir = Path(f"data/events/{candidate}")
if ev_dir.is_dir() and list(ev_dir.glob("*.json")):
report_date = candidate
break
if report_date is None:
# 没有任何事件数据,仍然尝试生成昨天日报(至少展示管道统计)
report_date = (date.today() - timedelta(days=1)).strftime("%Y%m%d")
logger.warning(
"日报: 近 3 日均无事件数据 ({} ~ {}), 日报将只含管道统计",
(date.today() - timedelta(days=3)).strftime("%Y%m%d"),
(date.today() - timedelta(days=1)).strftime("%Y%m%d"),
)
elif report_date != (date.today() - timedelta(days=1)).strftime("%Y%m%d"):
logger.warning(
"日报: 昨天 ({}) 无事件数据, 回退使用 {}",
(date.today() - timedelta(days=1)).strftime("%Y%m%d"),
report_date,
)
report_date = date.today().strftime("%Y%m%d")
logger.info("日报: report_date={} (新闻 30h 回溯, xwlb 前一日)", report_date)
path = generate_report(report_date, upload=True)
elapsed = (datetime.now() - started).total_seconds()
+70 -26
View File
@@ -13,6 +13,7 @@ import json
import os as _os
import re as _re
import subprocess
import time
from collections import Counter
from datetime import date, datetime, timedelta
from pathlib import Path
@@ -38,6 +39,13 @@ NEWS_DAYS_BACK = 1 # 新闻回溯天数
_MAX_HIGH_EVENTS = 20
# LLM 摘要调用重试参数(环境变量可覆盖)
_LLM_RETRY_TIMES = int(_os.environ.get("LLM_RETRY_TIMES", "3"))
_LLM_RETRY_BACKOFF_SEC = float(_os.environ.get("LLM_RETRY_BACKOFF_SEC", "2.0"))
# 日报新闻回溯窗口(小时):07:00 生成当日日报时覆盖昨日全天至今晨的新闻
_NEWS_LOOKBACK_HOURS = 30
def _load_source_names() -> dict[str, str]:
import yaml
@@ -102,16 +110,31 @@ def _load_events_from_dir(day_str: str) -> list[dict]:
def _collect_news_events(day_str: str) -> dict[str, Any]:
"""收集新闻事件(排除 cninfo)。
事件已按日期目录组织(data/events/{day_str}/),
不再用 datetime.now() 做 24h 二次过滤,
避免日报早上 8 点跑时前一天新闻被全部过滤掉
读取 `day_str` 与前一天两个事件目录,按 publish_time 过滤最近
`_NEWS_LOOKBACK_HOURS`(默认 30)小时内的新闻——07:00 生成当日日报时
可覆盖昨日全天至今晨的新闻。无 publish_time 的事件保留(容错)
"""
all_ev = _load_events_from_dir(day_str)
day = datetime.strptime(day_str, "%Y%m%d").date()
prev_day = (day - timedelta(days=1)).strftime("%Y%m%d")
all_ev = _load_events_from_dir(day_str) + _load_events_from_dir(prev_day)
# publish_time 过滤: 最近 30 小时(时间缺失/格式异常的事件保留)
cutoff = (datetime.now() - timedelta(hours=_NEWS_LOOKBACK_HOURS)).astimezone()
news_ev: list[dict] = []
for e in all_ev:
if e["source_id"] == "cninfo":
continue
pt = e.get("publish_time")
if pt:
try:
# naive 时间假定为本地时区, 与带时区(aware)的 cutoff 统一比较
t = datetime.fromisoformat(pt)
if t.tzinfo is None:
t = t.astimezone()
if t < cutoff:
continue
except (ValueError, TypeError):
pass # 时间格式异常时保留
news_ev.append(e)
sentiments: Counter = Counter()
@@ -311,16 +334,21 @@ def _score_xwlb_importance(title: str, content: str = "") -> int:
def _collect_xwlb(day_str: str) -> dict[str, Any]:
"""收集新闻联播要闻(从 doorcome API /api/xwlbFine/ 获取)。
《新闻联播》每天 19:00 播出:日报在早上生成时当日联播尚未播出,
因此固定取 `day_str` 前一日(最近一期已播出)的联播数据。
API 返回 AI 精编后的独立新闻条目(含标题+正文),
跳过第 1 条"内容提要"(仅为节目开场白)。
返回: {"items": [event_dict, ...], "date": "MM月DD日", "source_date": "20260622"}
返回: {"items": [event_dict, ...], "date": "MM月DD日", "source_date": "前一日"}
"""
import urllib.request
result: dict[str, Any] = {"items": [], "date": "", "source_date": day_str}
# 取前一晚(已播出)的联播:day_str 前一天
prev_day = (datetime.strptime(day_str, "%Y%m%d") - timedelta(days=1)).strftime("%Y%m%d")
result: dict[str, Any] = {"items": [], "date": "", "source_date": prev_day}
api_url = f"https://api.doorcome.cn/api/xwlbFine/?start_date={day_str}&end_date={day_str}"
api_url = f"https://api.doorcome.cn/api/xwlbFine/?start_date={prev_day}&end_date={prev_day}"
try:
req = urllib.request.Request(api_url)
with urllib.request.urlopen(req, timeout=15) as resp:
@@ -338,7 +366,7 @@ def _collect_xwlb(day_str: str) -> dict[str, Any]:
if dates:
d = min(dates)
result["date"] = f"{d[5:7]}{d[8:10]}"
result["source_date"] = day_str
result["source_date"] = prev_day
# 转换为事件格式,跳过第 1 条(内容提要/开场白)
events: list[dict] = []
@@ -595,27 +623,43 @@ def _build_prompt(lines: list[str], day_str: str) -> str:
def _llm_call(client, model: str, prompt: str, max_tokens: int = 1500) -> str:
"""单次 LLM 调用,返回 strip 后的文本。
"""单次 LLM 调用(带重试),返回 strip 后的文本。
失败按指数退避重试 `_LLM_RETRY_TIMES` 次(默认 3),全部失败则抛出最后一次异常。
若 finish_reason 为 'length' 则说明达到 max_tokens 上限被截断。
"""
resp = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": "你是 A 股日报撰写助手,输出简洁、有洞察的新闻摘要。"},
{"role": "user", "content": prompt},
],
temperature=0.3,
max_tokens=max_tokens,
)
content = (resp.choices[0].message.content or "").strip()
finish = getattr(resp.choices[0], "finish_reason", None)
if finish == "length":
logger.warning(
"AI 摘要可能被截断: max_tokens={} finish_reason=length 实际输出 {} 字符",
max_tokens, len(content),
)
return content
last_exc: Exception | None = None
for attempt in range(_LLM_RETRY_TIMES):
try:
resp = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": "你是 A 股日报撰写助手,输出简洁、有洞察的新闻摘要。"},
{"role": "user", "content": prompt},
],
temperature=0.3,
max_tokens=max_tokens,
)
content = (resp.choices[0].message.content or "").strip()
finish = getattr(resp.choices[0], "finish_reason", None)
if finish == "length":
logger.warning(
"AI 摘要可能被截断: max_tokens={} finish_reason=length 实际输出 {} 字符",
max_tokens, len(content),
)
return content
except Exception as e:
last_exc = e
if attempt < _LLM_RETRY_TIMES - 1:
wait = _LLM_RETRY_BACKOFF_SEC * (2 ** attempt)
logger.warning(
"AI 摘要 LLM 调用失败(第 {}/{} 次): {}; {} 秒后重试",
attempt + 1, _LLM_RETRY_TIMES, e, round(wait, 2),
)
time.sleep(wait)
logger.error("AI 摘要 LLM 调用重试 {} 次仍失败: {}", _LLM_RETRY_TIMES, last_exc)
assert last_exc is not None
raise last_exc
# --------------------------------------------------------------------------- #