DEV Community

Cover image for Why Your AI News Aggregator Misses Half the Stories (and How to Fix It)
Alan West
Alan West

Posted on

Why Your AI News Aggregator Misses Half the Stories (and How to Fix It)

Every developer I know has tried to build some kind of automated briefing system. You wire up a few RSS feeds, maybe hit the Hacker News API, throw it at an LLM for summarization, and call it done. Then two weeks later you realize you missed a major framework release because your pipeline silently dropped it.

I've built three different versions of this for myself over the past year. Each time, I thought I'd nailed it. Each time, I was wrong. Here's what actually goes wrong and how to build a multi-source intelligence pipeline that doesn't quietly fail on you.

The Root Cause: Silent Failures Everywhere

The core problem isn't the AI summarization — that part is honestly the easy bit. The problem is source reliability and data quality upstream of your LLM.

Here's what typically happens:

  • An RSS feed changes its URL or schema, your parser returns empty results, and you never notice
  • Rate limiting kicks in on an API, you get partial data, and your pipeline treats it as "nothing new today"
  • Your LLM context window fills up with noise, so it drops the signal you actually cared about
  • Duplicate stories from different sources waste your token budget

The frustrating part? None of these throw errors. Your cron job runs, your script exits with code 0, and you get a cheerful summary of... incomplete data.

Step 1: Build Source Fetchers That Know When They Fail

Stop treating source fetching as a simple HTTP GET. Each source needs a health check built into the fetcher itself.

import httpx
from dataclasses import dataclass
from datetime import datetime, timedelta

@dataclass
class FetchResult:
    source: str
    items: list
    is_healthy: bool
    warning: str | None = None

async def fetch_with_health_check(
    source_name: str,
    url: str,
    min_expected_items: int = 3,
    max_age_hours: int = 24
) -> FetchResult:
    try:
        async with httpx.AsyncClient(timeout=15.0) as client:
            resp = await client.get(url)
            resp.raise_for_status()
            items = parse_feed(resp.text)  # your parser here

            # Health check: did we get suspiciously few items?
            if len(items) < min_expected_items:
                return FetchResult(
                    source=source_name,
                    items=items,
                    is_healthy=False,
                    warning=f"Only {len(items)} items (expected >= {min_expected_items})"
                )

            # Health check: is the newest item stale?
            newest = max(items, key=lambda x: x.published)
            if newest.published < datetime.now() - timedelta(hours=max_age_hours):
                return FetchResult(
                    source=source_name,
                    items=items,
                    is_healthy=False,
                    warning=f"Newest item is {max_age_hours}+ hours old"
                )

            return FetchResult(source=source_name, items=items, is_healthy=True)

    except (httpx.HTTPError, Exception) as e:
        return FetchResult(
            source=source_name, items=[], is_healthy=False,
            warning=f"Fetch failed: {str(e)}"
        )
Enter fullscreen mode Exit fullscreen mode

The key insight: a successful HTTP response doesn't mean you got useful data. Checking item count and freshness catches 90% of the silent failures I've encountered.

Step 2: Deduplicate Before You Summarize

If the same story appears in four sources, you don't want your LLM spending tokens on it four times. But naive URL deduplication misses a lot — the same story often has completely different URLs across sources.

I use a two-pass approach: exact URL matching first, then fuzzy title similarity.

from difflib import SequenceMatcher

def deduplicate_items(items: list, similarity_threshold: float = 0.7) -> list:
    seen_urls = set()
    unique_items = []

    for item in items:
        # Pass 1: exact URL match
        normalized_url = item.url.rstrip("/").lower()
        if normalized_url in seen_urls:
            continue
        seen_urls.add(normalized_url)

        # Pass 2: fuzzy title matching against kept items
        is_duplicate = False
        for kept in unique_items:
            ratio = SequenceMatcher(
                None,
                item.title.lower(),
                kept.title.lower()
            ).ratio()
            if ratio > similarity_threshold:
                # Keep the one with more metadata (longer description, etc.)
                if len(item.description or "") > len(kept.description or ""):
                    unique_items.remove(kept)
                    unique_items.append(item)
                is_duplicate = True
                break

        if not is_duplicate:
            unique_items.append(item)

    return unique_items
Enter fullscreen mode Exit fullscreen mode

A 0.7 similarity threshold works well in practice. Go lower and you'll merge stories that are related but distinct. Go higher and obvious duplicates slip through.

Step 3: Structure Your LLM Prompt for Relevance, Not Just Summary

Here's where most people go wrong. They dump all their fetched items into a prompt that says "summarize these." The LLM dutifully summarizes everything, including stuff you don't care about, and buries what matters.

Instead, give the LLM a scoring rubric specific to what you care about.

def build_briefing_prompt(items: list, interests: list[str]) -> str:
    items_text = "\n---\n".join(
        f"Title: {item.title}\nSource: {item.source}\n"
        f"Description: {item.description[:500]}"  # truncate to save tokens
        for item in items
    )

    return f"""You are generating a daily technical briefing.

Relevance criteria (score each item 1-5):
- Directly relates to: {', '.join(interests)}
- Announces a breaking change or security issue: always include
- Is a major release (not a patch): include
- Is general tech news with no actionable insight: exclude

Items to evaluate:
{items_text}

Return ONLY items scoring 3 or higher. For each:
1. One-line summary (what happened)
2. Why it matters (one sentence)
3. Action needed? (yes/no, with brief explanation if yes)

Order by relevance score descending."""
Enter fullscreen mode Exit fullscreen mode

The "Action needed?" field is the real killer feature. Most briefings tell you what happened. Knowing whether you need to do something about it is what actually saves time.

Step 4: Add a Circuit Breaker for Bad Days

Sometimes multiple sources fail at once. Maybe GitHub is having an incident, or your IP got rate-limited across several APIs. You need to know when your briefing is incomplete rather than getting a confident-sounding summary based on 30% of your usual data.

def should_send_briefing(results: list[FetchResult]) -> tuple[bool, str]:
    healthy = [r for r in results if r.is_healthy]
    total = len(results)
    health_ratio = len(healthy) / total if total > 0 else 0

    if health_ratio < 0.5:
        # More than half the sources failed — don't send a misleading briefing
        failed = [r.source for r in results if not r.is_healthy]
        return False, f"Skipping briefing: {len(failed)}/{total} sources unhealthy"

    if health_ratio < 0.8:
        # Some sources failed — send but with a warning header
        failed = [r.source for r in results if not r.is_healthy]
        return True, f"⚠️ Partial briefing: {', '.join(failed)} unavailable"

    return True, "All sources healthy"
Enter fullscreen mode Exit fullscreen mode

This is the difference between a toy project and something you actually rely on. Without this, you'll eventually make a decision based on absence of information — "I didn't see any security advisories" when really your security feed was down.

Prevention: Making It Observable

After getting burned enough times, I added a few things that seem obvious in retrospect:

  • Log source health daily — even when everything is fine. When something breaks, you want history to spot the trend.
  • Track item counts per source over time. A feed that usually gives you 15 items but suddenly gives you 2 is probably broken, even if those 2 items are valid.
  • Send yourself the health report, not just the briefing. I have mine append a footer like "12 sources checked, 11 healthy, 47 items processed, 18 included."
  • Version your prompts. When you tweak the scoring rubric, keep the old one around. You'll want to A/B test whether your changes actually improved relevance.

The Bigger Lesson

The pattern here applies way beyond news aggregation. Any pipeline where you're pulling data from multiple external sources, processing it, and making decisions based on the output has the same failure modes. Data pipelines, monitoring dashboards, CI/CD systems pulling from package registries — they all fail silently in the same ways.

The fix is always the same: treat the absence of data as a signal, not a non-event. A source returning zero results should be louder than a source returning a hundred. Build your health checks at the data layer, not the application layer, and make sure your system knows the difference between "nothing happened" and "I couldn't check."

I'm still iterating on my own setup, but these four patterns — health-checked fetchers, upstream deduplication, structured relevance scoring, and circuit breakers — have made it something I actually trust every morning instead of something I abandoned after a month.

Top comments (0)