初始化
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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_batch(mock)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
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_article(mock)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
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
|
||||
@@ -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
|
||||
@@ -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_extract(mock 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
|
||||
@@ -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"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# _search(mock)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
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)
|
||||
@@ -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_pipeline(mock 各模块)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user