Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e31e74ad6 | ||
|
|
72f515bf61 | ||
|
|
0c085d603e | ||
|
|
152b2f28c4 | ||
|
|
46b9bcd7a0 | ||
|
|
68eb1add3d |
@@ -1,12 +1,25 @@
|
||||
name: Build and Push
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Create Gitea Release
|
||||
run: |
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"tag_name": "${{ github.ref_name }}", "name": "Release ${{ github.ref_name }}", "body": "Automated release for ${{ github.ref_name }}"}' \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
needs: release
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
@@ -5,6 +5,47 @@ All notable changes to Library Desk 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.7.1] - 2026-01-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- **CI workflow** - Updated Gitea Actions to trigger on tag push (matching core-api)
|
||||
|
||||
## [1.7.0] - 2026-01-07
|
||||
|
||||
### Added
|
||||
|
||||
- **Combined Environment Endpoint** - `POST /volatile/fetch/environment/{city}`
|
||||
- Fetches weather and air quality concurrently with `asyncio.gather()`
|
||||
- Single geocode lookup shared between both API calls
|
||||
- More efficient than calling weather and air_quality separately
|
||||
- Reduces wall-clock time and eliminates redundant geocoding
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Scheduler executor name** - Fixed `rest_api` → `rest_api_executor` in SchedulerTask model and register_volatile_fetch() to prevent "Executor module not found" errors
|
||||
|
||||
## [1.6.2] - 2025-12-30
|
||||
|
||||
### Added
|
||||
|
||||
- **System Statistics Endpoint** - `GET /stats`
|
||||
- Neo4j: node counts by type (Document, Entity, Collection, Search)
|
||||
- Qdrant: collection counts, total vectors, per-collection breakdown
|
||||
- Wiki.js: total page count
|
||||
- Paperless: documents, tags, correspondents, document types
|
||||
|
||||
- **Weather/Forecast Separation** - Split weather into two distinct namespaces
|
||||
- `POST /volatile/fetch/weather/{city}` - Current conditions only (1hr TTL)
|
||||
- `POST /volatile/fetch/forecast/{city}` - 7-day outlook (12hr TTL)
|
||||
- Different update frequencies for efficient caching
|
||||
- `FORECAST` namespace added to volatile namespaces
|
||||
|
||||
### Changed
|
||||
|
||||
- Weather namespace TTL changed from 30 minutes to 1 hour (current conditions)
|
||||
- Forecast data now stored separately with 12 hour TTL
|
||||
|
||||
## [1.6.1] - 2025-12-30
|
||||
|
||||
### Added
|
||||
|
||||
@@ -41,12 +41,3 @@ Check for duplicate or highly similar documents using vector similarity and grap
|
||||
3. Check graph relationships
|
||||
4. Return candidates with similarity scores
|
||||
|
||||
## System Statistics
|
||||
|
||||
#### `GET /stats`
|
||||
Get system statistics (wiki pages, neo4j nodes, qdrant vectors).
|
||||
|
||||
**Implementation needed:**
|
||||
- Query Neo4j for node count
|
||||
- Query Qdrant for vector count
|
||||
- Query Wiki.js for page count
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "library-desk"
|
||||
version = "1.6.1"
|
||||
version = "1.7.1"
|
||||
description = "Coordination service for The Library system - HybridRAG queries, document ingestion, entity extraction, and knowledge consolidation"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -18,7 +18,7 @@ class SchedulerTask(BaseModel):
|
||||
|
||||
task_name: str = Field(..., description="Unique task identifier")
|
||||
service: str = Field(default="library-desk", description="Service that owns this task")
|
||||
executor: str = Field(default="rest_api", description="Executor type")
|
||||
executor: str = Field(default="rest_api_executor", description="Executor type")
|
||||
priority: int = Field(default=50, ge=1, le=100, description="Priority (lower = higher)")
|
||||
description: Optional[str] = Field(None, description="Human-readable description")
|
||||
enabled: bool = Field(default=True, description="Whether task is enabled")
|
||||
@@ -291,7 +291,7 @@ class SchedulerClient:
|
||||
task = SchedulerTask(
|
||||
task_name=task_name,
|
||||
service="library-desk",
|
||||
executor="rest_api",
|
||||
executor="rest_api_executor",
|
||||
priority=60, # Background maintenance priority
|
||||
description=description or f"Prefetch {namespace}/{key} for {user}",
|
||||
minute=schedule.get("minute", -1),
|
||||
|
||||
+88
-1
@@ -18,7 +18,7 @@ from pathlib import Path
|
||||
|
||||
from src.config import Settings, get_settings, __version__
|
||||
from src.core.dependencies import (
|
||||
verify_api_key, QdrantDep, WikiJSDep, OllamaDep, Neo4jDep
|
||||
verify_api_key, QdrantDep, WikiJSDep, OllamaDep, Neo4jDep, PaperlessDep
|
||||
)
|
||||
from src.core.multi_tenancy import DEFAULT_USER
|
||||
|
||||
@@ -85,6 +85,14 @@ class HealthResponse(BaseModel):
|
||||
services: Dict[str, Any]
|
||||
|
||||
|
||||
class StatsResponse(BaseModel):
|
||||
"""System statistics response model."""
|
||||
neo4j: Dict[str, int]
|
||||
qdrant: Dict[str, Any]
|
||||
wiki_pages: int
|
||||
paperless: Dict[str, Any]
|
||||
|
||||
|
||||
# Routes
|
||||
@app.get("/", tags=["Root"])
|
||||
async def root() -> Dict[str, str]:
|
||||
@@ -141,6 +149,85 @@ async def health(settings: Settings = Depends(get_settings)) -> HealthResponse:
|
||||
)
|
||||
|
||||
|
||||
@app.get("/stats", response_model=StatsResponse, tags=["System"])
|
||||
async def stats(
|
||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||
neo4j: Neo4jDep = None,
|
||||
qdrant: QdrantDep = None,
|
||||
wikijs: WikiJSDep = None,
|
||||
paperless: PaperlessDep = None,
|
||||
api_key: str = Depends(verify_api_key)
|
||||
) -> StatsResponse:
|
||||
"""
|
||||
Get system statistics.
|
||||
|
||||
Returns counts for:
|
||||
- Neo4j: nodes by type (Document, Entity, Collection, Search)
|
||||
- Qdrant: vectors per collection
|
||||
- Wiki.js: total page count
|
||||
- Paperless: documents, tags, correspondents, document types
|
||||
"""
|
||||
# Neo4j node counts by label
|
||||
neo4j_stats = {}
|
||||
try:
|
||||
for label in ["Document", "Entity", "Collection", "Search"]:
|
||||
result = await neo4j.execute_query(
|
||||
f"MATCH (n:{label}) RETURN count(n) as count"
|
||||
)
|
||||
neo4j_stats[label.lower() + "_nodes"] = result[0]["count"] if result else 0
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get Neo4j stats: {e}")
|
||||
neo4j_stats = {"error": str(e)}
|
||||
|
||||
# Qdrant collection stats
|
||||
qdrant_stats = {}
|
||||
try:
|
||||
collections = await qdrant.list_collections()
|
||||
qdrant_stats["collections"] = len(collections)
|
||||
qdrant_stats["total_vectors"] = sum(c.get("vectors_count", 0) for c in collections)
|
||||
qdrant_stats["by_collection"] = {
|
||||
c["name"]: c["vectors_count"] for c in collections
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get Qdrant stats: {e}")
|
||||
qdrant_stats = {"error": str(e)}
|
||||
|
||||
# Wiki.js page count
|
||||
wiki_pages = 0
|
||||
try:
|
||||
pages = await wikijs.list_all_pages(user)
|
||||
wiki_pages = len(pages)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get Wiki.js stats: {e}")
|
||||
|
||||
# Paperless-ngx document stats
|
||||
paperless_stats = {}
|
||||
try:
|
||||
# Get document count (page_size=1 for efficiency, we just need the count)
|
||||
docs_result = await paperless.list_documents(page_size=1)
|
||||
paperless_stats["documents"] = docs_result.get("count", 0)
|
||||
|
||||
# Get metadata counts
|
||||
tags = await paperless.list_tags()
|
||||
paperless_stats["tags"] = len(tags)
|
||||
|
||||
correspondents = await paperless.list_correspondents()
|
||||
paperless_stats["correspondents"] = len(correspondents)
|
||||
|
||||
doc_types = await paperless.list_document_types()
|
||||
paperless_stats["document_types"] = len(doc_types)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get Paperless stats: {e}")
|
||||
paperless_stats = {"error": str(e)}
|
||||
|
||||
return StatsResponse(
|
||||
neo4j=neo4j_stats,
|
||||
qdrant=qdrant_stats,
|
||||
wiki_pages=wiki_pages,
|
||||
paperless=paperless_stats
|
||||
)
|
||||
|
||||
|
||||
@app.post("/ingest/check-updates", tags=["Ingestion"])
|
||||
async def check_updates(
|
||||
documents: Dict[str, Any],
|
||||
|
||||
@@ -18,7 +18,8 @@ class VolatileNamespace(str, Enum):
|
||||
Each namespace can have different default TTLs and refresh schedules.
|
||||
"""
|
||||
# Real-time external data
|
||||
WEATHER = "weather" # Current conditions, forecasts
|
||||
WEATHER = "weather" # Current conditions (temperature, humidity, wind)
|
||||
FORECAST = "forecast" # Multi-day weather outlook
|
||||
SUN = "sun" # Sunrise, sunset, daylight duration
|
||||
NEWS = "news" # Headlines, breaking news
|
||||
FINANCIAL = "financial" # Stock prices, exchange rates, crypto
|
||||
@@ -38,7 +39,8 @@ class VolatileNamespace(str, Enum):
|
||||
|
||||
# Default TTLs per namespace (in seconds)
|
||||
NAMESPACE_DEFAULT_TTL: Dict[str, int] = {
|
||||
VolatileNamespace.WEATHER: 1800, # 30 min - weather changes slowly
|
||||
VolatileNamespace.WEATHER: 3600, # 1 hour - current conditions
|
||||
VolatileNamespace.FORECAST: 43200, # 12 hours - forecast stable longer
|
||||
VolatileNamespace.SUN: 86400, # 24 hours - sun times change daily
|
||||
VolatileNamespace.NEWS: 3600, # 1 hour - news cycles
|
||||
VolatileNamespace.FINANCIAL: 300, # 5 min - markets move fast
|
||||
|
||||
+105
-5
@@ -233,16 +233,16 @@ async def store_volatile(
|
||||
async def fetch_weather(
|
||||
city: str,
|
||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||
ttl: int = Query(default=86400, ge=60, le=604800, description="TTL in seconds"),
|
||||
ttl: int = Query(default=3600, ge=60, le=86400, description="TTL in seconds (default 1 hour)"),
|
||||
qdrant: QdrantDep = None,
|
||||
ollama: OllamaDep = None,
|
||||
api_key: str = Depends(verify_api_key)
|
||||
):
|
||||
"""
|
||||
Fetch current weather for a city and store in volatile cache.
|
||||
Fetch current weather conditions for a city and store in volatile cache.
|
||||
|
||||
Called by scheduler for prefetch or on-demand. Geocodes city name
|
||||
and fetches weather from Open-Meteo API.
|
||||
Stores temperature, humidity, wind, UV index. For forecasts use /fetch/forecast.
|
||||
Called by scheduler for hourly prefetch or on-demand.
|
||||
|
||||
**Example:**
|
||||
```
|
||||
@@ -257,7 +257,49 @@ async def fetch_weather(
|
||||
weather_provider=weather_provider,
|
||||
)
|
||||
|
||||
result = await fetch_service.fetch_weather(user, city, ttl=ttl)
|
||||
result = await fetch_service.fetch_current_weather(user, city, ttl=ttl)
|
||||
|
||||
if not result.success:
|
||||
raise HTTPException(status_code=500, detail=result.error)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"namespace": result.namespace,
|
||||
"key": result.key,
|
||||
"record": result.record,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/fetch/forecast/{city}")
|
||||
async def fetch_forecast(
|
||||
city: str,
|
||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||
days: int = Query(default=7, ge=1, le=16, description="Forecast days (1-16)"),
|
||||
ttl: int = Query(default=43200, ge=60, le=604800, description="TTL in seconds (default 12 hours)"),
|
||||
qdrant: QdrantDep = None,
|
||||
ollama: OllamaDep = None,
|
||||
api_key: str = Depends(verify_api_key)
|
||||
):
|
||||
"""
|
||||
Fetch weather forecast for a city and store in volatile cache.
|
||||
|
||||
Stores multi-day outlook with highs/lows, precipitation, UV.
|
||||
For current conditions use /fetch/weather.
|
||||
|
||||
**Example:**
|
||||
```
|
||||
POST /volatile/fetch/forecast/amsterdam?user=jpmschweitzer&days=7
|
||||
```
|
||||
"""
|
||||
volatile_service = get_volatile_service(qdrant, ollama)
|
||||
weather_provider = get_weather_provider()
|
||||
|
||||
fetch_service = VolatileFetchService(
|
||||
volatile_service=volatile_service,
|
||||
weather_provider=weather_provider,
|
||||
)
|
||||
|
||||
result = await fetch_service.fetch_forecast(user, city, days=days, ttl=ttl)
|
||||
|
||||
if not result.success:
|
||||
raise HTTPException(status_code=500, detail=result.error)
|
||||
@@ -503,6 +545,64 @@ async def fetch_air_quality(
|
||||
}
|
||||
|
||||
|
||||
@router.post("/fetch/environment/{city}")
|
||||
async def fetch_environment(
|
||||
city: str,
|
||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||
weather_ttl: int = Query(default=3600, ge=60, le=86400, description="Weather TTL in seconds"),
|
||||
air_quality_ttl: int = Query(default=3600, ge=60, le=86400, description="Air quality TTL in seconds"),
|
||||
qdrant: QdrantDep = None,
|
||||
ollama: OllamaDep = None,
|
||||
api_key: str = Depends(verify_api_key)
|
||||
):
|
||||
"""
|
||||
Fetch weather and air quality concurrently for a city.
|
||||
|
||||
Performs a single geocode lookup and fetches both weather and air quality
|
||||
data in parallel, storing both in volatile cache. More efficient than
|
||||
calling /fetch/weather and /fetch/air_quality separately.
|
||||
|
||||
**Example:**
|
||||
```
|
||||
POST /volatile/fetch/environment/rotterdam?user=jpmschweitzer
|
||||
```
|
||||
|
||||
**Response includes:**
|
||||
- weather: Current conditions (temperature, humidity, wind, UV)
|
||||
- air_quality: AQI indices, pollutants, pollen data
|
||||
"""
|
||||
volatile_service = get_volatile_service(qdrant, ollama)
|
||||
weather_provider = get_weather_provider()
|
||||
|
||||
fetch_service = VolatileFetchService(
|
||||
volatile_service=volatile_service,
|
||||
weather_provider=weather_provider,
|
||||
)
|
||||
|
||||
result = await fetch_service.fetch_environment(
|
||||
user, city, weather_ttl=weather_ttl, air_quality_ttl=air_quality_ttl
|
||||
)
|
||||
|
||||
if not result.success:
|
||||
raise HTTPException(status_code=500, detail="; ".join(result.errors))
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"key": result.key,
|
||||
"weather": {
|
||||
"success": result.weather.success if result.weather else False,
|
||||
"record": result.weather.record if result.weather else None,
|
||||
"error": result.weather.error if result.weather else None,
|
||||
},
|
||||
"air_quality": {
|
||||
"success": result.air_quality.success if result.air_quality else False,
|
||||
"record": result.air_quality.record if result.air_quality else None,
|
||||
"error": result.air_quality.error if result.air_quality else None,
|
||||
},
|
||||
"errors": result.errors,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{namespace}/{key}", response_model=VolatileRecordResponse)
|
||||
async def get_record(
|
||||
namespace: str,
|
||||
|
||||
@@ -5,9 +5,10 @@ Orchestrates fetching data from external APIs and storing in volatile cache.
|
||||
Called by scheduler for prefetch or by HybridRAG for reactive caching.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from src.apis import (
|
||||
OpenMeteoProvider,
|
||||
@@ -36,6 +37,16 @@ class FetchResult:
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class EnvironmentFetchResult:
|
||||
"""Result of combined environment fetch (weather + air quality)."""
|
||||
success: bool
|
||||
key: str
|
||||
weather: Optional[FetchResult] = None
|
||||
air_quality: Optional[FetchResult] = None
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class VolatileFetchService:
|
||||
"""
|
||||
Service to fetch external data and store in volatile cache.
|
||||
@@ -67,12 +78,86 @@ class VolatileFetchService:
|
||||
self.news = news_provider
|
||||
self.financial = financial_provider
|
||||
|
||||
async def fetch_weather(
|
||||
async def fetch_current_weather(
|
||||
self,
|
||||
user: str,
|
||||
city: str,
|
||||
ttl: int = 3600, # 1 hour
|
||||
) -> FetchResult:
|
||||
"""
|
||||
Fetch current weather conditions for a city and store in volatile cache.
|
||||
|
||||
Args:
|
||||
user: User identifier
|
||||
city: City name (will be geocoded)
|
||||
ttl: Time-to-live in seconds (default 1 hour)
|
||||
|
||||
Returns:
|
||||
FetchResult with success status and stored record
|
||||
"""
|
||||
try:
|
||||
# Geocode city and get current conditions
|
||||
location = await self.weather.geocode(city)
|
||||
if not location:
|
||||
return FetchResult(
|
||||
success=False,
|
||||
namespace="weather",
|
||||
key=city.lower(),
|
||||
error=f"Could not geocode city: {city}"
|
||||
)
|
||||
|
||||
current = await self.weather.get_current(location)
|
||||
|
||||
# Generate natural language summary
|
||||
text = current.to_text()
|
||||
|
||||
# Convert to storage format
|
||||
data = {
|
||||
"temperature": current.temperature,
|
||||
"feels_like": current.feels_like,
|
||||
"humidity": current.humidity,
|
||||
"wind_speed": current.wind_speed,
|
||||
"wind_direction": current.wind_direction,
|
||||
"conditions": current.condition_text,
|
||||
"condition_code": current.condition.value,
|
||||
"uv_index": current.uv_index,
|
||||
"location": current.location,
|
||||
"text": text,
|
||||
}
|
||||
|
||||
# Store in volatile cache
|
||||
record = await self.volatile.store(
|
||||
user=user,
|
||||
namespace=VolatileNamespace.WEATHER,
|
||||
key=city.lower(),
|
||||
data=data,
|
||||
source="openmeteo",
|
||||
ttl=ttl,
|
||||
)
|
||||
|
||||
logger.info(f"Stored current weather for {city} (user={user})")
|
||||
return FetchResult(
|
||||
success=True,
|
||||
namespace="weather",
|
||||
key=city.lower(),
|
||||
record=record
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch current weather for {city}: {e}")
|
||||
return FetchResult(
|
||||
success=False,
|
||||
namespace="weather",
|
||||
key=city.lower(),
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
async def fetch_forecast(
|
||||
self,
|
||||
user: str,
|
||||
city: str,
|
||||
days: int = 7,
|
||||
ttl: int = 86400, # 24 hours
|
||||
ttl: int = 43200, # 12 hours
|
||||
) -> FetchResult:
|
||||
"""
|
||||
Fetch weather forecast for a city and store in volatile cache.
|
||||
@@ -81,7 +166,7 @@ class VolatileFetchService:
|
||||
user: User identifier
|
||||
city: City name (will be geocoded)
|
||||
days: Number of forecast days (1-16)
|
||||
ttl: Time-to-live in seconds
|
||||
ttl: Time-to-live in seconds (default 12 hours)
|
||||
|
||||
Returns:
|
||||
FetchResult with success status and stored record
|
||||
@@ -92,13 +177,12 @@ class VolatileFetchService:
|
||||
if not location:
|
||||
return FetchResult(
|
||||
success=False,
|
||||
namespace="weather",
|
||||
namespace="forecast",
|
||||
key=city.lower(),
|
||||
error=f"Could not geocode city: {city}"
|
||||
)
|
||||
|
||||
forecast = await self.weather.get_forecast(location, days=days)
|
||||
current = forecast.current
|
||||
|
||||
# Build daily forecast array
|
||||
daily_forecasts = []
|
||||
@@ -116,32 +200,23 @@ class VolatileFetchService:
|
||||
})
|
||||
|
||||
# Generate natural language summary
|
||||
forecast_lines = [current.to_text()]
|
||||
for day in forecast.daily[:5]: # First 5 days
|
||||
forecast_lines = [f"{city} {days}-day forecast:"]
|
||||
for day in forecast.daily:
|
||||
forecast_lines.append(day.to_text())
|
||||
text = "\n".join(forecast_lines)
|
||||
|
||||
# Convert to storage format
|
||||
data = {
|
||||
"current": {
|
||||
"temperature": current.temperature,
|
||||
"feels_like": current.feels_like,
|
||||
"humidity": current.humidity,
|
||||
"wind_speed": current.wind_speed,
|
||||
"wind_direction": current.wind_direction,
|
||||
"conditions": current.condition_text,
|
||||
"condition_code": current.condition.value,
|
||||
"uv_index": current.uv_index,
|
||||
},
|
||||
"days": days,
|
||||
"daily": daily_forecasts,
|
||||
"location": current.location,
|
||||
"location": forecast.current.location,
|
||||
"text": text,
|
||||
}
|
||||
|
||||
# Store in volatile cache
|
||||
record = await self.volatile.store(
|
||||
user=user,
|
||||
namespace=VolatileNamespace.WEATHER,
|
||||
namespace=VolatileNamespace.FORECAST,
|
||||
key=city.lower(),
|
||||
data=data,
|
||||
source="openmeteo",
|
||||
@@ -151,16 +226,16 @@ class VolatileFetchService:
|
||||
logger.info(f"Stored {days}-day forecast for {city} (user={user})")
|
||||
return FetchResult(
|
||||
success=True,
|
||||
namespace="weather",
|
||||
namespace="forecast",
|
||||
key=city.lower(),
|
||||
record=record
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch weather for {city}: {e}")
|
||||
logger.error(f"Failed to fetch forecast for {city}: {e}")
|
||||
return FetchResult(
|
||||
success=False,
|
||||
namespace="weather",
|
||||
namespace="forecast",
|
||||
key=city.lower(),
|
||||
error=str(e)
|
||||
)
|
||||
@@ -532,3 +607,130 @@ class VolatileFetchService:
|
||||
key=city.lower(),
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
async def fetch_environment(
|
||||
self,
|
||||
user: str,
|
||||
city: str,
|
||||
weather_ttl: int = 3600,
|
||||
air_quality_ttl: int = 3600,
|
||||
) -> EnvironmentFetchResult:
|
||||
"""
|
||||
Fetch weather and air quality concurrently for a city.
|
||||
|
||||
Performs a single geocode lookup and fetches both weather and air quality
|
||||
data in parallel, storing both in volatile cache.
|
||||
|
||||
Args:
|
||||
user: User identifier
|
||||
city: City name (will be geocoded once)
|
||||
weather_ttl: TTL for weather data (default 1 hour)
|
||||
air_quality_ttl: TTL for air quality data (default 1 hour)
|
||||
|
||||
Returns:
|
||||
EnvironmentFetchResult with both weather and air quality results
|
||||
"""
|
||||
errors: list[str] = []
|
||||
key = city.lower()
|
||||
|
||||
# Single geocode lookup (shared by both fetches)
|
||||
try:
|
||||
location = await self.weather.geocode(city)
|
||||
if not location:
|
||||
return EnvironmentFetchResult(
|
||||
success=False,
|
||||
key=key,
|
||||
errors=[f"Could not geocode city: {city}"]
|
||||
)
|
||||
except Exception as e:
|
||||
return EnvironmentFetchResult(
|
||||
success=False,
|
||||
key=key,
|
||||
errors=[f"Geocoding failed: {e}"]
|
||||
)
|
||||
|
||||
# Fetch weather and air quality concurrently
|
||||
async def fetch_weather_data() -> FetchResult:
|
||||
try:
|
||||
current = await self.weather.get_current(location)
|
||||
text = current.to_text()
|
||||
data = {
|
||||
"temperature": current.temperature,
|
||||
"feels_like": current.feels_like,
|
||||
"humidity": current.humidity,
|
||||
"wind_speed": current.wind_speed,
|
||||
"wind_direction": current.wind_direction,
|
||||
"conditions": current.condition_text,
|
||||
"condition_code": current.condition.value,
|
||||
"uv_index": current.uv_index,
|
||||
"location": current.location,
|
||||
"text": text,
|
||||
}
|
||||
record = await self.volatile.store(
|
||||
user=user,
|
||||
namespace=VolatileNamespace.WEATHER,
|
||||
key=key,
|
||||
data=data,
|
||||
source="openmeteo",
|
||||
ttl=weather_ttl,
|
||||
)
|
||||
return FetchResult(success=True, namespace="weather", key=key, record=record)
|
||||
except Exception as e:
|
||||
return FetchResult(success=False, namespace="weather", key=key, error=str(e))
|
||||
|
||||
async def fetch_air_quality_data() -> FetchResult:
|
||||
try:
|
||||
air_quality = await self.weather.get_air_quality(location)
|
||||
data = {
|
||||
"location": air_quality.location,
|
||||
"aqi_european": air_quality.aqi_european,
|
||||
"aqi_us": air_quality.aqi_us,
|
||||
"pm2_5": air_quality.pm2_5,
|
||||
"pm10": air_quality.pm10,
|
||||
"ozone": air_quality.ozone,
|
||||
"nitrogen_dioxide": air_quality.nitrogen_dioxide,
|
||||
"sulphur_dioxide": air_quality.sulphur_dioxide,
|
||||
"carbon_monoxide": air_quality.carbon_monoxide,
|
||||
"pollen_grass": air_quality.pollen_grass,
|
||||
"pollen_birch": air_quality.pollen_birch,
|
||||
"pollen_alder": air_quality.pollen_alder,
|
||||
"text": air_quality.to_text(),
|
||||
}
|
||||
record = await self.volatile.store(
|
||||
user=user,
|
||||
namespace=VolatileNamespace.AIR_QUALITY,
|
||||
key=key,
|
||||
data=data,
|
||||
source="openmeteo",
|
||||
ttl=air_quality_ttl,
|
||||
)
|
||||
return FetchResult(success=True, namespace="air_quality", key=key, record=record)
|
||||
except Exception as e:
|
||||
return FetchResult(success=False, namespace="air_quality", key=key, error=str(e))
|
||||
|
||||
# Run both fetches concurrently
|
||||
weather_result, air_quality_result = await asyncio.gather(
|
||||
fetch_weather_data(),
|
||||
fetch_air_quality_data(),
|
||||
)
|
||||
|
||||
# Collect any errors
|
||||
if not weather_result.success:
|
||||
errors.append(f"Weather: {weather_result.error}")
|
||||
if not air_quality_result.success:
|
||||
errors.append(f"Air quality: {air_quality_result.error}")
|
||||
|
||||
success = weather_result.success or air_quality_result.success
|
||||
logger.info(
|
||||
f"Environment fetch for {city} (user={user}): "
|
||||
f"weather={'ok' if weather_result.success else 'failed'}, "
|
||||
f"air_quality={'ok' if air_quality_result.success else 'failed'}"
|
||||
)
|
||||
|
||||
return EnvironmentFetchResult(
|
||||
success=success,
|
||||
key=key,
|
||||
weather=weather_result,
|
||||
air_quality=air_quality_result,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -98,7 +98,7 @@ class TestVolatileNamespaces:
|
||||
|
||||
def test_weather_default_ttl(self):
|
||||
"""Test weather namespace default TTL."""
|
||||
assert NAMESPACE_DEFAULT_TTL[VolatileNamespace.WEATHER] == 1800 # 30 min
|
||||
assert NAMESPACE_DEFAULT_TTL[VolatileNamespace.WEATHER] == 3600 # 1 hour (current conditions)
|
||||
|
||||
def test_financial_default_ttl(self):
|
||||
"""Test financial namespace default TTL."""
|
||||
@@ -110,7 +110,7 @@ class TestVolatileNamespaces:
|
||||
|
||||
def test_namespace_count(self):
|
||||
"""Test we have the expected number of namespaces."""
|
||||
assert len(VolatileNamespace) == 12 # Including SUN for sunrise/sunset
|
||||
assert len(VolatileNamespace) == 13 # Including SUN, FORECAST
|
||||
|
||||
|
||||
class TestVolatileListResponse:
|
||||
@@ -274,7 +274,7 @@ class TestVolatileService:
|
||||
def test_get_default_ttl_known_namespace(self, volatile_service):
|
||||
"""Test default TTL for known namespace."""
|
||||
ttl = volatile_service._get_default_ttl("weather")
|
||||
assert ttl == 1800 # Weather namespace default
|
||||
assert ttl == 3600 # Weather namespace default (1 hour)
|
||||
|
||||
def test_get_default_ttl_unknown_namespace(self, volatile_service):
|
||||
"""Test default TTL for unknown namespace."""
|
||||
@@ -366,7 +366,7 @@ class TestVolatileService:
|
||||
ttl=None # Not specified
|
||||
)
|
||||
|
||||
assert result.ttl == 1800 # Weather default
|
||||
assert result.ttl == 3600 # Weather default (1 hour)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_empty_collection(self, volatile_service, mock_qdrant, mock_ollama):
|
||||
|
||||
Reference in New Issue
Block a user