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>
526 lines
17 KiB
Python
526 lines
17 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.core.dependencies import (
|
|
WikiJSDep, Neo4jDep, QdrantDep, OllamaDep,
|
|
verify_api_key, get_settings, get_hybrid_rag_service, get_ingestion_service,
|
|
RequiredUserQuery
|
|
)
|
|
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: RequiredUserQuery,
|
|
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: RequiredUserQuery,
|
|
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": "<tenant>"
|
|
}
|
|
```
|
|
|
|
The `user` field is REQUIRED (no default tenant).
|
|
"""
|
|
try:
|
|
page = await wiki_service.create_page(page_data)
|
|
|
|
user = page_data.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,
|
|
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
|
|
|
|
# Build services. HybridRAG comes from the single wiring point in
|
|
# dependencies so it includes volatile_service (a previous inline
|
|
# copy here lacked it).
|
|
wiki_service = WikiService(wiki_client)
|
|
vector_service = VectorService(qdrant_client, wiki_client, ollama_client)
|
|
graph_service = GraphService(neo4j_client, wiki_client)
|
|
hybrid_rag_service = get_hybrid_rag_service()
|
|
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: RequiredUserQuery,
|
|
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: RequiredUserQuery,
|
|
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: RequiredUserQuery,
|
|
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(
|
|
user: RequiredUserQuery,
|
|
q: str = Query(..., min_length=1, description="Search query"),
|
|
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: RequiredUserQuery,
|
|
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: RequiredUserQuery,
|
|
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")
|