feat: M9 日报结构化入库(HTML 改为写入 MySQL news_report/news_event,report_type=intl)

- 新增 report_db/ 包(models/schema/db,复用 news 项目实现,幂等 upsert)
- reporter.py: _build_report_data + generate_report 写库返回 report_id
- pipeline/cli 适配 report_id 返回值;HTML 渲染/上传保留 deprecated
- 新增 tests/test_report_db.py;.env.example 增加 NEWS_DB_* 配置
- 已部署 pi5 并验证:真实生成 report_id=184(12 事件)+ 幂等覆盖
This commit is contained in:
2026-08-04 11:09:17 +08:00
parent e1ec5f836d
commit d4a55bcaaa
14 changed files with 669 additions and 69 deletions
+179
View File
@@ -0,0 +1,179 @@
"""日报结构化入库:模型 + _build_report_data 组装(纯逻辑,不连 DB)。"""
from __future__ import annotations
import json
from collections import Counter
from datetime import date, datetime
import pytest
from pydantic import ValidationError
from report_db.models import EventRow, ReportData
from scheduler.reporter import _build_report_data
# --------------------------------------------------------------------------- #
# 模型默认值
# --------------------------------------------------------------------------- #
class TestEventRow:
def test_minimal(self) -> None:
ev = EventRow(section="intl", rank=1, title="标题")
assert ev.importance is None
assert ev.sentiment is None
def test_full(self) -> None:
ev = EventRow(
section="intl", rank=2, importance=4, event_type="地缘政治",
title="t", summary="s", sentiment="negative", source="InvestingLive",
url="https://x.com/1",
)
assert ev.sentiment == "negative"
def test_missing_title_raises(self) -> None:
with pytest.raises(ValidationError):
EventRow(section="intl", rank=1) # type: ignore[call-arg]
class TestReportData:
def test_defaults(self) -> None:
r = ReportData(
report_date=date(2026, 8, 4),
report_type="intl",
generated_at=datetime(2026, 8, 4, 8, 0),
)
assert r.file_name == ""
assert r.stats == {}
assert r.events == []
def test_with_events(self) -> None:
r = ReportData(
report_date=date(2026, 8, 4),
report_type="intl",
generated_at=datetime(2026, 8, 4, 8, 0),
ai_summary="摘要",
events=[EventRow(section="intl", rank=1, title="t")],
)
assert len(r.events) == 1
# --------------------------------------------------------------------------- #
# _build_report_dataintl 结构)
# --------------------------------------------------------------------------- #
def _fake_high_event(title_zh: str, importance: int, *,
event_type: str = "其他", sentiment: str = "neutral",
summary_zh: str = "摘要", source_id: str = "investinglive",
url: str = "https://investinglive.com/news/1") -> dict:
"""构造 _dedup_events 之后的事件结构(含 article 属性)。"""
return {
"importance": importance,
"sentiment": sentiment,
"summary_zh": summary_zh,
"event_type": event_type,
"stock_codes": [],
"article": {
"title": f"EN {title_zh}",
"title_zh": title_zh,
"url": url,
"source_id": source_id,
},
}
class TestBuildReportData:
def test_sections_and_ranks(self) -> None:
now = datetime(2026, 8, 4, 8, 0, 0)
high = [
_fake_high_event("新闻A", 5),
_fake_high_event("新闻B", 4),
_fake_high_event("新闻C", 3),
]
r = _build_report_data(
now, {"raw_total": 100, "proc": 90}, high,
Counter(), Counter(), Counter(), Counter(), "AI摘要",
)
assert r.report_date == date(2026, 8, 4)
assert r.report_type == "intl"
assert r.file_name == ""
assert r.ai_summary == "AI摘要"
assert [(e.section, e.rank) for e in r.events] == [
("intl", 1), ("intl", 2), ("intl", 3),
]
def test_field_mapping(self) -> None:
now = datetime(2026, 8, 4, 8, 0, 0)
high = [_fake_high_event("英伟达财报超预期", 5, event_type="公司财报",
sentiment="positive", summary_zh="营收超预期",
source_id="investinglive",
url="https://investinglive.com/news/42")]
r = _build_report_data(now, {}, high, Counter(), Counter(), Counter(),
Counter(), "")
ev = r.events[0]
assert ev.title == "英伟达财报超预期" # title_zh 优先
assert ev.importance == 5
assert ev.event_type == "公司财报"
assert ev.summary == "营收超预期"
assert ev.sentiment == "positive"
assert ev.url == "https://investinglive.com/news/42"
assert ev.source == "InvestingLive" # 域名 → 源展示名
def test_source_fallback_without_url(self) -> None:
now = datetime(2026, 8, 4, 8, 0, 0)
ev = _fake_high_event("无URL事件", 4, url="")
r = _build_report_data(now, {}, [ev], Counter(), Counter(), Counter(),
Counter(), "")
assert r.events[0].url is None
# url 为空时回退 source_id → sources.yaml 展示名
assert r.events[0].source == "InvestingLive"
def test_stats_snapshot(self) -> None:
now = datetime(2026, 8, 4, 8, 0, 0)
high = [
_fake_high_event("A", 5, event_type="宏观", sentiment="positive"),
_fake_high_event("B", 4, event_type="宏观", sentiment="negative"),
]
r = _build_report_data(
now,
{"raw_total": 120, "proc": 100, "deduped": 90,
"emb_count": 80, "qdrant_count": 75},
high,
Counter({"positive": 1, "negative": 1}),
Counter({5: 1, 4: 1}),
Counter({"宏观": 2}),
Counter({"investinglive": 2}),
"摘要",
)
# stats 可 JSON 序列化(入库时 json.dumps
json.dumps(r.stats, ensure_ascii=False)
assert r.stats["pipeline"]["raw_total"] == 120
assert r.stats["sentiment"] == {"positive": 1, "negative": 1}
assert r.stats["importance"] == [
{"importance": 4, "count": 1}, {"importance": 5, "count": 1},
]
assert r.stats["event_types"] == [{"event_type": "宏观", "count": 2}]
assert r.stats["source_dist"] == [{"source": "InvestingLive", "count": 2}]
def test_title_truncated(self) -> None:
now = datetime(2026, 8, 4, 8, 0, 0)
ev = _fake_high_event("" * 600, 4)
r = _build_report_data(now, {}, [ev], Counter(), Counter(), Counter(),
Counter(), "")
assert len(r.events[0].title) == 512
def test_empty_events(self) -> None:
now = datetime(2026, 8, 4, 8, 0, 0)
r = _build_report_data(now, {}, [], Counter(), Counter(), Counter(),
Counter(), "")
assert r.events == []
assert r.ai_summary is None
def test_question_mark_sentiment_normalized(self) -> None:
now = datetime(2026, 8, 4, 8, 0, 0)
ev = _fake_high_event("未分类情绪", 4, sentiment="?")
r = _build_report_data(now, {}, [ev], Counter(), Counter(), Counter(),
Counter(), "")
# "?" 不写入 DB,留 None
assert r.events[0].sentiment is None