""" Graph router for Library Desk API. Endpoints for Neo4j knowledge graph operations. """ from fastapi import APIRouter, HTTPException, Depends, Query from typing import Optional, List import logging from src.models.graph import ( CypherQueryRequest, CypherQueryResponse, UpdateFromPageRequest, GraphUpdateSummary, NodeListResponse, GraphNodeDetail, MindMapResponse ) from src.services.graph_service import GraphService from src.core.dependencies import ( Neo4jDep, WikiJSDep, verify_api_key, RequiredUserQuery ) logger = logging.getLogger(__name__) router = APIRouter(prefix="/graph", tags=["Graph"]) # 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) @router.post("/query", response_model=CypherQueryResponse) async def execute_cypher_query( request: CypherQueryRequest, graph_service: GraphService = Depends(get_graph_service), api_key: str = Depends(verify_api_key) ): """ Execute a raw Cypher query (ADMIN/DEBUG — read-only, NOT tenant-scoped). **Security model:** - Write clauses (CREATE/MERGE/DELETE/SET/REMOVE/DROP/DETACH/FOREACH/ LOAD CSV) and CALL procedures are rejected with 400. - Execution happens in a read-only Neo4j session as a hard backstop. - Results are NOT restricted to the requesting user's tenant labels — scope your own patterns (e.g. match `User__Document`). For tenant-scoped access use /graph/nodes instead. **Example Request:** ```json { "query": "MATCH (d:User_Llm_Tester_Document:Document) RETURN d LIMIT 10", "parameters": {}, "user": "" } ``` """ try: return await graph_service.execute_query( query=request.query, parameters=request.parameters, user=request.user ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error(f"Cypher query failed: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Query execution failed") @router.get("/nodes", response_model=NodeListResponse) async def list_nodes( user: RequiredUserQuery, node_type: Optional[str] = Query(default=None, description="Node type filter"), limit: int = Query(default=100, ge=1, le=500, description="Maximum nodes"), graph_service: GraphService = Depends(get_graph_service), api_key: str = Depends(verify_api_key) ): """ List graph nodes for a user. Optionally filter by node type (Document, Person, Project, Concept, etc.). **Example:** `/graph/nodes?user=&node_type=Document&limit=50` """ try: return await graph_service.list_nodes( user=user, node_type=node_type, limit=limit ) except Exception as e: logger.error(f"Failed to list nodes: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Failed to list nodes") @router.get("/nodes/{node_id}", response_model=GraphNodeDetail) async def get_node( node_id: str, user: RequiredUserQuery, graph_service: GraphService = Depends(get_graph_service), api_key: str = Depends(verify_api_key) ): """ Get detailed information about a graph node. Returns the node, its relationships, and connected nodes. **Example:** `/graph/nodes/4:abc123def:0?user=` """ try: node = await graph_service.get_node(node_id, user) if not node: raise HTTPException(status_code=404, detail=f"Node {node_id} not found") return node except HTTPException: raise except Exception as e: logger.error(f"Failed to get node {node_id}: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Failed to get node") @router.post("/update-from-page/{page_id}", response_model=GraphUpdateSummary) async def update_graph_from_page( page_id: int, user: RequiredUserQuery, force_refresh: bool = Query(default=False, description="Force re-extraction"), graph_service: GraphService = Depends(get_graph_service), api_key: str = Depends(verify_api_key) ): """ Update knowledge graph from a wiki page. This endpoint: 1. Fetches the page from Wiki.js 2. Extracts entities (people, projects, concepts, technologies) 3. Creates/updates Document node 4. Creates entity nodes and MENTIONS relationships 5. Returns summary of what was updated **Use Cases:** - Called automatically after page creation/update (via BackgroundTasks) - Called manually by user/Librarian to refresh graph - Called by Scheduler for batch processing **Example:** `POST /graph/update-from-page/4?user=` **Returns:** Summary with nodes/relationships created and entities extracted """ try: summary = await graph_service.update_from_page( page_id=page_id, user=user, force_refresh=force_refresh ) if not summary.success: raise HTTPException( status_code=500, detail=f"Graph update failed: {summary.error_message}" ) return summary except HTTPException: raise except Exception as e: logger.error(f"Failed to update graph from page {page_id}: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Graph update failed") @router.post("/mindmap", response_model=MindMapResponse) async def generate_mindmap( user: RequiredUserQuery, center_node_id: str = Query(..., description="Central node ID"), depth: int = Query(default=2, ge=1, le=5, description="Traversal depth"), graph_service: GraphService = Depends(get_graph_service), api_key: str = Depends(verify_api_key) ): """ Generate mind map data for visualization. Traverses the graph from a center node and returns nodes/links in a format suitable for D3.js or similar visualization libraries. **Parameters:** - `center_node_id`: The node to center the mind map on - `depth`: How many hops away from center to include (1-5) - `user`: User identifier for scoping **Example:** `POST /graph/mindmap?center_node_id=4:abc:0&depth=2` **Returns:** Nodes and links for visualization """ try: return await graph_service.generate_mindmap( center_node_id=center_node_id, user=user, depth=depth ) except Exception as e: logger.error(f"Failed to generate mindmap: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Mindmap generation failed") @router.post("/generate-entity-pages") async def generate_entity_pages( user: RequiredUserQuery, min_mentions: int = Query(default=5, ge=1, le=100, description="Minimum mentions threshold"), entity_types: Optional[List[str]] = Query(default=None, description="Entity types to process"), graph_service: GraphService = Depends(get_graph_service), api_key: str = Depends(verify_api_key) ): """ Generate wiki stub pages for graph entities. Creates pages in `/entities/{type}/{name}` namespace for entities that have been mentioned in multiple documents. This creates a bidirectional knowledge graph ↔ wiki synchronization. **Threshold:** Entities must be mentioned in at least `min_mentions` documents (default: 5) **Auto-stub flow:** 1. Find entities with >= min_mentions 2. Check if entity already has a wiki page 3. If not, create stub page with: - List of mentioning documents - Related entities (co-occurring) - Auto-generated tag to prevent feedback loops **Feedback loop protection:** Pages tagged with `entity-stub` skip entity extraction **Parameters:** - `min_mentions`: Minimum number of document mentions required (default: 5) - `entity_types`: List of types to process (default: Person, Technology, Concept, Project) - `user`: User identifier for scoping **Example:** `POST /graph/generate-entity-pages?min_mentions=3&user=jpmschweitzer` **Returns:** Summary with list of pages created and skipped """ try: result = await graph_service.generate_entity_stubs( user=user, min_mentions=min_mentions, entity_types=entity_types ) return result except Exception as e: logger.error(f"Failed to generate entity pages: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Entity page generation failed")