- Remove unused get_default_user() from dependencies.py - Remove unused imports from routers: - wiki.py: HTTPAuthorizationCredentials, Security - graph.py: Neo4jClient, WikiJSClient - hybrid_rag.py: VectorService, GraphService (duplicates) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
536 lines
18 KiB
Python
536 lines
18 KiB
Python
"""
|
|
Wiki router for Library Desk API.
|
|
|
|
Endpoints for wiki page and dossier management.
|
|
All operations are scoped to user namespaces for multi-tenancy.
|
|
"""
|
|
|
|
from fastapi import APIRouter, HTTPException, Depends, Query, BackgroundTasks
|
|
from typing import Optional
|
|
import logging
|
|
|
|
from src.models.wiki import (
|
|
WikiPage, WikiPageList, WikiPageCreate, WikiPageUpdate, WikiPageMove,
|
|
WikiOperationResponse, WikiSearchResponse,
|
|
DossierList, WikiSearchResult,
|
|
WikiSmartCreateRequest, WikiSmartCreateResponse
|
|
)
|
|
from src.services.wiki_service import WikiService
|
|
from src.services.graph_service import GraphService
|
|
from src.services.vector_service import VectorService
|
|
from src.clients.wikijs_client import WikiJSClient
|
|
from src.clients.neo4j_client import Neo4jClient
|
|
from src.clients.qdrant_client import QdrantClientWrapper
|
|
from src.clients.ollama_client import OllamaClient
|
|
from src.core.dependencies import (
|
|
WikiJSDep, Neo4jDep, QdrantDep, OllamaDep, SearXNGDep, ContentExtractorDep,
|
|
verify_api_key, get_settings, get_hybrid_rag_service, get_ingestion_service
|
|
)
|
|
from src.core.multi_tenancy import DEFAULT_USER
|
|
from src.services.hybrid_rag_service import HybridRAGService
|
|
from src.services.wiki_page_writer import WikiPageWriter
|
|
from src.services.entity_linking_utils import apply_bidirectional_entity_linking
|
|
from src.config import Settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/wiki", tags=["Wiki"])
|
|
|
|
|
|
# Dependency to get wiki service
|
|
def get_wiki_service(wiki_client: WikiJSDep) -> WikiService:
|
|
"""Get wiki service instance."""
|
|
return WikiService(wiki_client)
|
|
|
|
|
|
# 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)
|
|
|
|
|
|
# Dependency to get vector service
|
|
def get_vector_service(
|
|
qdrant_client: QdrantDep,
|
|
wiki_client: WikiJSDep,
|
|
ollama_client: OllamaDep
|
|
) -> VectorService:
|
|
"""Get vector service instance."""
|
|
return VectorService(qdrant_client, wiki_client, ollama_client)
|
|
|
|
|
|
# Page operations
|
|
@router.get("/pages", response_model=WikiPageList)
|
|
async def list_pages(
|
|
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
|
tag: Optional[str] = Query(default=None, description="Filter by tag (dossier)"),
|
|
limit: int = Query(default=50, ge=1, le=200, description="Maximum pages to return"),
|
|
wiki_service: WikiService = Depends(get_wiki_service),
|
|
api_key: str = Depends(verify_api_key)
|
|
):
|
|
"""
|
|
List wiki pages for a user.
|
|
|
|
Optionally filter by tag (dossier). Pages are scoped to user's namespace.
|
|
|
|
**Example:** `/wiki/pages?user=jpmschweitzer&tag=projects&limit=20`
|
|
"""
|
|
try:
|
|
return await wiki_service.list_pages(user=user, tag=tag, limit=limit)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
except Exception as e:
|
|
logger.error(f"Failed to list pages: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail="Internal server error")
|
|
|
|
|
|
@router.get("/pages/{page_id}", response_model=WikiPage)
|
|
async def get_page(
|
|
page_id: int,
|
|
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
|
wiki_service: WikiService = Depends(get_wiki_service),
|
|
api_key: str = Depends(verify_api_key)
|
|
):
|
|
"""
|
|
Get a single wiki page by ID.
|
|
|
|
Access is restricted to pages within the user's namespace.
|
|
|
|
**Example:** `/wiki/pages/123?user=jpmschweitzer`
|
|
"""
|
|
try:
|
|
page = await wiki_service.get_page(page_id=page_id, user=user)
|
|
if not page:
|
|
raise HTTPException(status_code=404, detail=f"Page {page_id} not found or access denied")
|
|
return page
|
|
except HTTPException:
|
|
raise
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
except Exception as e:
|
|
logger.error(f"Failed to get page {page_id}: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail="Internal server error")
|
|
|
|
|
|
@router.post("/pages", response_model=WikiPage, status_code=201)
|
|
async def create_page(
|
|
page_data: WikiPageCreate,
|
|
background_tasks: BackgroundTasks,
|
|
wiki_service: WikiService = Depends(get_wiki_service),
|
|
graph_service: GraphService = Depends(get_graph_service),
|
|
vector_service: VectorService = Depends(get_vector_service),
|
|
api_key: str = Depends(verify_api_key)
|
|
):
|
|
"""
|
|
Create a new wiki page.
|
|
|
|
The page will be created in the user's namespace. If path doesn't start
|
|
with namespace, it will be automatically prefixed.
|
|
|
|
**Auto-updates knowledge graph and vector embeddings**: After creating
|
|
the page, the graph and vectors are automatically updated in the
|
|
background to extract entities/relationships and generate semantic embeddings.
|
|
|
|
**Example Request:**
|
|
```json
|
|
{
|
|
"title": "Library Desk Architecture",
|
|
"path": "/projects/library-desk/architecture",
|
|
"content": "# Architecture\\n\\nThis describes...",
|
|
"description": "Architecture documentation",
|
|
"tags": ["projects", "architecture"],
|
|
"user": "jpmschweitzer"
|
|
}
|
|
```
|
|
"""
|
|
try:
|
|
page = await wiki_service.create_page(page_data)
|
|
|
|
user = page_data.user or DEFAULT_USER
|
|
|
|
# Schedule BOTH graph and vector updates in background (non-blocking)
|
|
background_tasks.add_task(
|
|
graph_service.update_from_page,
|
|
page_id=page.id,
|
|
user=user
|
|
)
|
|
background_tasks.add_task(
|
|
vector_service.update_from_page,
|
|
page_id=page.id,
|
|
user=user
|
|
)
|
|
|
|
logger.info(f"Page {page.id} created, graph and vector updates scheduled")
|
|
return page
|
|
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
except Exception as e:
|
|
logger.error(f"Failed to create page: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail="Internal server error")
|
|
|
|
|
|
@router.post("/pages/smart-create", response_model=WikiSmartCreateResponse, status_code=201)
|
|
async def smart_create_page(
|
|
request: WikiSmartCreateRequest,
|
|
background_tasks: BackgroundTasks,
|
|
wiki_client: WikiJSDep,
|
|
neo4j_client: Neo4jDep,
|
|
qdrant_client: QdrantDep,
|
|
ollama_client: OllamaDep,
|
|
searxng_client: SearXNGDep,
|
|
content_extractor: ContentExtractorDep,
|
|
settings: Settings = Depends(get_settings),
|
|
api_key: str = Depends(verify_api_key)
|
|
):
|
|
"""
|
|
Create wiki page with intelligent research.
|
|
|
|
Combines HybridRAG search with LLM content generation to create
|
|
rich, well-researched wiki pages in a single API call.
|
|
|
|
**Process:**
|
|
1. Runs HybridRAG search on the topic (wiki + graph + web)
|
|
2. Uses LLM to synthesize findings into structured wiki content
|
|
3. Creates the page with proper attribution/sources
|
|
4. Indexes into vectors + knowledge graph (background)
|
|
5. Applies bidirectional entity linking (background)
|
|
|
|
**Example Request:**
|
|
```json
|
|
{
|
|
"topic": "Docker orchestration patterns",
|
|
"path": "/technology/containers/docker-orchestration",
|
|
"tags": ["technology", "devops", "containers"],
|
|
"user": "jpmschweitzer",
|
|
"include_web_research": true,
|
|
"include_wiki_search": true
|
|
}
|
|
```
|
|
|
|
**Returns:**
|
|
- Created page with ID, path, content
|
|
- Research summary (wiki/web/graph result counts)
|
|
- Entity linking statistics (forward/backward links)
|
|
"""
|
|
try:
|
|
user = request.user or DEFAULT_USER
|
|
|
|
# Build services
|
|
wiki_service = WikiService(wiki_client)
|
|
vector_service = VectorService(qdrant_client, wiki_client, ollama_client)
|
|
graph_service = GraphService(neo4j_client, wiki_client)
|
|
hybrid_rag_service = HybridRAGService(
|
|
vector_service=vector_service,
|
|
graph_service=graph_service,
|
|
searxng_client=searxng_client,
|
|
ollama_client=ollama_client,
|
|
content_extractor=content_extractor,
|
|
settings=settings
|
|
)
|
|
wiki_page_writer = WikiPageWriter(ollama_client=ollama_client, settings=settings)
|
|
|
|
# Step 1-5: Research + Generate + Create page
|
|
page, research_data = await wiki_service.smart_create_page(
|
|
topic=request.topic,
|
|
user=user,
|
|
path=request.path,
|
|
tags=request.tags,
|
|
hybrid_rag_service=hybrid_rag_service,
|
|
wiki_page_writer=wiki_page_writer,
|
|
include_web=request.include_web_research,
|
|
include_wiki=request.include_wiki_search
|
|
)
|
|
|
|
# Schedule graph and vector updates in background
|
|
background_tasks.add_task(
|
|
graph_service.update_from_page,
|
|
page_id=page.id,
|
|
user=user
|
|
)
|
|
background_tasks.add_task(
|
|
vector_service.update_from_page,
|
|
page_id=page.id,
|
|
user=user
|
|
)
|
|
|
|
# Schedule bidirectional entity linking in background
|
|
async def run_entity_linking():
|
|
ingestion_service = get_ingestion_service()
|
|
return await apply_bidirectional_entity_linking(
|
|
page_id=page.id,
|
|
page_title=page.title,
|
|
user=user,
|
|
neo4j_client=neo4j_client,
|
|
wiki_service=wiki_service,
|
|
ingestion_service=ingestion_service
|
|
)
|
|
|
|
background_tasks.add_task(run_entity_linking)
|
|
|
|
logger.info(
|
|
f"Smart page created: id={page.id}, path={page.path}, "
|
|
f"sources={research_data['sources_used']}"
|
|
)
|
|
|
|
return WikiSmartCreateResponse(
|
|
page=page,
|
|
research_summary=research_data["research_summary"],
|
|
sources_used=research_data["sources_used"],
|
|
search_id=research_data["search_id"],
|
|
entity_linking={"forward_links": 0, "backward_links": 0, "pages_updated": 0}
|
|
# Note: entity_linking stats are 0 here as it runs in background
|
|
)
|
|
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
except Exception as e:
|
|
logger.error(f"Failed to smart create page: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail="Internal server error")
|
|
|
|
|
|
@router.put("/pages/{page_id}", response_model=WikiPage)
|
|
async def update_page(
|
|
page_id: int,
|
|
page_data: WikiPageUpdate,
|
|
background_tasks: BackgroundTasks,
|
|
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
|
wiki_service: WikiService = Depends(get_wiki_service),
|
|
graph_service: GraphService = Depends(get_graph_service),
|
|
vector_service: VectorService = Depends(get_vector_service),
|
|
api_key: str = Depends(verify_api_key)
|
|
):
|
|
"""
|
|
Update an existing wiki page.
|
|
|
|
Only pages within the user's namespace can be updated.
|
|
Partial updates are supported - only provided fields will be updated.
|
|
|
|
**Auto-updates knowledge graph and vector embeddings**: After updating
|
|
the page, the graph and vectors are automatically refreshed in the
|
|
background to reflect the changes.
|
|
|
|
**Example Request:**
|
|
```json
|
|
{
|
|
"title": "Updated Title",
|
|
"tags": ["projects", "updated"]
|
|
}
|
|
```
|
|
"""
|
|
try:
|
|
page = await wiki_service.update_page(
|
|
page_id=page_id,
|
|
page_data=page_data,
|
|
user=user
|
|
)
|
|
|
|
# Schedule BOTH graph and vector updates in background (non-blocking)
|
|
background_tasks.add_task(
|
|
graph_service.update_from_page,
|
|
page_id=page_id,
|
|
user=user,
|
|
force_refresh=True # Force refresh on updates
|
|
)
|
|
background_tasks.add_task(
|
|
vector_service.update_from_page,
|
|
page_id=page_id,
|
|
user=user,
|
|
force_refresh=True # Force refresh on updates
|
|
)
|
|
|
|
logger.info(f"Page {page_id} updated, graph and vector refresh scheduled")
|
|
return page
|
|
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
except Exception as e:
|
|
logger.error(f"Failed to update page {page_id}: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail="Internal server error")
|
|
|
|
|
|
@router.delete("/pages/{page_id}", response_model=WikiOperationResponse)
|
|
async def delete_page(
|
|
page_id: int,
|
|
background_tasks: BackgroundTasks,
|
|
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
|
wiki_service: WikiService = Depends(get_wiki_service),
|
|
vector_service: VectorService = Depends(get_vector_service),
|
|
graph_service: GraphService = Depends(get_graph_service),
|
|
api_key: str = Depends(verify_api_key)
|
|
):
|
|
"""
|
|
Delete a wiki page.
|
|
|
|
Only pages within the user's namespace can be deleted.
|
|
This operation cannot be undone.
|
|
|
|
**Auto-cleanup**: Vector chunks and graph nodes for this page are
|
|
automatically deleted in the background.
|
|
"""
|
|
try:
|
|
success = await wiki_service.delete_page(page_id=page_id, user=user)
|
|
|
|
# Schedule vector cleanup in background
|
|
background_tasks.add_task(
|
|
vector_service.delete_page_chunks,
|
|
page_id=page_id,
|
|
user=user
|
|
)
|
|
|
|
# Schedule graph cleanup in background
|
|
background_tasks.add_task(
|
|
graph_service.delete_page,
|
|
page_id=page_id,
|
|
user=user
|
|
)
|
|
|
|
logger.info(f"Page {page_id} deleted, vector and graph cleanup scheduled")
|
|
return WikiOperationResponse(
|
|
success=success,
|
|
message=f"Page {page_id} deleted successfully",
|
|
page_id=page_id
|
|
)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
except Exception as e:
|
|
logger.error(f"Failed to delete page {page_id}: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail="Internal server error")
|
|
|
|
|
|
@router.post("/pages/{page_id}/move", response_model=WikiOperationResponse)
|
|
async def move_page(
|
|
page_id: int,
|
|
move_data: WikiPageMove,
|
|
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
|
wiki_service: WikiService = Depends(get_wiki_service),
|
|
api_key: str = Depends(verify_api_key)
|
|
):
|
|
"""
|
|
Move or rename a wiki page.
|
|
|
|
The new path must be within the user's namespace.
|
|
|
|
**Example Request:**
|
|
```json
|
|
{
|
|
"new_path": "/projects/library-desk/docs/architecture"
|
|
}
|
|
```
|
|
"""
|
|
try:
|
|
success = await wiki_service.move_page(
|
|
page_id=page_id,
|
|
new_path=move_data.new_path,
|
|
user=user
|
|
)
|
|
if not success:
|
|
raise HTTPException(status_code=500, detail="Failed to move page")
|
|
|
|
return WikiOperationResponse(
|
|
success=True,
|
|
message=f"Page {page_id} moved successfully",
|
|
page_id=page_id,
|
|
page_path=move_data.new_path
|
|
)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Failed to move page {page_id}: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail="Internal server error")
|
|
|
|
|
|
# Search operations
|
|
@router.get("/search", response_model=WikiSearchResponse)
|
|
async def search_pages(
|
|
q: str = Query(..., min_length=1, description="Search query"),
|
|
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
|
limit: int = Query(default=20, ge=1, le=100, description="Maximum results"),
|
|
wiki_service: WikiService = Depends(get_wiki_service),
|
|
api_key: str = Depends(verify_api_key)
|
|
):
|
|
"""
|
|
Search wiki pages within user's namespace.
|
|
|
|
**Example:** `/wiki/search?q=architecture&user=jpmschweitzer&limit=10`
|
|
"""
|
|
try:
|
|
results = await wiki_service.search_pages(
|
|
query=q,
|
|
user=user,
|
|
limit=limit
|
|
)
|
|
|
|
return WikiSearchResponse(
|
|
results=[
|
|
WikiSearchResult(
|
|
id=r.id,
|
|
path=r.path,
|
|
title=r.title,
|
|
description=r.description,
|
|
relevance=None
|
|
)
|
|
for r in results
|
|
],
|
|
query=q,
|
|
total=len(results)
|
|
)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
except Exception as e:
|
|
logger.error(f"Search failed: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail="Internal server error")
|
|
|
|
|
|
# Dossier operations
|
|
@router.get("/dossiers", response_model=DossierList)
|
|
async def list_dossiers(
|
|
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
|
wiki_service: WikiService = Depends(get_wiki_service),
|
|
api_key: str = Depends(verify_api_key)
|
|
):
|
|
"""
|
|
List all dossiers (unique tags) for a user.
|
|
|
|
Dossiers are tag-based collections of pages.
|
|
|
|
**Example:** `/wiki/dossiers?user=jpmschweitzer`
|
|
"""
|
|
try:
|
|
return await wiki_service.list_dossiers(user=user)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
except Exception as e:
|
|
logger.error(f"Failed to list dossiers: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail="Internal server error")
|
|
|
|
|
|
@router.get("/dossiers/{dossier_name}/pages", response_model=WikiPageList)
|
|
async def get_dossier_pages(
|
|
dossier_name: str,
|
|
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
|
limit: int = Query(default=100, ge=1, le=500, description="Maximum pages"),
|
|
wiki_service: WikiService = Depends(get_wiki_service),
|
|
api_key: str = Depends(verify_api_key)
|
|
):
|
|
"""
|
|
Get all pages in a dossier.
|
|
|
|
Returns pages tagged with the dossier name.
|
|
|
|
**Example:** `/wiki/dossiers/projects/pages?user=jpmschweitzer`
|
|
"""
|
|
try:
|
|
return await wiki_service.get_dossier_pages(
|
|
dossier_name=dossier_name,
|
|
user=user,
|
|
limit=limit
|
|
)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
except Exception as e:
|
|
logger.error(f"Failed to get dossier pages: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail="Internal server error")
|