/query/graph scoping was a documented no-op (graph_service returned the query unscoped) and neo4j_client permitted writes; a live probe showed a nonexistent user could read the whole graph. - Add Neo4jClient.execute_read() that opens the session with default_access_mode=READ_ACCESS so the database refuses writes even if validation is bypassed. - GraphService.execute_query() now rejects queries containing CREATE/MERGE/DELETE/DETACH/SET/REMOVE/DROP/FOREACH/LOAD or any CALL (conservative word-boundary denylist on the uppercased query) and executes through the read-only session; the no-op _scope_query_to_user is removed. - Remove the false user-scoping claims from /query/graph (main.py) and /graph/query docs and the CypherQueryRequest model: the endpoints are documented as admin/debug, unscoped read-only (per-tenant label injection for arbitrary Cypher would need a real parser; /graph/nodes remains the tenant-scoped path). - Offline unit tests: denylist coverage (incl. lowercase/multiline/CALL), word-boundary false-positive check, and READ_ACCESS session assertion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
256 lines
8.5 KiB
Python
256 lines
8.5 KiB
Python
"""
|
|
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_<Tenant>_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": "<tenant>"
|
|
}
|
|
```
|
|
"""
|
|
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=<tenant>&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=<tenant>`
|
|
"""
|
|
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=<tenant>`
|
|
|
|
**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")
|