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:
@@ -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_data(intl 结构)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user