From fcf5c8ccee5d30cfcc37adfd8a6f9b3484607dd6 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 8 Jan 2026 21:49:05 +0100 Subject: [PATCH] feat: add /tools/news endpoint for news ticker integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add NewsHeadline and NewsResponse schemas - Add NewsService for parsing news from Qdrant volatile collection - Add GET /tools/news endpoint to tools controller - News fetched from 'news' namespace in volatile_{user} collection 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 13 ++++ pyproject.toml | 2 +- src/domains/tools/controller.py | 62 ++++++++++++++++ src/domains/tools/news/__init__.py | 5 ++ src/domains/tools/news/schemas.py | 56 ++++++++++++++ src/domains/tools/news/service.py | 113 +++++++++++++++++++++++++++++ 6 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 src/domains/tools/news/__init__.py create mode 100644 src/domains/tools/news/schemas.py create mode 100644 src/domains/tools/news/service.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f9eda28..09a31ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.11.0] - 2026-01-08 + +### Added + +- **News Headlines API** - New endpoint for news ticker integration + - `GET /tools/news` - Fetch news headlines from user's volatile collection + - Returns headlines with title, description, source, and URL + - Data sourced from `volatile_{user}` Qdrant collection (news namespace) + - Uses `preferred_username` from OIDC, falls back to `default` +- News subdomain under tools (`src/domains/tools/news/`) +- `NewsHeadline` and `NewsResponse` Pydantic schemas +- `NewsService` for parsing news data from Qdrant + ## [1.10.12] - 2026-01-08 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index a15ab2e..2b50b3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "core-api" -version = "1.10.12" +version = "1.11.0" description = "Core Code API - Infrastructure management and tools API" readme = "README.md" requires-python = ">=3.12" diff --git a/src/domains/tools/controller.py b/src/domains/tools/controller.py index 5821e5f..6a3b0a9 100644 --- a/src/domains/tools/controller.py +++ b/src/domains/tools/controller.py @@ -18,6 +18,8 @@ from src.domains.tools.system.schemas import SystemStatsResponse from src.domains.tools.system.service import SystemStatsService from src.domains.tools.environment.schemas import EnvironmentResponse from src.domains.tools.environment.service import EnvironmentService +from src.domains.tools.news.schemas import NewsResponse +from src.domains.tools.news.service import NewsService from src.domains.auth.oidc import get_optional_user logger = get_logger(__name__) @@ -38,6 +40,7 @@ class ToolsController(BaseController): self.dns_service = DNSService() self.system_stats_service = SystemStatsService() self.environment_service = EnvironmentService() + self.news_service = NewsService() def create_router(self) -> APIRouter: """Create and configure the router""" @@ -213,6 +216,65 @@ class ToolsController(BaseController): detail=f"Failed to fetch environment data: {str(e)}" ) + @router.get( + "/news", + response_model=NewsResponse, + status_code=status.HTTP_200_OK, + summary="Get news headlines", + description=""" + Get news headlines for the authenticated user. + + Fetches news data from the Qdrant volatile collection for the authenticated user. + Falls back to 'default' user if not authenticated. + + **Data Returned:** + - **Headlines:** List of news headlines with title, description, source, url + - **Category:** News category (general, technology, etc.) + - **Sources:** List of news sources + + **Data Source:** Qdrant volatile_{user} collection (news namespace) + + **Use Cases:** + - Dashboard news ticker + - News feed widgets + - Information display + """ + ) + async def get_news( + user: Optional[Dict] = Depends(get_optional_user), + ) -> NewsResponse: + """ + Get news headlines + + Args: + user: Optional authenticated user from OIDC + + Returns: + News headlines response + + Raises: + HTTPException: 500 for processing errors + """ + try: + # Get user identifier from OIDC claims, fallback to 'default' + user_id = "default" + if user: + user_id = user.get("preferred_username") or user.get("sub", "default") + # Strip email domain if present (e.g., "user@example.com" -> "user") + if "@" in user_id: + user_id = user_id.split("@")[0] + + logger.info(f"Fetching news data for user: {user_id}") + result = await self.news_service.get_news(user_id) + return result + + except Exception as e: + logger.error(f"Failed to get news data: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to fetch news data: {str(e)}" + ) + return router diff --git a/src/domains/tools/news/__init__.py b/src/domains/tools/news/__init__.py new file mode 100644 index 0000000..22395b4 --- /dev/null +++ b/src/domains/tools/news/__init__.py @@ -0,0 +1,5 @@ +""" +News subdomain for Tools. + +Provides news headlines from Qdrant volatile collection. +""" diff --git a/src/domains/tools/news/schemas.py b/src/domains/tools/news/schemas.py new file mode 100644 index 0000000..135b44d --- /dev/null +++ b/src/domains/tools/news/schemas.py @@ -0,0 +1,56 @@ +""" +News data schemas for Tools domain. + +Provides Pydantic models for news headlines retrieved from the Qdrant volatile collection. +""" +from datetime import datetime +from typing import Optional, List +from pydantic import Field + +from src.shared.base import BaseSchema + + +class NewsHeadline(BaseSchema): + """Single news headline.""" + + title: str = Field( + ..., + description="Headline title" + ) + description: Optional[str] = Field( + None, + description="Brief description or summary" + ) + source: Optional[str] = Field( + None, + description="News source name" + ) + url: Optional[str] = Field( + None, + description="Link to full article" + ) + + +class NewsResponse(BaseSchema): + """News headlines response.""" + + headlines: List[NewsHeadline] = Field( + default_factory=list, + description="List of news headlines" + ) + category: Optional[str] = Field( + None, + description="News category (e.g., 'general', 'technology')" + ) + sources: Optional[List[str]] = Field( + None, + description="List of source names" + ) + updated_at: datetime = Field( + default_factory=datetime.utcnow, + description="Timestamp when data was fetched" + ) + user: Optional[str] = Field( + None, + description="User identifier used for data lookup" + ) diff --git a/src/domains/tools/news/service.py b/src/domains/tools/news/service.py new file mode 100644 index 0000000..dcbe248 --- /dev/null +++ b/src/domains/tools/news/service.py @@ -0,0 +1,113 @@ +""" +News data service for Tools domain. + +Fetches news headlines from the Qdrant volatile collection. +""" +from datetime import datetime +from typing import Optional, Dict, Any, List + +from src.shared.logging import get_logger +from src.shared.clients.qdrant_client import get_qdrant_client +from src.domains.tools.news.schemas import ( + NewsHeadline, + NewsResponse, +) + +logger = get_logger(__name__) + + +class NewsService: + """ + Service for fetching news data from Qdrant volatile collection. + + Retrieves news headlines for a specific user. + """ + + def __init__(self): + """Initialize news service with Qdrant client.""" + self.qdrant = get_qdrant_client() + + def _parse_headlines(self, raw_data: Any) -> List[NewsHeadline]: + """ + Parse raw news data into list of NewsHeadline schemas. + + Handles various formats from different news sources. + """ + if not raw_data: + return [] + + try: + # Handle dict with nested headlines list + headlines_list = raw_data + if isinstance(raw_data, dict): + headlines_list = raw_data.get("headlines") or raw_data.get("articles") or [] + + if not isinstance(headlines_list, list): + return [] + + headlines = [] + for item in headlines_list: + if isinstance(item, dict): + headlines.append(NewsHeadline( + title=item.get("title", ""), + description=item.get("description") or item.get("summary"), + source=item.get("source") or item.get("provider"), + url=item.get("url") or item.get("link"), + )) + elif isinstance(item, str): + # Simple string headlines + headlines.append(NewsHeadline(title=item)) + + return headlines + + except Exception as e: + logger.warning(f"Failed to parse news headlines: {e}") + return [] + + async def get_news(self, user: str = "default") -> NewsResponse: + """ + Get news headlines for a user. + + Fetches news from the user's volatile collection. + + Args: + user: User identifier (default: 'default') + + Returns: + NewsResponse with headlines + """ + logger.info(f"Fetching news data for user: {user}") + + # Get raw data from Qdrant + news_records = await self.qdrant.get_by_namespace(user, "news") + + headlines = [] + category = None + sources = None + + if news_records: + raw_data = news_records[0].get("raw_data", {}) + headlines = self._parse_headlines(raw_data) + if isinstance(raw_data, dict): + category = raw_data.get("category") + sources = raw_data.get("sources") + + return NewsResponse( + headlines=headlines, + category=category, + sources=sources, + updated_at=datetime.utcnow(), + user=user, + ) + + +# Singleton instance +_news_service: Optional[NewsService] = None + + +def get_news_service() -> NewsService: + """Get or create singleton news service instance.""" + global _news_service + if _news_service is None: + _news_service = NewsService() + return _news_service