Compare commits

...
3 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.5 152b2f28c4 release: v1.6.2 - Stats endpoint, weather/forecast separation
Build and Push / build (release) Successful in 30s
- GET /stats endpoint with Neo4j, Qdrant, Wiki.js, Paperless stats
- Split weather into current (1hr TTL) and forecast (12hr TTL)
- New FORECAST namespace for multi-day outlook

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 12:50:44 +01:00
jpmschweitzerandClaude Opus 4.5 46b9bcd7a0 feat: separate current weather from forecast into distinct namespaces
- Add FORECAST namespace for multi-day outlook (12hr TTL)
- WEATHER namespace now stores only current conditions (1hr TTL)
- Split fetch_weather into fetch_current_weather + fetch_forecast
- Add POST /volatile/fetch/forecast/{city} endpoint
- Different update frequencies for efficient caching

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 12:44:16 +01:00
jpmschweitzerandClaude Opus 4.5 68eb1add3d feat: add GET /stats endpoint with 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

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 12:07:02 +01:00
8 changed files with 251 additions and 44 deletions
+21
View File
@@ -5,6 +5,27 @@ 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/), 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.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 ## [1.6.1] - 2025-12-30
### Added ### Added
-9
View File
@@ -41,12 +41,3 @@ Check for duplicate or highly similar documents using vector similarity and grap
3. Check graph relationships 3. Check graph relationships
4. Return candidates with similarity scores 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
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "library-desk" name = "library-desk"
version = "1.6.1" version = "1.6.2"
description = "Coordination service for The Library system - HybridRAG queries, document ingestion, entity extraction, and knowledge consolidation" description = "Coordination service for The Library system - HybridRAG queries, document ingestion, entity extraction, and knowledge consolidation"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
+88 -1
View File
@@ -18,7 +18,7 @@ from pathlib import Path
from src.config import Settings, get_settings, __version__ from src.config import Settings, get_settings, __version__
from src.core.dependencies import ( 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 from src.core.multi_tenancy import DEFAULT_USER
@@ -85,6 +85,14 @@ class HealthResponse(BaseModel):
services: Dict[str, Any] 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 # Routes
@app.get("/", tags=["Root"]) @app.get("/", tags=["Root"])
async def root() -> Dict[str, str]: 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"]) @app.post("/ingest/check-updates", tags=["Ingestion"])
async def check_updates( async def check_updates(
documents: Dict[str, Any], documents: Dict[str, Any],
+4 -2
View File
@@ -18,7 +18,8 @@ class VolatileNamespace(str, Enum):
Each namespace can have different default TTLs and refresh schedules. Each namespace can have different default TTLs and refresh schedules.
""" """
# Real-time external data # 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 SUN = "sun" # Sunrise, sunset, daylight duration
NEWS = "news" # Headlines, breaking news NEWS = "news" # Headlines, breaking news
FINANCIAL = "financial" # Stock prices, exchange rates, crypto FINANCIAL = "financial" # Stock prices, exchange rates, crypto
@@ -38,7 +39,8 @@ class VolatileNamespace(str, Enum):
# Default TTLs per namespace (in seconds) # Default TTLs per namespace (in seconds)
NAMESPACE_DEFAULT_TTL: Dict[str, int] = { 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.SUN: 86400, # 24 hours - sun times change daily
VolatileNamespace.NEWS: 3600, # 1 hour - news cycles VolatileNamespace.NEWS: 3600, # 1 hour - news cycles
VolatileNamespace.FINANCIAL: 300, # 5 min - markets move fast VolatileNamespace.FINANCIAL: 300, # 5 min - markets move fast
+47 -5
View File
@@ -233,16 +233,16 @@ async def store_volatile(
async def fetch_weather( async def fetch_weather(
city: str, city: str,
user: str = Query(default=DEFAULT_USER, description="User identifier"), 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, qdrant: QdrantDep = None,
ollama: OllamaDep = None, ollama: OllamaDep = None,
api_key: str = Depends(verify_api_key) 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 Stores temperature, humidity, wind, UV index. For forecasts use /fetch/forecast.
and fetches weather from Open-Meteo API. Called by scheduler for hourly prefetch or on-demand.
**Example:** **Example:**
``` ```
@@ -257,7 +257,49 @@ async def fetch_weather(
weather_provider=weather_provider, 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: if not result.success:
raise HTTPException(status_code=500, detail=result.error) raise HTTPException(status_code=500, detail=result.error)
+86 -22
View File
@@ -67,12 +67,86 @@ class VolatileFetchService:
self.news = news_provider self.news = news_provider
self.financial = financial_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, self,
user: str, user: str,
city: str, city: str,
days: int = 7, days: int = 7,
ttl: int = 86400, # 24 hours ttl: int = 43200, # 12 hours
) -> FetchResult: ) -> FetchResult:
""" """
Fetch weather forecast for a city and store in volatile cache. Fetch weather forecast for a city and store in volatile cache.
@@ -81,7 +155,7 @@ class VolatileFetchService:
user: User identifier user: User identifier
city: City name (will be geocoded) city: City name (will be geocoded)
days: Number of forecast days (1-16) days: Number of forecast days (1-16)
ttl: Time-to-live in seconds ttl: Time-to-live in seconds (default 12 hours)
Returns: Returns:
FetchResult with success status and stored record FetchResult with success status and stored record
@@ -92,13 +166,12 @@ class VolatileFetchService:
if not location: if not location:
return FetchResult( return FetchResult(
success=False, success=False,
namespace="weather", namespace="forecast",
key=city.lower(), key=city.lower(),
error=f"Could not geocode city: {city}" error=f"Could not geocode city: {city}"
) )
forecast = await self.weather.get_forecast(location, days=days) forecast = await self.weather.get_forecast(location, days=days)
current = forecast.current
# Build daily forecast array # Build daily forecast array
daily_forecasts = [] daily_forecasts = []
@@ -116,32 +189,23 @@ class VolatileFetchService:
}) })
# Generate natural language summary # Generate natural language summary
forecast_lines = [current.to_text()] forecast_lines = [f"{city} {days}-day forecast:"]
for day in forecast.daily[:5]: # First 5 days for day in forecast.daily:
forecast_lines.append(day.to_text()) forecast_lines.append(day.to_text())
text = "\n".join(forecast_lines) text = "\n".join(forecast_lines)
# Convert to storage format # Convert to storage format
data = { data = {
"current": { "days": days,
"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,
},
"daily": daily_forecasts, "daily": daily_forecasts,
"location": current.location, "location": forecast.current.location,
"text": text, "text": text,
} }
# Store in volatile cache # Store in volatile cache
record = await self.volatile.store( record = await self.volatile.store(
user=user, user=user,
namespace=VolatileNamespace.WEATHER, namespace=VolatileNamespace.FORECAST,
key=city.lower(), key=city.lower(),
data=data, data=data,
source="openmeteo", source="openmeteo",
@@ -151,16 +215,16 @@ class VolatileFetchService:
logger.info(f"Stored {days}-day forecast for {city} (user={user})") logger.info(f"Stored {days}-day forecast for {city} (user={user})")
return FetchResult( return FetchResult(
success=True, success=True,
namespace="weather", namespace="forecast",
key=city.lower(), key=city.lower(),
record=record record=record
) )
except Exception as e: 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( return FetchResult(
success=False, success=False,
namespace="weather", namespace="forecast",
key=city.lower(), key=city.lower(),
error=str(e) error=str(e)
) )
+4 -4
View File
@@ -98,7 +98,7 @@ class TestVolatileNamespaces:
def test_weather_default_ttl(self): def test_weather_default_ttl(self):
"""Test weather namespace default TTL.""" """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): def test_financial_default_ttl(self):
"""Test financial namespace default TTL.""" """Test financial namespace default TTL."""
@@ -110,7 +110,7 @@ class TestVolatileNamespaces:
def test_namespace_count(self): def test_namespace_count(self):
"""Test we have the expected number of namespaces.""" """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: class TestVolatileListResponse:
@@ -274,7 +274,7 @@ class TestVolatileService:
def test_get_default_ttl_known_namespace(self, volatile_service): def test_get_default_ttl_known_namespace(self, volatile_service):
"""Test default TTL for known namespace.""" """Test default TTL for known namespace."""
ttl = volatile_service._get_default_ttl("weather") 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): def test_get_default_ttl_unknown_namespace(self, volatile_service):
"""Test default TTL for unknown namespace.""" """Test default TTL for unknown namespace."""
@@ -366,7 +366,7 @@ class TestVolatileService:
ttl=None # Not specified ttl=None # Not specified
) )
assert result.ttl == 1800 # Weather default assert result.ttl == 3600 # Weather default (1 hour)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_search_empty_collection(self, volatile_service, mock_qdrant, mock_ollama): async def test_search_empty_collection(self, volatile_service, mock_qdrant, mock_ollama):