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:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user