Files
intl_news/tests/test_dedup.py
T
simon c72a5ed13a feat: AI 模型按场景独立配置(llm_scenes)+ 去重多来源合并
- system.yaml 新增 llm_scenes(translation / daily_report,含用途/方法/模型要求说明)
- load_llm_config(scene=...) 场景覆盖;日报摘要 temperature 0.3 硬编码 → 配置
- M3 去重:唯一篇记录 source_ids(跨源重复合并,首个来源为 source_id)
- source_ids 经翻译透传至 events,日报事件 source 多来源拼接展示(≤3 个)
- 已部署 pi5:merge 实证 10 源合并;日报 report_id=224 正常入库
2026-08-12 09:56:55 +08:00

633 lines
24 KiB
Python

"""M3 三层去重模块单元测试。"""
import json
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 dedup.pipeline import dedup_source
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
# --------------------------------------------------------------------------- #
# dedup_source 跨源来源合并(M9.2:最终显示新闻记录多个来源)
# --------------------------------------------------------------------------- #
class TestMergeSources:
"""dedup_source 在跨源重复时把来源合并进唯一篇。"""
DATE_STR = "20260805"
def _write_processed(
self,
base: Path,
source_id: str,
article: ProcessedArticle,
) -> None:
"""写入 data/processed/{source_id}/{date}/{url_hash}.json。"""
d = base / "data" / "processed" / source_id / self.DATE_STR
d.mkdir(parents=True, exist_ok=True)
(d / f"{article.url_hash}.json").write_text(
article.model_dump_json(indent=2, ensure_ascii=False), encoding="utf-8"
)
def test_cross_source_merge(self, tmp_path, monkeypatch):
"""同内容两源报道 → 唯一篇 source_ids 记录两个来源,主来源不变。"""
monkeypatch.chdir(tmp_path) # 隔离 data/ 相对路径与默认指纹库
content = ("The Federal Reserve kept interest rates unchanged on Wednesday. "
"Markets rallied in response.")
art_a = _make_article(
url="https://www.reuters.com/business/1", url_hash="aaaa111111111111",
source_id="reuters", content=content,
)
art_b = _make_article(
url="https://www.cnbc.com/2026/1", url_hash="bbbb222222222222",
source_id="cnbc", source_name="CNBC", content=content,
)
self._write_processed(tmp_path, "reuters", art_a)
self._write_processed(tmp_path, "cnbc", art_b)
with Deduper() as deduper:
dedup_source("reuters", deduper, self.DATE_STR)
dedup_source("cnbc", deduper, self.DATE_STR)
# 唯一篇 = reuters(先处理),跨源重复后 source_ids 合并
uniq = tmp_path / "data" / "deduped" / self.DATE_STR / "uniques" / "aaaa111111111111.json"
assert uniq.exists()
data = json.loads(uniq.read_text(encoding="utf-8"))
assert data["source_id"] == "reuters" # 主来源不变
assert data["source_ids"] == ["reuters", "cnbc"]
assert (tmp_path / "data" / "deduped" / self.DATE_STR / "uniques"
/ "bbbb222222222222.json").exists() is False # 重复篇不单独落盘
def test_unique_initializes_source_ids(self, tmp_path, monkeypatch):
"""无重复时唯一篇 source_ids 初始化为 [source_id]。"""
monkeypatch.chdir(tmp_path)
art = _make_article(url="https://x.com/1", url_hash="cccc333333333333",
source_id="ft", source_name="Financial Times")
self._write_processed(tmp_path, "ft", art)
with Deduper() as deduper:
dedup_source("ft", deduper, self.DATE_STR)
uniq = tmp_path / "data" / "deduped" / self.DATE_STR / "uniques" / "cccc333333333333.json"
data = json.loads(uniq.read_text(encoding="utf-8"))
assert data["source_ids"] == ["ft"]
def test_merge_idempotent(self, tmp_path, monkeypatch):
"""同一来源重复出现多次合并时去重(不产生重复来源)。"""
monkeypatch.chdir(tmp_path)
content = "Identical content across sources for idempotent test."
art_a = _make_article(
url="https://www.reuters.com/business/2", url_hash="dddd444444444444",
source_id="reuters", content=content,
)
art_b = _make_article(
url="https://www.cnbc.com/2026/2", url_hash="eeee555555555555",
source_id="cnbc", source_name="CNBC", content=content,
)
art_c = _make_article(
url="https://www.marketwatch.com/2", url_hash="ffff666666666666",
source_id="marketwatch", source_name="MarketWatch", content=content,
)
self._write_processed(tmp_path, "reuters", art_a)
self._write_processed(tmp_path, "cnbc", art_b)
self._write_processed(tmp_path, "marketwatch", art_c)
with Deduper() as deduper:
dedup_source("reuters", deduper, self.DATE_STR)
dedup_source("cnbc", deduper, self.DATE_STR)
dedup_source("marketwatch", deduper, self.DATE_STR)
uniq = tmp_path / "data" / "deduped" / self.DATE_STR / "uniques" / "dddd444444444444.json"
data = json.loads(uniq.read_text(encoding="utf-8"))
assert data["source_ids"] == ["reuters", "cnbc", "marketwatch"]