import logging from structs.headline import Headline from structs.story import Story logger = logging.getLogger(__name__) def cluster_stories(headlines: list[Headline], threshold: float) -> list[Story]: """Group headlines into stories using transitive similarity. Any two headlines whose cosine similarity meets ``threshold`` are linked, and links are unioned transitively, so a chain of near-duplicates collapses into a single story cluster even when the endpoints are not directly similar. """ n = len(headlines) if n == 0: logger.info("No headlines to cluster.") return [] parent = list(range(n)) def find(x): while parent[x] != x: parent[x] = parent[parent[x]] x = parent[x] return x def union(a, b): ra, rb = find(a), find(b) if ra != rb: parent[rb] = ra links = 0 for i in range(n): for j in range(i + 1, n): try: score = headlines[i].compare_headlines(headlines[j]) except Exception as e: logger.error("Error comparing headlines [%d] and [%d] during clustering: %s", i, j, e, exc_info=True) continue if score >= threshold: union(i, j) links += 1 # Assemble clusters (connected components) keyed by root index. components = {} for idx in range(n): components.setdefault(find(idx), []).append(headlines[idx]) stories = [Story(members) for members in components.values()] logger.info("Clustering complete: %d headlines -> %d stories via %d similarity links.", n, len(stories), links) return stories