Files
news/scheduler/reporter.py
T
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

1124 lines
43 KiB
Python
Raw 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.
"""每日摘要报告生成器 v2.0。
输出 HTML 日报,包含:
一、AI 摘要 (最新新闻 + {cninfo_days}d 公告/调研/互动)
二、重要事件: 新闻 (最新抓取, importance≥4, 最多20篇)
三、重要事件: 公告/互动 ({cninfo_days}d, cninfo, 最多20篇)
四、数据总览 (重要度/各源/M1-M6/情绪/事件类型分布)
"""
from __future__ import annotations
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
from typing import Any
from dotenv import load_dotenv
from loguru import logger
from report_db.models import EventRow, ReportData # noqa: F401 - 供 _build_report_data 注解使用
# 确保 .env 已加载(模块级常量依赖环境变量)
load_dotenv()
# --------------------------------------------------------------------------- #
# 配置
# --------------------------------------------------------------------------- #
UPLOAD_HOST = "simon@doorcome.cn"
UPLOAD_BASE = "/var/www/html/echart/research"
CNINFO_DAYS_BACK = int(_os.environ.get("STOCK_REPORT_DAYS", "15")) # 与个股日报共用参数, 默认值保持一致
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
try:
with open("configs/sources.yaml", encoding="utf-8") as f:
data = yaml.safe_load(f)
return {s["id"]: s["name"] for s in (data.get("sources") or []) if s.get("id")}
except Exception:
return {}
def _source_name(src_id: str) -> str:
return _load_source_names().get(src_id, src_id)
def _load_watchlist_codes() -> set[str]:
import yaml
try:
with open("configs/watchlist.yaml", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
return {it["code"] for it in (data.get("watchlist") or []) if it.get("code")}
except Exception:
return set()
# --------------------------------------------------------------------------- #
# 数据收集
# --------------------------------------------------------------------------- #
def _count_jsonl(path: Path) -> int:
if not path.is_file():
return 0
return sum(1 for _ in open(path, encoding="utf-8"))
def _count_json(pattern: str) -> int:
return len(list(Path().glob(pattern)))
def _load_events_from_dir(day_str: str) -> list[dict]:
"""从 data/events/{day_str}/ 加载所有事件。"""
events: list[dict] = []
ev_dir = Path(f"data/events/{day_str}")
if not ev_dir.is_dir():
return events
for fp in sorted(ev_dir.glob("*.json")):
try:
obj = json.loads(fp.read_text(encoding="utf-8"))
ev = obj.get("event", {})
events.append({
"title": obj.get("title", ""),
"url": obj.get("url", ""),
"source_id": obj.get("source_id", ""),
"publish_time": obj.get("publish_time"),
"event": ev,
})
except (json.JSONDecodeError, OSError):
pass
return events
def _collect_news_events(day_str: str) -> dict[str, Any]:
"""收集新闻事件(排除 cninfo)。
读取 `day_str` 与前一天两个事件目录,按 publish_time 过滤最近
`_NEWS_LOOKBACK_HOURS`(默认 30)小时内的新闻——07:00 生成当日日报时
可覆盖昨日全天至今晨的新闻。无 publish_time 的事件保留(容错)。
"""
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()
importances: Counter = Counter()
event_types: Counter = Counter()
for e in news_ev:
ev = e["event"]
sentiments[ev.get("sentiment", "?")] += 1
importances[ev.get("importance", 0)] += 1
event_types[ev.get("event_type", "?")] += 1
# 高重要度: 优先 ≥4, 不足时逐级回退(≥3 → ≥2 → 全部按 importance 排序)
def _get_high(evs, threshold):
return sorted(
[e for e in evs if e["event"].get("importance", 0) >= threshold],
key=lambda e: -e["event"].get("importance", 0),
)
min_show = 3
high = _get_high(news_ev, 4)
hi_threshold = 4
if len(high) < min_show:
high = _get_high(news_ev, 3)
hi_threshold = 3
if len(high) < min_show:
high = _get_high(news_ev, 2)
hi_threshold = 2
if len(high) < min_show:
high = sorted(news_ev, key=lambda e: -e["event"].get("importance", 0))
hi_threshold = 0
high = high[:_MAX_HIGH_EVENTS]
return {
"total": len(news_ev),
"high": high,
"hi_threshold": hi_threshold,
"sentiments": dict(sentiments),
"importances": dict(sorted(importances.items())),
"event_types": dict(event_types.most_common(10)),
}
def _collect_cninfo_events(today_str: str, days_back: int = CNINFO_DAYS_BACK) -> dict[str, Any]:
"""收集近 N 日 cninfo 公告/调研/互动(直接从 processed 数据读取,不依赖 M4 事件抽取)。
cninfo 公告/调研数据已结构化(stock_code/name/title/time/type),
无需经过 LLM 事件抽取即可直接用于日报。
"""
today = datetime.strptime(today_str, "%Y%m%d")
since_str = (today - timedelta(days=days_back)).strftime("%Y-%m-%d")
wl_codes = _load_watchlist_codes()
items: list[dict] = []
seen_urls: set[str] = set()
proc_root = Path("data/processed/cninfo")
if not proc_root.is_dir():
return {"total": 0, "high": [], "hi_threshold": 4, "by_day": {},
"announcement": 0, "research": 0, "irm": 0}
for day_dir in sorted(proc_root.glob("*"), reverse=True):
if not day_dir.is_dir():
continue
for fp in sorted(day_dir.glob("*.json"), reverse=True):
if fp.name == "index.jsonl":
continue
try:
obj = json.loads(fp.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
continue
url = obj.get("url") or ""
if url in seen_urls:
continue
seen_urls.add(url)
pt = (obj.get("publish_time") or "").strip()
# 按 publish_time 过滤
if pt and pt[:10] < since_str:
continue
item_type = obj.get("item_type") or "announcement"
# 互动易数据跳过(当前无法获取真实数据)
if item_type == "irm":
continue
# 过滤旧数据的 IRM 假阳性(标题为通用占位符或 URL 为 irm 搜索页)
if "互动问答" in (obj.get("title") or ""):
continue
if "irm.cninfo.com.cn" in (obj.get("url") or ""):
continue
title = obj.get("title") or ""
url = obj.get("url") or ""
stock_name = obj.get("author") or ""
content = obj.get("content") or ""
# 计算重要度(基于是否在 watchlist 中 + 内容长度)
code_in_title = ""
for c in wl_codes:
if c in title:
code_in_title = c
break
importance = 3 if code_in_title else 2
if item_type == "research":
importance = 3 # 调研通常更重要
items.append({
"title": title,
"url": url,
"source_id": "cninfo",
"publish_time": pt,
"event": {
"stock_codes": [code_in_title] if code_in_title else [],
"company_names": [stock_name] if stock_name else [],
"industries": [],
"sentiment": "neutral",
"importance": importance,
"event_type": {
"announcement": "公司公告",
"research": "投资者调研",
}.get(item_type, "公告"),
"summary": content[:120] if content else title[:120],
},
})
# 按发布时间排序(最新在前)
items.sort(key=lambda e: e.get("publish_time") or "", reverse=True)
# 按发布时间的日期分组统计
by_day: Counter = Counter()
for e in items:
pt = (e.get("publish_time") or "")[:10]
if pt:
by_day[pt] += 1
# 高重要度: 优先 ≥4(调研),逐级回退
def _get_high(evs, threshold):
return sorted(
[e for e in evs if e["event"].get("importance", 0) >= threshold],
key=lambda e: -e["event"].get("importance", 0),
)
min_show = 3
high = _get_high(items, 4)
hi_threshold = 4
if len(high) < min_show:
high = _get_high(items, 2)
hi_threshold = 2
if len(high) < min_show:
high = sorted(items, key=lambda e: -e["event"].get("importance", 0))
hi_threshold = 0
high = high[:_MAX_HIGH_EVENTS]
return {
"total": len(items),
"high": high,
"hi_threshold": hi_threshold,
"by_day": dict(by_day.most_common(7)),
"announcement": sum(1 for e in items if "公告" in (e["event"].get("event_type", "") or "")),
"research": sum(1 for e in items if "调研" in (e["event"].get("event_type", "") or "")),
"irm": sum(1 for e in items if "互动" in (e["event"].get("event_type", "") or "")),
}
def _score_xwlb_importance(title: str, content: str = "") -> int:
"""新闻联播条目启发式重要度评分 (1-5)。
基于标题+正文关键词匹配,优先匹配高等级:
5: 直接涉及股市/金融/货币政策
4: 重大经济/产业政策/能源
3: 领导人活动/外交/区域发展/外资
2: 一般国内要闻/农业/生态
1: 文化/体育/社会/国际简讯
"""
text = title + content
L5 = ["降准", "降息", "印花税", "IPO", "注册制", "退市",
"并购重组", "增持", "回购", "证券", "股市", "上市"]
L4 = ["经济", "财政", "税收", "国债", "专项债", "碳",
"产业", "制造业", "新能源", "芯片", "半导体", "人工智能",
"算力", "平台经济", "房地产", "外贸", "消费", "投资",
"供应链", "能源", "电力"]
L3 = ["习近平", "李强", "总理", "主席", "会谈", "访问",
"自贸区", "长三角", "粤港澳", "一带一路",
"央企", "国企", "营商环境", "外资", "达沃斯"]
L2 = ["会议", "改革", "立法", "监管", "粮食", "农业",
"水利", "铁路", "公路", "港口", "生态", "救灾"]
if any(kw in text for kw in L5):
return 5
if any(kw in text for kw in L4):
return 4
if any(kw in text for kw in L3):
return 3
if any(kw in text for kw in L2):
return 2
return 1
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": "前一日"}
"""
import urllib.request
# 取前一晚(已播出)的联播: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={prev_day}&end_date={prev_day}"
try:
req = urllib.request.Request(api_url)
with urllib.request.urlopen(req, timeout=15) as resp:
body = json.loads(resp.read().decode("utf-8"))
except Exception as e:
logger.warning("新闻联播 API 请求失败: {}", e)
return result
raw_news = body.get("data", {}).get("news", [])
if not raw_news:
return result
# 提取日期
dates = {n.get("news_days", "") for n in raw_news if n.get("news_days")}
if dates:
d = min(dates)
result["date"] = f"{d[5:7]}{d[8:10]}日"
result["source_date"] = prev_day
# 转换为事件格式,跳过第 1 条(内容提要/开场白)
events: list[dict] = []
for n in raw_news:
sid = n.get("daily_sub_id", 0)
if sid <= 1: # 跳过"内容提要"
continue
title = n.get("news_title", "")
content = n.get("news_improve", "")
importance = _score_xwlb_importance(title, content)
events.append({
"title": title[:100],
"url": "", # 新闻联播无独立文章链接
"source_id": "xwlb",
"publish_time": n.get("news_days", ""),
"event": {
"stock_codes": [],
"company_names": [],
"industries": [],
"sentiment": "neutral",
"importance": importance,
"event_type": "新闻联播",
"summary": content[:80] if content else title[:80],
},
})
# 按重要度降序
events.sort(key=lambda e: (-e["event"]["importance"], e["title"]))
result["items"] = events
return result
def _load_article_urls_from_index(index_path: Path) -> list[dict]:
"""从 index.jsonl 中加载 stage=article 的条目(排除列表页)。"""
if not index_path.is_file():
return []
articles: list[dict] = []
for line in open(index_path, encoding="utf-8"):
try:
obj = json.loads(line)
if obj.get("stage") == "article" and obj.get("success"):
articles.append(obj)
except (json.JSONDecodeError, KeyError):
pass
return articles
def _extract_date_from_url(url: str) -> datetime | None:
"""从 URL 中提取发布日期(用于估算 24h 新鲜度)。"""
import re as _re2
patterns = [
_re2.compile(r'/(\d{4})[-/](\d{2})[-/](\d{2})/'),
_re2.compile(r'/(\d{4})(\d{2})(\d{2})/'),
_re2.compile(r'(\d{4})(\d{2})(\d{2})\.(?:s?html|pdf)'),
_re2.compile(r'/t(\d{4})(\d{2})(\d{2})_'),
]
for pat in patterns:
m = pat.search(url)
if m:
try:
return datetime(int(m.group(1)), int(m.group(2)), int(m.group(3)))
except ValueError:
pass
return None
def _collect_pipeline_stats(day_str: str) -> dict[str, Any]:
"""收集管道统计数据(仅计文章级条目 + 24h 新鲜度)。"""
now = datetime.now()
cutoff_24h = now - timedelta(hours=24)
raw_by_source: dict[str, int] = {}
raw_by_source_24h: dict[str, int] = {}
raw_total = 0
raw_total_24h = 0
for idx in Path("data/raw").glob(f"*/{day_str}/index.jsonl"):
src = idx.parent.parent.name
articles = _load_article_urls_from_index(idx)
n = len(articles)
name = _source_name(src)
raw_by_source[name] = n
raw_total += n
# 统计 24h 内文章
n_24h = 0
for art in articles:
dt = _extract_date_from_url(art.get("url", ""))
if dt and dt >= cutoff_24h:
n_24h += 1
raw_by_source_24h[name] = n_24h
raw_total_24h += n_24h
# cninfo raw(仅计文章级条目)
cninfo_raw = 0
for idx in Path("data/raw/cninfo").glob("*/index.jsonl"):
cninfo_raw += len(_load_article_urls_from_index(idx))
proc = _count_json(f"data/processed/*/{day_str}/*.json")
deduped = _count_json(f"data/deduped/{day_str}/uniques/*.json")
dup_path = Path(f"data/deduped/{day_str}/duplicates.jsonl")
dups = _count_jsonl(dup_path)
emb_count = _count_json(f"data/embeddings/{day_str}/*.json")
qdrant_count = 0
try:
from vectorstore import VectorStore, make_qdrant_client
c = make_qdrant_client()
s = VectorStore(c)
qdrant_count = s.count()
s.close()
except Exception:
pass
return {
"raw_total": raw_total,
"raw_total_24h": raw_total_24h,
"raw_by_source": raw_by_source,
"raw_by_source_24h": raw_by_source_24h,
"cninfo_raw": cninfo_raw,
"proc": proc,
"deduped": deduped,
"dups": dups,
"emb_count": emb_count,
"qdrant_count": qdrant_count,
}
# --------------------------------------------------------------------------- #
# AI 摘要
# --------------------------------------------------------------------------- #
def _generate_ai_summary(news: dict, cninfo: dict, day_str: str,
xwlb: dict | None = None) -> str:
"""LLM 生成 500 字以内日报摘要,囊括全部新闻、公告及新闻联播。"""
lines: list[str] = []
# 新闻联播(如有)
if xwlb and xwlb.get("items"):
items = xwlb["items"]
lines.append(f"## 新闻联播要闻 ({xwlb.get('date', '')}, {len(items)} 条, 按重要度排序)")
for e in items[:10]:
ev = e["event"]
lines.append(f"- [重要度{ev.get('importance', 0)}] {e['title']}")
# 新闻(已在 _collect_news_events 中过滤)
if news["high"]:
lines.append(f"## 过去 24 小时高重要度新闻 ({len(news['high'])} 条)")
for e in news["high"][:12]:
ev = e["event"]
sentiment = ev.get("sentiment", "")
s_icon = {"positive": "利好", "negative": "利空", "neutral": "中性"}.get(sentiment, "")
lines.append(f"- [{s_icon}][{ev.get('event_type', '')}] {e['title']}{ev.get('summary', '')}")
# 公告/调研
if cninfo["high"]:
lines.append(f"## 近 {CNINFO_DAYS_BACK} 日重要公告/调研 ({len(cninfo['high'])} 条)")
for e in cninfo["high"][:8]:
ev = e.get("event", {})
lines.append(f"- [{ev.get('event_type', '公司公告')}] {e['title']}")
if not lines:
return ""
try:
from llm.client import load_llm_config, make_sync_client
config = load_llm_config()
client = make_sync_client(config)
return _llm_summarize(client, config.model, lines, day_str)
except Exception as e:
logger.warning("AI 摘要生成失败: {}", e)
return ""
def _split_lines_into_chunks(lines: list[str], max_chars: int = 3000) -> list[list[str]]:
"""将 lines 按 max_chars 分块,保证每条新闻(line)不被截断。"""
chunks: list[list[str]] = []
current: list[str] = []
current_len = 0
for line in lines:
line_len = len(line) + 1 # +1 for newline
if current and current_len + line_len > max_chars:
chunks.append(current)
current = []
current_len = 0
current.append(line)
current_len += line_len
if current:
chunks.append(current)
return chunks
def _llm_summarize(client, model: str, lines: list[str], day_str: str) -> str:
"""LLM 摘要:单块直接总结,多块先分段总结再合并。"""
chunks = _split_lines_into_chunks(lines)
if len(chunks) == 1:
return _llm_call(client, model, _build_prompt(chunks[0], day_str))
# 多块:每块独立总结
partials: list[str] = []
for i, chunk in enumerate(chunks, 1):
prompt = f"""以下是今日日报素材的第 {i}/{len(chunks)} 部分 (共 {len(lines)} 条, 本批 {len(chunk)} 条),请用要点总结,每条一行,以 "- " 开头:
{chr(10).join(chunk)}
直接输出要点列表:"""
result = _llm_call(client, model, prompt, max_tokens=800)
if result:
partials.append(result)
logger.info("AI 摘要: 分块 {}/{} 完成 ({} 字)", i, len(chunks), len(result))
if not partials:
logger.warning("AI 摘要: 所有分块均返回空")
return ""
if len(partials) < len(chunks):
logger.warning("AI 摘要: {}/{} 分块返回空, 仅合并成功部分", len(chunks) - len(partials), len(chunks))
# 合并:将各块摘要合成最终日报摘要
merge_prompt = f"""以下是 {len(partials)} 组分段摘要,请合并为一份完整的日报摘要 ({day_str})
{chr(10).join(f'--- 第{i+1}组 ---{chr(10)}{p}' for i, p in enumerate(partials))}
请合并为要点总结,每条一行以 "- " 开头,要求:
1. 前 3 条为影响最大的事件,说明为什么重要
2. 汇总近 {CNINFO_DAYS_BACK} 日公司公告/调研核心信息
3. 市场情绪基调(利好/利空/中性)
4. 值得持续关注的行业或主题
5. 纯要点,不要开场白/结束语
6. 总字数 500 字以内
直接输出要点列表:"""
return _llm_call(client, model, merge_prompt, max_tokens=1500)
def _build_prompt(lines: list[str], day_str: str) -> str:
"""构建标准日报摘要 prompt。"""
return f"""以下是今日需要总结的全部内容(含新闻联播、财经新闻、公司公告),请据此生成日报摘要 ({day_str})
{chr(10).join(lines)}
请用要点总结,每条一行,以 "- " 开头,要求:
1. 前 3 条为过去 24 小时影响最大的事件(优先参考新闻联播中的重大政策信号),说明为什么重要
2. 汇总近 {CNINFO_DAYS_BACK} 日重要公司公告/调研的核心信息
3. 市场情绪基调(利好/利空/中性)
4. 值得持续关注的行业或主题
5. 纯要点,不要开场白/结束语/标题
6. 总字数控制在 500 字以内
直接输出要点列表:"""
def _llm_call(client, model: str, prompt: str, max_tokens: int = 1500) -> str:
"""单次 LLM 调用(带重试),返回 strip 后的文本。
失败按指数退避重试 `_LLM_RETRY_TIMES` 次(默认 3),全部失败则抛出最后一次异常。
若 finish_reason 为 'length' 则说明达到 max_tokens 上限被截断。
"""
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
# --------------------------------------------------------------------------- #
# HTML 渲染
# --------------------------------------------------------------------------- #
_HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>A 股 Deep Research 日报 — {date}_{time}</title>
<style>
:root {{ --bg: #f8f9fa; --card: #fff; --text: #212529; --muted: #6c757d;
--accent: #2563eb; --border: #dee2e6; --pos: #059669; --neg: #dc2626;
--neu: #6b7280; --radius: 10px; }}
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; background: var(--bg); color: var(--text); line-height: 1.7; padding-bottom: 3rem; }}
.container {{ max-width: 1000px; margin: 0 auto; padding: 1.2rem; }}
header {{ background: linear-gradient(135deg, #1e293b, #334155); color: #fff; padding: 2.5rem 0 1.8rem; text-align: center; }}
header h1 {{ font-size: 1.8rem; }}
header p {{ color: #94a3b8; margin-top: .4rem; }}
h2 {{ font-size: 1.3rem; margin: 2rem 0 .8rem; padding-bottom: .4rem; border-bottom: 2px solid var(--accent); }}
h3 {{ font-size: 1.05rem; margin: 1.2rem 0 .5rem; color: #374151; }}
.ai-summary {{ background: linear-gradient(135deg, #eff6ff, #f0fdf4); border: 1px solid #93c5fd; border-radius: var(--radius); padding: 1.2rem 1.5rem; margin: 1rem 0; line-height: 1.9; font-size: .95em; }}
.stats-grid {{ display: grid; grid-template-columns: repeat(6, 1fr); gap: .8rem; margin: 1rem 0; }}
.stat-card {{ background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem .8rem; text-align: center; }}
.stat-card .num {{ font-size: 1.6rem; font-weight: 700; }}
.stat-card .label {{ font-size: .8em; color: var(--muted); margin-top: .2rem; }}
.source-grid {{ display: grid; grid-template-columns: repeat(6, 1fr); gap: .6rem; margin: .8rem 0; }}
.source-item {{ background: var(--card); border: 1px solid var(--border); border-radius: 6px; padding: .6rem; text-align: center; font-size: .85em; }}
.source-item .s-name {{ color: var(--muted); font-size: .8em; }}
.source-item .s-count {{ font-size: 1.2rem; font-weight: 600; }}
.sentiment-bar {{ display: flex; height: 24px; border-radius: 6px; overflow: hidden; margin: .5rem 0; }}
.sentiment-bar .pos {{ background: var(--pos); }}
.sentiment-bar .neg {{ background: var(--neg); }}
.sentiment-bar .neu {{ background: var(--neu); }}
.sentiment-legend {{ display: flex; gap: 1rem; margin: .5rem 0; font-size: .9em; }}
table {{ width: 100%; border-collapse: collapse; margin: .8rem 0; font-size: .93em; }}
th, td {{ border: 1px solid var(--border); padding: .5rem .7rem; text-align: left; }}
th {{ background: #f1f5f9; font-weight: 600; white-space: nowrap; }}
.event-row:hover {{ background: #f8fafc; }}
.badge {{ display: inline-block; padding: .1em .5em; border-radius: 10px; font-size: .78em; font-weight: 600; }}
.badge-pos {{ background: #d1fae5; color: #065f46; }}
.badge-neg {{ background: #fee2e2; color: #991b1b; }}
.badge-neu {{ background: #e5e7eb; color: #374151; }}
.imp {{ font-weight: 700; }}
.imp-5 {{ color: #dc2626; }} .imp-4 {{ color: #ea580c; }}
.subsection {{ margin: 1rem 0; }}
footer {{ text-align: center; color: var(--muted); font-size: .82em; margin-top: 3rem; padding-top: 1.2rem; border-top: 1px solid var(--border); }}
a {{ color: var(--accent); text-decoration: none; }}
a:hover {{ text-decoration: underline; }}
@media (max-width: 768px) {{
.stats-grid, .source-grid {{ grid-template-columns: repeat(3, 1fr); }}
}}
.xwlb-list ol {{ padding-left: 1.2em; }}
.xwlb-list li {{ padding: .35em 0; line-height: 1.55; border-bottom: 1px dashed var(--border); }}
.xwlb-num {{ color: var(--accent); font-weight: 600; margin-right: .3em; }}
</style>
</head>
<body>
<header>
<div class="container">
<h1>📊 A 股 Deep Research 日报</h1>
<p>{date} · 生成于 {generated_at}</p>
</div>
</header>
<main class="container">
<!-- ====== 一、AI 摘要 ====== -->
<h2>一、AI 摘要</h2>
<div class="ai-summary">{ai_summary}</div>
<!-- ====== 二、新闻联播要闻 ====== -->
{xwlb_section}
<!-- ====== 三、重要事件:新闻 ====== -->
<h2>三、🔥 重要事件:新闻 <small style="color:var(--muted)">({raw_total_24h}/{raw_total} 篇 24h 内, importance ≥ {news_threshold}, 共 {news_high_count} 篇)</small></h2>
{news_table}
<!-- ====== 四、重要事件:公告/互动 ====== -->
<h2>四、📋 重要事件:公告 / 调研 / 互动 <small style="color:var(--muted)">(近 {cninfo_days} 日, importance ≥ {cninfo_threshold}, 共 {cninfo_high_count} 篇)</small></h2>
{cninfo_table}
<!-- ====== 五、数据总览 ====== -->
<h2>五、数据总览</h2>
<h3>5.1 M1 → M6 管道</h3>
<div class="stats-grid">
<div class="stat-card"><div class="num">{raw_total} <small style="font-size:.45em;color:var(--muted)">({raw_total_24h} 24h)</small></div><div class="label">M1 原始文章</div></div>
<div class="stat-card"><div class="num">{cninfo_raw}</div><div class="label">M1 cninfo</div></div>
<div class="stat-card"><div class="num">{proc}</div><div class="label">M2 正文提取</div></div>
<div class="stat-card"><div class="num">{deduped}</div><div class="label">M3 去重唯一</div></div>
<div class="stat-card"><div class="num">{emb_count}</div><div class="label">M5 向量</div></div>
<div class="stat-card"><div class="num">{qdrant_count}</div><div class="label">M6 Qdrant</div></div>
</div>
<h3>5.2 各源数据 <small style="color:var(--muted)">({raw_total_24h}/{raw_total} 篇 24h 内)</small></h3>
<div class="source-grid">
{source_cards}
</div>
<h3>5.3 情绪分布 <small style="color:var(--muted)">(最新抓取)</small></h3>
{sentiment_section}
<h3>5.4 重要度分布</h3>
<table>
<tr><th>重要度</th>{importance_headers}</tr>
<tr><td>数量</td>{importance_counts}</tr>
</table>
<h3>5.5 事件类型分布</h3>
<table>
<tr><th>事件类型</th><th>数量</th></tr>
{event_type_rows}
</table>
</main>
<footer>
<div class="container">
<p>A 股 Deep Research 私有投研平台 · 自动生成于 {generated_at}</p>
</div>
</footer>
</body>
</html>"""
def _render_event_table(events: list[dict], show_source: bool = True,
show_summary: bool = True) -> str:
"""渲染事件表格。"""
if not events:
return "<p>暂无符合条件的数据</p>"
rows: list[str] = []
wl_codes = _load_watchlist_codes()
for i, e in enumerate(events, 1):
ev = e["event"]
sentiment = ev.get("sentiment", "")
icon = {"positive": "🟢", "negative": "🔴", "neutral": "⚪"}.get(sentiment, "")
badge_cls = {"positive": "badge-pos", "negative": "badge-neg"}.get(sentiment, "badge-neu")
imp = ev.get("importance", 0)
imp_cls = f"imp-{imp}" if imp >= 4 else ""
title = e["title"][:70]
src = _source_name(e.get("source_id", ""))
codes_in_event = {c.strip().split(".")[0] for c in (ev.get("stock_codes") or [])}
star = "⭐ " if codes_in_event & wl_codes else ""
code_str = f" <small>({','.join(list(codes_in_event)[:3])})</small>" if codes_in_event else ""
url = e.get("url", "")
title_cell = f'{star}<a href="{url}" target="_blank">{title}</a>{code_str}' if url else f"{star}{title}{code_str}"
cols = [
f"<td>{i}</td>",
f'<td><span class="badge {badge_cls}">{icon}</span></td>',
f"<td>{title_cell}</td>",
]
if show_source:
cols.append(f"<td>{src}</td>")
cols.append(f'<td class="imp {imp_cls}">{imp}</td>')
cols.append(f"<td>{ev.get('event_type', '')}</td>")
if show_summary:
cols.append(f"<td>{(ev.get('summary', '') or '')[:60]}</td>")
rows.append(f'<tr class="event-row">{"".join(cols)}</tr>')
headers = ["#", "", "标题"]
if show_source:
headers.append("源")
headers += ["重要度", "事件类型"]
if show_summary:
headers.append("摘要")
header_row = "".join(f"<th>{h}</th>" for h in headers)
return f"<table><tr>{header_row}</tr>{''.join(rows)}</table>"
def _render_source_cards(raw_by_source: dict[str, int],
raw_by_source_24h: dict[str, int],
cninfo_raw: int) -> str:
"""渲染源数据卡片,每行 6 个,显示总量和 24h 新鲜数。"""
cards: list[str] = []
# 新闻源
for name, count in sorted(raw_by_source.items()):
fresh = raw_by_source_24h.get(name, 0)
cards.append(
f'<div class="source-item">'
f'<div class="s-count">{count} <small style="font-size:.65em;color:var(--muted)">({fresh} 24h)</small></div>'
f'<div class="s-name">{name}</div>'
f'</div>'
)
# cninfo
cards.append(
f'<div class="source-item" style="border-color:var(--accent)">'
f'<div class="s-count">{cninfo_raw}</div>'
f'<div class="s-name">📋 cninfo</div>'
f'</div>'
)
return "\n".join(cards)
def _render_xwlb_section(xwlb: dict | None) -> str:
"""渲染新闻联播要闻 HTML 板块(重要事件格式,按重要度排序)。"""
if not xwlb or not xwlb.get("items"):
return ""
items = xwlb["items"]
date_label = xwlb.get("date", "")
source_date = xwlb.get("source_date", "")
table_html = _render_event_table(items, show_source=False, show_summary=False)
return f"""<h2>二、📺 新闻联播 <small style="color:var(--muted)">({date_label}, 共 {len(items)} 条, 按重要度排序)</small></h2>
{table_html}
<p style="color:var(--muted);font-size:.8em;margin-top:.5em">
来源: 央视《新闻联播》· 数据取自 doorcome API (xwlbFine) · {source_date}
</p>"""
def _render_html(news: dict, cninfo: dict, pipeline: dict,
ai_summary: str, day_str: str,
xwlb: dict | None = None) -> str:
"""组装完整 HTML(M10 起废弃:日报已改为结构化入库,此函数不再被调用,保留以便回退)。"""
# AI 摘要 → HTML
summary_html = _re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", ai_summary)
summary_html = _re.sub(r"\*(.+?)\*", r"<em>\1</em>", summary_html)
summary_html = _re.sub(r"`(.+?)`", r"<code>\1</code>", summary_html)
if summary_html.strip():
lines = summary_html.strip().splitlines()
if any(ln.strip().startswith("- ") for ln in lines):
items = []
for ln in lines:
s = ln.strip()
if s.startswith("- "):
items.append(f"<li>{s[2:]}</li>")
elif s:
items.append(f"<li>{s}</li>")
summary_html = f"<ul style='padding-left:1.5rem;margin:.5rem 0'>{''.join(items)}</ul>"
else:
summary_html = summary_html.replace("\n", "<br>")
else:
summary_html = "<p>AI 摘要暂不可用</p>"
# 新闻表格
news_table = _render_event_table(news["high"])
# cninfo 表格
cninfo_table = _render_event_table(cninfo["high"], show_source=False, show_summary=False)
# 源数据卡片
source_cards = _render_source_cards(
pipeline["raw_by_source"],
pipeline.get("raw_by_source_24h", {}),
pipeline["cninfo_raw"],
)
# 情绪
s = news["sentiments"]
pos = s.get("positive", 0)
neg = s.get("negative", 0)
neu = s.get("neutral", 0)
total_s = max(pos + neg + neu, 1)
sentiment_section = (
f'<div class="sentiment-bar">'
f'<div class="pos" style="width:{pos/total_s*100:.0f}%"></div>'
f'<div class="neg" style="width:{neg/total_s*100:.0f}%"></div>'
f'<div class="neu" style="width:{neu/total_s*100:.0f}%"></div>'
f'</div>'
f'<div class="sentiment-legend">'
f'<span>🟢 利好 {pos} ({pos/total_s:.0%})</span>'
f'<span>🔴 利空 {neg} ({neg/total_s:.0%})</span>'
f'<span>⚪ 中性 {neu} ({neu/total_s:.0%})</span>'
f'</div>'
)
# 重要度
imps = news["importances"]
imp_keys = sorted(imps.keys())
importance_headers = "".join(f"<th>等级 {k}</th>" for k in imp_keys)
importance_counts = "".join(f"<td>{imps[k]}</td>" for k in imp_keys)
# 事件类型
et = news["event_types"]
event_type_rows = "\n".join(
f"<tr><td>{k}</td><td>{v}</td></tr>" for k, v in et.items()
)
return _HTML_TEMPLATE.format(
date=day_str,
time=datetime.now().strftime("%H%M"),
generated_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
ai_summary=summary_html,
xwlb_section=_render_xwlb_section(xwlb) if xwlb else "",
news_high_count=len(news["high"]),
news_threshold=news.get("hi_threshold", 4),
news_table=news_table,
cninfo_high_count=len(cninfo["high"]),
cninfo_threshold=cninfo.get("hi_threshold", 4),
cninfo_days=CNINFO_DAYS_BACK,
cninfo_table=cninfo_table,
raw_total=pipeline["raw_total"],
raw_total_24h=pipeline.get("raw_total_24h", pipeline["raw_total"]),
cninfo_raw=pipeline["cninfo_raw"],
proc=pipeline["proc"],
deduped=pipeline["deduped"],
emb_count=pipeline["emb_count"],
qdrant_count=pipeline["qdrant_count"],
source_cards=source_cards,
sentiment_section=sentiment_section,
importance_headers=importance_headers,
importance_counts=importance_counts,
event_type_rows=event_type_rows,
)
# --------------------------------------------------------------------------- #
# 生成 + 上传
# --------------------------------------------------------------------------- #
def _build_report_data(news: dict, cninfo: dict, pipeline: dict,
ai_summary: str, day_str: str,
xwlb: dict | None = None) -> ReportData:
"""组装结构化日报数据(M10:写入 MySQL 的前置步骤)。
事件板块映射:news["high"]→news / cninfo["high"]→cninfo / xwlb["items"]→xwlb。
数据总览统计以 JSON 快照存入 stats(前端自行解析)。
"""
events: list[EventRow] = []
def _rows(items: list[dict], section: str) -> None:
for i, e in enumerate(items, 1):
ev = e.get("event", {})
events.append(
EventRow(
section=section,
rank=i,
importance=ev.get("importance"),
event_type=ev.get("event_type"),
title=str(e.get("title", ""))[:512],
summary=(ev.get("summary") or None),
sentiment=ev.get("sentiment") or None,
source=e.get("source_id") or None,
url=e.get("url") or None,
)
)
_rows(news.get("high", []), "news")
_rows(cninfo.get("high", []), "cninfo")
if xwlb:
_rows(xwlb.get("items", []), "xwlb")
stats: dict[str, Any] = {
"pipeline": pipeline,
"news": {
"total": news.get("total", 0),
"hi_threshold": news.get("hi_threshold"),
"sentiments": news.get("sentiments", {}),
"importances": news.get("importances", {}),
"event_types": news.get("event_types", {}),
},
"cninfo": {
"total": cninfo.get("total", 0),
"hi_threshold": cninfo.get("hi_threshold"),
"by_day": cninfo.get("by_day", {}),
"announcement": cninfo.get("announcement", 0),
"research": cninfo.get("research", 0),
"irm": cninfo.get("irm", 0),
},
}
if xwlb:
stats["xwlb"] = {"total": len(xwlb.get("items", [])), "date": xwlb.get("date", "")}
return ReportData(
report_date=datetime.strptime(day_str, "%Y%m%d").date(),
report_type="finance",
file_name="", # 新生成日报唯一键退化为 (report_date, finance, "")
generated_at=datetime.now(),
ai_summary=ai_summary or None,
stats=stats,
events=events,
)
def generate_report(day_str: str | None = None, *, upload: bool = True) -> int | None:
"""生成每日日报并结构化入库(M10 完全切换,不再生成 HTML)。
`upload` 参数保留以兼容 scheduler/pipeline.py 调用,已无实际作用。
返回 report_id(成功)或 None(无数据/失败)。
"""
day_str = day_str or date.today().strftime("%Y%m%d")
logger.info("生成日报: {}", day_str)
# 收集数据
try:
news = _collect_news_events(day_str)
cninfo = _collect_cninfo_events(day_str, days_back=CNINFO_DAYS_BACK)
pipeline = _collect_pipeline_stats(day_str)
xwlb = _collect_xwlb(day_str)
except Exception as e:
logger.exception("收集日报数据失败: {}", e)
return None
if news["total"] == 0 and cninfo["total"] == 0 and not xwlb.get("items"):
logger.warning("{} 无数据,跳过日报生成", day_str)
return None
# AI 摘要(新闻联播 + 新闻 + cninfo)
ai_summary = _generate_ai_summary(news, cninfo, day_str, xwlb=xwlb)
# 结构化入库(替代原 HTML 渲染 + 上传)
report = _build_report_data(news, cninfo, pipeline, ai_summary, day_str, xwlb=xwlb)
try:
from report_db import connect, save_report
conn = connect()
try:
report_id = save_report(conn, report)
finally:
conn.close()
except Exception as e:
logger.exception("日报入库失败: {}", e)
return None
logger.info("日报已入库: report_id={}", report_id)
return report_id
def _upload(html_path: Path, file_tag: str) -> bool:
"""上传 HTML 报告到 Web 服务器(M10 起废弃:不再被调用,保留以便回退)。"""
today_str = date.today().strftime("%Y%m%d")
remote_dir = f"{UPLOAD_BASE}/{today_str}/"
logger.info("上传日报到 {}:{}", UPLOAD_HOST, remote_dir)
try:
r1 = subprocess.run(
["ssh", UPLOAD_HOST, f"mkdir -p {remote_dir}"],
timeout=15, capture_output=True, text=True,
)
if r1.returncode != 0:
logger.warning("创建远程目录失败: {}", r1.stderr.strip())
return False
r2 = subprocess.run(
["scp", str(html_path), f"{UPLOAD_HOST}:{remote_dir}finance_news_daily_{file_tag}.html"],
timeout=30, capture_output=True, text=True,
)
if r2.returncode != 0:
logger.warning("上传日报失败: {}", r2.stderr.strip())
return False
logger.info("上传完成: http://doorcome.cn/echart/research/{}/", today_str)
return True
except Exception as e:
logger.warning("上传日报异常(不阻塞): {}", e)
return False