105 findings to zero. Most were mechanical — 67 unused imports, and assorted
f-strings without placeholders. Three groups needed a decision.
The 15 F821 "undefined name" were forward references, not runtime errors. Each
annotation is quoted — `-> "WikiService"`, `Optional["IngestionService"]` — with
the real import inside the function body to break an import cycle. A quoted
annotation is never evaluated, so the code ran; the names were simply
unresolvable to any checker. They now have a TYPE_CHECKING block, which costs
nothing at import time and keeps the cycle broken.
The 6 E402 split two ways. `import secrets`, `Security`, `Request` and
`HTTPBearer` in dependencies.py had drifted below several hundred lines of
factory functions for no reason — stdlib and fastapi, no cycle to avoid — and
moved up. The other three are deliberate and now say so: the VectorService and
GraphService aliases import back into dependencies.py, and main.py's routers
expect a configured app, so both must stay put.
Bare `except:` narrowed to `except Exception:` in three places, which stops them
swallowing KeyboardInterrupt and SystemExit.
The 5 unused locals were all genuinely dead. One is worth naming rather than
fixing: qdrant_client.delete()'s return value was bound and never read, so a
failed delete is indistinguishable from a successful one — the assignment is
gone, but nothing checks the status either way and that has not changed here.
`timing = {}` in _retrieve_parallel looked like it might mean the reported
per-leg timings were always zero; traced, and they come from output["timing"],
so the local was only vestigial.
426 passed, 29 skipped, unchanged. The app imports and the service aliases still
resolve, which is the check that mattered after moving imports in
dependencies.py.
The gate still prints "not gated here yet: test (T-56)" — lint is green, tests
remain unwired, and that is left visible rather than silently absent.
Co-Authored-By: Claude <noreply@anthropic.com>
241 lines
8.1 KiB
Python
241 lines
8.1 KiB
Python
"""
|
|
Aggregated news provider.
|
|
|
|
Combines multiple news sources into a single chronologically-sorted stream.
|
|
Source selection is driven by user preferences in the settings database.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
|
|
from .base import NewsProvider, NewsItem, NewsFeed
|
|
from .nos import NOSProvider
|
|
from .bbc import BBCProvider
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Registry of available news providers
|
|
PROVIDER_REGISTRY: dict[str, type[NewsProvider]] = {
|
|
"nos": NOSProvider,
|
|
"bbc": BBCProvider,
|
|
}
|
|
|
|
|
|
class AggregatedNewsProvider:
|
|
"""
|
|
Aggregated news provider that combines multiple sources.
|
|
|
|
Fetches from configured sources in parallel and merges results
|
|
into a single chronologically-sorted stream. Only fetches from
|
|
enabled categories per source.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
sources: list[str],
|
|
category_filters: dict[str, list[str]] | None = None,
|
|
timeout: int = 10
|
|
):
|
|
"""
|
|
Initialize aggregated provider.
|
|
|
|
Args:
|
|
sources: List of source names to aggregate (e.g., ["nos", "bbc"])
|
|
category_filters: Per-source enabled categories.
|
|
Example: {"nos": ["general", "tech"], "bbc": ["top", "world"]}
|
|
Empty list or missing entry = all categories allowed.
|
|
timeout: HTTP request timeout in seconds
|
|
"""
|
|
self.sources = sources
|
|
self.category_filters = category_filters or {}
|
|
self.timeout = timeout
|
|
self._providers: dict[str, NewsProvider] = {}
|
|
|
|
# Initialize configured providers
|
|
for source in sources:
|
|
if source in PROVIDER_REGISTRY:
|
|
self._providers[source] = PROVIDER_REGISTRY[source](timeout=timeout)
|
|
else:
|
|
logger.warning(f"Unknown news source '{source}' - skipping")
|
|
|
|
def _is_category_enabled(self, source: str, category: str) -> bool:
|
|
"""Check if a category is enabled for a source."""
|
|
allowed = self.category_filters.get(source, [])
|
|
# Empty list = all allowed
|
|
if not allowed:
|
|
return True
|
|
return category in allowed
|
|
|
|
def _get_enabled_categories(self, source: str) -> list[str]:
|
|
"""Get list of enabled categories for a source."""
|
|
provider = self._providers.get(source)
|
|
if not provider:
|
|
return []
|
|
|
|
allowed = self.category_filters.get(source, [])
|
|
if not allowed:
|
|
# All categories enabled
|
|
return provider.available_categories
|
|
|
|
# Filter to only enabled ones that exist
|
|
return [c for c in allowed if c in provider.available_categories]
|
|
|
|
@property
|
|
def available_sources(self) -> list[str]:
|
|
"""List of initialized source names."""
|
|
return list(self._providers.keys())
|
|
|
|
@property
|
|
def available_categories(self) -> dict[str, list[str]]:
|
|
"""Map of source -> available categories."""
|
|
return {
|
|
name: provider.available_categories
|
|
for name, provider in self._providers.items()
|
|
}
|
|
|
|
def _normalize_timestamp(self, item: NewsItem) -> datetime:
|
|
"""Get UTC timestamp for sorting, with fallback for missing timestamps."""
|
|
if item.published:
|
|
# Ensure UTC
|
|
if item.published.tzinfo is None:
|
|
return item.published.replace(tzinfo=timezone.utc)
|
|
return item.published.astimezone(timezone.utc)
|
|
# Fallback: use current time (item will sort to top)
|
|
return datetime.now(timezone.utc)
|
|
|
|
async def get_feed(
|
|
self,
|
|
category: str = "general",
|
|
limit: int = 20
|
|
) -> NewsFeed:
|
|
"""
|
|
Get aggregated news feed from all sources.
|
|
|
|
Args:
|
|
category: Category to fetch. Maps to source-specific categories:
|
|
- "general"/"top": general news from all sources
|
|
- "world": international news
|
|
- "tech": technology news
|
|
- "business"/"economy": business/economy news
|
|
- "politics": political news
|
|
limit: Maximum total items to return (after merging)
|
|
|
|
Returns:
|
|
NewsFeed with merged, chronologically-sorted items
|
|
"""
|
|
# Map generic categories to source-specific ones
|
|
category_map = {
|
|
"nos": {
|
|
"general": "general",
|
|
"top": "general",
|
|
"world": "world",
|
|
"tech": "tech",
|
|
"business": "economy",
|
|
"economy": "economy",
|
|
"politics": "politics",
|
|
},
|
|
"bbc": {
|
|
"general": "top",
|
|
"top": "top",
|
|
"world": "world",
|
|
"tech": "tech",
|
|
"business": "business",
|
|
"economy": "business",
|
|
"politics": "politics",
|
|
},
|
|
}
|
|
|
|
# Fetch from all sources in parallel
|
|
async def fetch_source(name: str, provider: NewsProvider) -> list[NewsItem]:
|
|
try:
|
|
source_category = category_map.get(name, {}).get(category, category)
|
|
if source_category not in provider.available_categories:
|
|
logger.debug(f"Category '{category}' not available for {name}")
|
|
return []
|
|
# Check if category is enabled for this source
|
|
if not self._is_category_enabled(name, source_category):
|
|
logger.debug(f"Category '{source_category}' disabled for {name}")
|
|
return []
|
|
feed = await provider.get_feed(source_category, limit=limit)
|
|
return feed.items
|
|
except Exception as e:
|
|
logger.error(f"Failed to fetch from {name}: {e}")
|
|
return []
|
|
|
|
tasks = [
|
|
fetch_source(name, provider)
|
|
for name, provider in self._providers.items()
|
|
]
|
|
results = await asyncio.gather(*tasks)
|
|
|
|
# Merge all items
|
|
all_items: list[NewsItem] = []
|
|
for items in results:
|
|
all_items.extend(items)
|
|
|
|
# Sort by timestamp (newest first)
|
|
all_items.sort(key=self._normalize_timestamp, reverse=True)
|
|
|
|
# Apply limit
|
|
all_items = all_items[:limit]
|
|
|
|
return NewsFeed(
|
|
source="aggregated",
|
|
category=category,
|
|
items=all_items,
|
|
fetched_at=datetime.now(timezone.utc)
|
|
)
|
|
|
|
async def get_headlines(
|
|
self,
|
|
categories: list[str] | None = None,
|
|
limit: int = 10
|
|
) -> NewsFeed:
|
|
"""
|
|
Get headlines from multiple categories, merged into one feed.
|
|
|
|
Args:
|
|
categories: Categories to fetch. If None, fetches from all
|
|
enabled categories across all sources.
|
|
limit: Maximum total items to return
|
|
|
|
Returns:
|
|
NewsFeed with merged headlines from all categories
|
|
"""
|
|
if categories is None:
|
|
# Collect all enabled categories across sources
|
|
all_categories: set[str] = set()
|
|
for source in self._providers:
|
|
all_categories.update(self._get_enabled_categories(source))
|
|
categories = list(all_categories) if all_categories else ["general"]
|
|
|
|
# Fetch all categories
|
|
tasks = [self.get_feed(cat, limit=limit) for cat in categories]
|
|
feeds = await asyncio.gather(*tasks)
|
|
|
|
# Merge and deduplicate by URL
|
|
seen_urls: set[str] = set()
|
|
all_items: list[NewsItem] = []
|
|
|
|
for feed in feeds:
|
|
for item in feed.items:
|
|
if item.url not in seen_urls:
|
|
seen_urls.add(item.url)
|
|
all_items.append(item)
|
|
|
|
# Sort by timestamp
|
|
all_items.sort(key=self._normalize_timestamp, reverse=True)
|
|
|
|
return NewsFeed(
|
|
source="aggregated",
|
|
category=",".join(categories),
|
|
items=all_items[:limit],
|
|
fetched_at=datetime.now(timezone.utc)
|
|
)
|
|
|
|
async def close(self):
|
|
"""Close all provider HTTP clients."""
|
|
for provider in self._providers.values():
|
|
await provider.close()
|