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:
@@ -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")
|
||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user