""" Library Desk - Main FastAPI Application Following best practices: - Async routes for I/O operations - Dependency injection for configuration - Proper error handling - OpenAPI documentation """ from fastapi import FastAPI, HTTPException, Depends, Query from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from pydantic import BaseModel from typing import Dict, Any import logging from pathlib import Path from src.config import Settings, get_settings, __version__ from src.core.dependencies import ( verify_api_key, QdrantDep, WikiJSDep, OllamaDep, Neo4jDep ) from src.core.multi_tenancy import DEFAULT_USER # Configure logging logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" ) logger = logging.getLogger(__name__) # Initialize FastAPI app app = FastAPI( title="Library Desk API", description="Coordination service for The Library system - HybridRAG queries, document ingestion, entity extraction, and mind map generation", version=__version__, docs_url="/docs", redoc_url="/redoc", ) # CORS middleware app.add_middleware( CORSMiddleware, allow_origins=["*"], # Configure appropriately for production allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # Register routers from src.routers import ( wiki, tools, graph, vector, hybrid_rag, consolidation, ingestion, entity_linking, webhooks, rag_search, content, maintenance, volatile ) app.include_router(wiki.router) app.include_router(tools.router) app.include_router(graph.router) app.include_router(vector.router) app.include_router(hybrid_rag.router) app.include_router(consolidation.router) app.include_router(ingestion.router) app.include_router(entity_linking.router) app.include_router(webhooks.router) app.include_router(rag_search.router) app.include_router(content.router) app.include_router(maintenance.router) app.include_router(volatile.router) # Mount static files directory for Wiki.js integration scripts static_dir = Path(__file__).parent.parent / "static" if static_dir.exists(): app.mount("/static", StaticFiles(directory=str(static_dir)), name="static") logger.info(f"Mounted static files from {static_dir}") # Response Models class HealthResponse(BaseModel): """Health check response model.""" status: str app_name: str version: str services: Dict[str, Any] # Routes @app.get("/", tags=["Root"]) async def root() -> Dict[str, str]: """Root endpoint.""" return { "message": "Library Desk API", "docs": "/docs", "health": "/health" } @app.get("/health", response_model=HealthResponse, tags=["System"]) async def health(settings: Settings = Depends(get_settings)) -> HealthResponse: """ Health check endpoint. Returns status of all connected services. """ from src.core.dependencies import check_service_health # Check service connectivity service_health = await check_service_health() # Overall status is healthy if at least Neo4j and Qdrant are up all_healthy = service_health.get("neo4j", False) and service_health.get("qdrant", False) overall_status = "healthy" if all_healthy else "degraded" return HealthResponse( status=overall_status, app_name=settings.app_name, version=settings.app_version, services={ "neo4j": { "url": settings.neo4j_uri, "healthy": service_health.get("neo4j", False) }, "qdrant": { "url": settings.qdrant_url, "healthy": service_health.get("qdrant", False) }, "wikijs": { "url": settings.wikijs_url, "healthy": service_health.get("wikijs", False) }, "searxng": { "url": settings.searxng_url, "healthy": service_health.get("searxng", False) }, "ollama": { "url": settings.ollama_url, "model": settings.ollama_model, "healthy": service_health.get("ollama", False) } } ) @app.post("/ingest/check-updates", tags=["Ingestion"]) async def check_updates( documents: Dict[str, Any], api_key: str = Depends(verify_api_key) ) -> Dict[str, Any]: """ Check which documents need updating based on content hashes. Used by Scheduler to determine what changed since last sync. TODO: Implement update detection: 1. Query existing documents by path 2. Compare content hashes 3. Return list of updates needed """ return { "message": "Update checking not yet implemented", "updates_needed": [], "up_to_date": [], "new_documents": [] } @app.get("/ingest/status/{document_id}", tags=["Ingestion"]) async def get_ingestion_status( document_id: str, api_key: str = Depends(verify_api_key) ) -> Dict[str, Any]: """ Get processing status for a document. TODO: Implement status tracking """ return { "message": "Status tracking not yet implemented", "document_id": document_id, "status": "unknown" } @app.get("/ingest/repo-status/{repository}", tags=["Ingestion"]) async def get_repo_status( repository: str, api_key: str = Depends(verify_api_key) ) -> Dict[str, Any]: """ Get indexing status for an entire repository. TODO: Implement repository-level statistics """ return { "message": "Repository status not yet implemented", "repository": repository, "total_documents": 0, "indexed_documents": 0 } # Query endpoints # NOTE: /query/hybrid is implemented in routers/hybrid_rag.py @app.post("/query/semantic", tags=["Query"]) async def semantic_query( query: str = Query(..., min_length=1, description="Search query text"), user: str = Query(default=DEFAULT_USER, description="User identifier"), limit: int = Query(default=10, ge=1, le=100, description="Maximum results"), score_threshold: float = Query(default=0.5, ge=0.0, le=1.0, description="Minimum similarity score"), qdrant_client: QdrantDep = None, wiki_client: WikiJSDep = None, ollama_client: OllamaDep = None, api_key: str = Depends(verify_api_key) ): """ Semantic search via Qdrant vector similarity. Searches document chunks using embedding similarity. Returns matching chunks with relevance scores, page titles, and paths. **Example:** ``` POST /query/semantic?query=docker%20configuration&user=jpmschweitzer&limit=10 ``` **Returns:** List of matching chunks with similarity scores (0-1) """ from src.services.vector_service import VectorService vector_service = VectorService(qdrant_client, wiki_client, ollama_client) try: return await vector_service.search( query=query, user=user, limit=limit, score_threshold=score_threshold ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error(f"Semantic search failed: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Search failed") @app.post("/query/graph", tags=["Query"]) async def graph_query( query: str = Query(..., description="Cypher query to execute"), user: str = Query(default=DEFAULT_USER, description="User for scoping (auto-filters results)"), neo4j_client: Neo4jDep = None, wiki_client: WikiJSDep = None, api_key: str = Depends(verify_api_key) ): """ Execute a Cypher query against the Neo4j knowledge graph. Queries are automatically scoped to the user's data for security. Use this for custom graph traversals beyond what /graph/nodes provides. **Example:** ``` POST /query/graph?query=MATCH%20(d:Document)-[:MENTIONS]->(p:Person)%20RETURN%20d,p&user=jpmschweitzer ``` **Security:** All queries are user-scoped to prevent cross-user data access. """ from src.services.graph_service import GraphService graph_service = GraphService(neo4j_client, wiki_client) try: return await graph_service.execute_query( query=query, parameters={}, user=user ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error(f"Graph query failed: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Query execution failed") # Deduplication endpoints @app.post("/deduplicate/check", tags=["Deduplication"]) async def check_duplicates( request: Dict[str, Any], api_key: str = Depends(verify_api_key) ) -> Dict[str, Any]: """ Check for duplicate or highly similar documents. Uses vector similarity and graph analysis. Expected fields: - document_id: str - similarity_threshold: float (default 0.85) TODO: Implement deduplication: 1. Get document embedding from Qdrant 2. Find similar vectors above threshold 3. Check graph relationships 4. Return candidates with similarity scores """ document_id = request.get("document_id") threshold = request.get("similarity_threshold", 0.85) return { "message": "Deduplication not yet implemented", "document_id": document_id, "threshold": threshold, "duplicates": [], "suggestions": None } # Application lifecycle @app.on_event("startup") async def startup_event(): """Initialize connections and resources on startup.""" from src.core.dependencies import startup_clients from src.services.wiki_change_listener import WikiChangeListener settings = get_settings() logger.info(f"Starting {settings.app_name} v{settings.app_version}") logger.info(f"Neo4j: {settings.neo4j_uri}") logger.info(f"Qdrant: {settings.qdrant_url}") logger.info(f"Wiki.js: {settings.wikijs_url}") logger.info(f"SearXNG: {settings.searxng_url}") logger.info(f"Ollama: {settings.ollama_url}") # Initialize all service clients await startup_clients() # Start Wiki.js change listener (PostgreSQL NOTIFY/LISTEN) # This enables automatic processing of user-edited pages try: wiki_listener = WikiChangeListener() await wiki_listener.start() # Store reference for shutdown app.state.wiki_listener = wiki_listener logger.info("Wiki.js change listener started successfully") except Exception as e: logger.error(f"Failed to start Wiki.js change listener: {e}", exc_info=True) logger.warning("Continuing without change listener - manual page updates will not be auto-processed") @app.on_event("shutdown") async def shutdown_event(): """Clean up resources on shutdown.""" from src.core.dependencies import shutdown_clients logger.info("Shutting down Library Desk API") # Stop Wiki.js change listener if running if hasattr(app.state, "wiki_listener"): try: await app.state.wiki_listener.stop() logger.info("Wiki.js change listener stopped") except Exception as e: logger.error(f"Error stopping Wiki.js change listener: {e}") # Close all service clients await shutdown_clients()