pdf-quiz-generator/backend/app/routers/admin.py
Daniel 031de53034 feat: rerank what a learner is shown, with Cohere through the proxy
Retrieval fused a bi-encoder and BM25 by reciprocal rank. A bi-encoder embeds a
document long before the question exists, so the two never meet: it is good at
"same topic" and mediocre at "answers this". A cross-encoder reads the pair.

The proxy already serves three — `cohere-rerank-v4.0-pro` is the default and
measurably better than the fast variant. Query text goes exactly where the
embeddings already go, and nothing new was signed up for.

It found a defect nobody was looking for. In AI Mode each finder scored
`1/(1+rank)` *within its own corpus*, so the best article, section, question and
card all scored 1.0 and the shortlist was a meaningless round-robin. A
cross-encoder is the first thing in this system that can compare a question
with a section. Candidates per kind widened so it can select rather than merely
reorder.

Measured against labels neither ranker produced. Questions, 60 disease tags:
precision@3 0.394 → 0.483. Sections, 60 article titles: 0.772 → 0.833.
"Management of bronchiolitis" led with influenza transmission and a pregnancy
question; "when do you image a first febrile seizure" returned the definition
rather than the sentence saying imaging is unnecessary.

And the honest negative, in docs/reranking.md: board vignettes are written
*not* to name their diagnosis, so on "what causes croup" it prefers a question
that says the word in passing over the barking-cough vignette that never says
it. Some of the bi-encoder's strength is traded away.

Not on the typeahead. A page of results is a choice being made and worth a
third of a second; a typeahead is a word being finished, runs on every
keystroke, and has nothing to judge yet.

The three-state thresholds stay on cosine, argued at the constant: a reranker
only ever sees a shortlist and structurally cannot answer the corpus-wide
question those numbers ask, and whether an answer claims to come from the
library is a promise that must not depend on a network hop.

Every failure returns None and leaves the order alone — unconfigured, no proxy,
connect error, bare 502, timeout, non-JSON, a duplicate or out-of-range index,
a non-numeric score, a list the wrong length. Verified against the running site
with a bogus model name: same results, fused order, no error to the reader.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 18:23:06 +02:00

808 lines
31 KiB
Python

import logging
import time
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy import text
from sqlalchemy.orm import Session
import httpx
from app.config import settings
from app.database import get_db
from app.models.user import User
from app.models.ai_model_config import AIModelConfig
from app.models.invite import InviteCode
from app.services import ai_service, invites, site_settings
from app.schemas.auth import UserResponse, UserUpdateRole, UserCreate
from app.schemas.admin import AIModelConfigCreate, AIModelConfigResponse, AIModelConfigUpdate
from app.utils.auth import require_admin, get_current_user, get_password_hash
router = APIRouter()
# --- User Management ---
@router.get("/users", response_model=list[UserResponse])
def list_users(
db: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
return db.query(User).order_by(User.created_at.desc()).all()
@router.put("/users/{user_id}/role", response_model=UserResponse)
def update_user_role(
user_id: int,
role_data: UserUpdateRole,
db: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
if role_data.role not in ("admin", "moderator", "user"):
raise HTTPException(status_code=400, detail="Role must be admin, moderator, or user")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.id == admin.id:
raise HTTPException(status_code=400, detail="Cannot change your own role")
user.role = role_data.role
db.commit()
db.refresh(user)
return user
@router.put("/users/{user_id}/unthrottle", response_model=UserResponse)
def set_user_unthrottle(
user_id: int,
data: dict,
db: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
"""Set or clear the unthrottle flag for a user — exempt from AI/TTS rate limits."""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
user.is_unthrottled = 1 if data.get("unthrottled") else 0
db.commit()
db.refresh(user)
return user
@router.delete("/users/{user_id}", status_code=204)
def delete_user(
user_id: int,
db: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
"""Delete a user and all their data (attempts, favorites, settings)."""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.id == admin.id:
raise HTTPException(status_code=400, detail="Cannot delete yourself")
# Two tables name a user and refuse to forget one: question_categories and
# quiz_categories are NOT NULL and NO ACTION. The taxonomy is the site's,
# not the author's, so it is handed to the administrator doing the deleting
# rather than deleted with them. Everything else the database already knows
# what to do with — every other foreign key is CASCADE or SET NULL.
db.execute(text("UPDATE question_categories SET user_id = :new WHERE user_id = :uid"),
{"new": admin.id, "uid": user_id})
db.execute(text("UPDATE quiz_categories SET user_id = :new WHERE user_id = :uid"),
{"new": admin.id, "uid": user_id})
db.delete(user)
db.commit()
@router.post("/users", response_model=UserResponse)
def create_user(
user_data: UserCreate,
db: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
"""Admin creates a user directly — email is auto-verified."""
from app.models.email_verification import EmailVerification
from datetime import datetime
email_normalized = user_data.email.lower().strip()
if db.query(User).filter(User.email == email_normalized).first():
raise HTTPException(status_code=400, detail="Email already registered")
user = User(
email=email_normalized,
hashed_password=get_password_hash(user_data.password),
name=user_data.name,
role="user",
)
db.add(user)
db.flush()
db.add(EmailVerification(
user_id=user.id,
token=f"admin_created_{user.id}",
expires_at=datetime.utcnow(),
verified_at=datetime.utcnow(),
))
db.commit()
db.refresh(user)
return user
# --- AI Model Configuration ---
@router.get("/models/available")
def list_available_models(
task: str = Query("extraction"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Returns active models for a given task — for users to choose when taking/creating a quiz."""
models = db.query(AIModelConfig).filter(
AIModelConfig.task == task,
AIModelConfig.is_active == True,
).order_by(AIModelConfig.is_default.desc(), AIModelConfig.name).all()
result = [{"id": m.id, "name": m.name, "model_id": m.model_id, "is_default": m.is_default} for m in models]
# Always include env default as fallback if nothing configured
if not result:
result.append({"id": None, "name": "Default (from config)", "model_id": settings.LITELLM_MODEL, "is_default": True})
return result
class LiteLLMSearchRequest(BaseModel):
api_key: str | None = None
api_base: str | None = None
mode: str | None = None
#: Short, unambiguous, and clinical enough that a medical model has no excuse.
#: Used to make a voice speak and a transcriber listen.
TEST_PHRASE = "Inspiratory stridor at rest."
#: Printed into a test image for the tool model. One word, upper case, and not
#: one a model could guess from the instruction it is given.
TEST_WORD = "STRIDOR"
log = logging.getLogger(__name__)
def _proxy_models(base: str, key: str | None, mode: str | None = None) -> tuple[list[str], bool]:
"""What the proxy will serve, and whether the answer knows about modes.
`/model/info` carries each model's mode — transcription, chat, speech — and
is the right question to ask. A virtual key scoped to `llm_api_routes`
cannot call it, which is how a working proxy came to report a red 403 on
the settings page. `/v1/models` is on that allowed list and answers with
ids only, so the fallback can say a model is there but not what it is for.
"""
headers = {"Authorization": f"Bearer {key}"} if key else {}
root = base.rstrip("/").removesuffix("/v1")
try:
resp = httpx.get(f"{root}/model/info", headers=headers, timeout=10)
resp.raise_for_status()
rows = resp.json().get("data", [])
return sorted({
row.get("model_name") for row in rows
if row.get("model_name")
and (mode is None or (row.get("model_info") or {}).get("mode") == mode)
}), True
except httpx.HTTPStatusError as err:
if err.response.status_code not in (401, 403, 404):
raise
resp = httpx.get(f"{root}/v1/models", headers=headers, timeout=10)
resp.raise_for_status()
return sorted(m["id"] for m in resp.json().get("data", [])), False
@router.post("/litellm/models")
def search_litellm_models(
data: LiteLLMSearchRequest,
admin: User = Depends(require_admin),
):
"""Query available models from LiteLLM proxy or OpenAI-compatible API."""
import logging
log = logging.getLogger(__name__)
base = (data.api_base or settings.LITELLM_API_BASE or "").rstrip("/")
key = data.api_key or settings.LITELLM_API_KEY
if base:
try:
models, by_mode = _proxy_models(base, key, data.mode)
return {"models": models, "source": base,
"mode": data.mode if by_mode else None,
# Said rather than implied: an unfiltered list looks like a
# filtered one that found everything.
"filtered": by_mode and bool(data.mode)}
except Exception as e:
log.warning(f"LiteLLM model search failed: {e}")
raise HTTPException(status_code=400, detail=f"Failed to query models API: {e}")
# With no proxy there is nowhere to ask. litellm used to answer this branch
# from its own built-in table, but only for providers whose own API keys are
# in the environment, and this deployment has none — everything goes through
# the proxy above — so it already returned nothing.
raise HTTPException(
status_code=400,
detail="No model endpoint is configured. Set the API base to your LLM proxy and try again.")
@router.get("/models", response_model=list[AIModelConfigResponse])
def list_models(
db: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
return db.query(AIModelConfig).order_by(AIModelConfig.task, AIModelConfig.name).all()
@router.post("/models", response_model=AIModelConfigResponse)
def create_model(
data: AIModelConfigCreate,
db: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
valid_tasks = ("extraction", "tts", "stt", "teach", "keyword", "flashcard", "article", "tool")
if data.task not in valid_tasks:
raise HTTPException(status_code=400, detail=f"Task must be one of: {', '.join(valid_tasks)}")
if data.is_default:
db.query(AIModelConfig).filter(
AIModelConfig.task == data.task,
AIModelConfig.is_default == True,
).update({"is_default": False})
# Auto-set as default if this is the first model for the task
existing = db.query(AIModelConfig).filter(
AIModelConfig.task == data.task, AIModelConfig.is_active == True,
).count()
if existing == 0:
data_dict = data.model_dump()
data_dict["is_default"] = True
else:
data_dict = data.model_dump()
model = AIModelConfig(**data_dict)
db.add(model)
try:
db.commit()
except Exception as e:
db.rollback()
if "uq_model_task" in str(e).lower() or "unique" in str(e).lower():
raise HTTPException(status_code=409, detail=f"Model '{data.model_id}' already exists for task '{data.task}'")
raise HTTPException(status_code=500, detail=str(e))
db.refresh(model)
return model
@router.put("/models/{model_id}", response_model=AIModelConfigResponse)
def update_model(
model_id: int,
data: AIModelConfigUpdate,
db: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
model = db.query(AIModelConfig).filter(AIModelConfig.id == model_id).first()
if not model:
raise HTTPException(status_code=404, detail="Model config not found")
update_data = data.model_dump(exclude_unset=True)
task = update_data.get("task", model.task)
if update_data.get("is_default"):
db.query(AIModelConfig).filter(
AIModelConfig.task == task,
AIModelConfig.is_default == True,
AIModelConfig.id != model_id,
).update({"is_default": False})
for key, value in update_data.items():
setattr(model, key, value)
db.commit()
db.refresh(model)
return model
@router.delete("/models/{model_id}", status_code=204)
def delete_model(
model_id: int,
db: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
model = db.query(AIModelConfig).filter(AIModelConfig.id == model_id).first()
if not model:
raise HTTPException(status_code=404, detail="Model config not found")
db.delete(model)
db.commit()
@router.post("/models/{model_id}/test")
def test_model(
model_id: int,
db: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
"""Exercise a model the way its job will.
A chat model answers a prompt, a voice speaks, a transcriber listens, and
the tool model reads a word off a picture. A test that only proved the id
was spelled correctly taught administrators to distrust the button."""
model = db.query(AIModelConfig).filter(AIModelConfig.id == model_id).first()
if not model:
raise HTTPException(status_code=404, detail="Model config not found")
if model.task == "tts":
# A voice is tested by making it speak. The old answer was an
# instruction ("press Preview") returned as an error, which taught
# administrators to distrust a working configuration.
try:
audio = ai_service.generate_tts_audio(
TEST_PHRASE, model_id=model.model_id, api_key=model.api_key or None)
except Exception as e:
raise HTTPException(status_code=502, detail=str(e)[:300])
if not audio:
raise HTTPException(status_code=502, detail=f"{model.model_id} returned no audio")
return {"message": f"{model.model_id} spoke {len(audio):,} bytes of audio"}
if model.task == "stt":
base = (settings.LITELLM_API_BASE or "").rstrip("/").removesuffix("/v1")
key = model.api_key or settings.LITELLM_API_KEY
if not base:
raise HTTPException(status_code=400, detail="LiteLLM API base is not configured")
try:
models, by_mode = _proxy_models(base, key, "audio_transcription")
if model.model_id not in models:
raise HTTPException(
status_code=404,
detail=f"{model.model_id} is not served by the proxy"
+ (" as a transcription model" if by_mode else ""))
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=502, detail=str(e)[:300])
# Presence is not proof. A voice we already have says a known phrase,
# and the model is asked what it heard — the only test that shows
# transcription actually working end to end.
spoken = None
try:
tts_id, tts_key = ai_service.get_model_for_task(db, "tts")
if tts_id:
spoken = ai_service.generate_tts_audio(TEST_PHRASE, model_id=tts_id, api_key=tts_key)
except Exception:
log.warning("Could not synthesise audio to test %s", model.model_id, exc_info=True)
if not spoken:
return {"message": f"{model.model_id} is served by the proxy. No voice is "
"configured, so it could not be given anything to hear."}
try:
heard = ai_service.transcribe_audio(
spoken, filename="test.mp3", content_type="audio/mpeg",
model_id=model.model_id, api_key=model.api_key or None)
except Exception as e:
raise HTTPException(status_code=502, detail=str(e)[:300])
if not heard:
raise HTTPException(status_code=502, detail=f"{model.model_id} heard nothing")
return {"message": f"{model.model_id} heard “{heard.strip()}"}
if model.task == "tool":
# The tool model exists to look at pictures for models that cannot, so
# a text prompt would test nothing about it. It is given a picture of a
# word and asked what it shows: only an eye gets that back.
from app.services import vision_service
image = vision_service.word_image(TEST_WORD)
try:
seen = vision_service.describe(
[image], model.model_id, model.api_key or None,
context="A test image containing a single printed word.",
use_cache=False)[0]
except Exception as e:
raise HTTPException(status_code=502, detail=str(e)[:300])
if TEST_WORD.lower() not in seen.lower():
raise HTTPException(
status_code=502,
detail=f"{model.model_id} answered, but did not read the word in the "
f"test image: “{seen[:160]}")
return {"message": f"{model.model_id} read “{TEST_WORD}” off the test image"}
try:
from app.services.ai_service import chat
reply = chat(
model=model.model_id,
messages=[{"role": "user", "content": "Reply with only the word: OK"}],
max_tokens=10,
api_key=model.api_key,
).strip()
return {"message": f"{model.model_id}{reply!r}"}
except Exception as e:
raise HTTPException(status_code=502, detail=str(e))
class TTSVoiceSearchRequest(BaseModel):
provider: str
api_key: str | None = None
region: str | None = None
KOKORO_VOICE_FALLBACKS = [
("am_adam", "Kokoro Adam"),
("am_michael", "Kokoro Michael"),
("af_bella", "Kokoro Bella"),
("af_nicole", "Kokoro Nicole"),
("bf_emma", "Kokoro Emma"),
("bm_lewis", "Kokoro Lewis"),
]
KITTEN_VOICE_FALLBACKS = [
("Bella", "Kitten Bella"),
("Jasper", "Kitten Jasper"),
("Luna", "Kitten Luna"),
("Bruno", "Kitten Bruno"),
("Rosie", "Kitten Rosie"),
("Hugo", "Kitten Hugo"),
("Kiki", "Kitten Kiki"),
("Leo", "Kitten Leo"),
]
SUPERTONIC_STYLE_FALLBACKS = [
("F1", "Supertonic F1"),
("F2", "Supertonic F2"),
("F3", "Supertonic F3"),
("F4", "Supertonic F4"),
("F5", "Supertonic F5"),
("M1", "Supertonic M1"),
("M2", "Supertonic M2"),
("M3", "Supertonic M3"),
("M4", "Supertonic M4"),
("M5", "Supertonic M5"),
]
def _kokoro_voice_options(model_name: str) -> list[dict]:
base = settings.LOCAL_SPEECH_GATEWAY_URL.rstrip("/")
voices = []
friendly_names = {voice_id: name for voice_id, name in KOKORO_VOICE_FALLBACKS}
if base:
try:
resp = httpx.get(f"{base}/v1/audio/voices", timeout=10)
resp.raise_for_status()
voices = [
v for v in resp.json().get("voices", [])
if v.get("profile") == "kokoro" and v.get("voice")
]
except Exception:
voices = []
if not voices:
voices = [
{"voice": voice_id, "name": name, "profile": "kokoro"}
for voice_id, name in KOKORO_VOICE_FALLBACKS
]
return [
{
"model_id": f"{model_name}:{v['voice']}",
"name": friendly_names.get(v["voice"], v.get("name") or f"Kokoro {v['voice']}"),
"labels": {"provider": "litellm", "model": model_name, "voice": v["voice"]},
}
for v in voices
]
def _static_voice_options(model_name: str, voices: list[tuple[str, str]]) -> list[dict]:
return [
{
"model_id": f"{model_name}:{voice_id}",
"name": name,
"labels": {"provider": "litellm", "model": model_name, "voice": voice_id},
}
for voice_id, name in voices
]
@router.post("/tts/voices")
def search_tts_voices(
data: TTSVoiceSearchRequest,
admin: User = Depends(require_admin),
):
"""Discover local TTS voices/models from LiteLLM or the local speech gateway."""
import logging
log = logging.getLogger(__name__)
provider = data.provider
api_key = data.api_key
region = data.region
if provider != "litellm":
raise HTTPException(status_code=400, detail="TTS discovery is routed through LiteLLM only")
if provider == "litellm":
base = (settings.LITELLM_API_BASE or "").rstrip("/").removesuffix("/v1")
key = api_key or settings.LITELLM_API_KEY
if not base:
raise HTTPException(status_code=400, detail="LiteLLM API base is not configured")
try:
headers = {"Authorization": f"Bearer {key}"} if key else {}
resp = httpx.get(f"{base}/model/info", headers=headers, timeout=10)
resp.raise_for_status()
models = resp.json().get("data", [])
voices = []
for m in models:
model_name = m.get("model_name")
if not model_name or (m.get("model_info") or {}).get("mode") != "audio_speech":
continue
if model_name == "local-kokoro-tts":
voices.extend(_kokoro_voice_options(model_name))
continue
if model_name == "local-kitten-tts":
voices.extend(_static_voice_options(model_name, KITTEN_VOICE_FALLBACKS))
continue
if model_name == "local-supertonic-tts":
voices.extend(_static_voice_options(model_name, SUPERTONIC_STYLE_FALLBACKS))
continue
voices.append({
"model_id": model_name,
"name": model_name,
"labels": {"provider": "litellm", "mode": "audio_speech"},
})
return {"voices": voices}
except HTTPException:
raise
except Exception as e:
log.warning(f"LiteLLM local TTS discovery failed: {e}")
raise HTTPException(status_code=400, detail=f"LiteLLM TTS discovery error: {e}")
raise HTTPException(status_code=400, detail=f"Unknown provider '{provider}'. Valid: litellm")
# --- Invite codes ---
class InviteIn(BaseModel):
note: str | None = Field(default=None, max_length=200)
@router.get("/invites")
def list_invites(db: Session = Depends(get_db), admin: User = Depends(require_admin)):
"""Every code, newest first, with who it let in."""
rows = db.query(InviteCode).order_by(InviteCode.created_at.desc()).limit(200).all()
users = {u.id: u for u in db.query(User).filter(
User.id.in_({r.used_by for r in rows if r.used_by}))} if rows else {}
return [invites.as_json(row, users) for row in rows]
@router.post("/invites", status_code=201)
def create_invite(data: InviteIn, db: Session = Depends(get_db),
admin: User = Depends(require_admin)):
row = invites.create(db, created_by=admin.id, note=data.note)
return invites.as_json(row, {})
@router.delete("/invites/{invite_id}", status_code=204)
def revoke_invite(invite_id: int, db: Session = Depends(get_db),
admin: User = Depends(require_admin)):
"""Withdraw an unused code, or clear away a spent one.
An unused code is withdrawn — it stays listed, so it is clear that it was
issued and then stopped. A spent or already-withdrawn code has nothing left
to stop, and a list that only grows is a list nobody reads; removing it
loses who it let in, but that person has an account, which is the record
that matters.
"""
row = db.get(InviteCode, invite_id)
if not row:
raise HTTPException(404, "Invite not found")
if row.used_by is not None or row.revoked_at is not None:
db.delete(row)
db.commit()
return
row.revoked_at = datetime.utcnow()
db.commit()
# --- System Settings ---
@router.get("/settings")
def get_settings(admin: User = Depends(require_admin)):
"""Get system settings."""
try:
import redis as redis_lib
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
registration_enabled = r.get("settings:registration_enabled")
embedding_model = r.get("settings:embedding_model")
rerank_model = r.get("settings:rerank_model")
sso_only = r.get("settings:sso_only")
return {
"registration_enabled": registration_enabled != "false",
"embedding_model": embedding_model or settings.LITELLM_EMBEDDING_MODEL or "",
# Blank is a valid answer and means result lists keep the order rank
# fusion gave them, so it is stored and read as written, not defaulted.
"rerank_model": rerank_model if rerank_model is not None else (settings.LITELLM_RERANK_MODEL or ""),
"sso_only": sso_only == "true",
"sso_configured": bool(settings.OIDC_PROVIDER_URL and settings.OIDC_CLIENT_ID),
"sso_provider_name": settings.OIDC_PROVIDER_NAME,
**site_settings.all_flags(),
}
except Exception:
return {
"registration_enabled": True,
"embedding_model": settings.LITELLM_EMBEDDING_MODEL or "",
"rerank_model": settings.LITELLM_RERANK_MODEL or "",
"sso_only": False,
"sso_configured": bool(settings.OIDC_PROVIDER_URL and settings.OIDC_CLIENT_ID),
"sso_provider_name": settings.OIDC_PROVIDER_NAME,
**site_settings.FLAGS,
}
@router.put("/settings")
def update_settings(
settings_data: dict,
admin: User = Depends(require_admin),
):
"""Update system settings."""
try:
import redis as redis_lib
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
if "registration_enabled" in settings_data:
value = "true" if settings_data["registration_enabled"] else "false"
r.set("settings:registration_enabled", value)
for flag in site_settings.FLAGS:
if flag in settings_data:
site_settings.set_flag(flag, bool(settings_data[flag]))
if "embedding_model" in settings_data:
r.set("settings:embedding_model", settings_data["embedding_model"])
if "rerank_model" in settings_data:
r.set("settings:rerank_model", (settings_data["rerank_model"] or "").strip())
if "sso_only" in settings_data:
value = "true" if settings_data["sso_only"] else "false"
r.set("settings:sso_only", value)
return {"success": True, "message": "Settings updated"}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to update settings: {str(e)}")
@router.post("/embedding/test")
def test_embedding(admin: User = Depends(require_admin)):
"""Test the currently configured embedding model."""
from app.services.embedding_service import generate_embedding, _get_embedding_model
model = _get_embedding_model()
if not model:
raise HTTPException(status_code=400, detail="No embedding model configured")
result = generate_embedding("The quick brown fox jumps over the lazy dog")
if result is None:
raise HTTPException(status_code=500, detail=f"Embedding failed for model: {model}")
return {"model": model, "dimensions": len(result), "status": "ok"}
@router.post("/rerank/test")
def test_rerank(admin: User = Depends(require_admin)):
"""Check that the configured reranker answers, and that it answers sensibly.
A reranker that returns 200 and ranks the decoy first is worse than one that
is switched off, and nothing else on the site would ever tell you: its whole
output is an order somebody has to already know the right answer to judge.
"""
from app.services.rerank_service import rerank, rerank_model
model = rerank_model()
if not model:
raise HTTPException(status_code=400, detail="No rerank model configured")
documents = [
"Sourdough bread needs a starter culture and a long, cool proof.",
"Croup is a viral laryngotracheitis, usually parainfluenza, and presents "
"with a barking cough and inspiratory stridor.",
]
started = time.perf_counter()
scores = rerank("what causes croup in a toddler", documents)
elapsed_ms = int((time.perf_counter() - started) * 1000)
if scores is None:
raise HTTPException(status_code=500, detail=f"Rerank failed for model: {model}")
return {
"model": model,
"elapsed_ms": elapsed_ms,
"scores": [round(score, 4) for score in scores],
"ordered_correctly": scores[1] > scores[0],
"status": "ok" if scores[1] > scores[0] else "suspect",
}
@router.get("/classification-snapshots")
def list_classification_snapshots(
limit: int = Query(10, ge=1, le=50),
db: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
"""List recent classification rollback snapshots."""
rows = db.execute(text("""
SELECT s.id,
s.job_id,
s.created_by,
u.name AS created_by_name,
u.email AS created_by_email,
s.reason,
s.question_count,
s.link_count,
s.created_at
FROM question_classification_snapshots s
LEFT JOIN users u ON u.id = s.created_by
ORDER BY s.created_at DESC, s.id DESC
LIMIT :limit
"""), {"limit": limit}).mappings().all()
return [dict(row) for row in rows]
@router.post("/classification-snapshots/{snapshot_id}/rollback")
def rollback_classification_snapshot(
snapshot_id: int,
db: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
"""Restore question tag assignments from a saved snapshot."""
snapshot = db.execute(text("""
SELECT id, question_count, link_count, created_at
FROM question_classification_snapshots
WHERE id = :snapshot_id
"""), {"snapshot_id": snapshot_id}).mappings().first()
if not snapshot:
raise HTTPException(status_code=404, detail="Classification snapshot not found")
try:
db.execute(text("DELETE FROM question_tag_links"))
db.execute(text("""
INSERT INTO question_tags (name, type)
SELECT DISTINCT tag_name, tag_type
FROM question_classification_snapshot_links
WHERE snapshot_id = :snapshot_id
ON CONFLICT (LOWER(name), type) DO NOTHING
"""), {"snapshot_id": snapshot_id})
result = db.execute(text("""
INSERT INTO question_tag_links (question_id, tag_id)
SELECT sl.question_id, t.id
FROM question_classification_snapshot_links sl
JOIN question_tags t
ON LOWER(t.name) = LOWER(sl.tag_name)
AND t.type = sl.tag_type
WHERE sl.snapshot_id = :snapshot_id
ON CONFLICT DO NOTHING
"""), {"snapshot_id": snapshot_id})
db.commit()
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"Failed to roll back classification snapshot: {e}")
return {
"snapshot_id": snapshot_id,
"restored_links": result.rowcount if result.rowcount is not None else snapshot["link_count"],
"snapshot_question_count": snapshot["question_count"],
"snapshot_link_count": snapshot["link_count"],
}
@router.get("/embedding/health")
def embedding_health(db: Session = Depends(get_db), admin: User = Depends(require_admin)):
"""How much of the bank is semantically searchable under the active model.
Vectors from two different embedding models are not comparable, so a model
change has to be visible rather than silently degrading search quality.
"""
from app.services import embedding_service
return embedding_service.stale_embedding_counts(db)
@router.post("/embedding/regenerate")
def regenerate_embeddings(
stale_only: bool = Query(True, description="Only rows with no vector or a vector from another model"),
admin: User = Depends(require_admin),
):
"""Queue a background task to re-embed questions with the current model."""
import uuid
from app.tasks.quiz_tasks import regenerate_embeddings as regen_task
job_id = str(uuid.uuid4())
regen_task.delay(job_id, admin.id, stale_only)
scope = "missing and stale" if stale_only else "all"
return {"job_id": job_id, "message": f"Regenerating {scope} embeddings — progress in the Jobs badge."}