- Conversion `async def` → `def` de TOUTES les routes dont le corps ne contient ni `await`, ni `async with`, ni `async for`, ni `asyncio` (scan automatique corps par corps sur app/ : 352 converties, 0 dangereuses, vérifié `asyncio`/`run_coroutine`/`.result()` absents). FastAPI exécute ces handlers dans son threadpool → tout leur SQLite (`get_conn()` + `conn.execute`) quitte l'event loop, sans changer une ligne de logique. - Répartition : api_v2 60, dashboard 40, collections 25, board 23, workspace 19, wiki 17, permissions 14, api 14, main.py 6, + 35 fichiers. - Les 4 routers prioritaires de l'audit sont couverts par ce lot : api_v2 60 + dashboard 40 + collections 25 + board 23 = 148 conversions (le reste de leurs routes attend la phase 2 : elles ont de vrais `await`). - Reste (phase 2) : les 311 routes avec de vrais `await` → enrouler les blocs DB dans `await anyio.to_thread.run_sync(...)` ; pas de wrapper partagé livré (rien ne l'appellerait — YAGNI jusqu'au premier usage). suite **1037/1037** (233 s) · `ruff check app tests` OK · docs à jour
96 lines
3.9 KiB
Python
96 lines
3.9 KiB
Python
"""FlowDeck — hybrid search + Ask AI API (v6.9.0).
|
|
|
|
``GET /api/v2/search/hybrid`` — lexical (FTS5/LIKE) fused with vector cosine
|
|
(RRF), workspace-scoped, ACL-filtered, paginated with ``X-Total-Count``.
|
|
``POST /api/v2/search/ask`` — RAG answer with ``[[fdpage:ID]]`` citations
|
|
(LLM when configured, extractive offline fallback), cached 10 min.
|
|
|
|
Auth: session cookie first, Bearer fallback (``read`` scope suffices).
|
|
See ``docs/V69_Search_Ask_AI.md``.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from fastapi import APIRouter, HTTPException, Request
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from app.auth.session import SessionManager
|
|
from app.db import get_conn
|
|
from app.services import semantic_search as sem
|
|
from app.services.api_v2_helpers import (
|
|
audit_log,
|
|
has_scope,
|
|
paginate_headers,
|
|
parse_pagination,
|
|
resolve_bearer_token,
|
|
)
|
|
|
|
router = APIRouter(tags=["search-ai"])
|
|
|
|
|
|
def _auth_user(request: Request) -> dict:
|
|
sess = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if sess:
|
|
return sess
|
|
auth = request.headers.get("authorization") or request.headers.get("Authorization") or ""
|
|
if auth.lower().startswith("bearer "):
|
|
user = resolve_bearer_token(auth[7:].strip())
|
|
if not user:
|
|
raise HTTPException(401, "Invalid or expired API token")
|
|
if not has_scope(user.get("_token_scopes") or "read", "read"):
|
|
raise HTTPException(403, "Insufficient scope. Required: read")
|
|
return user
|
|
raise HTTPException(401, "Authentication required")
|
|
|
|
|
|
@router.get("/api/v2/search/hybrid")
|
|
def hybrid(request: Request):
|
|
user = _auth_user(request)
|
|
q = (request.query_params.get("q") or request.query_params.get("query") or "").strip()
|
|
if not q:
|
|
raise HTTPException(400, "q is required")
|
|
limit, offset = parse_pagination(request)
|
|
ws_raw = request.query_params.get("workspace_id")
|
|
workspace_id = int(ws_raw) if ws_raw and str(ws_raw).isdigit() else None
|
|
results, _total = sem.hybrid_search(q, user, limit=limit + offset,
|
|
workspace_id=workspace_id)
|
|
page = results[offset:offset + limit]
|
|
# Index-on-read: a fresh page may not be indexed yet (scheduler runs every
|
|
# 5 min). Best-effort is handled by tests calling index_resource directly.
|
|
resp = JSONResponse({"query": q, "results": page,
|
|
"total": len(results), "limit": limit, "offset": offset})
|
|
for k, v in paginate_headers(len(results)).items():
|
|
resp.headers[k] = v
|
|
return resp
|
|
|
|
|
|
@router.post("/api/v2/search/ask")
|
|
async def ask_ai(request: Request):
|
|
user = _auth_user(request)
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
body = {}
|
|
question = (body.get("question") or body.get("q") or "").strip()
|
|
if not question:
|
|
raise HTTPException(400, "question is required")
|
|
ws = body.get("workspace_id")
|
|
workspace_id = int(ws) if isinstance(ws, int) or (isinstance(ws, str) and ws.isdigit()) else None
|
|
out = await sem.ask(question, user, workspace_id)
|
|
audit_log(user, "search.ask", "search", "", question[:200], request)
|
|
return {"question": question, "workspace_id": workspace_id, **out}
|
|
|
|
|
|
@router.get("/api/v2/search/index-status")
|
|
def index_status(request: Request):
|
|
"""How many resources are indexed vs pending (owner/admin visibility)."""
|
|
user = _auth_user(request)
|
|
with get_conn() as conn:
|
|
indexed = conn.execute("SELECT COUNT(*) FROM semantic_index_state").fetchone()[0]
|
|
vectors = conn.execute("SELECT COUNT(*) FROM semantic_embeddings").fetchone()[0]
|
|
pages_total = conn.execute(
|
|
"SELECT COUNT(*) FROM pages WHERE (deleted_at IS NULL OR deleted_at='') "
|
|
"AND COALESCE(search_excluded, 0)=0").fetchone()[0]
|
|
return {"indexed_resources": indexed, "vectors": vectors,
|
|
"indexable_pages": pages_total, "model": sem.MODEL, "dim": sem.DIM,
|
|
"user_id": user.get("id")}
|