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/),
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
### Added
-9
View File
@@ -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
View File
@@ -1,6 +1,6 @@
[project]
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"
readme = "README.md"
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.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],
+4 -2
View File
@@ -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
+47 -5
View File
@@ -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)
+86 -22
View File
@@ -67,12 +67,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 +155,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 +166,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 +189,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 +215,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)
)
+4 -4
View File
@@ -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):