feat: 日报结构化入库(M10 前后端分离数据层)

- 新增 report_db 包: MySQL 连接/建表/幂等写入 (news_report/news_event, myquant 库)
- 新增 report_import 包: 历史 178 份日报 HTML 解析入库, 表头驱动列映射
- reporter.py 完全切换: generate_report 结构化入库, 不再生成/上传 HTML
- CLI: 新增 report-import 子命令
- 依赖: uv add pymysql; 配置: NEWS_DB_* / REPORT_HISTORY_DIR
- 文档: docs/report_db_design.md(实现逻辑), docs/db_schema.md(表结构供 API/前端)
- 测试: 24 个单测通过 (parser/builder/models/importer)
This commit is contained in:
2026-08-03 21:32:07 +08:00
parent f2c80c5a9c
commit 366e60e8a9
25 changed files with 1934 additions and 33 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+87
View File
@@ -0,0 +1,87 @@
"""日报结构化组装单元测试(_build_report_data,纯逻辑)。"""
from __future__ import annotations
import json
from datetime import date
from scheduler.reporter import _build_report_data
def _fake_event(title: str, importance: int, event_type: str = "其他",
sentiment: str = "neutral", source_id: str = "cls",
url: str = "https://x.com/1") -> dict:
return {
"title": title,
"url": url,
"source_id": source_id,
"event": {
"stock_codes": [],
"company_names": [],
"industries": [],
"sentiment": sentiment,
"importance": importance,
"event_type": event_type,
"summary": f"{title}的摘要",
},
}
class TestBuildReportData:
def test_sections_and_ranks(self) -> None:
news = {
"total": 2, "hi_threshold": 4,
"high": [_fake_event("新闻A", 5), _fake_event("新闻B", 4)],
"sentiments": {"neutral": 2}, "importances": {5: 1, 4: 1},
"event_types": {"其他": 2},
}
cninfo = {
"total": 1, "hi_threshold": 2,
"high": [_fake_event("公告C", 3, event_type="公告")],
"by_day": {"07月10日": 1}, "announcement": 1, "research": 0, "irm": 0,
}
pipeline = {"raw_total": 100, "proc": 90}
xwlb = {"items": [_fake_event("联播D", 4, event_type="新闻联播", source_id="xwlb")],
"date": "07月10日"}
r = _build_report_data(news, cninfo, pipeline, "AI摘要", "20260710", xwlb=xwlb)
assert r.report_date == date(2026, 7, 10)
assert r.report_type == "finance"
assert r.file_name == ""
assert r.ai_summary == "AI摘要"
assert [(e.section, e.rank) for e in r.events] == [
("news", 1), ("news", 2), ("cninfo", 1), ("xwlb", 1),
]
assert r.events[0].source == "cls"
assert r.events[3].source == "xwlb"
def test_stats_snapshot(self) -> None:
news = {"total": 1, "hi_threshold": 4, "high": [], "sentiments": {},
"importances": {}, "event_types": {}}
cninfo = {"total": 0, "hi_threshold": 0, "high": [], "by_day": {},
"announcement": 0, "research": 0, "irm": 0}
r = _build_report_data(news, cninfo, {"raw_total": 100}, "s", "20260710")
# stats 可 JSON 序列化(入库时 json.dumps
json.dumps(r.stats, ensure_ascii=False)
assert r.stats["pipeline"] == {"raw_total": 100}
assert r.stats["news"]["total"] == 1
assert "xwlb" not in r.stats
def test_title_truncated(self) -> None:
news = {"total": 1, "hi_threshold": 4,
"high": [_fake_event("" * 600, 4)], "sentiments": {},
"importances": {}, "event_types": {}}
cninfo = {"total": 0, "hi_threshold": 0, "high": [], "by_day": {},
"announcement": 0, "research": 0, "irm": 0}
r = _build_report_data(news, cninfo, {}, "s", "20260710")
assert len(r.events[0].title) == 512
def test_empty_events(self) -> None:
news = {"total": 0, "hi_threshold": 0, "high": [], "sentiments": {},
"importances": {}, "event_types": {}}
cninfo = {"total": 0, "hi_threshold": 0, "high": [], "by_day": {},
"announcement": 0, "research": 0, "irm": 0}
r = _build_report_data(news, cninfo, {}, None, "20260710")
assert r.events == []
assert r.ai_summary is None
+51
View File
@@ -0,0 +1,51 @@
"""日报数据模型单元测试。"""
from __future__ import annotations
from datetime import date, datetime
import pytest
from pydantic import ValidationError
from report_db.models import EventRow, ReportData
class TestEventRow:
def test_minimal(self) -> None:
ev = EventRow(section="news", 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="ForexLive",
url="https://x.com/1",
)
assert ev.sentiment == "negative"
def test_missing_title_raises(self) -> None:
with pytest.raises(ValidationError):
EventRow(section="news", rank=1) # type: ignore[call-arg]
class TestReportData:
def test_defaults(self) -> None:
r = ReportData(
report_date=date(2026, 7, 11),
report_type="finance",
generated_at=datetime(2026, 7, 11, 7, 0),
)
assert r.file_name == ""
assert r.stats == {}
assert r.events == []
def test_with_events(self) -> None:
r = ReportData(
report_date=date(2026, 7, 11),
report_type="finance",
generated_at=datetime(2026, 7, 11, 7, 0),
ai_summary="摘要",
events=[EventRow(section="xwlb", rank=1, title="t")],
)
assert len(r.events) == 1
+32
View File
@@ -0,0 +1,32 @@
"""历史导入器单元测试(文件匹配/扫描逻辑,不依赖真实 DB)。"""
from __future__ import annotations
from pathlib import Path
from report_import.importer import _match_file
class TestMatchFile:
def test_finance(self) -> None:
p = Path("20260711/finance_news_daily_20260710_0720.html")
assert _match_file(p, None, None)
assert _match_file(p, "20260710", None)
assert _match_file(p, None, "finance")
assert not _match_file(p, "20260711", None) # 文件名日期不含 20260711
assert not _match_file(p, None, "intl")
def test_no_timestamp_suffix(self) -> None:
# 早期文件无时间戳后缀,也应匹配
p = Path("20260616/finance_news_daily_20260616.html")
assert _match_file(p, None, None)
assert _match_file(p, "20260616", "finance")
def test_intl(self) -> None:
p = Path("20260711/intl_news_daily_20260711_070304.html")
assert _match_file(p, "20260711", "intl")
assert not _match_file(p, None, "finance")
def test_non_report_ignored(self) -> None:
assert not _match_file(Path("20260711/002714.SZ_0724.html"), None, None)
assert not _match_file(Path("20260711/readme.md"), None, None)
+98
View File
@@ -0,0 +1,98 @@
"""历史日报解析器单元测试(基于真实样例 HTML)。"""
from __future__ import annotations
from datetime import date, datetime
from pathlib import Path
import pytest
from report_import.parser import (
ReportParseError,
parse_finance_report,
parse_intl_report,
parse_report,
)
FIXTURES = Path(__file__).parent / "fixtures"
FINANCE_HTML = (FIXTURES / "finance_news_daily_20260710_0720.html").read_text(encoding="utf-8")
INTL_HTML = (FIXTURES / "intl_news_daily_20260711_070304.html").read_text(encoding="utf-8")
class TestFinanceParse:
def test_metadata(self) -> None:
r = parse_finance_report(FINANCE_HTML, "finance_news_daily_20260710_0720.html")
assert r.report_date == date(2026, 7, 10)
assert r.report_type == "finance"
assert r.file_name == "finance_news_daily_20260710_0720.html"
assert r.generated_at == datetime(2026, 7, 11, 7, 20, 27) # header"生成于"优先
def test_ai_summary_lines(self) -> None:
r = parse_finance_report(FINANCE_HTML, "finance_news_daily_20260710_0720.html")
assert r.ai_summary is not None
assert len(r.ai_summary.splitlines()) >= 5
assert "碳达峰" in r.ai_summary
def test_sections_and_ranks(self) -> None:
r = parse_finance_report(FINANCE_HTML, "finance_news_daily_20260710_0720.html")
sections = {e.section for e in r.events}
assert sections == {"xwlb", "news", "cninfo"}
assert sum(1 for e in r.events if e.section == "xwlb") == 16
assert sum(1 for e in r.events if e.section == "news") == 20
assert sum(1 for e in r.events if e.section == "cninfo") == 20
def test_event_fields(self) -> None:
r = parse_finance_report(FINANCE_HTML, "finance_news_daily_20260710_0720.html")
xwlb = next(e for e in r.events if e.section == "xwlb")
assert xwlb.importance == 4
assert xwlb.event_type == "新闻联播"
assert xwlb.sentiment == "neutral"
assert "张国清" in xwlb.title
news = next(e for e in r.events if e.section == "news" and e.source)
assert news.source # 新闻板块带来源
def test_stats_keys(self) -> None:
r = parse_finance_report(FINANCE_HTML, "finance_news_daily_20260710_0720.html")
for key in ("pipeline", "sources", "sentiment", "importance", "event_types"):
assert key in r.stats, f"缺少 stats.{key}"
assert r.stats["pipeline"]["M1 原始文章"] == 758
class TestIntlParse:
def test_metadata(self) -> None:
r = parse_intl_report(INTL_HTML, "intl_news_daily_20260711_070304.html")
assert r.report_date == date(2026, 7, 11)
assert r.report_type == "intl"
assert r.generated_at == datetime(2026, 7, 11, 7, 3, 30)
def test_events_and_source_from_small(self) -> None:
r = parse_intl_report(INTL_HTML, "intl_news_daily_20260711_070304.html")
assert len(r.events) == 19
first = r.events[0]
assert first.section == "intl"
assert first.source == "investinglive.com" # 从摘要 <small>[来源]</small> 提取
assert first.url.startswith("https://investinglive.com/")
assert first.importance == 4
assert first.event_type == "地缘政治"
# 摘要中不应残留 [来源] 标记
assert first.summary is not None and "[investinglive.com]" not in first.summary
def test_stats_keys(self) -> None:
r = parse_intl_report(INTL_HTML, "intl_news_daily_20260711_070304.html")
for key in ("pipeline", "sentiment", "importance", "event_types", "source_dist"):
assert key in r.stats
assert r.stats["source_dist"][0]["来源"] == "ForexLive"
class TestParseReportDispatch:
def test_dispatch_finance(self) -> None:
r = parse_report(FINANCE_HTML, "finance_news_daily_20260710_0720.html")
assert r.report_type == "finance"
def test_dispatch_intl(self) -> None:
r = parse_report(INTL_HTML, "intl_news_daily_20260711_070304.html")
assert r.report_type == "intl"
def test_bad_filename_raises(self) -> None:
with pytest.raises(ReportParseError):
parse_report("<html></html>", "not_a_report.html")