add prototype/test_cluster.py
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
"""Tests for the story-layer clustering.
|
||||
|
||||
Acceptance (per plan): two reworded copies of the same story -> ONE cluster.
|
||||
Also: exact-duplicate fast path, and distinct stories stay separate.
|
||||
"""
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from cluster import Clusterer, Item
|
||||
|
||||
|
||||
def _item(title, url, source="src", summary="body", published=None):
|
||||
return Item(guid=url, url=url, title=title, summary=summary,
|
||||
source=source, published=published or time.time())
|
||||
|
||||
|
||||
def _clustered(items):
|
||||
return Clusterer().cluster(items)
|
||||
|
||||
|
||||
def test_two_rewrites_merge_into_one_cluster():
|
||||
"""The core accept: BBC + NPR reworded coverage of the same crash -> 1 story.
|
||||
|
||||
Wire-syndication pattern: divergent lede/headline, but the body largely
|
||||
re-uses the same wire text (4 shared sentences).
|
||||
"""
|
||||
core = ("The two crew members on board were killed. "
|
||||
"Investigators said the plane lost contact with the tower shortly "
|
||||
"before impact. Authorities have opened an inquiry into the cause. "
|
||||
"Air traffic around Miami Beach was halted for hours after the crash.")
|
||||
items = [
|
||||
_item("Cargo Plane Crashes Near Miami Beach Killing Two People",
|
||||
"https://bbc.example/cargo-crash",
|
||||
source="BBC",
|
||||
summary="A cargo aircraft went down close to Miami Beach on Tuesday "
|
||||
"morning. " + core),
|
||||
_item("Two dead after cargo jet crashes outside Miami",
|
||||
"https://npr.example/miami-crash",
|
||||
source="NPR",
|
||||
summary="Emergency crews responded to the wreckage near Miami Beach. "
|
||||
+ core),
|
||||
]
|
||||
stories = _clustered(items)
|
||||
assert len(stories) == 1, f"expected 1 cluster, got {len(stories)}"
|
||||
assert stories[0].coverage_count == 2
|
||||
|
||||
|
||||
def test_exact_duplicate_title_dedupes_without_similarity():
|
||||
items = [
|
||||
_item("OpenAI Sued Over Training Data", "https://tc.example/openai-suit",
|
||||
source="TechCrunch"),
|
||||
_item("OpenAI Sued Over Training Data", "https://verge.example/openai-suit-2",
|
||||
source="The Verge"),
|
||||
_item("OpenAI Sued Over Training Data", "https://mirror.example/openai-suit-3",
|
||||
source="Mirror"),
|
||||
]
|
||||
stories = _clustered(items)
|
||||
assert len(stories) == 1
|
||||
assert stories[0].coverage_count == 3
|
||||
|
||||
|
||||
def test_unrelated_stories_stay_separate():
|
||||
items = [
|
||||
_item("Apple unveils new iPhone at launch event", "https://a.example/1",
|
||||
summary="Apple took the stage in Cupertino to reveal the next iPhone."),
|
||||
_item("Hurricane forecast calls for record storm surge", "https://a.example/2",
|
||||
summary="Meteorologists upgraded the storm to a category five system."),
|
||||
_item("Local bakery wins national award for sourdough", "https://a.example/3",
|
||||
summary="Judges praised the long fermentation and crust texture."),
|
||||
]
|
||||
stories = _clustered(items)
|
||||
assert len(stories) == 3
|
||||
|
||||
|
||||
def test_chain_merging_transitive():
|
||||
"""A≈B and B≈C => all three one cluster, even if A≉C."""
|
||||
core = ("The two crew members on board were killed. Authorities have opened "
|
||||
"an inquiry into the cause. Air traffic around Miami Beach was halted "
|
||||
"for hours after the crash.")
|
||||
items = [
|
||||
_item("Cargo plane crash near Miami leaves two dead", "https://bbc.example/x",
|
||||
source="BBC", summary="The aircraft went down on Tuesday morning. " + core),
|
||||
_item("Two dead as cargo aircraft crashes by Miami beach", "https://npr.example/x",
|
||||
source="NPR", summary="Emergency crews reached the wreckage. " + core),
|
||||
_item("Miami cargo crash kills both pilots on board", "https://wp.example/x",
|
||||
source="WaPo", summary="The plane came down just off the beach. " + core),
|
||||
]
|
||||
stories = _clustered(items)
|
||||
assert len(stories) == 1
|
||||
assert stories[0].coverage_count == 3
|
||||
|
||||
|
||||
def test_ranking_prefers_high_coverage_and_freshness():
|
||||
from cluster import compute_scores
|
||||
now = time.time()
|
||||
items = [
|
||||
_item("Viral story covered everywhere", "https://a.example/hot", published=now - 1000),
|
||||
_item("Viral story covered everywhere", "https://b.example/hot-2", published=now - 900),
|
||||
_item("Viral story covered everywhere", "https://c.example/hot-3", published=now - 800),
|
||||
_item("Niche old post", "https://d.example/niche", published=now - 86400 * 3),
|
||||
]
|
||||
stories = _clustered(items)
|
||||
assert len(stories) == 2
|
||||
scored = compute_scores(stories, time.time())
|
||||
# 3-source fresh story outranks the single-source old one
|
||||
assert scored[0][1].coverage_count == 3
|
||||
|
||||
|
||||
def test_summary_field_capped_input():
|
||||
"""LLM lead text is headline + ~300 chars, never the long tail (defended in code)."""
|
||||
from cluster import llm_summarize_topk
|
||||
import os
|
||||
os.environ.pop("LLM_BASE_URL", None)
|
||||
# passes without error even with no LLM backend (network -> skipped/strange localhost)
|
||||
# we only assert the display cap helper field exists on Story
|
||||
items = [_item("Test", "https://a.example/t")]
|
||||
s = _clustered(items)[0]
|
||||
assert hasattr(s, "summary")
|
||||
Reference in New Issue
Block a user