diff --git a/prototype/test_cluster.py b/prototype/test_cluster.py new file mode 100644 index 0000000..5ece1c3 --- /dev/null +++ b/prototype/test_cluster.py @@ -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") \ No newline at end of file