Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcf5c8ccee | ||
|
|
768cea2c89 | ||
|
|
26ecc3e5fd | ||
|
|
5ff4ba0a43 | ||
|
|
bb438e22d6 | ||
|
|
3bb3b01dbd | ||
|
|
ce761a9d2c |
@@ -5,6 +5,63 @@ 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/),
|
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).
|
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
|
||||||
|
|
||||||
|
- **Forecast data retrieval** - Pass raw_data to service instead of extracting wrong field
|
||||||
|
- Qdrant client was extracting `days` (integer 7) instead of `daily` (list)
|
||||||
|
- Now passes full raw_data for service to parse correctly
|
||||||
|
|
||||||
|
## [1.10.11] - 2026-01-08
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Weather wind direction** - Convert integer degrees to cardinal direction string
|
||||||
|
- Scheduler stores wind_direction as degrees (e.g., 135)
|
||||||
|
- Schema expects string (e.g., "SE")
|
||||||
|
- Added `_degrees_to_cardinal()` conversion
|
||||||
|
|
||||||
|
## [1.10.10] - 2026-01-08
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Environment data parsing** - Fix parsing of scheduler-generated Qdrant data
|
||||||
|
- Forecast: Check `daily` key first (scheduler stores day count in `days`, list in `daily`)
|
||||||
|
- Sun times: Use `sunrise_iso`/`sunset_iso` fields, handle time-only format fallback
|
||||||
|
- Sun times: Calculate daylight from `daylight_duration_seconds` or `daylight_hours`
|
||||||
|
- Air quality: Support `aqi_us`/`aqi_european` and `nitrogen_dioxide` field names
|
||||||
|
|
||||||
|
## [1.10.9] - 2026-01-08
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Environment user ID cleanup** - Strip email domain from user identifier
|
||||||
|
- If `preferred_username` is an email, extract just the username part
|
||||||
|
- Ensures Qdrant collection name matches (e.g., `volatile_jpmschweitzer` not `volatile_jpmschweitzer@gmail.com`)
|
||||||
|
|
||||||
|
## [1.10.8] - 2026-01-08
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **OIDC audience validation** - python-jose requires string audience, not list
|
||||||
|
- Extract and validate audience from unverified claims first
|
||||||
|
- Use token's actual audience for JWT decode (after validating it's allowed)
|
||||||
|
- Fixes "audience must be a string or None" error
|
||||||
|
|
||||||
## [1.10.7] - 2026-01-08
|
## [1.10.7] - 2026-01-08
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "core-api"
|
name = "core-api"
|
||||||
version = "1.10.7"
|
version = "1.11.0"
|
||||||
description = "Core Code API - Infrastructure management and tools API"
|
description = "Core Code API - Infrastructure management and tools API"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
+11
-6
@@ -131,15 +131,21 @@ async def get_current_user(
|
|||||||
token = credentials.credentials
|
token = credentials.credentials
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# First, extract issuer from unverified claims to know which JWKS to use
|
# First, extract issuer and audience from unverified claims
|
||||||
unverified_claims = jwt.get_unverified_claims(token)
|
unverified_claims = jwt.get_unverified_claims(token)
|
||||||
token_issuer = unverified_claims.get("iss", "")
|
token_issuer = unverified_claims.get("iss", "")
|
||||||
|
token_audience = unverified_claims.get("aud", "")
|
||||||
|
|
||||||
# Validate issuer is in our allowed list
|
# Validate issuer is in our allowed list
|
||||||
if not oidc_config.is_valid_issuer(token_issuer):
|
if not oidc_config.is_valid_issuer(token_issuer):
|
||||||
logger.warning(f"Invalid token issuer: {token_issuer}")
|
logger.warning(f"Invalid token issuer: {token_issuer}")
|
||||||
raise HTTPException(status_code=401, detail="Invalid token issuer")
|
raise HTTPException(status_code=401, detail="Invalid token issuer")
|
||||||
|
|
||||||
|
# Validate audience is in our allowed list
|
||||||
|
if token_audience not in oidc_config.audiences:
|
||||||
|
logger.warning(f"Invalid token audience: {token_audience}")
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid token audience")
|
||||||
|
|
||||||
# Decode token header to get key ID
|
# Decode token header to get key ID
|
||||||
unverified_header = jwt.get_unverified_header(token)
|
unverified_header = jwt.get_unverified_header(token)
|
||||||
kid = unverified_header.get("kid")
|
kid = unverified_header.get("kid")
|
||||||
@@ -160,18 +166,17 @@ async def get_current_user(
|
|||||||
logger.warning(f"No matching key found for kid: {kid}")
|
logger.warning(f"No matching key found for kid: {kid}")
|
||||||
raise HTTPException(status_code=401, detail="Invalid token key")
|
raise HTTPException(status_code=401, detail="Invalid token key")
|
||||||
|
|
||||||
# Verify and decode token (accepts any of the configured audiences)
|
# Verify and decode token using the token's actual issuer and audience
|
||||||
# Use the token's issuer for validation (already verified it's in our allowed list)
|
|
||||||
payload = jwt.decode(
|
payload = jwt.decode(
|
||||||
token,
|
token,
|
||||||
rsa_key,
|
rsa_key,
|
||||||
algorithms=["RS256"],
|
algorithms=["RS256"],
|
||||||
audience=oidc_config.audiences,
|
audience=token_audience, # Use the token's audience (already validated)
|
||||||
issuer=token_issuer,
|
issuer=token_issuer, # Use the token's issuer (already validated)
|
||||||
)
|
)
|
||||||
|
|
||||||
user_email = payload.get("email", "unknown")
|
user_email = payload.get("email", "unknown")
|
||||||
logger.info(f"Authenticated user: {user_email}")
|
logger.info(f"Authenticated user: {user_email} (issuer: {token_issuer})")
|
||||||
|
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|||||||
@@ -185,15 +185,22 @@ async def get_current_user(
|
|||||||
|
|
||||||
kid = unverified_header.get("kid")
|
kid = unverified_header.get("kid")
|
||||||
token_issuer = unverified_claims.get("iss", "")
|
token_issuer = unverified_claims.get("iss", "")
|
||||||
|
token_audience = unverified_claims.get("aud", "")
|
||||||
|
|
||||||
if not kid:
|
if not kid:
|
||||||
raise HTTPException(status_code=401, detail="Invalid token format")
|
raise HTTPException(status_code=401, detail="Invalid token format")
|
||||||
|
|
||||||
# Validate issuer is in allowed list
|
# Validate issuer is in allowed list
|
||||||
|
logger.debug(f"Token issuer: {token_issuer}, allowed issuers: {oidc_config.issuers}")
|
||||||
if not oidc_config.is_valid_issuer(token_issuer):
|
if not oidc_config.is_valid_issuer(token_issuer):
|
||||||
logger.warning(f"Invalid token issuer: {token_issuer}")
|
logger.warning(f"Invalid token issuer: {token_issuer} (allowed: {oidc_config.issuers})")
|
||||||
raise HTTPException(status_code=401, detail="Invalid token issuer")
|
raise HTTPException(status_code=401, detail="Invalid token issuer")
|
||||||
|
|
||||||
|
# Validate audience is in allowed list
|
||||||
|
if token_audience not in oidc_config.audiences:
|
||||||
|
logger.warning(f"Invalid token audience: {token_audience} (allowed: {oidc_config.audiences})")
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid token audience")
|
||||||
|
|
||||||
# Get JWKS for this specific issuer
|
# Get JWKS for this specific issuer
|
||||||
jwks = get_jwks_for_issuer(token_issuer)
|
jwks = get_jwks_for_issuer(token_issuer)
|
||||||
rsa_key = None
|
rsa_key = None
|
||||||
@@ -207,13 +214,13 @@ async def get_current_user(
|
|||||||
logger.warning(f"No matching key found for kid: {kid}")
|
logger.warning(f"No matching key found for kid: {kid}")
|
||||||
raise HTTPException(status_code=401, detail="Invalid token key")
|
raise HTTPException(status_code=401, detail="Invalid token key")
|
||||||
|
|
||||||
# Verify and decode token using the token's actual issuer
|
# Verify and decode token using the token's actual issuer and audience
|
||||||
payload = jwt.decode(
|
payload = jwt.decode(
|
||||||
token,
|
token,
|
||||||
rsa_key,
|
rsa_key,
|
||||||
algorithms=["RS256"],
|
algorithms=["RS256"],
|
||||||
audience=oidc_config.audiences,
|
audience=token_audience, # Use the token's audience (already validated)
|
||||||
issuer=token_issuer, # Use the token's issuer for validation
|
issuer=token_issuer, # Use the token's issuer (already validated)
|
||||||
)
|
)
|
||||||
|
|
||||||
user_email = payload.get("email", "unknown")
|
user_email = payload.get("email", "unknown")
|
||||||
@@ -315,12 +322,14 @@ async def get_optional_user(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if not credentials:
|
if not credentials:
|
||||||
|
logger.debug("No credentials provided for optional auth")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return await get_current_user(credentials)
|
return await get_current_user(credentials)
|
||||||
except HTTPException:
|
except HTTPException as e:
|
||||||
# Invalid token - return None instead of raising
|
# Invalid token - log and return None instead of raising
|
||||||
|
logger.warning(f"Optional auth failed: {e.detail}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ from src.domains.tools.system.schemas import SystemStatsResponse
|
|||||||
from src.domains.tools.system.service import SystemStatsService
|
from src.domains.tools.system.service import SystemStatsService
|
||||||
from src.domains.tools.environment.schemas import EnvironmentResponse
|
from src.domains.tools.environment.schemas import EnvironmentResponse
|
||||||
from src.domains.tools.environment.service import EnvironmentService
|
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
|
from src.domains.auth.oidc import get_optional_user
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -38,6 +40,7 @@ class ToolsController(BaseController):
|
|||||||
self.dns_service = DNSService()
|
self.dns_service = DNSService()
|
||||||
self.system_stats_service = SystemStatsService()
|
self.system_stats_service = SystemStatsService()
|
||||||
self.environment_service = EnvironmentService()
|
self.environment_service = EnvironmentService()
|
||||||
|
self.news_service = NewsService()
|
||||||
|
|
||||||
def create_router(self) -> APIRouter:
|
def create_router(self) -> APIRouter:
|
||||||
"""Create and configure the router"""
|
"""Create and configure the router"""
|
||||||
@@ -198,6 +201,9 @@ class ToolsController(BaseController):
|
|||||||
user_id = "default"
|
user_id = "default"
|
||||||
if user:
|
if user:
|
||||||
user_id = user.get("preferred_username") or user.get("sub", "default")
|
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 environment data for user: {user_id}")
|
logger.info(f"Fetching environment data for user: {user_id}")
|
||||||
result = await self.environment_service.get_current(user_id)
|
result = await self.environment_service.get_current(user_id)
|
||||||
@@ -210,6 +216,65 @@ class ToolsController(BaseController):
|
|||||||
detail=f"Failed to fetch environment data: {str(e)}"
|
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
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -43,13 +43,18 @@ class EnvironmentService:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Handle wind direction - convert degrees to cardinal if integer
|
||||||
|
wind_dir = raw_data.get("wind_direction") or raw_data.get("wind_dir")
|
||||||
|
if isinstance(wind_dir, (int, float)):
|
||||||
|
wind_dir = self._degrees_to_cardinal(wind_dir)
|
||||||
|
|
||||||
return WeatherData(
|
return WeatherData(
|
||||||
temperature=raw_data.get("temperature") or raw_data.get("temp"),
|
temperature=raw_data.get("temperature") or raw_data.get("temp"),
|
||||||
feels_like=raw_data.get("feels_like") or raw_data.get("feelslike"),
|
feels_like=raw_data.get("feels_like") or raw_data.get("feelslike"),
|
||||||
conditions=raw_data.get("conditions") or raw_data.get("weather") or raw_data.get("description"),
|
conditions=raw_data.get("conditions") or raw_data.get("weather") or raw_data.get("description"),
|
||||||
humidity=raw_data.get("humidity"),
|
humidity=raw_data.get("humidity"),
|
||||||
wind_speed=raw_data.get("wind_speed") or raw_data.get("windspeed") or raw_data.get("wind"),
|
wind_speed=raw_data.get("wind_speed") or raw_data.get("windspeed") or raw_data.get("wind"),
|
||||||
wind_direction=raw_data.get("wind_direction") or raw_data.get("wind_dir"),
|
wind_direction=wind_dir,
|
||||||
pressure=raw_data.get("pressure"),
|
pressure=raw_data.get("pressure"),
|
||||||
visibility=raw_data.get("visibility"),
|
visibility=raw_data.get("visibility"),
|
||||||
uv_index=raw_data.get("uv_index") or raw_data.get("uv"),
|
uv_index=raw_data.get("uv_index") or raw_data.get("uv"),
|
||||||
@@ -60,6 +65,13 @@ class EnvironmentService:
|
|||||||
logger.warning(f"Failed to parse weather data: {e}")
|
logger.warning(f"Failed to parse weather data: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _degrees_to_cardinal(self, degrees: float) -> str:
|
||||||
|
"""Convert wind direction degrees to cardinal direction."""
|
||||||
|
directions = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
|
||||||
|
"S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"]
|
||||||
|
index = round(degrees / 22.5) % 16
|
||||||
|
return directions[index]
|
||||||
|
|
||||||
def _parse_forecast(self, raw_data: Any) -> Optional[List[ForecastDay]]:
|
def _parse_forecast(self, raw_data: Any) -> Optional[List[ForecastDay]]:
|
||||||
"""
|
"""
|
||||||
Parse raw forecast data into list of ForecastDay schemas.
|
Parse raw forecast data into list of ForecastDay schemas.
|
||||||
@@ -73,7 +85,15 @@ class EnvironmentService:
|
|||||||
# Normalize to list
|
# Normalize to list
|
||||||
forecast_list = raw_data
|
forecast_list = raw_data
|
||||||
if isinstance(raw_data, dict):
|
if isinstance(raw_data, dict):
|
||||||
forecast_list = raw_data.get("days") or raw_data.get("forecast") or []
|
# Check 'daily' first (scheduler format), then 'forecast', then 'days'
|
||||||
|
# Note: 'days' might be an integer count, so check 'daily' first
|
||||||
|
forecast_list = raw_data.get("daily") or raw_data.get("forecast")
|
||||||
|
if forecast_list is None:
|
||||||
|
days_value = raw_data.get("days")
|
||||||
|
if isinstance(days_value, list):
|
||||||
|
forecast_list = days_value
|
||||||
|
else:
|
||||||
|
forecast_list = []
|
||||||
|
|
||||||
if not isinstance(forecast_list, list):
|
if not isinstance(forecast_list, list):
|
||||||
return None
|
return None
|
||||||
@@ -83,8 +103,8 @@ class EnvironmentService:
|
|||||||
if isinstance(day, dict):
|
if isinstance(day, dict):
|
||||||
days.append(ForecastDay(
|
days.append(ForecastDay(
|
||||||
date=day.get("date", ""),
|
date=day.get("date", ""),
|
||||||
high=day.get("high") or day.get("maxtemp") or day.get("temp_max"),
|
high=day.get("high") or day.get("temp_high") or day.get("maxtemp") or day.get("temp_max"),
|
||||||
low=day.get("low") or day.get("mintemp") or day.get("temp_min"),
|
low=day.get("low") or day.get("temp_low") or day.get("mintemp") or day.get("temp_min"),
|
||||||
conditions=day.get("conditions") or day.get("weather") or day.get("description"),
|
conditions=day.get("conditions") or day.get("weather") or day.get("description"),
|
||||||
precipitation_chance=day.get("precipitation_chance") or day.get("pop") or day.get("precip"),
|
precipitation_chance=day.get("precipitation_chance") or day.get("pop") or day.get("precip"),
|
||||||
icon=day.get("icon"),
|
icon=day.get("icon"),
|
||||||
@@ -106,19 +126,39 @@ class EnvironmentService:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
sunrise = raw_data.get("sunrise")
|
# Prefer ISO format fields (sunrise_iso, sunset_iso) over time-only fields
|
||||||
sunset = raw_data.get("sunset")
|
sunrise = raw_data.get("sunrise_iso") or raw_data.get("sunrise")
|
||||||
|
sunset = raw_data.get("sunset_iso") or raw_data.get("sunset")
|
||||||
|
|
||||||
# Parse datetime strings if needed
|
# Parse datetime strings if needed
|
||||||
if isinstance(sunrise, str):
|
if isinstance(sunrise, str):
|
||||||
sunrise = datetime.fromisoformat(sunrise.replace("Z", "+00:00"))
|
# Handle time-only format (HH:MM) by combining with today's date
|
||||||
|
if len(sunrise) <= 5 and ":" in sunrise:
|
||||||
|
today = datetime.now().date()
|
||||||
|
sunrise = datetime.strptime(f"{today} {sunrise}", "%Y-%m-%d %H:%M")
|
||||||
|
else:
|
||||||
|
sunrise = datetime.fromisoformat(sunrise.replace("Z", "+00:00"))
|
||||||
if isinstance(sunset, str):
|
if isinstance(sunset, str):
|
||||||
sunset = datetime.fromisoformat(sunset.replace("Z", "+00:00"))
|
# Handle time-only format (HH:MM) by combining with today's date
|
||||||
|
if len(sunset) <= 5 and ":" in sunset:
|
||||||
|
today = datetime.now().date()
|
||||||
|
sunset = datetime.strptime(f"{today} {sunset}", "%Y-%m-%d %H:%M")
|
||||||
|
else:
|
||||||
|
sunset = datetime.fromisoformat(sunset.replace("Z", "+00:00"))
|
||||||
|
|
||||||
# Calculate daylight minutes if not provided
|
# Get daylight from various field names
|
||||||
daylight_minutes = raw_data.get("daylight_minutes") or raw_data.get("daylight")
|
daylight_minutes = raw_data.get("daylight_minutes") or raw_data.get("daylight")
|
||||||
if daylight_minutes is None and sunrise and sunset:
|
if daylight_minutes is None:
|
||||||
daylight_minutes = int((sunset - sunrise).total_seconds() / 60)
|
# Try to calculate from daylight_duration_seconds or daylight_hours
|
||||||
|
daylight_seconds = raw_data.get("daylight_duration_seconds")
|
||||||
|
if daylight_seconds:
|
||||||
|
daylight_minutes = int(daylight_seconds / 60)
|
||||||
|
else:
|
||||||
|
daylight_hours = raw_data.get("daylight_hours")
|
||||||
|
if daylight_hours:
|
||||||
|
daylight_minutes = int(daylight_hours * 60)
|
||||||
|
elif sunrise and sunset:
|
||||||
|
daylight_minutes = int((sunset - sunrise).total_seconds() / 60)
|
||||||
|
|
||||||
# Parse optional fields
|
# Parse optional fields
|
||||||
solar_noon = raw_data.get("solar_noon")
|
solar_noon = raw_data.get("solar_noon")
|
||||||
@@ -167,7 +207,8 @@ class EnvironmentService:
|
|||||||
if not isinstance(raw_data, dict):
|
if not isinstance(raw_data, dict):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
aqi = raw_data.get("aqi") or raw_data.get("index")
|
# Try various AQI field names - prefer US AQI, then European, then generic
|
||||||
|
aqi = raw_data.get("aqi") or raw_data.get("aqi_us") or raw_data.get("aqi_european") or raw_data.get("index")
|
||||||
if isinstance(aqi, (int, float)):
|
if isinstance(aqi, (int, float)):
|
||||||
aqi = int(aqi)
|
aqi = int(aqi)
|
||||||
|
|
||||||
@@ -177,7 +218,7 @@ class EnvironmentService:
|
|||||||
pm25=raw_data.get("pm25") or raw_data.get("pm2_5"),
|
pm25=raw_data.get("pm25") or raw_data.get("pm2_5"),
|
||||||
pm10=raw_data.get("pm10"),
|
pm10=raw_data.get("pm10"),
|
||||||
o3=raw_data.get("o3") or raw_data.get("ozone"),
|
o3=raw_data.get("o3") or raw_data.get("ozone"),
|
||||||
no2=raw_data.get("no2"),
|
no2=raw_data.get("no2") or raw_data.get("nitrogen_dioxide"),
|
||||||
location=raw_data.get("location"),
|
location=raw_data.get("location"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""
|
||||||
|
News subdomain for Tools.
|
||||||
|
|
||||||
|
Provides news headlines from Qdrant volatile collection.
|
||||||
|
"""
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -190,19 +190,10 @@ class QdrantReadClient:
|
|||||||
if aqi:
|
if aqi:
|
||||||
result["air_quality"] = aqi if isinstance(aqi, dict) else {"aqi": aqi}
|
result["air_quality"] = aqi if isinstance(aqi, dict) else {"aqi": aqi}
|
||||||
|
|
||||||
# Fetch forecast data
|
# Fetch forecast data - pass raw_data to service for parsing
|
||||||
forecast_records = await self.get_by_namespace(user, "forecast")
|
forecast_records = await self.get_by_namespace(user, "forecast")
|
||||||
if forecast_records:
|
if forecast_records:
|
||||||
# Forecast might be a single record with list or multiple records
|
result["forecast"] = forecast_records[0].get("raw_data")
|
||||||
first_record = forecast_records[0].get("raw_data")
|
|
||||||
if isinstance(first_record, list):
|
|
||||||
result["forecast"] = first_record
|
|
||||||
elif isinstance(first_record, dict):
|
|
||||||
# Could be a dict with 'days' or 'forecast' key
|
|
||||||
result["forecast"] = first_record.get(
|
|
||||||
"days",
|
|
||||||
first_record.get("forecast", [first_record])
|
|
||||||
)
|
|
||||||
|
|
||||||
# Fetch sun times data
|
# Fetch sun times data
|
||||||
sun_records = await self.get_by_namespace(user, "sun")
|
sun_records = await self.get_by_namespace(user, "sun")
|
||||||
|
|||||||
Reference in New Issue
Block a user