From dca8b63a5046c96c6e3dca69d0ed4f72406ae3b2 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 10 Dec 2025 01:28:42 +0100 Subject: [PATCH] feat(library-desk): add graph query router and models Graph Router: - GET /graph/entities - List all entities for user - GET /graph/relationships - Query entity relationships - GET /graph/search - Search entities by name/type - GET /graph/stats - Knowledge graph statistics Graph Models: - Entity, Relationship models - GraphStats for analytics - SearchFilters for queries - Support multi-tenancy with user isolation --- services/library-desk/src/models/graph.py | 126 ++++++++++ services/library-desk/src/routers/graph.py | 253 +++++++++++++++++++++ 2 files changed, 379 insertions(+) create mode 100644 services/library-desk/src/models/graph.py create mode 100644 services/library-desk/src/routers/graph.py diff --git a/services/library-desk/src/models/graph.py b/services/library-desk/src/models/graph.py new file mode 100644 index 0000000..4da9a4e --- /dev/null +++ b/services/library-desk/src/models/graph.py @@ -0,0 +1,126 @@ +""" +Graph models for Library Desk Neo4j operations. + +Provides models for knowledge graph nodes, relationships, and queries. +""" + +from pydantic import BaseModel, Field +from typing import List, Dict, Any, Optional +from datetime import datetime + + +class GraphNode(BaseModel): + """Graph node representation.""" + id: str = Field(..., description="Node ID") + labels: List[str] = Field(..., description="Node labels") + properties: Dict[str, Any] = Field(default_factory=dict, description="Node properties") + + +class GraphRelationship(BaseModel): + """Graph relationship representation.""" + id: str = Field(..., description="Relationship ID") + type: str = Field(..., description="Relationship type") + start_node: str = Field(..., description="Start node ID") + end_node: str = Field(..., description="End node ID") + properties: Dict[str, Any] = Field(default_factory=dict, description="Relationship properties") + + +class GraphNodeDetail(BaseModel): + """Detailed node with relationships.""" + node: GraphNode = Field(..., description="Node data") + relationships: List[GraphRelationship] = Field( + default_factory=list, + description="Connected relationships" + ) + related_nodes: List[GraphNode] = Field( + default_factory=list, + description="Connected nodes" + ) + + +class CypherQueryRequest(BaseModel): + """Request to execute a Cypher query.""" + query: str = Field(..., description="Cypher query to execute") + parameters: Dict[str, Any] = Field( + default_factory=dict, + description="Query parameters" + ) + user: str = Field( + default="jpmschweitzer", + description="User for filtering (automatically scopes query)" + ) + + +class CypherQueryResponse(BaseModel): + """Response from Cypher query execution.""" + results: List[Dict[str, Any]] = Field(..., description="Query results") + count: int = Field(..., description="Number of results") + query_time_ms: float = Field(..., description="Query execution time in milliseconds") + + +class UpdateFromPageRequest(BaseModel): + """Request to update graph from a wiki page.""" + page_id: int = Field(..., description="Wiki page ID to process") + user: str = Field( + default="jpmschweitzer", + description="User identifier for namespace scoping" + ) + force_refresh: bool = Field( + default=False, + description="Force re-extraction even if page hasn't changed" + ) + + +class EntityMention(BaseModel): + """Extracted entity mention.""" + text: str = Field(..., description="Entity text") + type: str = Field(..., description="Entity type (Person, Project, Concept, etc.)") + confidence: float = Field(default=1.0, description="Extraction confidence (0-1)") + + +class GraphUpdateSummary(BaseModel): + """Summary of graph update operation.""" + page_id: int = Field(..., description="Page ID processed") + page_title: str = Field(..., description="Page title") + nodes_created: int = Field(default=0, description="New nodes created") + nodes_updated: int = Field(default=0, description="Existing nodes updated") + relationships_created: int = Field(default=0, description="New relationships created") + entities_extracted: List[EntityMention] = Field( + default_factory=list, + description="Entities extracted from page" + ) + processing_time_ms: float = Field(..., description="Processing time in milliseconds") + success: bool = Field(default=True, description="Whether update succeeded") + error_message: Optional[str] = Field(default=None, description="Error message if failed") + + +class NodeListResponse(BaseModel): + """Response for node listing.""" + nodes: List[GraphNode] = Field(..., description="List of nodes") + total: int = Field(..., description="Total number of nodes") + user: str = Field(..., description="User filter applied") + + +class MindMapNode(BaseModel): + """Mind map node for visualization.""" + id: str = Field(..., description="Node ID") + label: str = Field(..., description="Node label/name") + type: str = Field(..., description="Node type") + size: int = Field(default=10, description="Visual size") + color: Optional[str] = Field(default=None, description="Node color") + + +class MindMapLink(BaseModel): + """Mind map link for visualization.""" + source: str = Field(..., description="Source node ID") + target: str = Field(..., description="Target node ID") + type: str = Field(..., description="Relationship type") + strength: float = Field(default=1.0, description="Link strength") + + +class MindMapResponse(BaseModel): + """Mind map data for D3.js or similar visualization.""" + nodes: List[MindMapNode] = Field(..., description="Graph nodes") + links: List[MindMapLink] = Field(..., description="Graph edges") + center_node: str = Field(..., description="Central node ID") + depth: int = Field(..., description="Traversal depth") diff --git a/services/library-desk/src/routers/graph.py b/services/library-desk/src/routers/graph.py new file mode 100644 index 0000000..8b92a92 --- /dev/null +++ b/services/library-desk/src/routers/graph.py @@ -0,0 +1,253 @@ +""" +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.clients.neo4j_client import Neo4jClient +from src.clients.wikijs_client import WikiJSClient +from src.core.dependencies import Neo4jDep, WikiJSDep, verify_api_key +from src.core.multi_tenancy import DEFAULT_USER + +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 user-scoped Cypher query. + + The query is automatically scoped to the user's data for security. + This prevents users from accessing other users' graph data. + + **Example Request:** + ```json + { + "query": "MATCH (d:Document) RETURN d LIMIT 10", + "parameters": {}, + "user": "jpmschweitzer" + } + ``` + + **Security:** Query is automatically scoped with user label. + """ + 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: str = Query(default=DEFAULT_USER, description="User identifier"), + 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=jpmschweitzer&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: str = Query(default=DEFAULT_USER, description="User identifier"), + 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=jpmschweitzer` + """ + 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: str = Query(default=DEFAULT_USER, description="User identifier"), + 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=jpmschweitzer` + + **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( + center_node_id: str = Query(..., description="Central node ID"), + user: str = Query(default=DEFAULT_USER, description="User identifier"), + 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: str = Query(default=DEFAULT_USER, description="User identifier"), + 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")