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
This commit is contained in:
2026-07-29 17:53:43 +03:30
parent 754da323ff
commit a309e64841
5 changed files with 379 additions and 0 deletions

1
src/admin/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Admin router — system health, Qdrant CRUD, chunk preview, questions, cost estimation."""

84
src/admin/routes.py Normal file
View File

@@ -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)

275
src/admin/service.py Normal file
View File

@@ -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)

View File

@@ -1,6 +1,7 @@
"""FastAPI app factory. Composes all domain routers and middleware.""" """FastAPI app factory. Composes all domain routers and middleware."""
import logging import logging
import os
import time import time
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request from starlette.requests import Request
@@ -8,6 +9,7 @@ from starlette.responses import Response
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from src.core.exceptions import ( from src.core.exceptions import (
ChunkingError, ChunkingError,
@@ -18,6 +20,7 @@ from src.core.exceptions import (
) )
from src.documents.routes import router as documents_router from src.documents.routes import router as documents_router
from src.benchmarking.routes import router as benchmarking_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 from src.storage.sqlite import init_db
# Configure logging # Configure logging
@@ -91,6 +94,11 @@ def create_app() -> FastAPI:
# Mount domain routers # Mount domain routers
app.include_router(documents_router) app.include_router(documents_router)
app.include_router(benchmarking_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 return app

View File

@@ -311,6 +311,17 @@ def list_experiments(
conn.close() 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 ─────────────────────────────────────────────── # ── Internal helpers ───────────────────────────────────────────────
_JSON_FIELDS = {"chunk_counts", "retrieved_chunks", "latency_breakdown", _JSON_FIELDS = {"chunk_counts", "retrieved_chunks", "latency_breakdown",