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:
+4
-28
@@ -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
@@ -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
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
Reference in New Issue
Block a user