From a309e648419ee2aff9a3e4eea95928969da4c4c3 Mon Sep 17 00:00:00 2001 From: Mahdi Bazrafshan Date: Wed, 29 Jul 2026 17:53:43 +0330 Subject: [PATCH] feat(admin): add admin backend endpoints and service layer Why: - Dashboard needs system health, Qdrant management, chunk preview, questions, and cost estimation endpoints Changes: - Add admin router with 11 endpoints (health, Qdrant CRUD, chunk preview, questions management, cost estimation) - Add delete_experiment to SQLite storage - Mount admin router and dashboard static files at /app Impact: - New /admin/* API routes available - Dashboard served at /app/ via StaticFiles --- src/admin/__init__.py | 1 + src/admin/routes.py | 84 +++++++++++++ src/admin/service.py | 275 ++++++++++++++++++++++++++++++++++++++++++ src/main.py | 8 ++ src/storage/sqlite.py | 11 ++ 5 files changed, 379 insertions(+) create mode 100644 src/admin/__init__.py create mode 100644 src/admin/routes.py create mode 100644 src/admin/service.py diff --git a/src/admin/__init__.py b/src/admin/__init__.py new file mode 100644 index 0000000..76c9c0a --- /dev/null +++ b/src/admin/__init__.py @@ -0,0 +1 @@ +"""Admin router — system health, Qdrant CRUD, chunk preview, questions, cost estimation.""" diff --git a/src/admin/routes.py b/src/admin/routes.py new file mode 100644 index 0000000..cc652ee --- /dev/null +++ b/src/admin/routes.py @@ -0,0 +1,84 @@ +"""Admin API routes — system health, Qdrant management, chunk preview, questions, cost.""" + +import os +from fastapi import APIRouter, UploadFile, File +from src.admin import service + +router = APIRouter(prefix="/admin") + + +# ── Health ────────────────────────────────────────────────── + +@router.get("/health") +async def health_check(): + """Return system health: server status, Qdrant connectivity, SQLite status.""" + return service.get_health() + + +# ── Qdrant Collections ───────────────────────────────────── + +@router.get("/qdrant/collections") +async def list_qdrant_collections(): + """List all Qdrant collections with point counts.""" + return service.list_qdrant_collections() + + +@router.post("/qdrant/collections/{collection_name}") +async def create_qdrant_collection(collection_name: str): + """Create a new Qdrant collection.""" + return service.create_qdrant_collection(collection_name) + + +@router.delete("/qdrant/collections/{collection_name}") +async def delete_qdrant_collection(collection_name: str): + """Delete a Qdrant collection entirely.""" + return service.delete_qdrant_collection(collection_name) + + +@router.delete("/qdrant/collections/{collection_name}/points") +async def wipe_qdrant_collection_points(collection_name: str): + """Delete all points in a collection (keep collection structure).""" + return service.wipe_qdrant_collection_points(collection_name) + + +# ── Chunk Preview ─────────────────────────────────────────── + +@router.get("/chunks/{doc_id}") +async def preview_chunks(doc_id: str, strategy: str | None = None): + """Preview all chunks for a document, optionally filtered by strategy.""" + return service.preview_chunks(doc_id, strategy) + + +# ── Questions Dataset ─────────────────────────────────────── + +@router.get("/questions") +async def list_question_files(): + """List available question JSON files on disk.""" + return service.list_question_files() + + +@router.post("/questions/upload") +async def upload_questions(file: UploadFile = File(...)): + """Upload a questions JSON file to the project.""" + content = await file.read() + return service.upload_questions(file.filename, content) + + +@router.get("/questions/{file_id}") +async def get_questions(file_id: str): + """Get full content of a questions file.""" + return service.get_questions(file_id) + + +@router.delete("/questions/{file_id}") +async def delete_questions(file_id: str): + """Delete a questions file from disk.""" + return service.delete_questions(file_id) + + +# ── Cost Estimation ───────────────────────────────────────── + +@router.post("/cost-estimate") +async def cost_estimate(num_questions: int, num_strategies: int): + """Estimate cost for a benchmark run without executing.""" + return service.estimate_cost(num_questions, num_strategies) diff --git a/src/admin/service.py b/src/admin/service.py new file mode 100644 index 0000000..74aa699 --- /dev/null +++ b/src/admin/service.py @@ -0,0 +1,275 @@ +"""Admin service — health checks, Qdrant management, chunk preview, questions CRUD, cost estimation.""" + +import json +import logging +import os +from pathlib import Path +from typing import Any + +from qdrant_client.models import VectorParams, Distance + +from src.core.dependencies import get_qdrant_client, get_openai_client +from src.core.config import settings +from src.core.models import StrategyName +from src.storage import qdrant as qdrant_store +from src.storage import sqlite as db + +logger = logging.getLogger(__name__) + +# Project root (two levels up from src/admin/) +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent +QUESTIONS_DIR = PROJECT_ROOT / "files" + + +# ── Health ────────────────────────────────────────────────── + +def get_health() -> dict[str, Any]: + """Check server, Qdrant, and SQLite status.""" + result: dict[str, Any] = {"status": "ok"} + + # Check Qdrant + try: + client = get_qdrant_client() + collections = client.get_collections() + result["qdrant_connected"] = True + result["qdrant_collections"] = len(collections.collections) + except Exception as exc: + result["qdrant_connected"] = False + result["qdrant_error"] = str(exc) + logger.warning("Qdrant health check failed: %s", exc) + + # Check SQLite + try: + conn = db._connect() + conn.execute("SELECT 1") + conn.close() + result["sqlite_ok"] = True + except Exception as exc: + result["sqlite_ok"] = False + result["sqlite_error"] = str(exc) + logger.warning("SQLite health check failed: %s", exc) + + # Check OpenAI + try: + client = get_openai_client() + # Just check the client exists; don't make a real API call + result["openai_configured"] = bool(settings.openai_api_key) + except Exception: + result["openai_configured"] = False + + return result + + +# ── Qdrant Collections ───────────────────────────────────── + +def list_qdrant_collections() -> dict[str, Any]: + """List all Qdrant collections with their point counts.""" + client = get_qdrant_client() + collections_data = client.get_collections().collections + + result = [] + for col in collections_data: + try: + info = client.get_collection(collection_name=col.name) + result.append({ + "name": col.name, + "points_count": info.points_count or 0, + }) + except Exception as exc: + result.append({ + "name": col.name, + "points_count": None, + "error": str(exc), + }) + + return {"collections": result} + + +def create_qdrant_collection(collection_name: str) -> dict[str, Any]: + """Create a new Qdrant collection.""" + client = get_qdrant_client() + + existing = [c.name for c in client.get_collections().collections] + if collection_name in existing: + return {"created": False, "message": f"Collection '{collection_name}' already exists"} + + client.create_collection( + collection_name=collection_name, + vectors_config=VectorParams( + size=qdrant_store.VECTOR_DIMENSION, + distance=Distance.COSINE, + ), + ) + logger.info("Created Qdrant collection: %s", collection_name) + return {"created": True, "collection": collection_name} + + +def delete_qdrant_collection(collection_name: str) -> dict[str, Any]: + """Delete a Qdrant collection entirely.""" + client = get_qdrant_client() + client.delete_collection(collection_name=collection_name) + logger.info("Deleted Qdrant collection: %s", collection_name) + return {"deleted": True, "collection": collection_name} + + +def wipe_qdrant_collection_points(collection_name: str) -> dict[str, Any]: + """Delete all points in a collection but keep the collection.""" + client = get_qdrant_client() + from qdrant_client.models import PointIdsList + + info = client.get_collection(collection_name=collection_name) + count = info.points_count or 0 + + if count == 0: + return {"deleted": 0, "collection": collection_name} + + client.delete( + collection_name=collection_name, + points_selector=PointIdsList(points=list(range(count))), + ) + logger.info("Wiped %d points from %s", count, collection_name) + return {"deleted": count, "collection": collection_name} + + +# ── Chunk Preview ─────────────────────────────────────────── + +def preview_chunks(doc_id: str, strategy: str | None = None) -> dict[str, Any]: + """Preview chunks for a document. Uses Qdrant scroll to fetch chunks with payload.""" + client = get_qdrant_client() + + from qdrant_client.models import Filter, FieldCondition, MatchValue + + # Get document info from SQLite + doc = db.get_document(doc_id) + if doc is None: + return {"error": f"Document not found: {doc_id}"} + + doc_name = doc.get("filename", "") + + # Determine which collections to search + strategies_to_search = [] + if strategy: + strategies_to_search = [strategy] + else: + strategies_to_search = [s.value for s in StrategyName] + + results = {} + for strat_name in strategies_to_search: + col_name = f"{strat_name}_collection" + + try: + existing = [c.name for c in client.get_collections().collections] + if col_name not in existing: + results[strat_name] = {"chunks": [], "count": 0} + continue + + scroll_filter = Filter( + must=[FieldCondition(key="document_name", match=MatchValue(value=doc_name))] + ) + points, _ = client.scroll( + collection_name=col_name, + scroll_filter=scroll_filter, + limit=10000, + with_payload=True, + with_vectors=False, + ) + + chunks = [] + for p in points: + payload = p.payload or {} + chunks.append({ + "chunk_id": payload.get("chunk_id", str(p.id)), + "chunk_index": payload.get("chunk_index"), + "text": (payload.get("text") or "")[:500], + "token_count": payload.get("token_count"), + "character_count": payload.get("character_count"), + "parent_id": payload.get("parent_id"), + }) + + # Sort by chunk_index + chunks.sort(key=lambda c: c.get("chunk_index") or 0) + results[strat_name] = {"chunks": chunks, "count": len(chunks)} + + except Exception as exc: + results[strat_name] = {"error": str(exc), "chunks": [], "count": 0} + + return {"document_id": doc_id, "filename": doc_name, "strategies": results} + + +# ── Questions Dataset ─────────────────────────────────────── + +def list_question_files() -> dict[str, Any]: + """List JSON files in the files/ directory.""" + QUESTIONS_DIR.mkdir(parents=True, exist_ok=True) + + files = [] + for f in sorted(QUESTIONS_DIR.glob("*.json")): + try: + with open(f, encoding="utf-8") as fh: + data = json.load(fh) + count = len(data.get("questions", [])) + except Exception: + count = -1 + files.append({ + "id": f.name, + "name": f.name, + "questions_count": count, + "size_bytes": f.stat().st_size, + }) + + return {"files": files} + + +def upload_questions(filename: str, content: bytes) -> dict[str, Any]: + """Save a questions JSON file to the files/ directory.""" + QUESTIONS_DIR.mkdir(parents=True, exist_ok=True) + + # Validate JSON + try: + data = json.loads(content.decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + return {"error": f"Invalid JSON: {exc}"} + + if "questions" not in data and not isinstance(data, list): + return {"error": "Invalid format: must have a 'questions' key or be a list"} + + # Ensure filename ends with .json + if not filename.endswith(".json"): + filename = filename + ".json" + + dest = QUESTIONS_DIR / filename + dest.write_bytes(content) + logger.info("Uploaded questions file: %s", dest) + + return {"uploaded": True, "id": filename, "questions_count": len(data.get("questions", []) if isinstance(data, dict) else data)} + + +def get_questions(file_id: str) -> dict[str, Any]: + """Read and return the content of a questions file.""" + path = QUESTIONS_DIR / file_id + if not path.exists(): + return {"error": f"File not found: {file_id}"} + + with open(path, encoding="utf-8") as f: + data = json.load(f) + + return {"id": file_id, "data": data} + + +def delete_questions(file_id: str) -> dict[str, Any]: + """Delete a questions JSON file.""" + path = QUESTIONS_DIR / file_id + if not path.exists(): + return {"error": f"File not found: {file_id}"} + + path.unlink() + logger.info("Deleted questions file: %s", path) + return {"deleted": True, "id": file_id} + + +# ── Cost Estimation ───────────────────────────────────────── + +def estimate_cost(num_questions: int, num_strategies: int) -> dict[str, Any]: + """Estimate benchmark cost. Delegates to benchmark service.""" + from src.benchmarking.benchmark_service import estimate_cost as bench_estimate + return bench_estimate(num_questions, num_strategies) diff --git a/src/main.py b/src/main.py index 60bfc3d..6f1d89b 100644 --- a/src/main.py +++ b/src/main.py @@ -1,6 +1,7 @@ """FastAPI app factory. Composes all domain routers and middleware.""" import logging +import os import time from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request @@ -8,6 +9,7 @@ from starlette.responses import Response from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles from src.core.exceptions import ( ChunkingError, @@ -18,6 +20,7 @@ from src.core.exceptions import ( ) from src.documents.routes import router as documents_router from src.benchmarking.routes import router as benchmarking_router +from src.admin.routes import router as admin_router from src.storage.sqlite import init_db # Configure logging @@ -91,6 +94,11 @@ def create_app() -> FastAPI: # Mount domain routers app.include_router(documents_router) app.include_router(benchmarking_router) + app.include_router(admin_router) + + # Mount dashboard at /app + static_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static") + app.mount("/app", StaticFiles(directory=static_dir, html=True), name="dashboard") return app diff --git a/src/storage/sqlite.py b/src/storage/sqlite.py index 21ce59b..d43c57d 100644 --- a/src/storage/sqlite.py +++ b/src/storage/sqlite.py @@ -311,6 +311,17 @@ def list_experiments( conn.close() +def delete_experiment(experiment_id: str) -> bool: + """Delete an experiment by ID.""" + conn = _connect() + try: + cur = conn.execute("DELETE FROM experiments WHERE id = ?", (experiment_id,)) + conn.commit() + return cur.rowcount > 0 + finally: + conn.close() + + # ── Internal helpers ─────────────────────────────────────────────── _JSON_FIELDS = {"chunk_counts", "retrieved_chunks", "latency_breakdown",