feat: add maintenance system with index reconciliation
Complete maintenance subsystem for index health and cleanup: Endpoints: - GET /maintenance/health - lightweight (or detailed) health check - POST /maintenance/cleanup/all - full orphan cleanup - POST /maintenance/cleanup/vectors - purge orphan vector chunks - POST /maintenance/cleanup/graph - purge orphan graph nodes - POST /maintenance/reconcile-index - cleanup + reindex missing pages Bidirectional orphan detection: - find_documents_without_vectors() in GraphService - find_chunks_without_graph_nodes() in VectorService Redis integration: - Tracks last_cleanup timestamp for scheduler visibility Config additions: - Document store, volatile cache, and maintenance settings - VectorServiceDep and GraphServiceDep type aliases 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
+19
-2
@@ -43,8 +43,10 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# Wiki.js Configuration
|
# Wiki.js Configuration
|
||||||
wikijs_url: str = Field(default="http://wiki:3000", description="Wiki.js URL")
|
wikijs_url: str = Field(default="http://wiki:3000", description="Wiki.js URL")
|
||||||
wikijs_username: str = Field(..., description="Wiki.js username")
|
wiki_graphql_api: str = Field(..., description="Wiki.js GraphQL API token (JWT)")
|
||||||
wikijs_password: str = Field(..., description="Wiki.js password")
|
# Legacy auth fields - kept for backwards compatibility but deprecated
|
||||||
|
wikijs_username: str = Field(default="", description="Wiki.js username (deprecated, use wiki_graphql_api)")
|
||||||
|
wikijs_password: str = Field(default="", description="Wiki.js password (deprecated, use wiki_graphql_api)")
|
||||||
|
|
||||||
# Wiki.js Database Configuration (for change listener)
|
# Wiki.js Database Configuration (for change listener)
|
||||||
wikijs_db_host: str = Field(default="postgres-shared", description="Wiki.js PostgreSQL host")
|
wikijs_db_host: str = Field(default="postgres-shared", description="Wiki.js PostgreSQL host")
|
||||||
@@ -99,6 +101,21 @@ class Settings(BaseSettings):
|
|||||||
content_extraction_timeout: int = Field(default=5, ge=1, le=30, description="Trafilatura per-URL timeout in seconds")
|
content_extraction_timeout: int = Field(default=5, ge=1, le=30, description="Trafilatura per-URL timeout in seconds")
|
||||||
content_max_length: int = Field(default=2000, ge=500, le=10000, description="Max extracted content length per result")
|
content_max_length: int = Field(default=2000, ge=500, le=10000, description="Max extracted content length per result")
|
||||||
|
|
||||||
|
# Document Store Configuration
|
||||||
|
document_store_enabled: bool = Field(default=True, description="Enable document store feature")
|
||||||
|
document_catalog_path_prefix: str = Field(default="docs", description="Wiki path prefix for catalog pages")
|
||||||
|
|
||||||
|
# Volatile Cache Configuration
|
||||||
|
volatile_cache_enabled: bool = Field(default=True, description="Enable volatile cache feature")
|
||||||
|
volatile_default_ttl: int = Field(default=3600, ge=60, le=86400, description="Default TTL in seconds")
|
||||||
|
volatile_weather_ttl: int = Field(default=1800, ge=60, le=7200, description="Weather data TTL in seconds")
|
||||||
|
volatile_news_ttl: int = Field(default=7200, ge=300, le=86400, description="News data TTL in seconds")
|
||||||
|
volatile_financial_ttl: int = Field(default=300, ge=60, le=3600, description="Financial data TTL in seconds")
|
||||||
|
|
||||||
|
# Maintenance Configuration
|
||||||
|
maintenance_orphan_cleanup_enabled: bool = Field(default=True, description="Enable automatic orphan cleanup")
|
||||||
|
maintenance_cleanup_batch_size: int = Field(default=100, ge=10, le=1000, description="Cleanup batch size")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def qdrant_url(self) -> str:
|
def qdrant_url(self) -> str:
|
||||||
"""Computed Qdrant URL."""
|
"""Computed Qdrant URL."""
|
||||||
|
|||||||
@@ -76,13 +76,12 @@ def get_wikijs_client() -> WikiJSClient:
|
|||||||
Get Wiki.js client singleton.
|
Get Wiki.js client singleton.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Initialized Wiki.js GraphQL client with username/password auth
|
Initialized Wiki.js GraphQL client with API token auth
|
||||||
"""
|
"""
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
client = WikiJSClient(
|
client = WikiJSClient(
|
||||||
base_url=settings.wikijs_url,
|
base_url=settings.wikijs_url,
|
||||||
username=settings.wikijs_username,
|
api_token=settings.wiki_graphql_api
|
||||||
password=settings.wikijs_password
|
|
||||||
)
|
)
|
||||||
logger.debug("Created Wiki.js client instance")
|
logger.debug("Created Wiki.js client instance")
|
||||||
return client
|
return client
|
||||||
@@ -425,3 +424,12 @@ async def verify_api_key(
|
|||||||
detail="Invalid API key"
|
detail="Invalid API key"
|
||||||
)
|
)
|
||||||
return credentials.credentials
|
return credentials.credentials
|
||||||
|
|
||||||
|
|
||||||
|
# Service type aliases for FastAPI endpoint dependencies
|
||||||
|
# These are defined after the factory functions
|
||||||
|
from src.services.vector_service import VectorService
|
||||||
|
from src.services.graph_service import GraphService
|
||||||
|
|
||||||
|
VectorServiceDep = Annotated[VectorService, Depends(get_vector_service)]
|
||||||
|
GraphServiceDep = Annotated[GraphService, Depends(get_graph_service)]
|
||||||
|
|||||||
+3
-1
@@ -50,7 +50,8 @@ app.add_middleware(
|
|||||||
# Register routers
|
# Register routers
|
||||||
from src.routers import (
|
from src.routers import (
|
||||||
wiki, tools, graph, vector, hybrid_rag, consolidation,
|
wiki, tools, graph, vector, hybrid_rag, consolidation,
|
||||||
ingestion, entity_linking, webhooks, rag_search, content
|
ingestion, entity_linking, webhooks, rag_search, content,
|
||||||
|
maintenance
|
||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(wiki.router)
|
app.include_router(wiki.router)
|
||||||
@@ -64,6 +65,7 @@ app.include_router(entity_linking.router)
|
|||||||
app.include_router(webhooks.router)
|
app.include_router(webhooks.router)
|
||||||
app.include_router(rag_search.router)
|
app.include_router(rag_search.router)
|
||||||
app.include_router(content.router)
|
app.include_router(content.router)
|
||||||
|
app.include_router(maintenance.router)
|
||||||
|
|
||||||
# Mount static files directory for Wiki.js integration scripts
|
# Mount static files directory for Wiki.js integration scripts
|
||||||
static_dir = Path(__file__).parent.parent / "static"
|
static_dir = Path(__file__).parent.parent / "static"
|
||||||
|
|||||||
@@ -0,0 +1,794 @@
|
|||||||
|
"""
|
||||||
|
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.core.dependencies import (
|
||||||
|
VectorServiceDep, GraphServiceDep, WikiJSDep, RedisDep,
|
||||||
|
verify_api_key
|
||||||
|
)
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# ========== 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.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))
|
||||||
@@ -1261,3 +1261,353 @@ Feel free to expand it with more details!
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to create entity mentions: {e}", exc_info=True)
|
logger.error(f"Failed to create entity mentions: {e}", exc_info=True)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
# ========== Cleanup Methods ==========
|
||||||
|
|
||||||
|
async def delete_document_node(
|
||||||
|
self,
|
||||||
|
document_id: str,
|
||||||
|
user: str
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Delete a Document Store document node and all its relationships.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
document_id: Document UUID (Document Store)
|
||||||
|
user: User identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of nodes deleted (1 if successful, 0 if not found)
|
||||||
|
"""
|
||||||
|
user_doc_label = get_neo4j_user_label(user)
|
||||||
|
|
||||||
|
delete_query = f"""
|
||||||
|
MATCH (d:{user_doc_label}:Document {{document_id: $document_id}})
|
||||||
|
DETACH DELETE d
|
||||||
|
RETURN count(d) as deleted_count
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await self.neo4j.execute_query(
|
||||||
|
delete_query,
|
||||||
|
{"document_id": document_id}
|
||||||
|
)
|
||||||
|
|
||||||
|
deleted_count = result[0]["deleted_count"] if result else 0
|
||||||
|
|
||||||
|
if deleted_count > 0:
|
||||||
|
logger.info(f"Deleted Document node for document {document_id}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"No Document node found for document {document_id}")
|
||||||
|
|
||||||
|
return deleted_count
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to delete document {document_id} from graph: {e}", exc_info=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async def delete_collection_node(
|
||||||
|
self,
|
||||||
|
collection_id: str,
|
||||||
|
user: str
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Delete a DocumentCollection node and all contained documents.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
collection_id: Collection UUID
|
||||||
|
user: User identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of nodes deleted (collection + documents)
|
||||||
|
"""
|
||||||
|
user_doc_label = get_neo4j_user_label(user)
|
||||||
|
|
||||||
|
# Delete collection and all documents it contains
|
||||||
|
delete_query = f"""
|
||||||
|
MATCH (c:{user_doc_label}:DocumentCollection {{id: $collection_id}})
|
||||||
|
OPTIONAL MATCH (c)-[:CONTAINS]->(d:Document)
|
||||||
|
DETACH DELETE c, d
|
||||||
|
RETURN count(c) + count(d) as deleted_count
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await self.neo4j.execute_query(
|
||||||
|
delete_query,
|
||||||
|
{"collection_id": collection_id}
|
||||||
|
)
|
||||||
|
|
||||||
|
deleted_count = result[0]["deleted_count"] if result else 0
|
||||||
|
logger.info(f"Deleted collection {collection_id} with {deleted_count} total nodes")
|
||||||
|
return deleted_count
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to delete collection {collection_id}: {e}", exc_info=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async def find_orphan_entities(
|
||||||
|
self,
|
||||||
|
user: str
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Find entities with no MENTIONS relationships (orphaned).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of orphaned entities {id, name, type}
|
||||||
|
"""
|
||||||
|
from src.core.multi_tenancy import get_neo4j_user_base_label
|
||||||
|
|
||||||
|
user_base_label = get_neo4j_user_base_label(user)
|
||||||
|
|
||||||
|
query = f"""
|
||||||
|
MATCH (e:{user_base_label})
|
||||||
|
WHERE NOT e:Document
|
||||||
|
AND NOT e:DocumentCollection
|
||||||
|
AND NOT EXISTS {{ (d:Document)-[:MENTIONS]->(e) }}
|
||||||
|
RETURN elementId(e) as id, e.name as name, labels(e) as labels
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = await self.neo4j.execute_query(query, {})
|
||||||
|
|
||||||
|
orphans = []
|
||||||
|
for r in results:
|
||||||
|
labels = r.get("labels", [])
|
||||||
|
entity_type = next(
|
||||||
|
(l for l in labels if l != user_base_label),
|
||||||
|
"Unknown"
|
||||||
|
)
|
||||||
|
orphans.append({
|
||||||
|
"id": r["id"],
|
||||||
|
"name": r["name"],
|
||||||
|
"type": entity_type
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(f"Found {len(orphans)} orphan entities for user {user}")
|
||||||
|
return orphans
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to find orphan entities: {e}", exc_info=True)
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def purge_orphan_entities(
|
||||||
|
self,
|
||||||
|
user: str
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Delete all orphaned entities (entities with no MENTIONS relationships).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of entities purged
|
||||||
|
"""
|
||||||
|
from src.core.multi_tenancy import get_neo4j_user_base_label
|
||||||
|
|
||||||
|
user_base_label = get_neo4j_user_base_label(user)
|
||||||
|
|
||||||
|
query = f"""
|
||||||
|
MATCH (e:{user_base_label})
|
||||||
|
WHERE NOT e:Document
|
||||||
|
AND NOT e:DocumentCollection
|
||||||
|
AND NOT EXISTS {{ (d:Document)-[:MENTIONS]->(e) }}
|
||||||
|
DETACH DELETE e
|
||||||
|
RETURN count(e) as purged_count
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = await self.neo4j.execute_query(query, {})
|
||||||
|
purged_count = results[0]["purged_count"] if results else 0
|
||||||
|
|
||||||
|
logger.info(f"Purged {purged_count} orphan entities for user {user}")
|
||||||
|
return purged_count
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to purge orphan entities: {e}", exc_info=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async def get_all_document_references(
|
||||||
|
self,
|
||||||
|
user: str
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get all Document node references for orphan detection.
|
||||||
|
|
||||||
|
Returns page_id for wiki docs and document_id for Document Store docs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of document references {page_id, document_id, doc_type, title}
|
||||||
|
"""
|
||||||
|
user_doc_label = get_neo4j_user_label(user)
|
||||||
|
|
||||||
|
query = f"""
|
||||||
|
MATCH (d:{user_doc_label}:Document)
|
||||||
|
RETURN d.page_id as page_id,
|
||||||
|
d.document_id as document_id,
|
||||||
|
COALESCE(d.doc_type, 'wiki') as doc_type,
|
||||||
|
d.title as title
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = await self.neo4j.execute_query(query, {})
|
||||||
|
|
||||||
|
references = []
|
||||||
|
for r in results:
|
||||||
|
references.append({
|
||||||
|
"page_id": r.get("page_id"),
|
||||||
|
"document_id": r.get("document_id"),
|
||||||
|
"doc_type": r.get("doc_type", "wiki"),
|
||||||
|
"title": r.get("title")
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(f"Found {len(references)} document references for user {user}")
|
||||||
|
return references
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get document references: {e}", exc_info=True)
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def purge_stale_documents_by_ids(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
page_ids: List[int] = None,
|
||||||
|
document_ids: List[str] = None
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Delete specific stale Document nodes by their IDs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
page_ids: List of wiki page IDs to delete
|
||||||
|
document_ids: List of Document Store document IDs to delete
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of documents purged
|
||||||
|
"""
|
||||||
|
user_doc_label = get_neo4j_user_label(user)
|
||||||
|
total_purged = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Purge by page_id (wiki docs)
|
||||||
|
if page_ids:
|
||||||
|
query = f"""
|
||||||
|
MATCH (d:{user_doc_label}:Document)
|
||||||
|
WHERE d.page_id IN $page_ids
|
||||||
|
DETACH DELETE d
|
||||||
|
RETURN count(d) as purged_count
|
||||||
|
"""
|
||||||
|
results = await self.neo4j.execute_query(query, {"page_ids": page_ids})
|
||||||
|
count = results[0]["purged_count"] if results else 0
|
||||||
|
total_purged += count
|
||||||
|
logger.info(f"Purged {count} wiki Document nodes")
|
||||||
|
|
||||||
|
# Purge by document_id (Document Store docs)
|
||||||
|
if document_ids:
|
||||||
|
query = f"""
|
||||||
|
MATCH (d:{user_doc_label}:Document)
|
||||||
|
WHERE d.document_id IN $document_ids
|
||||||
|
DETACH DELETE d
|
||||||
|
RETURN count(d) as purged_count
|
||||||
|
"""
|
||||||
|
results = await self.neo4j.execute_query(query, {"document_ids": document_ids})
|
||||||
|
count = results[0]["purged_count"] if results else 0
|
||||||
|
total_purged += count
|
||||||
|
logger.info(f"Purged {count} Document Store Document nodes")
|
||||||
|
|
||||||
|
return total_purged
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to purge stale documents: {e}", exc_info=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async def cleanup_broken_relationships(
|
||||||
|
self,
|
||||||
|
user: str
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Clean up broken FOUND relationships from SearchQuery nodes.
|
||||||
|
|
||||||
|
Removes relationships pointing to deleted documents.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of relationships cleaned
|
||||||
|
"""
|
||||||
|
query = """
|
||||||
|
MATCH (sq:SearchQuery)-[r:FOUND]->(d)
|
||||||
|
WHERE NOT EXISTS { (d) }
|
||||||
|
DELETE r
|
||||||
|
RETURN count(r) as cleaned_count
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = await self.neo4j.execute_query(query, {})
|
||||||
|
cleaned_count = results[0]["cleaned_count"] if results else 0
|
||||||
|
|
||||||
|
if cleaned_count > 0:
|
||||||
|
logger.info(f"Cleaned {cleaned_count} broken FOUND relationships")
|
||||||
|
|
||||||
|
return cleaned_count
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to cleanup broken relationships: {e}", exc_info=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async def find_documents_without_vectors(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
vector_references: List[Dict[str, Any]]
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Find Document nodes that have no corresponding vectors.
|
||||||
|
|
||||||
|
Used for bidirectional orphan detection - graph nodes without vector data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
vector_references: List of vector refs from VectorService.get_all_chunk_references()
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of orphan documents {page_id, document_id, doc_type, title}
|
||||||
|
"""
|
||||||
|
# Get all graph document references
|
||||||
|
graph_docs = await self.get_all_document_references(user)
|
||||||
|
|
||||||
|
if not graph_docs:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Build sets of IDs that have vectors
|
||||||
|
vector_page_ids = {
|
||||||
|
ref.get("page_id") for ref in vector_references
|
||||||
|
if ref.get("doc_type") == "wiki" and ref.get("page_id")
|
||||||
|
}
|
||||||
|
vector_doc_ids = {
|
||||||
|
ref.get("document_id") for ref in vector_references
|
||||||
|
if ref.get("doc_type") != "wiki" and ref.get("document_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find graph docs with no vectors
|
||||||
|
orphans = []
|
||||||
|
for doc in graph_docs:
|
||||||
|
doc_type = doc.get("doc_type", "wiki")
|
||||||
|
|
||||||
|
if doc_type == "wiki":
|
||||||
|
page_id = doc.get("page_id")
|
||||||
|
if page_id and page_id not in vector_page_ids:
|
||||||
|
orphans.append(doc)
|
||||||
|
else:
|
||||||
|
document_id = doc.get("document_id")
|
||||||
|
if document_id and document_id not in vector_doc_ids:
|
||||||
|
orphans.append(doc)
|
||||||
|
|
||||||
|
logger.info(f"Found {len(orphans)} graph documents without vectors for user {user}")
|
||||||
|
return orphans
|
||||||
|
|||||||
@@ -356,3 +356,189 @@ class VectorService:
|
|||||||
collections=[],
|
collections=[],
|
||||||
total=0
|
total=0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ========== Cleanup Methods ==========
|
||||||
|
|
||||||
|
async def delete_document_chunks(
|
||||||
|
self,
|
||||||
|
document_id: str,
|
||||||
|
user: str
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Delete all chunks for a document (Document Store).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
document_id: Document UUID
|
||||||
|
user: User identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of chunks deleted
|
||||||
|
"""
|
||||||
|
collection_name = get_qdrant_collection_name(user)
|
||||||
|
|
||||||
|
try:
|
||||||
|
deleted_count = await self.qdrant.delete_by_filter(
|
||||||
|
collection_name=collection_name,
|
||||||
|
filter_conditions={"document_id": document_id}
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Deleted chunks for document {document_id}")
|
||||||
|
return deleted_count
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to delete chunks for document {document_id}: {e}", exc_info=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async def delete_collection_chunks(
|
||||||
|
self,
|
||||||
|
collection_id: str,
|
||||||
|
user: str
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Delete all chunks for a document collection.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
collection_id: Collection UUID
|
||||||
|
user: User identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of chunks deleted
|
||||||
|
"""
|
||||||
|
collection_name = get_qdrant_collection_name(user)
|
||||||
|
|
||||||
|
try:
|
||||||
|
deleted_count = await self.qdrant.delete_by_filter(
|
||||||
|
collection_name=collection_name,
|
||||||
|
filter_conditions={"collection_id": collection_id}
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Deleted chunks for collection {collection_id}")
|
||||||
|
return deleted_count
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to delete chunks for collection {collection_id}: {e}", exc_info=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async def get_all_chunk_references(
|
||||||
|
self,
|
||||||
|
user: str
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get all chunk references for orphan detection.
|
||||||
|
|
||||||
|
Returns list of {id, page_id, document_id} for all chunks.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of chunk references
|
||||||
|
"""
|
||||||
|
collection_name = get_qdrant_collection_name(user)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if collection exists
|
||||||
|
exists = await self.qdrant.collection_exists(collection_name)
|
||||||
|
if not exists:
|
||||||
|
return []
|
||||||
|
|
||||||
|
all_points = await self.qdrant.scroll_all_points(
|
||||||
|
collection_name=collection_name,
|
||||||
|
batch_size=100,
|
||||||
|
with_payload=True
|
||||||
|
)
|
||||||
|
|
||||||
|
references = []
|
||||||
|
for point in all_points:
|
||||||
|
payload = point.get("payload", {})
|
||||||
|
references.append({
|
||||||
|
"chunk_id": point["id"],
|
||||||
|
"page_id": payload.get("page_id"),
|
||||||
|
"document_id": payload.get("document_id"),
|
||||||
|
"collection_id": payload.get("collection_id"),
|
||||||
|
"doc_type": payload.get("doc_type", "wiki")
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(f"Found {len(references)} chunks for user {user}")
|
||||||
|
return references
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get chunk references: {e}", exc_info=True)
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def purge_chunks_by_ids(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
chunk_ids: List[str]
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Delete specific chunks by their IDs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
chunk_ids: List of chunk IDs to delete
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of chunks deleted
|
||||||
|
"""
|
||||||
|
if not chunk_ids:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
collection_name = get_qdrant_collection_name(user)
|
||||||
|
|
||||||
|
try:
|
||||||
|
deleted_count = await self.qdrant.delete_by_ids(
|
||||||
|
collection_name=collection_name,
|
||||||
|
point_ids=chunk_ids
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Purged {deleted_count} orphan chunks for user {user}")
|
||||||
|
return deleted_count
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to purge chunks: {e}", exc_info=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def find_chunks_without_graph_nodes(
|
||||||
|
self,
|
||||||
|
chunk_references: List[Dict[str, Any]],
|
||||||
|
graph_references: List[Dict[str, Any]]
|
||||||
|
) -> List[str]:
|
||||||
|
"""
|
||||||
|
Find vector chunks that have no corresponding graph Document node.
|
||||||
|
|
||||||
|
Used for bidirectional orphan detection - vectors without graph representation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
chunk_references: List from get_all_chunk_references()
|
||||||
|
graph_references: List from GraphService.get_all_document_references()
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of orphan chunk IDs
|
||||||
|
"""
|
||||||
|
# Build sets of IDs that have graph nodes
|
||||||
|
graph_page_ids = {
|
||||||
|
ref.get("page_id") for ref in graph_references
|
||||||
|
if ref.get("doc_type") == "wiki" and ref.get("page_id")
|
||||||
|
}
|
||||||
|
graph_doc_ids = {
|
||||||
|
ref.get("document_id") for ref in graph_references
|
||||||
|
if ref.get("doc_type") != "wiki" and ref.get("document_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find chunks with no graph node
|
||||||
|
orphan_ids = []
|
||||||
|
for chunk in chunk_references:
|
||||||
|
doc_type = chunk.get("doc_type", "wiki")
|
||||||
|
|
||||||
|
if doc_type == "wiki":
|
||||||
|
page_id = chunk.get("page_id")
|
||||||
|
if page_id and page_id not in graph_page_ids:
|
||||||
|
orphan_ids.append(chunk["chunk_id"])
|
||||||
|
else:
|
||||||
|
document_id = chunk.get("document_id")
|
||||||
|
if document_id and document_id not in graph_doc_ids:
|
||||||
|
orphan_ids.append(chunk["chunk_id"])
|
||||||
|
|
||||||
|
logger.info(f"Found {len(orphan_ids)} vector chunks without graph nodes")
|
||||||
|
return orphan_ids
|
||||||
|
|||||||
@@ -0,0 +1,488 @@
|
|||||||
|
"""
|
||||||
|
Tests for maintenance router and cleanup functionality.
|
||||||
|
|
||||||
|
Tests cleanup of:
|
||||||
|
- Orphan vector chunks
|
||||||
|
- Orphan entities in graph
|
||||||
|
- Stale document nodes
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
from src.routers.maintenance import (
|
||||||
|
cleanup_vectors,
|
||||||
|
cleanup_graph,
|
||||||
|
cleanup_all,
|
||||||
|
maintenance_health,
|
||||||
|
reindex_page,
|
||||||
|
CleanupResult,
|
||||||
|
VectorCleanupResponse,
|
||||||
|
GraphCleanupResponse,
|
||||||
|
FullCleanupResponse,
|
||||||
|
HealthCheckResponse,
|
||||||
|
ReindexResponse
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCleanupResult:
|
||||||
|
"""Test CleanupResult model."""
|
||||||
|
|
||||||
|
def test_cleanup_result_defaults(self):
|
||||||
|
"""Test CleanupResult with default values."""
|
||||||
|
result = CleanupResult(duration_ms=100.0)
|
||||||
|
assert result.orphans_found == 0
|
||||||
|
assert result.orphans_purged == 0
|
||||||
|
assert result.duration_ms == 100.0
|
||||||
|
|
||||||
|
def test_cleanup_result_with_values(self):
|
||||||
|
"""Test CleanupResult with actual values."""
|
||||||
|
result = CleanupResult(
|
||||||
|
orphans_found=10,
|
||||||
|
orphans_purged=8,
|
||||||
|
duration_ms=250.5
|
||||||
|
)
|
||||||
|
assert result.orphans_found == 10
|
||||||
|
assert result.orphans_purged == 8
|
||||||
|
assert result.duration_ms == 250.5
|
||||||
|
|
||||||
|
|
||||||
|
class TestVectorCleanupResponse:
|
||||||
|
"""Test VectorCleanupResponse model."""
|
||||||
|
|
||||||
|
def test_vector_cleanup_response(self):
|
||||||
|
"""Test VectorCleanupResponse structure."""
|
||||||
|
response = VectorCleanupResponse(
|
||||||
|
success=True,
|
||||||
|
wiki_chunks=CleanupResult(orphans_found=5, orphans_purged=5, duration_ms=50),
|
||||||
|
document_chunks=CleanupResult(orphans_found=3, orphans_purged=3, duration_ms=50),
|
||||||
|
chunks_without_graph=CleanupResult(orphans_found=2, orphans_purged=2, duration_ms=50),
|
||||||
|
total_chunks_scanned=100,
|
||||||
|
total_orphans_purged=10,
|
||||||
|
duration_ms=100
|
||||||
|
)
|
||||||
|
assert response.success is True
|
||||||
|
assert response.wiki_chunks.orphans_found == 5
|
||||||
|
assert response.document_chunks.orphans_found == 3
|
||||||
|
assert response.chunks_without_graph.orphans_found == 2
|
||||||
|
assert response.total_orphans_purged == 10
|
||||||
|
|
||||||
|
|
||||||
|
class TestGraphCleanupResponse:
|
||||||
|
"""Test GraphCleanupResponse model."""
|
||||||
|
|
||||||
|
def test_graph_cleanup_response(self):
|
||||||
|
"""Test GraphCleanupResponse structure."""
|
||||||
|
response = GraphCleanupResponse(
|
||||||
|
success=True,
|
||||||
|
orphan_entities=CleanupResult(orphans_found=10, orphans_purged=10, duration_ms=25),
|
||||||
|
stale_wiki_documents=CleanupResult(orphans_found=2, orphans_purged=2, duration_ms=25),
|
||||||
|
stale_store_documents=CleanupResult(orphans_found=0, orphans_purged=0, duration_ms=25),
|
||||||
|
docs_without_vectors=CleanupResult(orphans_found=1, orphans_purged=1, duration_ms=25),
|
||||||
|
broken_relationships_cleaned=5,
|
||||||
|
duration_ms=100
|
||||||
|
)
|
||||||
|
assert response.success is True
|
||||||
|
assert response.orphan_entities.orphans_found == 10
|
||||||
|
assert response.docs_without_vectors.orphans_found == 1
|
||||||
|
assert response.broken_relationships_cleaned == 5
|
||||||
|
|
||||||
|
|
||||||
|
class TestHealthCheckResponse:
|
||||||
|
"""Test HealthCheckResponse model."""
|
||||||
|
|
||||||
|
def test_health_check_healthy(self):
|
||||||
|
"""Test healthy status."""
|
||||||
|
response = HealthCheckResponse(
|
||||||
|
status="healthy",
|
||||||
|
orphan_vector_count=0,
|
||||||
|
orphan_entity_count=0,
|
||||||
|
stale_document_count=0
|
||||||
|
)
|
||||||
|
assert response.status == "healthy"
|
||||||
|
assert response.recommendations == []
|
||||||
|
|
||||||
|
def test_health_check_degraded(self):
|
||||||
|
"""Test degraded status with recommendations."""
|
||||||
|
response = HealthCheckResponse(
|
||||||
|
status="degraded",
|
||||||
|
orphan_vector_count=15,
|
||||||
|
orphan_entity_count=3,
|
||||||
|
stale_document_count=0,
|
||||||
|
recommendations=[
|
||||||
|
"Found 15 orphan vector chunks. Consider running POST /maintenance/cleanup/vectors"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert response.status == "degraded"
|
||||||
|
assert len(response.recommendations) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
class TestVectorCleanup:
|
||||||
|
"""Test vector cleanup endpoint."""
|
||||||
|
|
||||||
|
async def test_cleanup_vectors_no_orphans(self):
|
||||||
|
"""Test cleanup when no orphans exist."""
|
||||||
|
# Mock services
|
||||||
|
vector_service = AsyncMock()
|
||||||
|
vector_service.get_all_chunk_references.return_value = [
|
||||||
|
{"chunk_id": "c1", "page_id": 1, "doc_type": "wiki"}
|
||||||
|
]
|
||||||
|
# find_chunks_without_graph_nodes is not async
|
||||||
|
vector_service.find_chunks_without_graph_nodes = MagicMock(return_value=[])
|
||||||
|
|
||||||
|
graph_service = AsyncMock()
|
||||||
|
graph_service.get_all_document_references.return_value = [
|
||||||
|
{"page_id": 1, "doc_type": "wiki", "title": "Test"}
|
||||||
|
]
|
||||||
|
|
||||||
|
wiki_client = AsyncMock()
|
||||||
|
wiki_client.list_all_pages.return_value = [{"id": 1, "path": "test"}]
|
||||||
|
|
||||||
|
# Call cleanup
|
||||||
|
result = await cleanup_vectors(
|
||||||
|
user="testuser",
|
||||||
|
dry_run=False,
|
||||||
|
vector_service=vector_service,
|
||||||
|
graph_service=graph_service,
|
||||||
|
wiki_client=wiki_client,
|
||||||
|
api_key="test"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert result.wiki_chunks.orphans_found == 0
|
||||||
|
assert result.chunks_without_graph.orphans_found == 0
|
||||||
|
assert result.total_orphans_purged == 0
|
||||||
|
|
||||||
|
async def test_cleanup_vectors_with_orphans(self):
|
||||||
|
"""Test cleanup when orphans exist."""
|
||||||
|
# Mock services
|
||||||
|
vector_service = AsyncMock()
|
||||||
|
vector_service.get_all_chunk_references.return_value = [
|
||||||
|
{"chunk_id": "c1", "page_id": 1, "doc_type": "wiki"},
|
||||||
|
{"chunk_id": "c2", "page_id": 999, "doc_type": "wiki"}, # Orphan
|
||||||
|
{"chunk_id": "c3", "page_id": 999, "doc_type": "wiki"}, # Orphan
|
||||||
|
]
|
||||||
|
vector_service.purge_chunks_by_ids.return_value = 2
|
||||||
|
# find_chunks_without_graph_nodes is not async
|
||||||
|
vector_service.find_chunks_without_graph_nodes = MagicMock(return_value=[])
|
||||||
|
|
||||||
|
graph_service = AsyncMock()
|
||||||
|
graph_service.get_all_document_references.return_value = [
|
||||||
|
{"page_id": 1, "doc_type": "wiki", "title": "Test"}
|
||||||
|
]
|
||||||
|
|
||||||
|
wiki_client = AsyncMock()
|
||||||
|
wiki_client.list_all_pages.return_value = [{"id": 1, "path": "test"}]
|
||||||
|
|
||||||
|
# Call cleanup
|
||||||
|
result = await cleanup_vectors(
|
||||||
|
user="testuser",
|
||||||
|
dry_run=False,
|
||||||
|
vector_service=vector_service,
|
||||||
|
graph_service=graph_service,
|
||||||
|
wiki_client=wiki_client,
|
||||||
|
api_key="test"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert result.wiki_chunks.orphans_found == 2
|
||||||
|
assert result.wiki_chunks.orphans_purged == 2
|
||||||
|
assert result.total_orphans_purged == 2
|
||||||
|
|
||||||
|
async def test_cleanup_vectors_dry_run(self):
|
||||||
|
"""Test cleanup dry run doesn't purge."""
|
||||||
|
# Mock services
|
||||||
|
vector_service = AsyncMock()
|
||||||
|
vector_service.get_all_chunk_references.return_value = [
|
||||||
|
{"chunk_id": "c1", "page_id": 999, "doc_type": "wiki"}, # Orphan
|
||||||
|
]
|
||||||
|
# find_chunks_without_graph_nodes is not async
|
||||||
|
vector_service.find_chunks_without_graph_nodes = MagicMock(return_value=[])
|
||||||
|
|
||||||
|
graph_service = AsyncMock()
|
||||||
|
graph_service.get_all_document_references.return_value = []
|
||||||
|
|
||||||
|
wiki_client = AsyncMock()
|
||||||
|
wiki_client.list_all_pages.return_value = []
|
||||||
|
|
||||||
|
# Call cleanup in dry run mode
|
||||||
|
result = await cleanup_vectors(
|
||||||
|
user="testuser",
|
||||||
|
dry_run=True,
|
||||||
|
vector_service=vector_service,
|
||||||
|
graph_service=graph_service,
|
||||||
|
wiki_client=wiki_client,
|
||||||
|
api_key="test"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert result.wiki_chunks.orphans_found == 1
|
||||||
|
assert result.wiki_chunks.orphans_purged == 0 # Not purged due to dry run
|
||||||
|
vector_service.purge_chunks_by_ids.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
class TestGraphCleanup:
|
||||||
|
"""Test graph cleanup endpoint."""
|
||||||
|
|
||||||
|
async def test_cleanup_graph_no_orphans(self):
|
||||||
|
"""Test cleanup when no orphans exist."""
|
||||||
|
vector_service = AsyncMock()
|
||||||
|
vector_service.get_all_chunk_references.return_value = [
|
||||||
|
{"chunk_id": "c1", "page_id": 1, "doc_type": "wiki"}
|
||||||
|
]
|
||||||
|
|
||||||
|
graph_service = AsyncMock()
|
||||||
|
graph_service.find_orphan_entities.return_value = []
|
||||||
|
graph_service.get_all_document_references.return_value = [
|
||||||
|
{"page_id": 1, "doc_type": "wiki", "title": "Test"}
|
||||||
|
]
|
||||||
|
graph_service.find_documents_without_vectors.return_value = []
|
||||||
|
graph_service.cleanup_broken_relationships.return_value = 0
|
||||||
|
|
||||||
|
wiki_client = AsyncMock()
|
||||||
|
wiki_client.list_all_pages.return_value = [{"id": 1, "path": "test"}]
|
||||||
|
|
||||||
|
result = await cleanup_graph(
|
||||||
|
user="testuser",
|
||||||
|
dry_run=False,
|
||||||
|
vector_service=vector_service,
|
||||||
|
graph_service=graph_service,
|
||||||
|
wiki_client=wiki_client,
|
||||||
|
api_key="test"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert result.orphan_entities.orphans_found == 0
|
||||||
|
assert result.stale_wiki_documents.orphans_found == 0
|
||||||
|
assert result.docs_without_vectors.orphans_found == 0
|
||||||
|
|
||||||
|
async def test_cleanup_graph_with_orphan_entities(self):
|
||||||
|
"""Test cleanup of orphan entities."""
|
||||||
|
vector_service = AsyncMock()
|
||||||
|
vector_service.get_all_chunk_references.return_value = []
|
||||||
|
|
||||||
|
graph_service = AsyncMock()
|
||||||
|
graph_service.find_orphan_entities.return_value = [
|
||||||
|
{"id": "e1", "name": "Orphan1", "type": "Person"},
|
||||||
|
{"id": "e2", "name": "Orphan2", "type": "Technology"},
|
||||||
|
]
|
||||||
|
graph_service.purge_orphan_entities.return_value = 2
|
||||||
|
graph_service.get_all_document_references.return_value = []
|
||||||
|
graph_service.find_documents_without_vectors.return_value = []
|
||||||
|
graph_service.cleanup_broken_relationships.return_value = 0
|
||||||
|
|
||||||
|
wiki_client = AsyncMock()
|
||||||
|
wiki_client.list_all_pages.return_value = []
|
||||||
|
|
||||||
|
result = await cleanup_graph(
|
||||||
|
user="testuser",
|
||||||
|
dry_run=False,
|
||||||
|
vector_service=vector_service,
|
||||||
|
graph_service=graph_service,
|
||||||
|
wiki_client=wiki_client,
|
||||||
|
api_key="test"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert result.orphan_entities.orphans_found == 2
|
||||||
|
assert result.orphan_entities.orphans_purged == 2
|
||||||
|
|
||||||
|
async def test_cleanup_graph_with_stale_documents(self):
|
||||||
|
"""Test cleanup of stale document nodes."""
|
||||||
|
vector_service = AsyncMock()
|
||||||
|
vector_service.get_all_chunk_references.return_value = [
|
||||||
|
{"chunk_id": "c1", "page_id": 1, "doc_type": "wiki"}
|
||||||
|
]
|
||||||
|
|
||||||
|
graph_service = AsyncMock()
|
||||||
|
graph_service.find_orphan_entities.return_value = []
|
||||||
|
graph_service.get_all_document_references.return_value = [
|
||||||
|
{"page_id": 1, "doc_type": "wiki", "title": "Exists"},
|
||||||
|
{"page_id": 999, "doc_type": "wiki", "title": "Deleted"}, # Stale
|
||||||
|
]
|
||||||
|
graph_service.find_documents_without_vectors.return_value = []
|
||||||
|
graph_service.purge_stale_documents_by_ids.return_value = 1
|
||||||
|
graph_service.cleanup_broken_relationships.return_value = 0
|
||||||
|
|
||||||
|
wiki_client = AsyncMock()
|
||||||
|
wiki_client.list_all_pages.return_value = [{"id": 1, "path": "test"}]
|
||||||
|
|
||||||
|
result = await cleanup_graph(
|
||||||
|
user="testuser",
|
||||||
|
dry_run=False,
|
||||||
|
vector_service=vector_service,
|
||||||
|
graph_service=graph_service,
|
||||||
|
wiki_client=wiki_client,
|
||||||
|
api_key="test"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert result.stale_wiki_documents.orphans_found == 1
|
||||||
|
assert result.stale_wiki_documents.orphans_purged == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
class TestFullCleanup:
|
||||||
|
"""Test full cleanup endpoint."""
|
||||||
|
|
||||||
|
async def test_full_cleanup(self):
|
||||||
|
"""Test full cleanup runs both vector and graph cleanup."""
|
||||||
|
vector_service = AsyncMock()
|
||||||
|
vector_service.get_all_chunk_references.return_value = []
|
||||||
|
# find_chunks_without_graph_nodes is not async
|
||||||
|
vector_service.find_chunks_without_graph_nodes = MagicMock(return_value=[])
|
||||||
|
|
||||||
|
graph_service = AsyncMock()
|
||||||
|
graph_service.find_orphan_entities.return_value = []
|
||||||
|
graph_service.get_all_document_references.return_value = []
|
||||||
|
graph_service.find_documents_without_vectors.return_value = []
|
||||||
|
graph_service.cleanup_broken_relationships.return_value = 0
|
||||||
|
|
||||||
|
wiki_client = AsyncMock()
|
||||||
|
wiki_client.list_all_pages.return_value = []
|
||||||
|
|
||||||
|
result = await cleanup_all(
|
||||||
|
user="testuser",
|
||||||
|
dry_run=False,
|
||||||
|
vector_service=vector_service,
|
||||||
|
graph_service=graph_service,
|
||||||
|
wiki_client=wiki_client,
|
||||||
|
api_key="test"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert result.vector_cleanup.success is True
|
||||||
|
assert result.graph_cleanup.success is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
class TestMaintenanceHealth:
|
||||||
|
"""Test maintenance health endpoint."""
|
||||||
|
|
||||||
|
async def test_health_healthy(self):
|
||||||
|
"""Test healthy status when no orphans."""
|
||||||
|
vector_service = AsyncMock()
|
||||||
|
vector_service.get_all_chunk_references.return_value = []
|
||||||
|
# find_chunks_without_graph_nodes is not async
|
||||||
|
vector_service.find_chunks_without_graph_nodes = MagicMock(return_value=[])
|
||||||
|
|
||||||
|
graph_service = AsyncMock()
|
||||||
|
graph_service.find_orphan_entities.return_value = []
|
||||||
|
graph_service.get_all_document_references.return_value = []
|
||||||
|
graph_service.find_documents_without_vectors.return_value = []
|
||||||
|
|
||||||
|
wiki_client = AsyncMock()
|
||||||
|
wiki_client.list_all_pages.return_value = []
|
||||||
|
|
||||||
|
result = await maintenance_health(
|
||||||
|
user="testuser",
|
||||||
|
vector_service=vector_service,
|
||||||
|
graph_service=graph_service,
|
||||||
|
wiki_client=wiki_client,
|
||||||
|
api_key="test"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.status == "healthy"
|
||||||
|
assert result.orphan_vector_count == 0
|
||||||
|
assert result.orphan_entity_count == 0
|
||||||
|
assert result.vectors_without_graph == 0
|
||||||
|
assert result.docs_without_vectors == 0
|
||||||
|
|
||||||
|
async def test_health_degraded(self):
|
||||||
|
"""Test degraded status with orphans."""
|
||||||
|
vector_service = AsyncMock()
|
||||||
|
vector_service.get_all_chunk_references.return_value = [
|
||||||
|
{"chunk_id": f"c{i}", "page_id": 999, "doc_type": "wiki"}
|
||||||
|
for i in range(15)
|
||||||
|
]
|
||||||
|
# find_chunks_without_graph_nodes is not async
|
||||||
|
vector_service.find_chunks_without_graph_nodes = MagicMock(return_value=[])
|
||||||
|
|
||||||
|
graph_service = AsyncMock()
|
||||||
|
graph_service.find_orphan_entities.return_value = [
|
||||||
|
{"id": f"e{i}", "name": f"Entity{i}", "type": "Entity"}
|
||||||
|
for i in range(3)
|
||||||
|
]
|
||||||
|
graph_service.get_all_document_references.return_value = []
|
||||||
|
graph_service.find_documents_without_vectors.return_value = []
|
||||||
|
|
||||||
|
wiki_client = AsyncMock()
|
||||||
|
wiki_client.list_all_pages.return_value = []
|
||||||
|
|
||||||
|
result = await maintenance_health(
|
||||||
|
user="testuser",
|
||||||
|
vector_service=vector_service,
|
||||||
|
graph_service=graph_service,
|
||||||
|
wiki_client=wiki_client,
|
||||||
|
api_key="test"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.status == "degraded"
|
||||||
|
assert result.orphan_vector_count == 15
|
||||||
|
assert result.orphan_entity_count == 3
|
||||||
|
assert len(result.recommendations) >= 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
class TestReindexPage:
|
||||||
|
"""Test reindex page endpoint."""
|
||||||
|
|
||||||
|
async def test_reindex_success(self):
|
||||||
|
"""Test successful page reindex."""
|
||||||
|
vector_service = AsyncMock()
|
||||||
|
vector_service.delete_page_chunks.return_value = 5
|
||||||
|
vector_service.update_from_page.return_value = MagicMock(
|
||||||
|
success=True,
|
||||||
|
chunks_created=6,
|
||||||
|
error_message=None
|
||||||
|
)
|
||||||
|
|
||||||
|
graph_service = AsyncMock()
|
||||||
|
graph_service.delete_page.return_value = 1
|
||||||
|
graph_service.update_from_page.return_value = MagicMock(
|
||||||
|
success=True,
|
||||||
|
error_message=None
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await reindex_page(
|
||||||
|
page_id=123,
|
||||||
|
user="testuser",
|
||||||
|
vector_service=vector_service,
|
||||||
|
graph_service=graph_service,
|
||||||
|
api_key="test"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert result.page_id == 123
|
||||||
|
assert result.vectors_deleted == 5
|
||||||
|
assert result.vectors_created == 6
|
||||||
|
assert result.graph_updated is True
|
||||||
|
|
||||||
|
async def test_reindex_failure(self):
|
||||||
|
"""Test reindex with failure."""
|
||||||
|
vector_service = AsyncMock()
|
||||||
|
vector_service.delete_page_chunks.return_value = 0
|
||||||
|
vector_service.update_from_page.return_value = MagicMock(
|
||||||
|
success=False,
|
||||||
|
chunks_created=0,
|
||||||
|
error_message="Page not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
graph_service = AsyncMock()
|
||||||
|
graph_service.delete_page.return_value = 0
|
||||||
|
graph_service.update_from_page.return_value = MagicMock(
|
||||||
|
success=False,
|
||||||
|
error_message="Page not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await reindex_page(
|
||||||
|
page_id=999,
|
||||||
|
user="testuser",
|
||||||
|
vector_service=vector_service,
|
||||||
|
graph_service=graph_service,
|
||||||
|
api_key="test"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is False
|
||||||
|
assert result.error == "Page not found"
|
||||||
Reference in New Issue
Block a user