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>
171 lines
5.1 KiB
Python
171 lines
5.1 KiB
Python
"""
|
|
Vector router for Library Desk API.
|
|
|
|
Endpoints for semantic search and vector operations.
|
|
"""
|
|
|
|
from fastapi import APIRouter, HTTPException, Depends, Query
|
|
import logging
|
|
|
|
from src.models.vector import (
|
|
SearchRequest, SearchResponse,
|
|
VectorUpdateSummary,
|
|
CollectionListResponse,
|
|
DeletePageChunksResponse
|
|
)
|
|
from src.services.vector_service import VectorService
|
|
from src.core.dependencies import (
|
|
QdrantDep, WikiJSDep, OllamaDep, verify_api_key, RequiredUserQuery
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/vector", tags=["Vector"])
|
|
|
|
|
|
# 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)
|
|
|
|
|
|
@router.post("/search", response_model=SearchResponse)
|
|
async def semantic_search(
|
|
request: SearchRequest,
|
|
vector_service: VectorService = Depends(get_vector_service),
|
|
api_key: str = Depends(verify_api_key)
|
|
):
|
|
"""
|
|
Perform semantic search across user's documents.
|
|
|
|
Uses Ollama to generate query embedding, then searches Qdrant
|
|
for similar document chunks.
|
|
|
|
**Example Request:**
|
|
```json
|
|
{
|
|
"query": "how to configure docker",
|
|
"user": "<tenant>",
|
|
"limit": 10,
|
|
"score_threshold": 0.5
|
|
}
|
|
```
|
|
|
|
**Returns:** List of matching chunks with similarity scores
|
|
"""
|
|
try:
|
|
return await vector_service.search(
|
|
query=request.query,
|
|
user=request.user,
|
|
limit=request.limit,
|
|
score_threshold=request.score_threshold
|
|
)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
except Exception as e:
|
|
logger.error(f"Semantic search failed: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail="Search failed")
|
|
|
|
|
|
@router.post("/update-from-page/{page_id}", response_model=VectorUpdateSummary)
|
|
async def update_vectors_from_page(
|
|
page_id: int,
|
|
user: RequiredUserQuery,
|
|
force_refresh: bool = Query(default=False, description="Force re-embedding"),
|
|
vector_service: VectorService = Depends(get_vector_service),
|
|
api_key: str = Depends(verify_api_key)
|
|
):
|
|
"""
|
|
Update vector embeddings from a wiki page.
|
|
|
|
This endpoint:
|
|
1. Fetches the page from Wiki.js
|
|
2. Chunks the content (500 tokens with 50 token overlap)
|
|
3. Generates embeddings via Ollama
|
|
4. Upserts chunks to Qdrant with metadata
|
|
|
|
**Use Cases:**
|
|
- Called automatically after page creation/update (via BackgroundTasks)
|
|
- Called manually by user/Librarian to refresh vectors
|
|
- Called by Scheduler for batch processing
|
|
|
|
**Example:** `POST /vector/update-from-page/5?user=<tenant> (user is REQUIRED)`
|
|
|
|
**Returns:** Summary with chunks created and processing time
|
|
"""
|
|
try:
|
|
summary = await vector_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"Vector update failed: {summary.error_message}"
|
|
)
|
|
|
|
return summary
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Failed to update vectors from page {page_id}: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail="Vector update failed")
|
|
|
|
|
|
@router.delete("/pages/{page_id}", response_model=DeletePageChunksResponse)
|
|
async def delete_page_chunks(
|
|
page_id: int,
|
|
user: RequiredUserQuery,
|
|
vector_service: VectorService = Depends(get_vector_service),
|
|
api_key: str = Depends(verify_api_key)
|
|
):
|
|
"""
|
|
Delete all vector chunks for a wiki page.
|
|
|
|
This is automatically called when a page is deleted from the wiki.
|
|
|
|
**Example:** `DELETE /vector/pages/5?user=<tenant> (user is REQUIRED)`
|
|
"""
|
|
try:
|
|
deleted_count = await vector_service.delete_page_chunks(
|
|
page_id=page_id,
|
|
user=user
|
|
)
|
|
|
|
return DeletePageChunksResponse(
|
|
page_id=page_id,
|
|
chunks_deleted=deleted_count,
|
|
success=deleted_count > 0,
|
|
message=f"Deleted {deleted_count} chunks for page {page_id}"
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to delete chunks for page {page_id}: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail="Failed to delete chunks")
|
|
|
|
|
|
@router.get("/collections", response_model=CollectionListResponse)
|
|
async def list_collections(
|
|
vector_service: VectorService = Depends(get_vector_service),
|
|
api_key: str = Depends(verify_api_key)
|
|
):
|
|
"""
|
|
List all Qdrant collections with statistics.
|
|
|
|
Returns collection names, vector counts, and point counts.
|
|
|
|
**Example:** `GET /vector/collections`
|
|
"""
|
|
try:
|
|
return await vector_service.list_collections()
|
|
except Exception as e:
|
|
logger.error(f"Failed to list collections: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail="Failed to list collections")
|