Files
library-desk/src/routers/graph.py
T
jpmschweitzerandClaude a687b770ef fix: clear ruff so the pre-push gate passes
105 findings to zero. Most were mechanical — 67 unused imports, and assorted
f-strings without placeholders. Three groups needed a decision.

The 15 F821 "undefined name" were forward references, not runtime errors. Each
annotation is quoted — `-> "WikiService"`, `Optional["IngestionService"]` — with
the real import inside the function body to break an import cycle. A quoted
annotation is never evaluated, so the code ran; the names were simply
unresolvable to any checker. They now have a TYPE_CHECKING block, which costs
nothing at import time and keeps the cycle broken.

The 6 E402 split two ways. `import secrets`, `Security`, `Request` and
`HTTPBearer` in dependencies.py had drifted below several hundred lines of
factory functions for no reason — stdlib and fastapi, no cycle to avoid — and
moved up. The other three are deliberate and now say so: the VectorService and
GraphService aliases import back into dependencies.py, and main.py's routers
expect a configured app, so both must stay put.

Bare `except:` narrowed to `except Exception:` in three places, which stops them
swallowing KeyboardInterrupt and SystemExit.

The 5 unused locals were all genuinely dead. One is worth naming rather than
fixing: qdrant_client.delete()'s return value was bound and never read, so a
failed delete is indistinguishable from a successful one — the assignment is
gone, but nothing checks the status either way and that has not changed here.
`timing = {}` in _retrieve_parallel looked like it might mean the reported
per-leg timings were always zero; traced, and they come from output["timing"],
so the local was only vestigial.

426 passed, 29 skipped, unchanged. The app imports and the service aliases still
resolve, which is the check that mattered after moving imports in
dependencies.py.

The gate still prints "not gated here yet: test (T-56)" — lint is green, tests
remain unwired, and that is left visible rather than silently absent.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 17:04:58 +02:00

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,
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")