"""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") async 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") async 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")}