初始化

This commit is contained in:
2026-07-18 16:13:52 +08:00
parent c0070f0a5c
commit fe8b417ab6
75 changed files with 12898 additions and 1 deletions
+266
View File
@@ -0,0 +1,266 @@
"""M1 爬虫模块测试"""
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from crawler.crawler import (
ARTICLE_DELAY_SEC,
MAX_MEMORY_MB,
SOURCE_TIMEOUT_SEC,
compute_url_hash,
crawl_source,
)
from crawler.loader import get_source_by_id, load_sources
from crawler.models import ArticleItem, CrawlResult, SourceConfig
from crawler.storage import load_index, write_index_jsonl
# ════════════════════════════════════════════════
# URL Hash
# ════════════════════════════════════════════════
def test_compute_url_hash_consistency():
"""同一 URL 多次计算 hash 一致"""
h1 = compute_url_hash("https://example.com/article/123")
h2 = compute_url_hash("https://example.com/article/123")
assert h1 == h2
assert len(h1) == 16
def test_compute_url_hash_different():
"""不同 URL 产生不同 hash"""
h1 = compute_url_hash("https://example.com/a")
h2 = compute_url_hash("https://example.com/b")
assert h1 != h2
# ════════════════════════════════════════════════
# 配置加载
# ════════════════════════════════════════════════
def test_load_sources_from_real_config():
"""从项目真实配置文件加载"""
sources, settings = load_sources()
assert len(sources) == 12
assert settings["concurrency"] == 5
reuters = sources[0]
assert reuters.id == "reuters"
assert reuters.name == "Reuters"
assert reuters.enabled is True
def test_get_source_by_id():
"""按 ID 查找源"""
sources, _ = load_sources()
s = get_source_by_id("cnbc", sources)
assert s is not None
assert s.name == "CNBC"
s = get_source_by_id("nonexistent", sources)
assert s is None
def test_load_sources_missing_file():
"""配置文件不存在时抛出异常"""
with pytest.raises(FileNotFoundError):
load_sources(Path("/nonexistent/path.yaml"))
def test_source_config_output_dir():
"""SourceConfig.output_dir 属性"""
s = SourceConfig(
id="reuters",
name="Reuters",
homepage="https://example.com",
article_url_pattern="/article/",
)
today = __import__("datetime").datetime.now().strftime("%Y%m%d")
assert str(s.output_dir) == f"data/raw/reuters/{today}"
# ════════════════════════════════════════════════
# 存储
# ════════════════════════════════════════════════
def test_write_and_load_index_jsonl(tmp_path: Path, monkeypatch):
"""写入 index.jsonl 后再读取,数据一致"""
# 临时替换 data/raw 路径
import crawler.storage as storage_mod
articles = [
ArticleItem(
source_id="test_source",
source_name="Test Source",
url=f"https://example.com/article/{i}",
url_hash=f"hash{i:04d}",
title=f"Test Article {i}",
crawl_time="2026-06-21T00:00:00",
html_path=f"data/raw/test_source/20260621/hash{i:04d}.html",
status="success",
)
for i in range(3)
]
result = CrawlResult(
source_id="test_source",
source_name="Test Source",
total_found=3,
total_success=3,
articles=articles,
)
# Patch Path to use tmp_path
orig_path = Path
def mock_path(p: str) -> Path:
p_str = str(p)
if p_str.startswith("data/raw/"):
return orig_path(tmp_path) / p_str
return orig_path(p_str)
monkeypatch.setattr(storage_mod, "Path", mock_path)
index_path = write_index_jsonl(result)
assert index_path.exists()
# 读取
loaded = load_index("test_source", "20260621")
assert len(loaded) == 3
assert loaded[0].source_id == "test_source"
assert loaded[0].title == "Test Article 0"
def test_load_index_missing_file():
"""不存在的 index 返回空列表"""
articles = load_index("nonexistent", "20990101")
assert articles == []
def test_write_index_jsonl_dedup(tmp_path: Path, monkeypatch):
"""重复 url_hash 不重复写入"""
import crawler.storage as storage_mod
article = ArticleItem(
source_id="dedup_test",
source_name="Dedup Test",
url="https://example.com/same",
url_hash="same_hash_0001",
title="Same Article",
crawl_time="2026-06-21T00:00:00",
html_path="data/raw/dedup_test/20260621/same_hash_0001.html",
status="success",
)
result1 = CrawlResult(source_id="dedup_test", source_name="Dedup Test",
total_success=1, articles=[article])
result2 = CrawlResult(source_id="dedup_test", source_name="Dedup Test",
total_success=1, articles=[article])
def mock_path(p: str) -> Path:
p_str = str(p)
if p_str.startswith("data/raw/"):
return Path(tmp_path) / p_str
return Path(p_str)
monkeypatch.setattr(storage_mod, "Path", mock_path)
write_index_jsonl(result1)
write_index_jsonl(result2) # 重复写入
loaded = load_index("dedup_test", "20260621")
assert len(loaded) == 1 # 去重
# ════════════════════════════════════════════════
# 爬虫引擎 (Mock)
# ════════════════════════════════════════════════
@pytest.mark.asyncio
async def test_crawl_source_with_mock():
"""Mock Crawl4AI,测试 crawl_source 流程"""
source = SourceConfig(
id="mock_source",
name="Mock Source",
homepage="https://mock.example.com/",
article_url_pattern="/news/",
js_render=False,
max_articles_per_run=5,
)
# Mock Crawl4AI 的返回
mock_html = '<a href="/news/article1">Article 1</a><a href="/news/article2">Article 2</a>'
mock_result = MagicMock()
mock_result.success = True
mock_result.html = mock_html
mock_result.markdown = "# Test Article\n\nContent here."
mock_result.metadata = {"title": "Test Article"}
mock_result.error_message = ""
with patch("crawler.crawler.AsyncWebCrawler") as mock_crawler_cls:
mock_crawler = MagicMock()
mock_crawler.arun = AsyncMock(return_value=mock_result)
mock_crawler_cls.return_value.__aenter__ = AsyncMock(return_value=mock_crawler)
mock_crawler_cls.return_value.__aexit__ = AsyncMock(return_value=False)
result = await crawl_source(source)
assert result.source_id == "mock_source"
assert result.total_found == 2
assert result.total_success == 2
assert result.total_failed == 0
assert len(result.articles) == 2
# 每条 article 都有正确的 source_id
for article in result.articles:
assert article.source_id == "mock_source"
assert article.status == "success"
assert article.html_path
@pytest.mark.asyncio
async def test_crawl_source_homepage_failure():
"""首页抓取失败时优雅降级"""
source = SourceConfig(
id="fail_source",
name="Fail Source",
homepage="https://fail.example.com/",
article_url_pattern="/news/",
js_render=False,
)
mock_result = MagicMock()
mock_result.success = False
mock_result.html = ""
mock_result.error_message = "Connection timeout"
with patch("crawler.crawler.AsyncWebCrawler") as mock_crawler_cls:
mock_crawler = MagicMock()
mock_crawler.arun = AsyncMock(return_value=mock_result)
mock_crawler_cls.return_value.__aenter__ = AsyncMock(return_value=mock_crawler)
mock_crawler_cls.return_value.__aexit__ = AsyncMock(return_value=False)
result = await crawl_source(source)
assert result.total_found == 0
assert result.total_success == 0
# ════════════════════════════════════════════════
# 资源限制常量
# ════════════════════════════════════════════════
def test_max_memory_mb():
"""内存限制 < 2000 MB"""
assert 0 < MAX_MEMORY_MB < 2000
def test_source_timeout_sec():
"""单源超时在合理范围"""
assert SOURCE_TIMEOUT_SEC >= 3600 # 至少 1 小时
def test_article_delay_sec():
"""文章间隔 ≥ 1 秒"""
assert ARTICLE_DELAY_SEC >= 1.0
+533
View File
@@ -0,0 +1,533 @@
"""M3 三层去重模块单元测试。"""
from pathlib import Path
import pytest
from dedup import (
DEFAULT_HAMMING_THRESHOLD,
Deduper,
DedupLayer,
DedupResult,
Fingerprint,
FingerprintStore,
article_to_fingerprint,
content_hash,
hamming,
normalize_content,
simhash64,
)
from extractor.models import ProcessedArticle
# --------------------------------------------------------------------------- #
# 辅助工厂函数
# --------------------------------------------------------------------------- #
def _make_article(
*,
url: str = "https://www.reuters.com/business/1",
url_hash: str = "abc1234567890000",
source_id: str = "reuters",
source_name: str = "Reuters",
title: str = "Fed Holds Rates Steady as Markets Rally",
content: str = (
"The Federal Reserve held interest rates steady on Wednesday, "
"citing solid economic growth and a strong labor market. "
"Markets rallied in response, with the S&P 500 gaining 1.2 percent."
),
publish_time: str = "2026-06-16T10:00:00",
word_count: int = 0,
) -> ProcessedArticle:
return ProcessedArticle(
source_id=source_id,
source_name=source_name,
url=url,
url_hash=url_hash,
title=title,
content=content,
publish_time=publish_time,
word_count=word_count or len(content.split()),
)
@pytest.fixture
def tmp_db(tmp_path: Path) -> Path:
return tmp_path / "fp.sqlite3"
# --------------------------------------------------------------------------- #
# hasher 测试
# --------------------------------------------------------------------------- #
class TestNormalizeContent:
"""normalize_content 函数测试。"""
def test_strips_punctuation_and_whitespace(self):
norm = normalize_content("Hello, world!\nThis is text.")
assert norm == "HelloworldThisistext"
def test_handles_empty(self):
assert normalize_content("") == ""
assert normalize_content(" \n\t ") == ""
def test_preserves_letters_and_digits(self):
# $ 是货币符号(Sc 类别),会被保留;% 和 . 是标点(Po)会被移除
norm = normalize_content("AAPL up 5.2% to $150.00!")
assert norm == "AAPLup52to$15000"
def test_handles_chinese_characters(self):
norm = normalize_content("宁德时代 发布 新一代 麒麟电池!")
assert norm == "宁德时代发布新一代麒麟电池"
class TestContentHash:
"""content_hash 函数测试。"""
def test_deterministic(self):
a = "The Fed raised rates today."
b = "The Fed raised rates today."
assert content_hash(a) == content_hash(b)
def test_punctuation_invariant(self):
a = "Apple reports record earnings."
b = "Apple, reports... record earnings!!!"
assert content_hash(a) == content_hash(b)
def test_differs_for_different_text(self):
assert content_hash("Fed raises rates") != content_hash("Fed cuts rates")
class TestSimhash64:
"""simhash64 函数测试。"""
def test_identical_text_same_value(self):
text = "The Federal Reserve held interest rates steady on Wednesday."
assert simhash64(text) == simhash64(text)
def test_minor_changes_close_distance(self):
"""轻量改写,长文本汉明距离应在阈值内。"""
base = (
"The Federal Reserve held interest rates steady on Wednesday, "
"citing solid economic growth and a strong labor market. "
"Markets rallied in response, with the S&P 500 gaining 1.2 percent. "
"Analysts expect rates to remain unchanged through the summer."
) * 2
rewritten = "WASHINGTON (Reuters) - " + base + " (Reporting by John Smith)"
d = hamming(simhash64(base), simhash64(rewritten))
assert d <= DEFAULT_HAMMING_THRESHOLD, (
f"长文本前后加来源标识汉明距离 {d} 不应超过阈值"
)
def test_unrelated_text_far_distance(self):
"""完全不相关的两段长文本汉明距离应远大于阈值。"""
a = "The Federal Reserve held interest rates steady on Wednesday." * 5
b = "Apple announced a new iPhone model with revolutionary features." * 5
d = hamming(simhash64(a), simhash64(b))
assert d > DEFAULT_HAMMING_THRESHOLD * 2
def test_empty_returns_zero(self):
assert simhash64("") == 0
assert simhash64(" ") == 0
class TestHamming:
"""hamming 距离函数测试。"""
def test_same_value_zero(self):
assert hamming(0, 0) == 0
assert hamming(0xDEADBEEF, 0xDEADBEEF) == 0
def test_basic(self):
assert hamming(0xFF, 0x00) == 8
assert hamming(0xFF00FF00, 0x00FF00FF) == 32
def test_single_bit(self):
assert hamming(1, 0) == 1
assert hamming(1 << 63, 0) == 1
# --------------------------------------------------------------------------- #
# FingerprintStore 测试
# --------------------------------------------------------------------------- #
class TestFingerprintStore:
"""FingerprintStore 功能测试。"""
def test_upsert_and_get(self, tmp_db):
fp = Fingerprint(
url_hash="hash1",
content_hash="ch1",
simhash=0xDEADBEEFCAFEBABE,
source_id="reuters",
url="https://x/1",
title="Test Title",
publish_date="2026-06-16",
)
with FingerprintStore(tmp_db) as store:
store.upsert(fp)
got = store.get_by_url_hash("hash1")
assert got is not None
assert got.content_hash == "ch1"
assert got.simhash == 0xDEADBEEFCAFEBABE
assert got.publish_date == "2026-06-16"
def test_upsert_replaces_existing(self, tmp_db):
base = Fingerprint(
url_hash="h",
content_hash="ch1",
simhash=1,
source_id="reuters",
url="u",
title="t",
)
updated = base.model_copy(update={"content_hash": "ch2", "simhash": 999})
with FingerprintStore(tmp_db) as store:
store.upsert(base)
store.upsert(updated)
got = store.get_by_url_hash("h")
assert got is not None
assert got.content_hash == "ch2"
assert got.simhash == 999
assert store.count() == 1
def test_find_by_content_hash(self, tmp_db):
with FingerprintStore(tmp_db) as store:
store.upsert(Fingerprint(
url_hash="h1", content_hash="ch", simhash=0,
source_id="reuters", url="u1", title="t1",
))
assert store.find_by_content_hash("ch") is not None
assert store.find_by_content_hash("nope") is None
def test_candidates_within_window(self, tmp_db):
with FingerprintStore(tmp_db) as store:
for d, h in [
("2026-05-01", "old"),
("2026-06-15", "near"),
("2026-07-30", "far"),
]:
store.upsert(Fingerprint(
url_hash=h, content_hash=h, simhash=0,
source_id="reuters", url=f"u/{h}", title=h, publish_date=d,
))
cands = store.candidates_for_simhash("2026-06-16", window_days=7)
url_hashes = sorted(c.url_hash for c in cands)
assert url_hashes == ["near"]
def test_candidates_no_date_returns_all(self, tmp_db):
with FingerprintStore(tmp_db) as store:
store.upsert(Fingerprint(
url_hash="h1", content_hash="c1", simhash=0,
source_id="reuters", url="u", title="t", publish_date=None,
))
cands = store.candidates_for_simhash(None, 30)
assert len(cands) == 1
def test_simhash_high_bit_hex(self, tmp_db):
"""64 位 SimHash 高位为 1 时,hex 存取应保持无符号。"""
high = (1 << 63) | 0x1234
with FingerprintStore(tmp_db) as store:
store.upsert(Fingerprint(
url_hash="h", content_hash="c", simhash=high,
source_id="reuters", url="u", title="t",
))
got = store.get_by_url_hash("h")
assert got is not None
assert got.simhash == high
def test_count_by_source(self, tmp_db):
with FingerprintStore(tmp_db) as store:
for i, src in enumerate(["reuters", "reuters", "cnbc"]):
store.upsert(Fingerprint(
url_hash=f"h{i}", content_hash=f"c{i}", simhash=i,
source_id=src, url=f"u{i}", title=f"t{i}",
))
counts = store.count_by_source()
assert counts == {"reuters": 2, "cnbc": 1}
def test_date_range(self, tmp_db):
with FingerprintStore(tmp_db) as store:
store.upsert(Fingerprint(
url_hash="h1", content_hash="c1", simhash=0,
source_id="reuters", url="u1", title="t1",
publish_date="2026-06-10",
))
store.upsert(Fingerprint(
url_hash="h2", content_hash="c2", simhash=0,
source_id="cnbc", url="u2", title="t2",
publish_date="2026-06-20",
))
lo, hi = store.date_range()
assert lo == "2026-06-10"
assert hi == "2026-06-20"
def test_delete(self, tmp_db):
with FingerprintStore(tmp_db) as store:
store.upsert(Fingerprint(
url_hash="h1", content_hash="c1", simhash=0,
source_id="reuters", url="u1", title="t1",
))
store.delete("h1")
assert store.get_by_url_hash("h1") is None
assert store.count() == 0
# --------------------------------------------------------------------------- #
# article_to_fingerprint 测试
# --------------------------------------------------------------------------- #
class TestArticleToFingerprint:
"""article_to_fingerprint 转换测试。"""
def test_fields(self):
art = _make_article()
fp = article_to_fingerprint(art)
assert fp.url_hash == art.url_hash
assert fp.simhash == simhash64(art.content)
assert fp.content_hash == content_hash(art.content)
assert fp.publish_date == "2026-06-16"
assert fp.source_id == "reuters"
def test_handles_empty_publish_time(self):
art = _make_article(publish_time="")
fp = article_to_fingerprint(art)
assert fp.publish_date is None
# --------------------------------------------------------------------------- #
# Deduper 三层去重测试
# --------------------------------------------------------------------------- #
class TestDeduper:
"""Deduper 三层去重功能测试。"""
def test_first_article_is_unique(self, tmp_db):
art = _make_article()
with Deduper(db_path=tmp_db) as d:
result = d.ingest(art)
assert not result.is_duplicate
assert result.matched_layer is None
assert d.stats().total == 1
def test_layer1_url_hash(self, tmp_db):
"""同一 url_hash 直接命中 L1。"""
a1 = _make_article()
a2 = _make_article() # 同 url_hash 同 url
with Deduper(db_path=tmp_db) as d:
d.ingest(a1)
result = d.ingest(a2)
assert result.is_duplicate
assert result.matched_layer == DedupLayer.URL
assert d.stats().total == 1, "L1 命中应不写入新指纹"
def test_layer2_content_hash(self, tmp_db):
"""url 不同但 content 完全一致 → L2。"""
a1 = _make_article(url="https://a.com/1", url_hash="hash1aaaaaaaaaaa")
a2 = _make_article(url="https://b.com/2", url_hash="hash2bbbbbbbbbbb")
with Deduper(db_path=tmp_db) as d:
d.ingest(a1)
result = d.ingest(a2)
assert result.is_duplicate
assert result.matched_layer == DedupLayer.CONTENT
assert result.matched_url_hash == "hash1aaaaaaaaaaa"
def test_layer2_punctuation_difference_caught(self, tmp_db):
"""标点/空白差异不应阻止 L2 命中(normalize_content 应剥离)。"""
a1 = _make_article(
url="https://a/1", url_hash="aaaa",
content="The Fed raised rates today. Markets rallied strongly!"
)
a2 = _make_article(
url="https://b/2", url_hash="bbbb",
content="The Fed, raised... rates today!!! Markets -- rallied -- strongly."
)
assert content_hash(a1.content) == content_hash(a2.content)
with Deduper(db_path=tmp_db) as d:
d.ingest(a1)
result = d.ingest(a2)
assert result.matched_layer == DedupLayer.CONTENT
def test_layer3_simhash_minor_rewrite(self, tmp_db):
"""长文本 + 转载前后缀,落入 SimHash 层(贴近真实跨源转载场景)。"""
long_body = (
"The Federal Reserve held interest rates steady on Wednesday, "
"citing solid economic growth and a strong labor market. "
"Markets rallied in response, with the S&P 500 gaining 1.2 percent. "
"Treasury yields fell as investors welcomed the decision. "
"Analysts expect the central bank to remain on hold through September."
) * 2
rewritten = "By Reuters Staff - " + long_body + " (Additional reporting by Jane Doe)"
a1 = _make_article(url="https://a/1", url_hash="aaaaa", content=long_body)
a2 = _make_article(url="https://b/2", url_hash="bbbbb", content=rewritten)
# 必要前提:content_hash 不同(否则会被 L2 截胡)
assert content_hash(a1.content) != content_hash(a2.content)
with Deduper(db_path=tmp_db) as d:
d.ingest(a1)
result = d.ingest(a2)
assert result.is_duplicate
assert result.matched_layer == DedupLayer.SIMHASH
assert result.hamming_distance is not None
assert result.hamming_distance <= DEFAULT_HAMMING_THRESHOLD
def test_layer3_unrelated_articles_kept(self, tmp_db):
"""完全不相关文章不去重。"""
a1 = _make_article(
url="https://a/1", url_hash="aaaaa",
content="The Federal Reserve held interest rates steady on Wednesday." * 5,
)
a2 = _make_article(
url="https://b/2", url_hash="bbbbb",
content="Apple announced a new iPhone model with revolutionary features." * 5,
title="Apple Unveils New iPhone",
)
with Deduper(db_path=tmp_db) as d:
d.ingest(a1)
result = d.ingest(a2)
assert not result.is_duplicate
assert d.stats().total == 2
def test_layer3_outside_time_window_kept(self, tmp_db):
"""SimHash 相近,但 publish_date 距离过远(> 30 天)不去重。"""
body = (
"The Federal Reserve held interest rates steady on Wednesday, "
"citing solid economic growth and a strong labor market."
) * 3
a1 = _make_article(
url="https://a/1", url_hash="aaaa1", content=body,
publish_time="2026-01-01T09:00:00",
)
a2 = _make_article(
url="https://b/2", url_hash="bbbb2", content=body[:50] + body,
publish_time="2026-06-16T09:00:00",
)
assert content_hash(a1.content) != content_hash(a2.content)
with Deduper(db_path=tmp_db, time_window_days=30) as d:
d.ingest(a1)
result = d.ingest(a2)
assert not result.is_duplicate, "时间窗口外不应命中 SimHash"
def test_threshold_zero_only_exact_simhash(self, tmp_db):
"""阈值 0 → 仅当 SimHash 完全相同才视为重复(且会先被 L2 拦截)。"""
a1 = _make_article(
url="https://a/1", url_hash="aaaa1",
content="The Federal Reserve held rates steady on Wednesday."
)
a2 = _make_article(
url="https://b/2", url_hash="bbbb2",
content="The Federal Reserve held rates unchanged on Wednesday."
)
with Deduper(db_path=tmp_db, simhash_threshold=0) as d:
d.ingest(a1)
result = d.ingest(a2)
assert not result.is_duplicate
def test_ingest_same_source_mixed(self, tmp_db):
"""混合重复/不重复文章的同源摄入。"""
a1 = _make_article(url="https://a/1", url_hash="h1", content="Story A " * 10)
a2 = _make_article(url="https://a/2", url_hash="h2", content="Story B " * 10)
a3 = _make_article(url="https://a/3", url_hash="h3", content="Story B " * 10) # 同 a2
a4 = _make_article(url="https://a/4", url_hash="h4", content="Story C " * 10)
with Deduper(db_path=tmp_db) as d:
r1 = d.ingest(a1)
r2 = d.ingest(a2)
r3 = d.ingest(a3)
r4 = d.ingest(a4)
assert not r1.is_duplicate
assert not r2.is_duplicate
assert r3.is_duplicate # L2 命中
assert not r4.is_duplicate
assert d.stats().total == 3
# --------------------------------------------------------------------------- #
# Deduper - check 不写入
# --------------------------------------------------------------------------- #
class TestDeduperCheck:
"""Deduper.check() 只读测试。"""
def test_check_does_not_write(self, tmp_db):
art = _make_article()
with Deduper(db_path=tmp_db) as d:
result = d.check(art)
assert not result.is_duplicate
assert d.stats().total == 0 # check 不应入库
def test_check_detects_duplicate_after_ingest(self, tmp_db):
a1 = _make_article()
a2 = _make_article()
with Deduper(db_path=tmp_db) as d:
d.ingest(a1)
result = d.check(a2)
assert result.is_duplicate
assert result.matched_layer == DedupLayer.URL
# --------------------------------------------------------------------------- #
# Deduper - stats
# --------------------------------------------------------------------------- #
class TestDeduperStats:
"""统计信息测试。"""
def test_stats_aggregates_by_source(self, tmp_db):
with Deduper(db_path=tmp_db) as d:
d.ingest(_make_article(
source_id="reuters", url="https://a/1", url_hash="r000000000000001",
))
d.ingest(_make_article(
source_id="reuters", url="https://a/2", url_hash="r000000000000002",
content="Another completely different article about markets." * 10,
))
d.ingest(_make_article(
source_id="cnbc", url="https://b/1", url_hash="c000000000000001",
content="CNBC exclusive report on technology stocks." * 10,
))
stats = d.stats()
assert stats.total == 3
assert stats.by_source == {"reuters": 2, "cnbc": 1}
assert stats.earliest is not None
# --------------------------------------------------------------------------- #
# DedupResult 测试
# --------------------------------------------------------------------------- #
class TestDedupResult:
"""DedupResult 模型测试。"""
def test_short_summary_unique(self):
r = DedupResult(url_hash="abc", is_duplicate=False)
assert r.short_summary() == "[UNIQUE] abc"
def test_short_summary_duplicate_url(self):
r = DedupResult(
url_hash="abc",
is_duplicate=True,
matched_layer=DedupLayer.URL,
matched_url_hash="xyz",
)
assert "[DUP/url]" in r.short_summary()
def test_short_summary_duplicate_simhash(self):
r = DedupResult(
url_hash="abc",
is_duplicate=True,
matched_layer=DedupLayer.SIMHASH,
matched_url_hash="xyz",
hamming_distance=2,
)
summary = r.short_summary()
assert "[DUP/simhash]" in summary
assert "hd=2" in summary
+302
View File
@@ -0,0 +1,302 @@
"""M5 向量生成模块单元测试。"""
from unittest.mock import MagicMock
import pytest
from openai import OpenAI
from embedding.client import (
EmbeddingConfig,
_chunked,
embed_batch,
load_embedding_config,
)
from embedding.embedder import compose_text, embed_article
from embedding.models import EmbeddingError, EmbeddingResult
from llm.models import EnTranslatedArticle, EventExtraction, Sentiment
# --------------------------------------------------------------------------- #
# 辅助工厂
# --------------------------------------------------------------------------- #
def _make_article(
*,
url_hash: str = "abc123",
source_id: str = "reuters",
source_name: str = "Reuters",
url: str = "https://example.com/1",
title: str = "Fed Holds Rates Steady",
title_zh: str = "美联储维持利率不变",
content_en: str = "The Fed held rates steady.",
content_zh: str = "美联储维持利率不变,理由是经济增长稳健。市场应声上涨。",
events: list[EventExtraction] | None = None,
) -> EnTranslatedArticle:
if events is None:
events = [
EventExtraction(
event_type="央行决议",
stock_codes=[],
sentiment=Sentiment.POSITIVE,
importance=5,
summary_zh="美联储维持利率不变,市场反弹",
)
]
return EnTranslatedArticle(
source_id=source_id,
source_name=source_name,
url=url,
url_hash=url_hash,
title=title,
title_zh=title_zh,
content_en=content_en,
content_zh=content_zh,
events=events,
provider="deepseek",
model="deepseek-v4-flash",
)
# --------------------------------------------------------------------------- #
# EmbeddingConfig
# --------------------------------------------------------------------------- #
class TestEmbeddingConfig:
"""EmbeddingConfig 测试。"""
def test_valid_config(self):
cfg = EmbeddingConfig(
model="text-embedding-v3",
api_key="sk-test",
)
assert cfg.provider == "dashscope"
assert cfg.dimension == 1024
def test_missing_api_key_raises(self):
with pytest.raises(EmbeddingError, match="API_KEY"):
EmbeddingConfig(api_key="")
class TestLoadEmbeddingConfig:
"""load_embedding_config 测试。"""
def test_from_env(self, monkeypatch):
monkeypatch.setenv("DASHSCOPE_API_KEY", "sk-dashscope-test")
cfg = load_embedding_config()
assert cfg.provider == "dashscope"
assert cfg.api_key == "sk-dashscope-test"
def test_fallback_to_qwen_key(self, monkeypatch):
monkeypatch.delenv("DASHSCOPE_API_KEY", raising=False)
monkeypatch.setenv("QWEN_API_KEY", "sk-qwen-key")
cfg = load_embedding_config()
assert cfg.api_key == "sk-qwen-key"
def test_missing_key_raises(self, monkeypatch):
monkeypatch.delenv("DASHSCOPE_API_KEY", raising=False)
monkeypatch.delenv("QWEN_API_KEY", raising=False)
with pytest.raises(EmbeddingError, match="API_KEY"):
load_embedding_config()
# --------------------------------------------------------------------------- #
# _chunked
# --------------------------------------------------------------------------- #
class TestChunked:
"""_chunked 分块工具测试。"""
def test_empty(self):
assert _chunked([], 10) == []
def test_single_chunk(self):
assert _chunked(["a", "b", "c"], 10) == [["a", "b", "c"]]
def test_multiple_chunks(self):
assert _chunked(["a", "b", "c", "d", "e"], 2) == [
["a", "b"], ["c", "d"], ["e"]
]
def test_exact_fit(self):
assert _chunked(["a", "b", "c", "d"], 2) == [
["a", "b"], ["c", "d"]
]
# --------------------------------------------------------------------------- #
# embed_batchmock
# --------------------------------------------------------------------------- #
class TestEmbedBatch:
"""embed_batch 测试(mock DashScope API)。"""
def test_empty_list(self):
cfg = EmbeddingConfig(api_key="sk-test")
client = MagicMock(spec=OpenAI)
result = embed_batch(client, cfg, [])
assert result == []
def test_single_text(self):
cfg = EmbeddingConfig(api_key="sk-test", batch_size=5)
client = MagicMock(spec=OpenAI)
# Mock 返回
mock_resp = MagicMock()
mock_resp.data = [MagicMock(embedding=[0.1] * 1024)]
client.embeddings.create.return_value = mock_resp
result = embed_batch(client, cfg, ["测试文本"])
assert len(result) == 1
assert len(result[0]) == 1024
def test_batch_split(self):
"""超过 batch_size 自动分多批。"""
cfg = EmbeddingConfig(api_key="sk-test", batch_size=3)
client = MagicMock(spec=OpenAI)
def _make_mock(**kwargs):
input_texts = kwargs.get("input", [])
resp = MagicMock()
resp.data = [MagicMock(embedding=[0.5] * 1024) for _ in input_texts]
return resp
client.embeddings.create.side_effect = _make_mock
texts = ["a", "b", "c", "d", "e"] # 需要分 3+2 两批
result = embed_batch(client, cfg, texts)
assert len(result) == 5
assert client.embeddings.create.call_count == 2
# --------------------------------------------------------------------------- #
# compose_text
# --------------------------------------------------------------------------- #
class TestComposeText:
"""compose_text 测试。"""
def test_basic_composition(self):
article = _make_article()
text = compose_text(article)
assert "标题:" in text
assert "美联储维持利率不变" in text
assert "事件:" in text
assert "央行决议" in text
assert "正文:" in text
def test_no_events(self):
article = _make_article(events=[])
text = compose_text(article)
assert "事件:" not in text
assert "标题:" in text
assert "正文:" in text
def test_with_stock_codes(self):
article = _make_article(events=[
EventExtraction(
event_type="财报披露",
stock_codes=["AAPL", "TSLA"],
sentiment=Sentiment.POSITIVE,
importance=4,
summary_zh="苹果财报超预期",
)
])
text = compose_text(article)
assert "AAPL" in text
assert "TSLA" in text
assert "positive" in text
def test_truncation(self):
"""超长文本应被截断。"""
long_content = "这是测试正文。" * 500 # ~2500 chars
article = _make_article(content_zh=long_content)
text = compose_text(article, max_chars=500)
assert len(text) <= 500
def test_empty_article(self):
article = _make_article(title_zh="", content_zh="", events=[])
text = compose_text(article)
# 不会崩溃即可
assert isinstance(text, str)
# --------------------------------------------------------------------------- #
# embed_articlemock
# --------------------------------------------------------------------------- #
class TestEmbedArticle:
"""embed_article 测试(mock)。"""
def test_successful_embed(self):
cfg = EmbeddingConfig(api_key="sk-test")
client = MagicMock(spec=OpenAI)
mock_resp = MagicMock()
mock_resp.data = [MagicMock(embedding=[0.1] * 1024)]
client.embeddings.create.return_value = mock_resp
article = _make_article()
result = embed_article(client, cfg, article)
assert isinstance(result, EmbeddingResult)
assert result.url_hash == article.url_hash
assert result.dimension == 1024
assert len(result.vector) == 1024
assert result.provider == "dashscope"
assert len(result.embedded_text) > 0
# --------------------------------------------------------------------------- #
# EmbeddingResult
# --------------------------------------------------------------------------- #
class TestEmbeddingResult:
"""EmbeddingResult 模型测试。"""
def test_minimal(self):
r = EmbeddingResult(
url_hash="abc",
source_id="reuters",
vector=[0.5] * 1024,
model="text-embedding-v3",
)
assert r.dimension == 1024
assert len(r.vector) == 1024
def test_serialization(self):
r = EmbeddingResult(
url_hash="abc",
source_id="reuters",
vector=[0.1, 0.2, 0.3],
dimension=3,
embedded_text="测试",
model="text-embedding-v3",
)
data = r.model_dump_json()
assert "url_hash" in data
assert "vector" in data
# --------------------------------------------------------------------------- #
# EmbeddingError
# --------------------------------------------------------------------------- #
class TestEmbeddingError:
"""EmbeddingError 异常测试。"""
def test_basic(self):
err = EmbeddingError("测试错误", attempts=3)
assert err.reason == "测试错误"
assert err.attempts == 3
assert str(err) == "测试错误"
def test_default_attempts(self):
err = EmbeddingError("错误")
assert err.attempts == 0
+143
View File
@@ -0,0 +1,143 @@
"""M2 正文提取模块测试"""
from pathlib import Path
from extractor.extractor import (
MIN_CONTENT_WORDS,
_clean_markdown,
_count_words,
_extract_author,
_extract_title,
extract_article,
)
# ════════════════════════════════════════════════
# 辅助函数
# ════════════════════════════════════════════════
def test_count_words():
assert _count_words("hello world") == 2
assert _count_words("") == 0
assert _count_words(None) == 0
def test_extract_title():
html = "<html><head><title>Breaking News: Markets Rally</title></head></html>"
assert "Breaking News" in _extract_title(html)
assert _extract_title("") == ""
def test_extract_author():
html = '<meta name="author" content="John Doe">'
assert _extract_author(html) == "John Doe"
assert _extract_author("") == ""
def test_clean_markdown():
md = """ADVERTISEMENT - Continue Reading Below
[Sign In](https://example.com/signin)
# Real Article Title
This is the actual content of the article.
It has multiple paragraphs."""
cleaned = _clean_markdown(md)
assert "ADVERTISEMENT" not in cleaned
assert "Sign In" not in cleaned
assert "Real Article Title" in cleaned
assert "actual content" in cleaned
def test_clean_markdown_preserves_content():
md = "# Market Update\n\nStocks rose today.\n\n[Read More](https://example.com)"
cleaned = _clean_markdown(md)
assert "Market Update" in cleaned
assert "Stocks rose" in cleaned
# ════════════════════════════════════════════════
# 提取引擎
# ════════════════════════════════════════════════
def test_extract_article_trafilatura(tmp_path: Path):
"""用 trafilatura 从 HTML 提取正文"""
html = tmp_path / "test.html"
text = (
"<!DOCTYPE html><html><head><title>Fed Raises Rates</title>"
'<meta name="author" content="Jane Smith">'
"</head><body><nav>Menu items</nav><article>"
"<p>The Federal Reserve raised interest rates by 25 basis points "
"today in a widely expected move. Chair Powell noted that inflation "
"remains above target but is trending downward.</p>"
"<p>Markets reacted positively, with the S&P 500 gaining 1.2%.</p>"
"</article><footer>Copyright 2026</footer></body></html>"
)
html.write_text(text)
result = extract_article(
source_id="test",
source_name="Test",
url="https://example.com/article",
url_hash="abc123",
html_path=str(html),
md_path="",
)
assert result.status == "success"
assert result.extractor == "trafilatura"
assert "Federal Reserve" in result.content
assert "Jane Smith" in result.author
assert result.word_count >= 30
def test_extract_article_no_content(tmp_path: Path):
"""无有效正文时降级"""
html = tmp_path / "empty.html"
html.write_text("<html><head></head><body>Short.</body></html>")
result = extract_article(
source_id="test", source_name="Test",
url="https://example.com/nocontent",
url_hash="def456",
html_path=str(html), md_path="",
)
assert result.status == "no_content"
def test_extract_article_md_fallback(tmp_path: Path):
"""HTML 不可用但 MD 可用时回退"""
md = tmp_path / "test.md"
md_text = (
"# Market Analysis\n\n"
"This is a detailed analysis of market conditions today. "
"The dow jones industrial average showed significant movement "
"as investors reacted to economic data. "
"Trading volume was above average across major exchanges. "
"Analysts noted that technical indicators suggested continued "
"upward momentum in the near term. "
"Several key sectors led the rally including technology "
"financials and healthcare stocks. "
"The bond market also saw increased activity as yields moved lower."
)
md.write_text(md_text)
result = extract_article(
source_id="test", source_name="Test",
url="https://example.com/mdonly",
url_hash="ghi789",
html_path="/nonexistent/file.html",
md_path=str(md),
)
assert result.status == "success"
assert result.extractor == "crawl4ai_md"
assert result.word_count >= 30
assert "Market Analysis" in result.content
def test_min_content_threshold():
"""MIN_CONTENT_WORDS 阈值合理"""
assert MIN_CONTENT_WORDS >= 30
assert MIN_CONTENT_WORDS <= 100
+622
View File
@@ -0,0 +1,622 @@
"""M4 LLM 翻译 + 事件抽取模块单元测试。"""
import json
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from openai import OpenAI
from extractor.models import ProcessedArticle
from llm.client import LLMConfig, load_llm_config
from llm.extractor import (
MAX_CONTENT_CHARS,
PromptTemplate,
_extract_json_object,
parse_translation_json,
translate_and_extract,
)
from llm.models import (
INTERNATIONAL_EVENT_TYPES,
EnTranslatedArticle,
EventExtraction,
LLMCallError,
LLMTranslationOutput,
Sentiment,
)
# --------------------------------------------------------------------------- #
# 辅助工厂
# --------------------------------------------------------------------------- #
def _make_article(
*,
url: str = "https://www.reuters.com/business/1",
url_hash: str = "abc1234567890000",
source_id: str = "reuters",
source_name: str = "Reuters",
title: str = "Fed Holds Rates Steady as Markets Rally",
content: str = (
"The Federal Reserve held interest rates steady on Wednesday, "
"citing solid economic growth and a strong labor market. "
"Markets rallied in response, with the S&P 500 gaining 1.2 percent."
),
publish_time: str = "2026-06-16T10:00:00",
word_count: int = 0,
) -> ProcessedArticle:
return ProcessedArticle(
source_id=source_id,
source_name=source_name,
url=url,
url_hash=url_hash,
title=title,
content=content,
publish_time=publish_time,
word_count=word_count or len(content.split()),
)
# --------------------------------------------------------------------------- #
# Sentiment / 枚举
# --------------------------------------------------------------------------- #
class TestSentiment:
"""Sentiment 枚举测试。"""
def test_values(self):
assert Sentiment.POSITIVE == "positive"
assert Sentiment.NEUTRAL == "neutral"
assert Sentiment.NEGATIVE == "negative"
def test_from_string(self):
assert Sentiment("positive") == Sentiment.POSITIVE
assert Sentiment("neutral") == Sentiment.NEUTRAL
assert Sentiment("negative") == Sentiment.NEGATIVE
# --------------------------------------------------------------------------- #
# EventExtraction 模型校验
# --------------------------------------------------------------------------- #
class TestEventExtraction:
"""EventExtraction 模型测试。"""
def test_valid_event(self):
ev = EventExtraction(
event_type="财报披露",
stock_codes=["AAPL", "TSLA"],
sentiment="positive",
importance=4,
summary_zh="苹果第三季度营收超预期",
)
assert ev.event_type == "财报披露"
assert ev.stock_codes == ["AAPL", "TSLA"]
assert ev.sentiment == Sentiment.POSITIVE
assert ev.importance == 4
def test_stock_codes_filtered_and_uppercased(self):
ev = EventExtraction(
event_type="并购收购",
stock_codes=["aapl", " msft ", "", "INVALID123", "GOOGL"],
sentiment="neutral",
importance=3,
summary_zh="微软收购测试",
)
# INVALID123 > 5 chars → 过滤;空 → 过滤;小写 → 大写;去重
assert ev.stock_codes == ["AAPL", "MSFT", "GOOGL"]
def test_empty_stock_codes(self):
ev = EventExtraction(
event_type="宏观经济",
stock_codes=[],
sentiment="neutral",
importance=2,
summary_zh="GDP 数据发布",
)
assert ev.stock_codes == []
def test_importance_bounds(self):
# 1 和 5 都能通过
ev1 = EventExtraction(
event_type="宏观经济", sentiment="neutral", importance=1, summary_zh="t1"
)
assert ev1.importance == 1
ev5 = EventExtraction(
event_type="央行决议", sentiment="negative", importance=5, summary_zh="t5"
)
assert ev5.importance == 5
def test_importance_out_of_range_rejected(self):
with pytest.raises(Exception):
EventExtraction(
event_type="其他", sentiment="neutral", importance=0, summary_zh="t"
)
with pytest.raises(Exception):
EventExtraction(
event_type="其他", sentiment="neutral", importance=6, summary_zh="t"
)
def test_invalid_sentiment_rejected(self):
with pytest.raises(Exception):
EventExtraction(
event_type="其他", stock_codes=[], sentiment="happy", importance=3,
summary_zh="t",
)
def test_event_type_normalized(self):
"""空 event_type 默认"其他""""
ev = EventExtraction(
event_type="", sentiment="neutral", importance=2, summary_zh="测试"
)
assert ev.event_type == "其他"
def test_summary_zh_max_length_rejected(self):
"""超长 summary_zh>200 字符)直接拒绝。"""
long_summary = "测试" * 150 # 300 chars > 200
with pytest.raises(Exception):
EventExtraction(
event_type="行业动态",
sentiment="neutral",
importance=2,
summary_zh=long_summary,
)
# --------------------------------------------------------------------------- #
# LLMTranslationOutput
# --------------------------------------------------------------------------- #
class TestLLMTranslationOutput:
"""LLMTranslationOutput 模型测试。"""
def test_valid_full_output(self):
data = {
"title_zh": "美联储维持利率不变,市场上涨",
"content_zh": "美联储周三维持利率不变...",
"events": [
{
"event_type": "央行决议",
"stock_codes": [],
"sentiment": "positive",
"importance": 5,
"summary_zh": "美联储维持利率不变",
}
],
}
out = LLMTranslationOutput.model_validate(data)
assert out.title_zh == data["title_zh"]
assert len(out.events) == 1
assert out.events[0].event_type == "央行决议"
def test_no_events(self):
data = {
"title_zh": "每日市场简报",
"content_zh": "今日市场整体平淡...",
"events": [],
}
out = LLMTranslationOutput.model_validate(data)
assert out.events == []
def test_missing_title_zh_rejected(self):
data = {
"content_zh": "正文...",
"events": [],
}
with pytest.raises(Exception):
LLMTranslationOutput.model_validate(data)
def test_missing_content_zh_rejected(self):
data = {
"title_zh": "标题",
"events": [],
}
with pytest.raises(Exception):
LLMTranslationOutput.model_validate(data)
# --------------------------------------------------------------------------- #
# EnTranslatedArticle
# --------------------------------------------------------------------------- #
class TestEnTranslatedArticle:
"""EnTranslatedArticle 模型测试。"""
def test_minimal_construction(self):
article = EnTranslatedArticle(
source_id="reuters",
source_name="Reuters",
url="https://example.com/1",
url_hash="abc123",
title="Fed Holds Rates",
title_zh="美联储维持利率",
content_en="The Fed held rates steady.",
content_zh="美联储维持利率不变。",
provider="deepseek",
model="deepseek-v4-flash",
)
assert article.events == []
assert article.word_count_zh == 0
def test_with_events(self):
article = EnTranslatedArticle(
source_id="reuters",
source_name="Reuters",
url="https://example.com/1",
url_hash="abc123",
title="Apple Earnings",
title_zh="苹果财报",
content_en="Apple reported record earnings.",
content_zh="苹果公布了创纪录的财报。",
events=[
EventExtraction(
event_type="财报披露",
stock_codes=["AAPL"],
sentiment="positive",
importance=4,
summary_zh="苹果财报超预期",
)
],
provider="deepseek",
model="deepseek-v4-flash",
)
assert len(article.events) == 1
assert "AAPL" in article.short_summary()
def test_short_summary_no_events(self):
article = EnTranslatedArticle(
source_id="reuters",
source_name="Reuters",
url="https://example.com/1",
url_hash="abc123",
title="Market Wrap",
title_zh="市场综述",
content_en="Markets were flat today.",
content_zh="今日市场持平。",
provider="deepseek",
model="deepseek-v4-flash",
)
assert "-" in article.short_summary() or "0events" in article.short_summary()
# --------------------------------------------------------------------------- #
# PromptTemplate
# --------------------------------------------------------------------------- #
class TestPromptTemplate:
"""PromptTemplate 测试。"""
def test_parse_and_render(self, tmp_path: Path):
"""测试模板解析和渲染。"""
prompt_content = """# 测试标题
## System Prompt
你是翻译助手。
---
## User Input
标题: {title}
来源: {source_name}
正文: {content}
"""
prompt_path = tmp_path / "test_prompt.md"
prompt_path.write_text(prompt_content, encoding="utf-8")
tpl = PromptTemplate(template_path=prompt_path)
article = _make_article()
system, user = tpl.render(article)
assert "翻译助手" in system
assert article.title in user
assert article.source_name in user
assert article.content in user
def test_content_truncation(self, tmp_path: Path):
"""测试超长正文截断。"""
prompt_content = """## System Prompt
你是助手。
---
## User Input
正文: {content}
"""
prompt_path = tmp_path / "test_prompt.md"
prompt_path.write_text(prompt_content, encoding="utf-8")
tpl = PromptTemplate(template_path=prompt_path)
long_content = "X" * (MAX_CONTENT_CHARS + 500)
article = _make_article(content=long_content)
_system, user = tpl.render(article)
assert "正文过长已截断" in user
assert len("X" * MAX_CONTENT_CHARS) + len("\n\n[正文过长已截断]") < len(user)
# --------------------------------------------------------------------------- #
# JSON 提取
# --------------------------------------------------------------------------- #
class TestExtractJsonObject:
"""_extract_json_object 函数测试。"""
def test_plain_json(self):
raw = '{"key": "value"}'
assert _extract_json_object(raw) == '{"key": "value"}'
def test_json_with_fence(self):
raw = '```json\n{"key": "value"}\n```'
assert _extract_json_object(raw) == '{"key": "value"}'
def test_json_with_text_before(self):
raw = 'Here is the result:\n{"key": "value"}'
assert _extract_json_object(raw) == '{"key": "value"}'
def test_nested_braces(self):
raw = '{"outer": {"inner": [1, 2, 3]}}'
assert _extract_json_object(raw) == raw.strip()
def test_empty_string(self):
assert _extract_json_object("") == ""
# --------------------------------------------------------------------------- #
# parse_translation_json
# --------------------------------------------------------------------------- #
class TestParseTranslationJson:
"""parse_translation_json 函数测试。"""
def test_valid_json(self):
raw = json.dumps({
"title_zh": "测试标题",
"content_zh": "测试正文",
"events": [],
})
result = parse_translation_json(raw)
assert result.title_zh == "测试标题"
assert result.content_zh == "测试正文"
def test_invalid_json(self):
with pytest.raises(LLMCallError, match="JSON 解析失败"):
parse_translation_json("not valid json {{{")
def test_non_object(self):
with pytest.raises(LLMCallError, match="非对象"):
parse_translation_json("[1, 2, 3]")
def test_schema_validation_fails(self):
"""缺少必填字段时抛出 LLMCallError。"""
raw = json.dumps({"title_zh": "标题"}) # 缺少 content_zh
with pytest.raises(LLMCallError, match="schema 校验失败"):
parse_translation_json(raw)
# --------------------------------------------------------------------------- #
# LLMConfig / load_llm_config
# --------------------------------------------------------------------------- #
class TestLLMConfig:
"""LLMConfig 测试。"""
def test_valid_config(self):
cfg = LLMConfig(
provider="deepseek",
model="deepseek-chat",
api_key="sk-test",
base_url="https://api.deepseek.com",
)
assert cfg.provider == "deepseek"
def test_empty_api_key_raises(self):
with pytest.raises(ValueError, match="API key 为空"):
LLMConfig(
provider="deepseek",
model="deepseek-chat",
api_key="",
base_url="https://api.deepseek.com",
)
class TestLoadLLMConfig:
"""load_llm_config 函数测试。"""
def test_deepseek_from_env(self, monkeypatch):
"""从环境变量构造 DeepSeek 配置。"""
monkeypatch.setenv("DEEPSEEK_API_KEY", "sk-deepseek-test-key")
config = load_llm_config(provider="deepseek")
assert config.provider == "deepseek"
assert config.api_key == "sk-deepseek-test-key"
assert "deepseek" in config.base_url
def test_qwen_fallback_to_dashscope_key(self, monkeypatch):
"""Qwen 的 API Key 可回退到 DASHSCOPE_API_KEY。"""
monkeypatch.setenv("DASHSCOPE_API_KEY", "sk-dashscope-key")
monkeypatch.delenv("QWEN_API_KEY", raising=False)
config = load_llm_config(provider="qwen")
assert config.provider == "qwen"
assert config.api_key == "sk-dashscope-key"
def test_unknown_provider_raises(self):
with pytest.raises(ValueError, match="未知 LLM provider"):
load_llm_config(provider="openai")
def test_missing_api_key_raises(self, monkeypatch):
"""未配置 API Key 时应抛出明确错误。"""
monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False)
with pytest.raises(ValueError, match="API key 未配置"):
load_llm_config(provider="deepseek")
# --------------------------------------------------------------------------- #
# translate_and_extractmock LLM
# --------------------------------------------------------------------------- #
class TestTranslateAndExtract:
"""translate_and_extract 测试(mock LLM 响应)。"""
def test_successful_translation(self, monkeypatch):
"""Mock LLM 返回有效 JSON。"""
config = LLMConfig(
provider="deepseek",
model="test-model",
api_key="sk-test",
base_url="https://test.api",
)
article = _make_article()
# Mock OpenAI client
mock_client = MagicMock(spec=OpenAI)
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = json.dumps({
"title_zh": "美联储维持利率不变",
"content_zh": "美联储周三维持利率不变,理由是经济增长稳健。",
"events": [
{
"event_type": "央行决议",
"stock_codes": [],
"sentiment": "positive",
"importance": 5,
"summary_zh": "美联储维持利率不变,市场反弹",
}
],
})
mock_response.usage = MagicMock()
mock_response.usage.prompt_tokens = 500
mock_response.usage.completion_tokens = 200
mock_client.chat.completions.create.return_value = mock_response
result = translate_and_extract(
client=mock_client,
config=config,
article=article,
)
assert result.title_zh == "美联储维持利率不变"
assert len(result.events) == 1
assert result.events[0].event_type == "央行决议"
assert result.provider == "deepseek"
assert result.prompt_tokens == 500
assert result.completion_tokens == 200
assert result.word_count_zh > 0
def test_empty_content_zh_retries(self, monkeypatch):
"""LLM 返回空 content_zh 时触发重试。"""
config = LLMConfig(
provider="deepseek",
model="test-model",
api_key="sk-test",
base_url="https://test.api",
)
article = _make_article()
mock_client = MagicMock(spec=OpenAI)
# 始终返回空 content_zh
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = json.dumps({
"title_zh": "标题",
"content_zh": "",
"events": [],
})
mock_response.usage = MagicMock()
mock_response.usage.prompt_tokens = 100
mock_response.usage.completion_tokens = 10
mock_client.chat.completions.create.return_value = mock_response
with pytest.raises(LLMCallError, match="放弃"):
translate_and_extract(
client=mock_client,
config=config,
article=article,
max_attempts=2, # 减少重试加速测试
)
def test_retry_on_json_parse_failure(self, monkeypatch):
"""前 N-1 次返回无效 JSON,最后一次成功。"""
config = LLMConfig(
provider="deepseek",
model="test-model",
api_key="sk-test",
base_url="https://test.api",
)
article = _make_article()
mock_client = MagicMock(spec=OpenAI)
# 第一次失败,第二次成功
mock_client.chat.completions.create.side_effect = [
_mock_chat_response("not valid {{{ json"),
_mock_chat_response(json.dumps({
"title_zh": "测试标题",
"content_zh": "测试正文",
"events": [],
})),
]
result = translate_and_extract(
client=mock_client,
config=config,
article=article,
max_attempts=3,
)
assert result.attempts == 2 # 第二次成功
assert result.title_zh == "测试标题"
def _mock_chat_response(content: str) -> MagicMock:
"""Helper:构造 mock OpenAI chat completion 响应。"""
resp = MagicMock()
resp.choices = [MagicMock()]
resp.choices[0].message.content = content
resp.usage = MagicMock()
resp.usage.prompt_tokens = 100
resp.usage.completion_tokens = 50
return resp
# --------------------------------------------------------------------------- #
# INTERNATIONAL_EVENT_TYPES
# --------------------------------------------------------------------------- #
class TestEventTypes:
"""事件类型常量测试。"""
def test_has_expected_types(self):
assert "财报披露" in INTERNATIONAL_EVENT_TYPES
assert "并购收购" in INTERNATIONAL_EVENT_TYPES
assert "央行决议" in INTERNATIONAL_EVENT_TYPES
assert "地缘政治" in INTERNATIONAL_EVENT_TYPES
assert len(INTERNATIONAL_EVENT_TYPES) >= 10
# --------------------------------------------------------------------------- #
# LLMCallError
# --------------------------------------------------------------------------- #
class TestLLMCallError:
"""LLMCallError 异常测试。"""
def test_basic(self):
err = LLMCallError("测试错误", attempts=3)
assert err.reason == "测试错误"
assert err.attempts == 3
assert str(err) == "测试错误"
def test_default_attempts(self):
err = LLMCallError("错误")
assert err.attempts == 0
+188
View File
@@ -0,0 +1,188 @@
"""M8 MCP 服务模块单元测试。"""
from unittest.mock import MagicMock, patch
from vectorstore.models import SearchFilter
# --------------------------------------------------------------------------- #
# _fmt_results
# --------------------------------------------------------------------------- #
class TestFmtResults:
"""_fmt_results 格式化测试。"""
def test_empty_hits(self):
from mcp_server.server import _fmt_results
result = _fmt_results([], "test query")
assert "未找到" in result
def test_with_results(self):
from mcp_server.server import _fmt_results
hits = [{
"title": "Fed Holds Rates",
"title_zh": "美联储维持利率",
"source": "reuters",
"score": 0.95,
"url": "https://example.com/1",
"events": [{
"event_type": "央行决议",
"sentiment": "neutral",
"importance": 5,
"stock_codes": [],
"summary_zh": "美联储维持利率不变",
}],
}]
result = _fmt_results(hits, "Fed")
assert "美联储维持利率" in result
assert "reuters" in result
assert "0.95" in result
def test_with_stock_codes(self):
from mcp_server.server import _fmt_results
hits = [{
"title": "Apple Earnings",
"title_zh": "苹果财报",
"source": "reuters",
"score": 0.88,
"url": "https://example.com/1",
"events": [{
"event_type": "财报披露",
"sentiment": "positive",
"importance": 4,
"stock_codes": ["AAPL"],
"summary_zh": "苹果财报超预期",
}],
}]
result = _fmt_results(hits, "AAPL")
assert "AAPL" in result
assert "🟢利好" in result
# --------------------------------------------------------------------------- #
# _load_today_events
# --------------------------------------------------------------------------- #
class TestLoadTodayEvents:
"""_load_today_events 测试。"""
def test_no_data_dir(self):
from mcp_server.server import _load_today_events
events = _load_today_events("20990101")
assert events == []
@patch("mcp_server.server.Path.is_dir")
@patch("mcp_server.server.Path.glob")
def test_loads_high_importance_only(self, mock_glob, mock_is_dir):
import json
from mcp_server.server import _load_today_events
mock_is_dir.return_value = True
# 创建 mock 文件
mock_file = MagicMock()
mock_file.name = "test.json"
mock_file.read_text.return_value = json.dumps({
"title": "Test",
"title_zh": "测试",
"url": "https://x.com/1",
"source_id": "reuters",
"events": [
{"importance": 5, "event_type": "央行决议", "sentiment": "neutral",
"stock_codes": [], "summary_zh": "t1"},
{"importance": 2, "event_type": "其他", "sentiment": "neutral",
"stock_codes": [], "summary_zh": "t2"},
],
})
mock_glob.return_value = [mock_file]
events = _load_today_events("20260621")
# 只有 importance ≥ 4 的
assert len(events) == 1
assert events[0]["importance"] == 5
# --------------------------------------------------------------------------- #
# SearchFilter
# --------------------------------------------------------------------------- #
class TestSearchFilterMCP:
"""SearchFilter 用于 MCP 的测试。"""
def test_stock_filter(self):
f = SearchFilter(stock_codes=["AAPL"])
assert "AAPL" in f.stock_codes
def test_sentiment_filter(self):
f = SearchFilter(sentiment="positive")
assert f.sentiment == "positive"
# --------------------------------------------------------------------------- #
# _searchmock
# --------------------------------------------------------------------------- #
class TestSearch:
"""_search 函数测试(mock backend)。"""
@patch("mcp_server.server._get_backend")
def test_search_returns_formatted(self, mock_backend):
from mcp_server.server import _search
# Mock backend
be = MagicMock()
mock_backend.return_value = be
# Mock embed_batch
with patch("mcp_server.server.embed_batch") as mock_embed:
mock_embed.return_value = [[0.1] * 1024]
# Mock vector_store.query
mock_result = MagicMock()
mock_result.title = "Test"
mock_result.title_zh = "测试"
mock_result.url = "https://x.com/1"
mock_result.source_id = "reuters"
mock_result.score = 0.9
mock_result.publish_time = "2026-06-21"
mock_result.events = []
mock_result.content_zh_preview = ""
be.vector_store.query.return_value = [mock_result]
hits = _search("test")
assert len(hits) == 1
assert hits[0]["title"] == "Test"
# --------------------------------------------------------------------------- #
# MCP 模块导入
# --------------------------------------------------------------------------- #
class TestMCPImport:
"""MCP 服务模块导入测试。"""
def test_mcp_object_imports(self):
"""确保 mcp FastMCP 对象可导入。"""
from mcp_server.server import mcp
assert mcp is not None
assert mcp.name is not None
def test_tools_registered(self):
"""确保 5 个工具函数存在且已注册。"""
from mcp_server.server import (
get_stats,
get_today_events,
search_by_sentiment,
search_by_stock,
search_news,
)
# 验证函数存在且可调用
assert callable(search_news)
assert callable(search_by_stock)
assert callable(search_by_sentiment)
assert callable(get_today_events)
assert callable(get_stats)
+122
View File
@@ -0,0 +1,122 @@
"""M7 调度与日报模块单元测试。"""
from unittest.mock import patch
from scheduler.pipeline import PipelineResult, StepResult
# --------------------------------------------------------------------------- #
# StepResult
# --------------------------------------------------------------------------- #
class TestStepResult:
"""StepResult 测试。"""
def test_success_step(self):
sr = StepResult(name="extract", success=True, elapsed_sec=5.0, message="17 篇")
assert sr.name == "extract"
assert sr.success
assert sr.message == "17 篇"
def test_failed_step(self):
sr = StepResult(name="translate", success=False, elapsed_sec=30.0,
message="API timeout")
assert not sr.success
assert "timeout" in sr.message
# --------------------------------------------------------------------------- #
# PipelineResult
# --------------------------------------------------------------------------- #
class TestPipelineResult:
"""PipelineResult 测试。"""
def test_all_success(self):
result = PipelineResult(steps=[
StepResult(name="extract", success=True, elapsed_sec=1.0),
StepResult(name="dedup", success=True, elapsed_sec=0.5),
])
assert result.all_success
assert result.success_count == 2
def test_partial_failure(self):
result = PipelineResult(steps=[
StepResult(name="extract", success=True, elapsed_sec=1.0),
StepResult(name="translate", success=False, elapsed_sec=30.0),
StepResult(name="embed", success=True, elapsed_sec=2.0),
])
assert not result.all_success
assert result.success_count == 2
def test_empty(self):
result = PipelineResult()
assert result.all_success # 空集合 vacuously true
assert result.success_count == 0
# --------------------------------------------------------------------------- #
# run_pipelinemock 各模块)
# --------------------------------------------------------------------------- #
class TestRunPipeline:
"""run_pipeline 测试(mock 各步骤)。"""
@patch("scheduler.pipeline.run_step_extract")
@patch("scheduler.pipeline.run_step_dedup")
@patch("scheduler.pipeline.run_step_embed")
@patch("scheduler.pipeline.run_step_index")
def test_pipeline_runs_all_steps(self, mock_idx, mock_emb, mock_dedup, mock_ext):
from scheduler.pipeline import run_pipeline
mock_ext.return_value = StepResult(name="extract", success=True, elapsed_sec=1)
mock_dedup.return_value = StepResult(name="dedup", success=True, elapsed_sec=1)
mock_emb.return_value = StepResult(name="embed", success=True, elapsed_sec=1)
mock_idx.return_value = StepResult(name="index", success=True, elapsed_sec=1)
result = run_pipeline("20260621", skip_report=True)
assert result.success_count >= 4
@patch("scheduler.pipeline.run_step_extract")
@patch("scheduler.pipeline.run_step_dedup")
def test_pipeline_continues_on_failure(self, mock_dedup, mock_ext):
from scheduler.pipeline import run_pipeline
mock_ext.return_value = StepResult(name="extract", success=False, elapsed_sec=1,
message="error")
mock_dedup.return_value = StepResult(name="dedup", success=True, elapsed_sec=1)
result = run_pipeline("20260621", steps=["extract", "dedup"], skip_report=True)
# extract 失败但 dedup 仍然执行
assert result.success_count == 1
def test_unknown_step_skipped(self):
from scheduler.pipeline import run_pipeline
result = run_pipeline("20260621", steps=["nonexistent_step"], skip_report=True)
assert len(result.steps) == 1
assert not result.steps[0].success
# --------------------------------------------------------------------------- #
# generate_report(无数据场景)
# --------------------------------------------------------------------------- #
class TestGenerateReport:
"""generate_report 测试。"""
@patch("scheduler.reporter._load_events_window")
@patch("scheduler.reporter._collect_stats_window")
def test_no_data_returns_none(self, mock_stats, mock_events):
from scheduler.reporter import generate_report
mock_events.return_value = []
mock_stats.return_value = {
"proc": 0, "deduped": 0, "emb_count": 0,
"qdrant_count": 0, "raw_total": 0, "raw_by_source": {},
}
result = generate_report()
assert result is None
+297
View File
@@ -0,0 +1,297 @@
"""M6 Qdrant 向量存储模块单元测试。"""
from vectorstore.client import (
DEFAULT_COLLECTION,
VectorStore,
make_qdrant_client,
url_hash_to_uuid,
)
from vectorstore.models import CollectionInfo, SearchFilter, SearchResult
# --------------------------------------------------------------------------- #
# url_hash_to_uuid
# --------------------------------------------------------------------------- #
class TestUrlHashToUuid:
"""url_hash_to_uuid 函数测试。"""
def test_deterministic(self):
h = "abc1234567890000"
assert url_hash_to_uuid(h) == url_hash_to_uuid(h)
def test_different_hash_different_uuid(self):
assert url_hash_to_uuid("aaa1111111111111") != url_hash_to_uuid("bbb2222222222222")
def test_valid_uuid_format(self):
import uuid
result = url_hash_to_uuid("abc1234567890000")
uuid.UUID(result) # 应不抛异常
# --------------------------------------------------------------------------- #
# make_qdrant_client
# --------------------------------------------------------------------------- #
class TestMakeQdrantClient:
"""make_qdrant_client 测试。"""
def test_memory_mode(self):
client = make_qdrant_client(memory=True)
assert client is not None
client.close()
def test_custom_path(self, tmp_path):
path = str(tmp_path / "qdrant_test")
client = make_qdrant_client(path=path)
assert client is not None
client.close()
# --------------------------------------------------------------------------- #
# VectorStore — Collection 管理
# --------------------------------------------------------------------------- #
class TestVectorStoreInit:
"""VectorStore Collection 初始化测试。"""
def test_init_collection_creates(self):
client = make_qdrant_client(memory=True)
store = VectorStore(client)
try:
store.init_collection()
info = store.info()
assert info.exists
assert info.name == DEFAULT_COLLECTION
finally:
store.close()
def test_init_idempotent(self):
client = make_qdrant_client(memory=True)
store = VectorStore(client)
try:
store.init_collection()
store.init_collection() # 第二次不应报错
assert store.info().exists
finally:
store.close()
def test_recreate(self):
client = make_qdrant_client(memory=True)
store = VectorStore(client)
try:
store.init_collection()
store.init_collection(recreate=True)
assert store.info().exists
finally:
store.close()
def test_info_nonexistent(self):
client = make_qdrant_client(memory=True)
store = VectorStore(client, collection_name="nonexistent_test")
try:
info = store.info()
assert not info.exists
finally:
store.close()
# --------------------------------------------------------------------------- #
# VectorStore — upsert + query
# --------------------------------------------------------------------------- #
class TestVectorStoreUpsert:
"""VectorStore upsert 测试。"""
def test_upsert_and_count(self):
client = make_qdrant_client(memory=True)
store = VectorStore(client)
try:
store.init_collection()
points = [
{
"id": "hash0000000000001",
"vector": [0.1] * 1024,
"payload": {
"title": "Test Article",
"title_zh": "测试文章",
"url": "https://example.com/1",
"source_id": "reuters",
},
},
{
"id": "hash0000000000002",
"vector": [0.2] * 1024,
"payload": {
"title": "Another Article",
"title_zh": "另一篇文章",
"url": "https://example.com/2",
"source_id": "cnbc",
},
},
]
count = store.upsert(points)
assert count == 2
assert store.count() == 2
finally:
store.close()
def test_upsert_idempotent(self):
client = make_qdrant_client(memory=True)
store = VectorStore(client)
try:
store.init_collection()
points = [{
"id": "hash0000000000001",
"vector": [0.1] * 1024,
"payload": {"title": "Original"},
}]
store.upsert(points)
# 同一 id 第二次写入(更新)
points2 = [{
"id": "hash0000000000001",
"vector": [0.9] * 1024,
"payload": {"title": "Updated"},
}]
store.upsert(points2)
assert store.count() == 1 # 不应增加
finally:
store.close()
class TestVectorStoreQuery:
"""VectorStore query 测试。"""
def test_query_returns_results(self):
client = make_qdrant_client(memory=True)
store = VectorStore(client)
try:
store.init_collection()
# 写入 3 条
for i in range(3):
store.upsert([{
"id": f"hash{i:016d}",
"vector": [float(i) / 10] * 1024,
"payload": {
"title": f"Article {i}",
"title_zh": f"文章 {i}",
"url": f"https://example.com/{i}",
"source_id": "reuters",
"events": [],
},
}])
# 查询
query_vec = [0.15] * 1024 # 接近 hash0 和 hash1
results = store.query(query_vec, top_k=2)
assert len(results) == 2
assert results[0].score > 0 # 有相似度分数
finally:
store.close()
def test_query_with_filter(self):
client = make_qdrant_client(memory=True)
store = VectorStore(client)
try:
store.init_collection()
for i in range(5):
store.upsert([{
"id": f"hash{i:016d}",
"vector": [0.5] * 1024,
"payload": {
"title": f"A{i}",
"title_zh": f"{i}",
"url": f"https://x.com/{i}",
"source_id": "reuters" if i < 3 else "cnbc",
"events": [],
},
}])
# 过滤只查 reuters
sf = SearchFilter(source_id="reuters")
results = store.query([0.5] * 1024, top_k=10, search_filter=sf)
assert all(r.source_id == "reuters" for r in results)
finally:
store.close()
# --------------------------------------------------------------------------- #
# SearchFilter / SearchResult 模型
# --------------------------------------------------------------------------- #
class TestSearchFilter:
"""SearchFilter 模型测试。"""
def test_empty_filter(self):
f = SearchFilter()
assert f.source_id is None
def test_source_id_filter(self):
f = SearchFilter(source_id="reuters")
assert f.source_id == "reuters"
def test_multi_condition(self):
f = SearchFilter(
source_ids=["reuters", "cnbc"],
sentiment="positive",
importance_min=3,
publish_date_from="2026-06-01",
publish_date_to="2026-06-30",
)
assert f.sentiment == "positive"
assert f.importance_min == 3
class TestSearchResult:
"""SearchResult 模型测试。"""
def test_basic(self):
r = SearchResult(
url_hash="abc",
score=0.95,
title="Fed Holds Rates",
title_zh="美联储维持利率",
url="https://example.com/1",
source_id="reuters",
)
assert r.score == 0.95
assert "美联储" in r.short_summary()
def test_with_events(self):
r = SearchResult(
url_hash="abc",
score=0.88,
title="Apple Earnings",
title_zh="苹果财报",
source_id="reuters",
events=[{
"event_type": "财报披露",
"stock_codes": ["AAPL"],
"sentiment": "positive",
"importance": 4,
"summary_zh": "苹果财报超预期",
}],
)
assert "AAPL" in r.short_summary()
class TestCollectionInfo:
"""CollectionInfo 模型测试。"""
def test_basic(self):
info = CollectionInfo(name="test", exists=True, vectors_count=42)
assert info.exists
assert info.vectors_count == 42
def test_defaults(self):
info = CollectionInfo(name="empty", exists=False)
assert info.vectors_count == 0
assert info.indexed_vectors_count is None