- EventRow 新增 sources 字段;init_schema 幂等 ALTER 补列(MariaDB) - save_report 写入 sources JSON;_article_sources_full 完整来源列表 - source 字段保持 ≤3 拼接兼容;新增多源完整列表测试 - 已部署 pi5:sources 列写入验证通过(单源数组);完整多源待新数据
217 lines
9.0 KiB
Python
217 lines
9.0 KiB
Python
"""日报结构化入库:模型 + _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
|
||
|
||
def test_multi_source_label(self) -> None:
|
||
"""去重合并后的多来源 → source 拼接展示(Reuters, CNBC)+ sources 完整列表。"""
|
||
now = datetime(2026, 8, 4, 8, 0, 0)
|
||
ev = _fake_high_event("多来源事件", 5, source_id="reuters",
|
||
url="https://reuters.com/news/9")
|
||
# 模拟 M3 去重合并:source_ids 含两个来源(主源 reuters 居首)
|
||
ev["article"]["source_ids"] = ["reuters", "cnbc"]
|
||
r = _build_report_data(now, {}, [ev], Counter(), Counter(), Counter(),
|
||
Counter(), "")
|
||
assert r.events[0].source == "Reuters, CNBC"
|
||
assert r.events[0].sources == ["Reuters", "CNBC"] # 主源居首
|
||
assert r.events[0].url == "https://reuters.com/news/9"
|
||
|
||
def test_multi_source_full_list(self) -> None:
|
||
"""超过 3 个来源:source 截前 3,sources 存完整列表。"""
|
||
now = datetime(2026, 8, 4, 8, 0, 0)
|
||
ev = _fake_high_event("四来源事件", 5, source_id="barrons",
|
||
url="https://barrons.com/news/1")
|
||
ev["article"]["source_ids"] = ["barrons", "cnbc", "reuters", "ft"]
|
||
r = _build_report_data(now, {}, [ev], Counter(), Counter(), Counter(),
|
||
Counter(), "")
|
||
assert r.events[0].source == "Barron's, CNBC, Reuters" # 前 3 拼接
|
||
assert r.events[0].sources == [ # 完整 4 个
|
||
"Barron's", "CNBC", "Reuters", "Financial Times",
|
||
]
|
||
|
||
def test_single_source_falls_back(self) -> None:
|
||
"""source_ids 为空/单一时回退单源逻辑(不拼接)。"""
|
||
now = datetime(2026, 8, 4, 8, 0, 0)
|
||
ev = _fake_high_event("单来源事件", 4, source_id="investinglive",
|
||
url="https://investinglive.com/news/3")
|
||
ev["article"]["source_ids"] = ["investinglive"]
|
||
r = _build_report_data(now, {}, [ev], Counter(), Counter(), Counter(),
|
||
Counter(), "")
|
||
assert r.events[0].source == "InvestingLive"
|
||
assert r.events[0].sources == ["InvestingLive"]
|