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
This commit is contained in:
2025-12-10 01:28:42 +01:00
parent 8f29bfe064
commit dca8b63a50
2 changed files with 379 additions and 0 deletions
+126
View File
@@ -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")
+253
View File
@@ -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")