""" Wiki router for Library Desk API. Endpoints for wiki page and dossier management. All operations are scoped to user namespaces for multi-tenancy. """ from fastapi import APIRouter, HTTPException, Depends, Query, BackgroundTasks from typing import Optional import logging from src.models.wiki import ( WikiPage, WikiPageList, WikiPageCreate, WikiPageUpdate, WikiPageMove, WikiOperationResponse, WikiSearchResponse, DossierList, WikiSearchResult, WikiSmartCreateRequest, WikiSmartCreateResponse ) from src.services.wiki_service import WikiService from src.services.graph_service import GraphService from src.services.vector_service import VectorService from src.core.dependencies import ( WikiJSDep, Neo4jDep, QdrantDep, OllamaDep, verify_api_key, get_settings, get_hybrid_rag_service, get_ingestion_service, RequiredUserQuery ) from src.services.wiki_page_writer import WikiPageWriter from src.services.entity_linking_utils import apply_bidirectional_entity_linking from src.config import Settings logger = logging.getLogger(__name__) router = APIRouter(prefix="/wiki", tags=["Wiki"]) # Dependency to get wiki service def get_wiki_service(wiki_client: WikiJSDep) -> WikiService: """Get wiki service instance.""" return WikiService(wiki_client) # Dependency to get graph service def get_graph_service(neo4j_client: Neo4jDep, wiki_client: WikiJSDep) -> GraphService: """Get graph service instance.""" return GraphService(neo4j_client, wiki_client) # Dependency to get vector service def get_vector_service( qdrant_client: QdrantDep, wiki_client: WikiJSDep, ollama_client: OllamaDep ) -> VectorService: """Get vector service instance.""" return VectorService(qdrant_client, wiki_client, ollama_client) # Page operations @router.get("/pages", response_model=WikiPageList) async def list_pages( user: RequiredUserQuery, tag: Optional[str] = Query(default=None, description="Filter by tag (dossier)"), limit: int = Query(default=50, ge=1, le=200, description="Maximum pages to return"), wiki_service: WikiService = Depends(get_wiki_service), api_key: str = Depends(verify_api_key) ): """ List wiki pages for a user. Optionally filter by tag (dossier). Pages are scoped to user's namespace. **Example:** `/wiki/pages?user=jpmschweitzer&tag=projects&limit=20` """ try: return await wiki_service.list_pages(user=user, tag=tag, limit=limit) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error(f"Failed to list pages: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Internal server error") @router.get("/pages/{page_id}", response_model=WikiPage) async def get_page( page_id: int, user: RequiredUserQuery, wiki_service: WikiService = Depends(get_wiki_service), api_key: str = Depends(verify_api_key) ): """ Get a single wiki page by ID. Access is restricted to pages within the user's namespace. **Example:** `/wiki/pages/123?user=jpmschweitzer` """ try: page = await wiki_service.get_page(page_id=page_id, user=user) if not page: raise HTTPException(status_code=404, detail=f"Page {page_id} not found or access denied") return page except HTTPException: raise except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error(f"Failed to get page {page_id}: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Internal server error") @router.post("/pages", response_model=WikiPage, status_code=201) async def create_page( page_data: WikiPageCreate, background_tasks: BackgroundTasks, wiki_service: WikiService = Depends(get_wiki_service), graph_service: GraphService = Depends(get_graph_service), vector_service: VectorService = Depends(get_vector_service), api_key: str = Depends(verify_api_key) ): """ Create a new wiki page. The page will be created in the user's namespace. If path doesn't start with namespace, it will be automatically prefixed. **Auto-updates knowledge graph and vector embeddings**: After creating the page, the graph and vectors are automatically updated in the background to extract entities/relationships and generate semantic embeddings. **Example Request:** ```json { "title": "Library Desk Architecture", "path": "/projects/library-desk/architecture", "content": "# Architecture\\n\\nThis describes...", "description": "Architecture documentation", "tags": ["projects", "architecture"], "user": "" } ``` The `user` field is REQUIRED (no default tenant). """ try: page = await wiki_service.create_page(page_data) user = page_data.user # Schedule BOTH graph and vector updates in background (non-blocking) background_tasks.add_task( graph_service.update_from_page, page_id=page.id, user=user ) background_tasks.add_task( vector_service.update_from_page, page_id=page.id, user=user ) logger.info(f"Page {page.id} created, graph and vector updates scheduled") return page except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error(f"Failed to create page: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Internal server error") @router.post("/pages/smart-create", response_model=WikiSmartCreateResponse, status_code=201) async def smart_create_page( request: WikiSmartCreateRequest, background_tasks: BackgroundTasks, wiki_client: WikiJSDep, neo4j_client: Neo4jDep, qdrant_client: QdrantDep, ollama_client: OllamaDep, settings: Settings = Depends(get_settings), api_key: str = Depends(verify_api_key) ): """ Create wiki page with intelligent research. Combines HybridRAG search with LLM content generation to create rich, well-researched wiki pages in a single API call. **Process:** 1. Runs HybridRAG search on the topic (wiki + graph + web) 2. Uses LLM to synthesize findings into structured wiki content 3. Creates the page with proper attribution/sources 4. Indexes into vectors + knowledge graph (background) 5. Applies bidirectional entity linking (background) **Example Request:** ```json { "topic": "Docker orchestration patterns", "path": "/technology/containers/docker-orchestration", "tags": ["technology", "devops", "containers"], "user": "jpmschweitzer", "include_web_research": true, "include_wiki_search": true } ``` **Returns:** - Created page with ID, path, content - Research summary (wiki/web/graph result counts) - Entity linking statistics (forward/backward links) """ try: user = request.user # Build services. HybridRAG comes from the single wiring point in # dependencies so it includes volatile_service (a previous inline # copy here lacked it). wiki_service = WikiService(wiki_client) vector_service = VectorService(qdrant_client, wiki_client, ollama_client) graph_service = GraphService(neo4j_client, wiki_client) hybrid_rag_service = get_hybrid_rag_service() wiki_page_writer = WikiPageWriter(ollama_client=ollama_client, settings=settings) # Step 1-5: Research + Generate + Create page page, research_data = await wiki_service.smart_create_page( topic=request.topic, user=user, path=request.path, tags=request.tags, hybrid_rag_service=hybrid_rag_service, wiki_page_writer=wiki_page_writer, include_web=request.include_web_research, include_wiki=request.include_wiki_search ) # Schedule graph and vector updates in background background_tasks.add_task( graph_service.update_from_page, page_id=page.id, user=user ) background_tasks.add_task( vector_service.update_from_page, page_id=page.id, user=user ) # Schedule bidirectional entity linking in background async def run_entity_linking(): ingestion_service = get_ingestion_service() return await apply_bidirectional_entity_linking( page_id=page.id, page_title=page.title, user=user, neo4j_client=neo4j_client, wiki_service=wiki_service, ingestion_service=ingestion_service ) background_tasks.add_task(run_entity_linking) logger.info( f"Smart page created: id={page.id}, path={page.path}, " f"sources={research_data['sources_used']}" ) return WikiSmartCreateResponse( page=page, research_summary=research_data["research_summary"], sources_used=research_data["sources_used"], search_id=research_data["search_id"], entity_linking={"forward_links": 0, "backward_links": 0, "pages_updated": 0} # Note: entity_linking stats are 0 here as it runs in background ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error(f"Failed to smart create page: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Internal server error") @router.put("/pages/{page_id}", response_model=WikiPage) async def update_page( page_id: int, page_data: WikiPageUpdate, background_tasks: BackgroundTasks, user: RequiredUserQuery, wiki_service: WikiService = Depends(get_wiki_service), graph_service: GraphService = Depends(get_graph_service), vector_service: VectorService = Depends(get_vector_service), api_key: str = Depends(verify_api_key) ): """ Update an existing wiki page. Only pages within the user's namespace can be updated. Partial updates are supported - only provided fields will be updated. **Auto-updates knowledge graph and vector embeddings**: After updating the page, the graph and vectors are automatically refreshed in the background to reflect the changes. **Example Request:** ```json { "title": "Updated Title", "tags": ["projects", "updated"] } ``` """ try: page = await wiki_service.update_page( page_id=page_id, page_data=page_data, user=user ) # Schedule BOTH graph and vector updates in background (non-blocking) background_tasks.add_task( graph_service.update_from_page, page_id=page_id, user=user, force_refresh=True # Force refresh on updates ) background_tasks.add_task( vector_service.update_from_page, page_id=page_id, user=user, force_refresh=True # Force refresh on updates ) logger.info(f"Page {page_id} updated, graph and vector refresh scheduled") return page except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error(f"Failed to update page {page_id}: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Internal server error") @router.delete("/pages/{page_id}", response_model=WikiOperationResponse) async def delete_page( page_id: int, background_tasks: BackgroundTasks, user: RequiredUserQuery, wiki_service: WikiService = Depends(get_wiki_service), vector_service: VectorService = Depends(get_vector_service), graph_service: GraphService = Depends(get_graph_service), api_key: str = Depends(verify_api_key) ): """ Delete a wiki page. Only pages within the user's namespace can be deleted. This operation cannot be undone. **Auto-cleanup**: Vector chunks and graph nodes for this page are automatically deleted in the background. """ try: success = await wiki_service.delete_page(page_id=page_id, user=user) # Schedule vector cleanup in background background_tasks.add_task( vector_service.delete_page_chunks, page_id=page_id, user=user ) # Schedule graph cleanup in background background_tasks.add_task( graph_service.delete_page, page_id=page_id, user=user ) logger.info(f"Page {page_id} deleted, vector and graph cleanup scheduled") return WikiOperationResponse( success=success, message=f"Page {page_id} deleted successfully", page_id=page_id ) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) except Exception as e: logger.error(f"Failed to delete page {page_id}: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Internal server error") @router.post("/pages/{page_id}/move", response_model=WikiOperationResponse) async def move_page( page_id: int, move_data: WikiPageMove, user: RequiredUserQuery, wiki_service: WikiService = Depends(get_wiki_service), api_key: str = Depends(verify_api_key) ): """ Move or rename a wiki page. The new path must be within the user's namespace. **Example Request:** ```json { "new_path": "/projects/library-desk/docs/architecture" } ``` """ try: success = await wiki_service.move_page( page_id=page_id, new_path=move_data.new_path, user=user ) if not success: raise HTTPException(status_code=500, detail="Failed to move page") return WikiOperationResponse( success=True, message=f"Page {page_id} moved successfully", page_id=page_id, page_path=move_data.new_path ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except HTTPException: raise except Exception as e: logger.error(f"Failed to move page {page_id}: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Internal server error") # Search operations @router.get("/search", response_model=WikiSearchResponse) async def search_pages( user: RequiredUserQuery, q: str = Query(..., min_length=1, description="Search query"), limit: int = Query(default=20, ge=1, le=100, description="Maximum results"), wiki_service: WikiService = Depends(get_wiki_service), api_key: str = Depends(verify_api_key) ): """ Search wiki pages within user's namespace. **Example:** `/wiki/search?q=architecture&user=jpmschweitzer&limit=10` """ try: results = await wiki_service.search_pages( query=q, user=user, limit=limit ) return WikiSearchResponse( results=[ WikiSearchResult( id=r.id, path=r.path, title=r.title, description=r.description, relevance=None ) for r in results ], query=q, total=len(results) ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error(f"Search failed: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Internal server error") # Dossier operations @router.get("/dossiers", response_model=DossierList) async def list_dossiers( user: RequiredUserQuery, wiki_service: WikiService = Depends(get_wiki_service), api_key: str = Depends(verify_api_key) ): """ List all dossiers (unique tags) for a user. Dossiers are tag-based collections of pages. **Example:** `/wiki/dossiers?user=jpmschweitzer` """ try: return await wiki_service.list_dossiers(user=user) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error(f"Failed to list dossiers: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Internal server error") @router.get("/dossiers/{dossier_name}/pages", response_model=WikiPageList) async def get_dossier_pages( dossier_name: str, user: RequiredUserQuery, limit: int = Query(default=100, ge=1, le=500, description="Maximum pages"), wiki_service: WikiService = Depends(get_wiki_service), api_key: str = Depends(verify_api_key) ): """ Get all pages in a dossier. Returns pages tagged with the dossier name. **Example:** `/wiki/dossiers/projects/pages?user=jpmschweitzer` """ try: return await wiki_service.get_dossier_pages( dossier_name=dossier_name, user=user, limit=limit ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error(f"Failed to get dossier pages: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Internal server error")