Files
library-desk/src/routers/maintenance.py
T
jpmschweitzerandClaude Opus 4.5 983a934b85 feat: add Paperless orphan cleanup endpoint
- POST /maintenance/cleanup/paperless - detect and clean orphaned Paperless documents
- Checks indexed documents against Paperless API
- Removes vectors and graph nodes for deleted documents
- Supports dry_run mode for preview

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 17:00:56 +01:00

1092 lines
38 KiB
Python

"""
Maintenance router for Library Desk cleanup operations.
Provides endpoints to clean up orphaned data in vectors and graph:
- Orphan vector chunks (no matching page/document in graph)
- Orphan entities (no MENTIONS relationships)
- Stale documents (graph nodes with no matching wiki page)
- Broken relationships
"""
from fastapi import APIRouter, HTTPException, Depends, Query
from pydantic import BaseModel, Field
from typing import Optional, List, Dict, Any
import logging
import time
from src.services.vector_service import VectorService
from src.services.graph_service import GraphService
from src.services.volatile_service import VolatileCacheService
from src.core.dependencies import (
VectorServiceDep, GraphServiceDep, WikiJSDep, RedisDep,
QdrantDep, OllamaDep, PaperlessDep, verify_api_key
)
from src.config import get_settings
from src.core.multi_tenancy import DEFAULT_USER
from datetime import datetime, timezone
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/maintenance", tags=["Maintenance"])
# Redis key for tracking last cleanup timestamp
LAST_CLEANUP_KEY = "library:maintenance:last_cleanup:{user}"
async def _get_last_cleanup(redis, user: str) -> Optional[str]:
"""Get last cleanup timestamp from Redis."""
try:
key = LAST_CLEANUP_KEY.format(user=user)
return await redis.get(key)
except Exception as e:
logger.warning(f"Failed to get last cleanup timestamp: {e}")
return None
async def _set_last_cleanup(redis, user: str) -> None:
"""Store current timestamp as last cleanup time."""
try:
key = LAST_CLEANUP_KEY.format(user=user)
timestamp = datetime.now(timezone.utc).isoformat()
# Keep for 30 days
await redis.setex(key, 86400 * 30, timestamp)
logger.info(f"Recorded cleanup timestamp: {timestamp}")
except Exception as e:
logger.warning(f"Failed to store cleanup timestamp: {e}")
async def _find_unindexed_pages(
wiki_pages: List[Dict],
chunk_refs: List[Dict],
graph_docs: List[Dict]
) -> tuple[List[int], List[int]]:
"""
Find wiki pages that are missing from vectors or graph.
Returns:
Tuple of (pages_without_vectors, pages_without_graph)
"""
# Build sets of indexed page IDs
vectorized_page_ids = {
ref.get("page_id") for ref in chunk_refs
if ref.get("doc_type") == "wiki" and ref.get("page_id")
}
graphed_page_ids = {
doc.get("page_id") for doc in graph_docs
if doc.get("doc_type") == "wiki" and doc.get("page_id")
}
# Find wiki pages missing from each store
pages_without_vectors = []
pages_without_graph = []
for page in wiki_pages:
page_id = page.get("id")
if not page_id:
continue
if page_id not in vectorized_page_ids:
pages_without_vectors.append(page_id)
if page_id not in graphed_page_ids:
pages_without_graph.append(page_id)
return pages_without_vectors, pages_without_graph
async def _reindex_missing_pages(
page_ids: List[int],
user: str,
vector_service,
graph_service
) -> tuple[int, int, List[int]]:
"""
Reindex pages that are missing from vectors or graph.
Returns:
Tuple of (pages_reindexed, pages_failed, failed_page_ids)
"""
reindexed = 0
failed = 0
failed_ids = []
for page_id in page_ids:
try:
# Index to both stores
vector_result = await vector_service.update_from_page(page_id, user, force_refresh=True)
graph_result = await graph_service.update_from_page(page_id, user, force_refresh=True)
if vector_result.success and graph_result.success:
reindexed += 1
logger.info(f"Reindexed missing page {page_id}")
else:
failed += 1
failed_ids.append(page_id)
logger.warning(f"Failed to reindex page {page_id}: vector={vector_result.success}, graph={graph_result.success}")
except Exception as e:
failed += 1
failed_ids.append(page_id)
logger.error(f"Error reindexing page {page_id}: {e}")
return reindexed, failed, failed_ids
# ========== Response Models ==========
class CleanupResult(BaseModel):
"""Result of a cleanup operation."""
orphans_found: int = Field(default=0, description="Number of orphans detected")
orphans_purged: int = Field(default=0, description="Number of orphans deleted")
duration_ms: float = Field(description="Operation duration in milliseconds")
class VectorCleanupResponse(BaseModel):
"""Response from vector cleanup operation."""
success: bool
wiki_chunks: CleanupResult
document_chunks: CleanupResult
chunks_without_graph: CleanupResult # Vectors with no graph node
total_chunks_scanned: int
total_orphans_purged: int
duration_ms: float
class GraphCleanupResponse(BaseModel):
"""Response from graph cleanup operation."""
success: bool
orphan_entities: CleanupResult
stale_wiki_documents: CleanupResult
stale_store_documents: CleanupResult
docs_without_vectors: CleanupResult # Graph nodes with no vectors
broken_relationships_cleaned: int
duration_ms: float
class FullCleanupResponse(BaseModel):
"""Response from full cleanup operation."""
success: bool
vector_cleanup: VectorCleanupResponse
graph_cleanup: GraphCleanupResponse
total_duration_ms: float
class HealthCheckResponse(BaseModel):
"""Response from maintenance health check."""
status: str = Field(description="Health status: healthy, degraded, or unhealthy")
orphan_vector_count: int = Field(description="Number of orphan vector chunks (no source)")
orphan_entity_count: int = Field(description="Number of orphan entities")
stale_document_count: int = Field(description="Number of stale document nodes")
vectors_without_graph: int = Field(default=0, description="Vector chunks with no graph node")
docs_without_vectors: int = Field(default=0, description="Graph docs with no vectors")
unindexed_pages: int = Field(default=0, description="Wiki pages missing from indexes")
last_cleanup: Optional[str] = Field(default=None, description="Timestamp of last cleanup")
recommendations: List[str] = Field(default_factory=list)
class ReindexResponse(BaseModel):
"""Response from reindex operation."""
success: bool
page_id: int
vectors_deleted: int
vectors_created: int
graph_updated: bool
duration_ms: float
error: Optional[str] = None
class ReindexMissingResult(BaseModel):
"""Result of reindexing missing pages."""
pages_without_vectors: int = Field(description="Wiki pages with no vector embeddings")
pages_without_graph: int = Field(description="Wiki pages with no graph Document node")
pages_reindexed: int = Field(description="Pages successfully reindexed")
pages_failed: int = Field(description="Pages that failed to reindex")
failed_page_ids: List[int] = Field(default_factory=list)
duration_ms: float
class ReconcileIndexResponse(BaseModel):
"""Response from reconcile-index operation (cleanup + reindex-missing)."""
success: bool
cleanup: FullCleanupResponse
reindex_missing: ReindexMissingResult
total_duration_ms: float
class VolatileCleanupResponse(BaseModel):
"""Response from volatile cache cleanup operation."""
success: bool
collections_processed: int
total_expired_purged: int
by_collection: Dict[str, int] = Field(default_factory=dict)
duration_ms: float
class TestDataCleanupResponse(BaseModel):
"""Response from test data cleanup operation."""
success: bool
dry_run: bool
wiki_pages_deleted: int
graph_nodes_deleted: int
vector_chunks_deleted: int
pages_found: List[Dict[str, Any]] = Field(default_factory=list)
duration_ms: float
class PaperlessCleanupResponse(BaseModel):
"""Response from Paperless orphan cleanup operation."""
success: bool
dry_run: bool
paperless_ids_checked: int = Field(description="Total Paperless IDs found in indexes")
orphans_found: int = Field(description="Documents deleted from Paperless but still indexed")
orphan_ids: List[int] = Field(default_factory=list, description="Paperless IDs that are orphans")
vector_chunks_deleted: int = Field(description="Vector chunks removed")
graph_nodes_deleted: int = Field(description="Graph Document nodes removed")
duration_ms: float
# Test data path patterns - restricted to test user namespace only
# These are the only paths that can be cleaned up for safety
TEST_USER_PATH_PREFIXES = [
"users/llm-tester/",
"users/llm_tester/",
]
def _matches_test_user_path(path: str) -> bool:
"""Check if a path is in the test user namespace.
Only matches paths that START with test user prefixes for safety.
This prevents accidental deletion of non-test data.
"""
path_lower = path.lower()
return any(path_lower.startswith(prefix) for prefix in TEST_USER_PATH_PREFIXES)
# ========== Endpoints ==========
@router.post("/cleanup/vectors", response_model=VectorCleanupResponse)
async def cleanup_vectors(
user: str = Query(..., description="User identifier"),
dry_run: bool = Query(False, description="If true, only count orphans without deleting"),
vector_service: VectorServiceDep = None,
graph_service: GraphServiceDep = None,
wiki_client: WikiJSDep = None,
api_key: str = Depends(verify_api_key)
):
"""
Find and purge orphan vector chunks.
Orphan chunks are vector embeddings that reference:
- Wiki pages that no longer exist
- Document Store documents that no longer exist
- Chunks with no corresponding graph Document node (bidirectional check)
**Scheduler Task** - Recommended to run daily.
"""
start_time = time.time()
try:
# Get all vector chunk references
chunk_refs = await vector_service.get_all_chunk_references(user)
total_scanned = len(chunk_refs)
# Get all valid page IDs from wiki
wiki_pages = await wiki_client.list_all_pages()
valid_page_ids = {p.get("id") for p in wiki_pages if p.get("id")}
# Get all valid document references from graph
graph_docs = await graph_service.get_all_document_references(user)
valid_doc_ids = {d["document_id"] for d in graph_docs if d.get("document_id")}
# Find orphan wiki chunks (page_id not in wiki)
wiki_orphan_ids = []
doc_orphan_ids = []
for ref in chunk_refs:
doc_type = ref.get("doc_type", "wiki")
if doc_type == "wiki":
page_id = ref.get("page_id")
if page_id and page_id not in valid_page_ids:
wiki_orphan_ids.append(ref["chunk_id"])
else:
document_id = ref.get("document_id")
if document_id and document_id not in valid_doc_ids:
doc_orphan_ids.append(ref["chunk_id"])
# Bidirectional check: chunks with no graph node
chunks_without_graph = vector_service.find_chunks_without_graph_nodes(
chunk_refs, graph_docs
)
# Purge orphans if not dry run
wiki_purged = 0
doc_purged = 0
graph_orphans_purged = 0
if not dry_run:
if wiki_orphan_ids:
wiki_purged = await vector_service.purge_chunks_by_ids(user, wiki_orphan_ids)
if doc_orphan_ids:
doc_purged = await vector_service.purge_chunks_by_ids(user, doc_orphan_ids)
if chunks_without_graph:
graph_orphans_purged = await vector_service.purge_chunks_by_ids(
user, chunks_without_graph
)
duration_ms = (time.time() - start_time) * 1000
return VectorCleanupResponse(
success=True,
wiki_chunks=CleanupResult(
orphans_found=len(wiki_orphan_ids),
orphans_purged=wiki_purged,
duration_ms=duration_ms / 3
),
document_chunks=CleanupResult(
orphans_found=len(doc_orphan_ids),
orphans_purged=doc_purged,
duration_ms=duration_ms / 3
),
chunks_without_graph=CleanupResult(
orphans_found=len(chunks_without_graph),
orphans_purged=graph_orphans_purged,
duration_ms=duration_ms / 3
),
total_chunks_scanned=total_scanned,
total_orphans_purged=wiki_purged + doc_purged + graph_orphans_purged,
duration_ms=duration_ms
)
except Exception as e:
logger.error(f"Vector cleanup failed: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/cleanup/graph", response_model=GraphCleanupResponse)
async def cleanup_graph(
user: str = Query(..., description="User identifier"),
dry_run: bool = Query(False, description="If true, only count orphans without deleting"),
vector_service: VectorServiceDep = None,
graph_service: GraphServiceDep = None,
wiki_client: WikiJSDep = None,
api_key: str = Depends(verify_api_key)
):
"""
Find and purge orphan entities and stale documents from the graph.
Cleans up:
- Orphan entities (no MENTIONS relationships)
- Stale wiki Document nodes (page deleted from Wiki.js)
- Stale Document Store nodes (document deleted)
- Graph Document nodes with no corresponding vectors (bidirectional check)
- Broken FOUND relationships from SearchQuery nodes
"""
start_time = time.time()
try:
# 1. Find orphan entities
orphan_entities = await graph_service.find_orphan_entities(user)
entities_purged = 0
if not dry_run and orphan_entities:
entities_purged = await graph_service.purge_orphan_entities(user)
# 2. Find stale wiki documents
graph_docs = await graph_service.get_all_document_references(user)
wiki_docs = [d for d in graph_docs if d.get("doc_type") == "wiki" and d.get("page_id")]
# Get valid wiki page IDs
wiki_pages = await wiki_client.list_all_pages()
valid_page_ids = {p.get("id") for p in wiki_pages if p.get("id")}
stale_wiki_ids = [d["page_id"] for d in wiki_docs if d["page_id"] not in valid_page_ids]
wiki_docs_purged = 0
if not dry_run and stale_wiki_ids:
wiki_docs_purged = await graph_service.purge_stale_documents_by_ids(
user, page_ids=stale_wiki_ids
)
# 3. Find stale Document Store documents (these would be detected differently)
# For now, Document Store docs are only stale if the collection is deleted
# This will be more relevant once DocumentService exists
stale_store_docs = 0
store_docs_purged = 0
# 4. Bidirectional check: graph docs with no vectors
chunk_refs = await vector_service.get_all_chunk_references(user)
docs_without_vectors = await graph_service.find_documents_without_vectors(
user, chunk_refs
)
docs_without_vectors_purged = 0
if not dry_run and docs_without_vectors:
# Purge wiki docs without vectors
wiki_orphans = [d["page_id"] for d in docs_without_vectors
if d.get("doc_type") == "wiki" and d.get("page_id")]
doc_orphans = [d["document_id"] for d in docs_without_vectors
if d.get("doc_type") != "wiki" and d.get("document_id")]
if wiki_orphans:
docs_without_vectors_purged += await graph_service.purge_stale_documents_by_ids(
user, page_ids=wiki_orphans
)
if doc_orphans:
docs_without_vectors_purged += await graph_service.purge_stale_documents_by_ids(
user, document_ids=doc_orphans
)
# 5. Clean broken relationships
broken_rels_cleaned = 0
if not dry_run:
broken_rels_cleaned = await graph_service.cleanup_broken_relationships(user)
duration_ms = (time.time() - start_time) * 1000
return GraphCleanupResponse(
success=True,
orphan_entities=CleanupResult(
orphans_found=len(orphan_entities),
orphans_purged=entities_purged,
duration_ms=duration_ms / 5
),
stale_wiki_documents=CleanupResult(
orphans_found=len(stale_wiki_ids),
orphans_purged=wiki_docs_purged,
duration_ms=duration_ms / 5
),
stale_store_documents=CleanupResult(
orphans_found=stale_store_docs,
orphans_purged=store_docs_purged,
duration_ms=duration_ms / 5
),
docs_without_vectors=CleanupResult(
orphans_found=len(docs_without_vectors),
orphans_purged=docs_without_vectors_purged,
duration_ms=duration_ms / 5
),
broken_relationships_cleaned=broken_rels_cleaned,
duration_ms=duration_ms
)
except Exception as e:
logger.error(f"Graph cleanup failed: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/cleanup/all", response_model=FullCleanupResponse)
async def cleanup_all(
user: str = Query(..., description="User identifier"),
dry_run: bool = Query(False, description="If true, only count orphans without deleting"),
vector_service: VectorServiceDep = None,
graph_service: GraphServiceDep = None,
wiki_client: WikiJSDep = None,
redis: RedisDep = None,
api_key: str = Depends(verify_api_key)
):
"""
Full cleanup of vectors and graph.
Runs both vector and graph cleanup in sequence.
**Scheduler Task** - Recommended to run daily at low-traffic time.
**Scheduler Integration:**
```json
{
"task_name": "library_maintenance",
"schedule": "0 4 * * *",
"endpoint": "POST /maintenance/cleanup/all?user=jpmschweitzer",
"description": "Daily cleanup of orphan vectors and graph nodes"
}
```
"""
start_time = time.time()
try:
# Run vector cleanup
vector_result = await cleanup_vectors(
user=user,
dry_run=dry_run,
vector_service=vector_service,
graph_service=graph_service,
wiki_client=wiki_client,
api_key=api_key
)
# Run graph cleanup
graph_result = await cleanup_graph(
user=user,
dry_run=dry_run,
vector_service=vector_service,
graph_service=graph_service,
wiki_client=wiki_client,
api_key=api_key
)
total_duration_ms = (time.time() - start_time) * 1000
# Record cleanup timestamp (only if not dry run)
if not dry_run and redis:
await _set_last_cleanup(redis, user)
return FullCleanupResponse(
success=True,
vector_cleanup=vector_result,
graph_cleanup=graph_result,
total_duration_ms=total_duration_ms
)
except Exception as e:
logger.error(f"Full cleanup failed: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/cleanup/volatile", response_model=VolatileCleanupResponse)
async def cleanup_volatile(
qdrant: QdrantDep = None,
ollama: OllamaDep = None,
api_key: str = Depends(verify_api_key)
):
"""
Purge expired volatile cache records across all users.
Loops through all volatile_* collections and removes records where
ttl_expiry < current_timestamp.
**Scheduler Task** - Recommended to run every 10 minutes.
**Scheduler Integration:**
```json
{
"task_name": "volatile_cleanup",
"schedule": "*/10 * * * *",
"endpoint": "POST /maintenance/cleanup/volatile",
"description": "Purge expired volatile cache records"
}
```
"""
start_time = time.time()
try:
settings = get_settings()
service = VolatileCacheService(
qdrant_client=qdrant,
ollama_client=ollama,
settings=settings
)
# Purge expired from all volatile collections
results = await service.purge_all_expired()
total_purged = sum(results.values())
duration_ms = (time.time() - start_time) * 1000
logger.info(f"Volatile cleanup complete: {total_purged} expired records purged from {len(results)} collections")
return VolatileCleanupResponse(
success=True,
collections_processed=len(results),
total_expired_purged=total_purged,
by_collection=results,
duration_ms=duration_ms
)
except Exception as e:
logger.error(f"Volatile cleanup failed: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/cleanup/test-data", response_model=TestDataCleanupResponse)
async def cleanup_test_data(
dry_run: bool = Query(default=True, description="Preview only, don't delete"),
wiki: WikiJSDep = None,
vector_service: VectorServiceDep = None,
graph_service: GraphServiceDep = None,
api_key: str = Depends(verify_api_key)
):
"""
Purge LLM tester data from wiki, graph, and vectors.
**Security**: Only deletes pages in the test user namespace:
- users/llm-tester/*
- users/llm_tester/*
This endpoint cannot delete data outside these paths.
**Use dry_run=true (default) to preview what would be deleted.**
**Scheduler Integration:**
```json
{
"task_name": "test_data_cleanup",
"schedule": "0 3 * * 0",
"endpoint": "POST /maintenance/cleanup/test-data?dry_run=false",
"description": "Weekly cleanup of LLM test data"
}
```
"""
start_time = time.time()
try:
# List all wiki pages
all_pages = await wiki.list_all_pages(batch_size=500)
# Filter for test user paths only (security: restricted to test namespace)
test_pages = [
{"id": p["id"], "path": p["path"], "title": p.get("title", "")}
for p in all_pages
if _matches_test_user_path(p.get("path", ""))
]
logger.info(f"Found {len(test_pages)} test pages matching patterns: {TEST_USER_PATH_PREFIXES}")
wiki_deleted = 0
graph_deleted = 0
vector_deleted = 0
if not dry_run and test_pages:
for page in test_pages:
page_id = page["id"]
page_path = page["path"]
try:
# Delete vector chunks for this page (using DEFAULT_USER collection)
chunks_removed = await vector_service.delete_page_chunks(page_id, DEFAULT_USER)
vector_deleted += chunks_removed
# Delete graph node for this page (returns count, may be 0 if no node)
graph_removed = await graph_service.delete_page(page_id, DEFAULT_USER)
graph_deleted += graph_removed
# Delete wiki page (raises exception on failure, returns None on success)
await wiki.delete_page(page_id)
wiki_deleted += 1
logger.info(f"Deleted test page: {page_path} (id={page_id})")
except Exception as e:
logger.error(f"Failed to delete page {page_path}: {e}")
continue
duration_ms = (time.time() - start_time) * 1000
return TestDataCleanupResponse(
success=True,
dry_run=dry_run,
wiki_pages_deleted=wiki_deleted,
graph_nodes_deleted=graph_deleted,
vector_chunks_deleted=vector_deleted,
pages_found=test_pages,
duration_ms=duration_ms
)
except Exception as e:
logger.error(f"Test data cleanup failed: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/cleanup/paperless", response_model=PaperlessCleanupResponse)
async def cleanup_paperless_orphans(
user: str = Query(..., description="User identifier"),
dry_run: bool = Query(default=True, description="Preview only, don't delete"),
vector_service: VectorServiceDep = None,
graph_service: GraphServiceDep = None,
paperless: PaperlessDep = None,
api_key: str = Depends(verify_api_key)
):
"""
Find and clean up Paperless document orphans.
Detects documents that were indexed in Library Desk but have since been
deleted from Paperless-ngx. Removes orphaned vectors and graph nodes.
**Use dry_run=true (default) to preview what would be deleted.**
**Scheduler Integration:**
```json
{
"task_name": "paperless_orphan_cleanup",
"schedule": "0 5 * * *",
"endpoint": "POST /maintenance/cleanup/paperless?user=jpmschweitzer&dry_run=false",
"description": "Daily cleanup of orphaned Paperless documents"
}
```
"""
start_time = time.time()
try:
settings = get_settings()
if not settings.paperless_token:
raise HTTPException(status_code=503, detail="Paperless not configured")
# Get all document chunks from vectors with doc_type="document"
chunk_refs = await vector_service.get_all_chunk_references(user)
doc_chunks = [ref for ref in chunk_refs if ref.get("doc_type") == "document"]
# Extract unique paperless_ids
paperless_ids = list(set(
ref.get("paperless_id") for ref in doc_chunks
if ref.get("paperless_id")
))
logger.info(f"Found {len(paperless_ids)} unique Paperless IDs in indexes")
# Check each against Paperless API
orphan_ids = []
for pid in paperless_ids:
try:
doc = await paperless.get_document(pid)
if doc is None:
orphan_ids.append(pid)
except Exception as e:
# Document not found or API error - treat as orphan
logger.debug(f"Paperless document {pid} not found: {e}")
orphan_ids.append(pid)
logger.info(f"Found {len(orphan_ids)} orphaned Paperless documents")
# Delete orphans if not dry run
vectors_deleted = 0
graph_deleted = 0
if not dry_run and orphan_ids:
for pid in orphan_ids:
try:
# Delete vector chunks for this paperless_id
chunks_removed = await vector_service.delete_paperless_document_chunks(pid, user)
vectors_deleted += chunks_removed
# Delete graph node for this paperless_id
graph_removed = await graph_service.delete_paperless_document(pid, user)
graph_deleted += graph_removed
logger.info(f"Cleaned up orphaned Paperless document {pid}: {chunks_removed} chunks, {graph_removed} nodes")
except Exception as e:
logger.error(f"Failed to cleanup Paperless document {pid}: {e}")
duration_ms = (time.time() - start_time) * 1000
return PaperlessCleanupResponse(
success=True,
dry_run=dry_run,
paperless_ids_checked=len(paperless_ids),
orphans_found=len(orphan_ids),
orphan_ids=orphan_ids,
vector_chunks_deleted=vectors_deleted,
graph_nodes_deleted=graph_deleted,
duration_ms=duration_ms
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Paperless orphan cleanup failed: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/health", response_model=HealthCheckResponse)
async def maintenance_health(
user: str = Query(..., description="User identifier"),
detailed: bool = Query(False, description="If true, run full orphan analysis (slower)"),
vector_service: VectorServiceDep = None,
graph_service: GraphServiceDep = None,
wiki_client: WikiJSDep = None,
redis: RedisDep = None,
api_key: str = Depends(verify_api_key)
):
"""
Health check for maintenance status.
**Lightweight mode (default)**: Returns last cleanup timestamp and basic status.
Use for frequent uptime checks (every 30s).
**Detailed mode (?detailed=true)**: Runs full orphan/unindexed analysis.
Use for dashboards or before running reconcile-index.
"""
try:
# Get last cleanup timestamp from Redis (lightweight)
last_cleanup = None
if redis:
last_cleanup = await _get_last_cleanup(redis, user)
# Lightweight mode - just return basic status
if not detailed:
return HealthCheckResponse(
status="healthy" if last_cleanup else "unknown",
orphan_vector_count=0,
orphan_entity_count=0,
stale_document_count=0,
vectors_without_graph=0,
docs_without_vectors=0,
unindexed_pages=0,
last_cleanup=last_cleanup,
recommendations=[] if last_cleanup else ["No cleanup recorded. Run POST /maintenance/reconcile-index"]
)
# Detailed mode - full analysis
recommendations = []
# Count orphan vector chunks
chunk_refs = await vector_service.get_all_chunk_references(user)
wiki_pages = await wiki_client.list_all_pages()
valid_page_ids = {p.get("id") for p in wiki_pages if p.get("id")}
orphan_vector_count = sum(
1 for ref in chunk_refs
if ref.get("doc_type") == "wiki"
and ref.get("page_id") not in valid_page_ids
)
if orphan_vector_count > 10:
recommendations.append(
f"Found {orphan_vector_count} orphan vector chunks. "
"Consider running POST /maintenance/cleanup/vectors"
)
# Count orphan entities
orphan_entities = await graph_service.find_orphan_entities(user)
orphan_entity_count = len(orphan_entities)
if orphan_entity_count > 5:
recommendations.append(
f"Found {orphan_entity_count} orphan entities. "
"Consider running POST /maintenance/cleanup/graph"
)
# Count stale documents
graph_docs = await graph_service.get_all_document_references(user)
wiki_docs = [d for d in graph_docs if d.get("doc_type") == "wiki" and d.get("page_id")]
stale_document_count = sum(1 for d in wiki_docs if d["page_id"] not in valid_page_ids)
if stale_document_count > 0:
recommendations.append(
f"Found {stale_document_count} stale Document nodes. "
"Consider running POST /maintenance/cleanup/graph"
)
# Bidirectional: vectors without graph nodes
vectors_without_graph = len(vector_service.find_chunks_without_graph_nodes(
chunk_refs, graph_docs
))
if vectors_without_graph > 5:
recommendations.append(
f"Found {vectors_without_graph} vectors without graph nodes. "
"Consider running POST /maintenance/cleanup/vectors"
)
# Bidirectional: graph docs without vectors
docs_without_vectors_list = await graph_service.find_documents_without_vectors(
user, chunk_refs
)
docs_without_vectors = len(docs_without_vectors_list)
if docs_without_vectors > 5:
recommendations.append(
f"Found {docs_without_vectors} graph docs without vectors. "
"Consider running POST /maintenance/cleanup/graph"
)
# Unindexed pages: wiki pages missing from vectors or graph
pages_without_vectors, pages_without_graph = await _find_unindexed_pages(
wiki_pages, chunk_refs, graph_docs
)
unindexed_pages = len(set(pages_without_vectors + pages_without_graph))
if unindexed_pages > 0:
recommendations.append(
f"Found {unindexed_pages} wiki pages not in indexes. "
"Consider running POST /maintenance/reconcile-index"
)
# Determine overall status
total_issues = (orphan_vector_count + orphan_entity_count + stale_document_count +
vectors_without_graph + docs_without_vectors + unindexed_pages)
if total_issues == 0:
status = "healthy"
elif total_issues < 20:
status = "degraded"
else:
status = "unhealthy"
# Get last cleanup timestamp from Redis
last_cleanup = None
if redis:
last_cleanup = await _get_last_cleanup(redis, user)
return HealthCheckResponse(
status=status,
orphan_vector_count=orphan_vector_count,
orphan_entity_count=orphan_entity_count,
stale_document_count=stale_document_count,
vectors_without_graph=vectors_without_graph,
docs_without_vectors=docs_without_vectors,
unindexed_pages=unindexed_pages,
last_cleanup=last_cleanup,
recommendations=recommendations
)
except Exception as e:
logger.error(f"Health check failed: {e}", exc_info=True)
return HealthCheckResponse(
status="unhealthy",
orphan_vector_count=-1,
orphan_entity_count=-1,
stale_document_count=-1,
vectors_without_graph=-1,
docs_without_vectors=-1,
unindexed_pages=-1,
recommendations=[f"Health check failed: {str(e)}"]
)
@router.post("/reindex/{page_id}", response_model=ReindexResponse)
async def reindex_page(
page_id: int,
user: str = Query(..., description="User identifier"),
vector_service: VectorServiceDep = None,
graph_service: GraphServiceDep = None,
api_key: str = Depends(verify_api_key)
):
"""
Force re-index a wiki page.
Deletes existing vectors and graph data, then re-ingests.
Useful for fixing corrupted or stale data for a specific page.
"""
start_time = time.time()
try:
# Delete existing vectors
vectors_deleted = await vector_service.delete_page_chunks(page_id, user)
# Delete and recreate graph node
await graph_service.delete_page(page_id, user)
# Re-ingest
vector_result = await vector_service.update_from_page(page_id, user, force_refresh=True)
graph_result = await graph_service.update_from_page(page_id, user, force_refresh=True)
duration_ms = (time.time() - start_time) * 1000
return ReindexResponse(
success=vector_result.success and graph_result.success,
page_id=page_id,
vectors_deleted=vectors_deleted,
vectors_created=vector_result.chunks_created,
graph_updated=graph_result.success,
duration_ms=duration_ms,
error=vector_result.error_message or graph_result.error_message
)
except Exception as e:
duration_ms = (time.time() - start_time) * 1000
logger.error(f"Reindex failed for page {page_id}: {e}", exc_info=True)
return ReindexResponse(
success=False,
page_id=page_id,
vectors_deleted=0,
vectors_created=0,
graph_updated=False,
duration_ms=duration_ms,
error=str(e)
)
@router.post("/reconcile-index", response_model=ReconcileIndexResponse)
async def reconcile_index(
user: str = Query(..., description="User identifier"),
dry_run: bool = Query(False, description="If true, only detect issues without fixing"),
vector_service: VectorServiceDep = None,
graph_service: GraphServiceDep = None,
wiki_client: WikiJSDep = None,
redis: RedisDep = None,
api_key: str = Depends(verify_api_key)
):
"""
Full index reconciliation: cleanup orphans + reindex missing pages.
This is the recommended daily maintenance endpoint. It:
1. Cleans up orphan vectors and graph nodes (data without sources)
2. Reindexes wiki pages that are missing from vectors or graph
**Scheduler Task** - Recommended to run daily at low-traffic time.
**Scheduler Integration:**
```json
{
"task_name": "library_reconcile_index",
"schedule": "0 4 * * *",
"endpoint": "POST /maintenance/reconcile-index?user=jpmschweitzer",
"description": "Daily index reconciliation - cleanup + reindex missing"
}
```
"""
start_time = time.time()
try:
# Phase 1: Run full cleanup
cleanup_result = await cleanup_all(
user=user,
dry_run=dry_run,
vector_service=vector_service,
graph_service=graph_service,
wiki_client=wiki_client,
redis=redis,
api_key=api_key
)
# Phase 2: Find and reindex missing pages
reindex_start = time.time()
# Get current state
wiki_pages = await wiki_client.list_all_pages()
chunk_refs = await vector_service.get_all_chunk_references(user)
graph_docs = await graph_service.get_all_document_references(user)
# Find pages missing from indexes
pages_without_vectors, pages_without_graph = await _find_unindexed_pages(
wiki_pages, chunk_refs, graph_docs
)
# Combine unique page IDs that need reindexing
missing_page_ids = list(set(pages_without_vectors + pages_without_graph))
# Reindex missing pages (unless dry run)
reindexed = 0
failed = 0
failed_ids = []
if not dry_run and missing_page_ids:
reindexed, failed, failed_ids = await _reindex_missing_pages(
missing_page_ids, user, vector_service, graph_service
)
reindex_duration = (time.time() - reindex_start) * 1000
total_duration = (time.time() - start_time) * 1000
# Record reconciliation timestamp
if not dry_run and redis:
await _set_last_cleanup(redis, user)
return ReconcileIndexResponse(
success=cleanup_result.success and failed == 0,
cleanup=cleanup_result,
reindex_missing=ReindexMissingResult(
pages_without_vectors=len(pages_without_vectors),
pages_without_graph=len(pages_without_graph),
pages_reindexed=reindexed,
pages_failed=failed,
failed_page_ids=failed_ids,
duration_ms=reindex_duration
),
total_duration_ms=total_duration
)
except Exception as e:
logger.error(f"Reconcile-index failed: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))