pdf-quiz-generator/backend/app/routers/admin.py
Daniel 963ca04cf8
Some checks failed
Tests / backend (push) Failing after 7s
Tests / frontend (push) Successful in 30s
Tests / e2e (push) Failing after 33s
feat: no account creation here — the provider makes accounts
Gone: /auth/register, /auth/signup-policy, POST /admin/users, the
RegisterPage, the register half of the landing modal, the Register
button, the "Sign up" link, the "Create an account" hero button, and the
UserCreate schema. /register redirects to /login for anybody holding a
bookmark. A first admin on a fresh install still comes from
DEFAULT_ADMIN_EMAIL at startup, so nothing is locked out.

And no flash of the old way in. Both sign-in surfaces defaulted to "no
provider" and drew the email form while /auth/sso/config was in flight,
then swapped it — so a reload showed a form that does not exist, briefly,
every time. They render nothing until the answer arrives. The landing
modal is now one button, "Sign in with PedsHub SSO", with no sentence
under it: the button already says where you are going.

Also, the section strip takes the width it has. It sat inside the 1200px
measure that keeps an article readable, so on a wide desktop the last
entries fell off the end and a scroll arrow appeared beside acres of
empty space. Verified at 1280, 1600 and 1920: ten links, no arrows.

And "Make a deck" comes out of the strip and the phone menu — that was
an over-reach on my part. The landing CTA keeps it, pointing at
app.pedshub.com/#resources, which is what was actually asked for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 14:37:10 +02:00

754 lines
30 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.services import ai_service, site_settings, sso_roles, tts_voices
from app.schemas.auth import UserResponse, UserUpdateRole
from app.schemas.admin import AIModelConfigCreate, AIModelConfigResponse, AIModelConfigUpdate
from app.utils.auth import require_admin, get_current_user
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),
):
sso_roles.refuse_local_role_change()
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.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.task == "tts":
if not tts_voices.accepts(data.model_id):
model_part, voice_part = tts_voices.split(data.model_id)
raise HTTPException(
status_code=400,
detail=f"{model_part} does not accept the voice {voice_part}. It accepts: "
+ ", ".join(v for v, _ in tts_voices.voices_for(model_part)))
# A speech model is added with its voices: "groq-orpheus-english"
# becomes one row per voice the model accepts, so the administrator
# adds a model and the voices appear, ready to be tested one by one.
rows = tts_voices.expand(data.model_id, data.name if data.name != data.model_id else None)
if len(rows) > 1:
return _add_voice_rows(db, data, rows)
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
def _add_voice_rows(db: Session, data: AIModelConfigCreate, rows: list[tuple[str, str]]) -> AIModelConfig:
"""One row per voice; the first becomes default only if the task had none.
Returns the first row, which is what the caller shows. Voices already
present are left as they are rather than refused, so adding a model twice
fills in whatever was missing.
"""
existing = {m.model_id for m in db.query(AIModelConfig).filter(AIModelConfig.task == "tts").all()}
has_default = db.query(AIModelConfig).filter(
AIModelConfig.task == "tts", AIModelConfig.is_active == True, AIModelConfig.is_default == True,
).count() > 0
first = None
for model_id, name in rows:
if model_id in existing:
continue
row = AIModelConfig(name=name, model_id=model_id, task="tts", api_key=data.api_key,
is_active=data.is_active, is_default=not has_default and first is None)
db.add(row)
first = first or row
if first is None:
raise HTTPException(status_code=409, detail=f"Every voice of '{data.model_id}' is already added")
db.commit()
db.refresh(first)
return first
@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
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 tts_voices.KOKORO_VOICES}
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 tts_voices.KOKORO_VOICES
]
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
# Kitten and Supertonic were retired from the gateway; Orpheus
# and Fish are what it proxies now, and their voices are the
# family table's.
known = tts_voices.voices_for(model_name)
if known:
voices.extend(_static_voice_options(model_name, known))
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")
# --- 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")
# Read for as long as an older deployment may still have one in Redis;
# nothing writes it any more.
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(),
**site_settings.all_values(),
}
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,
**site_settings.VALUES,
}
@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]))
for name in site_settings.VALUES:
if name in settings_data:
site_settings.set_value(name, str(settings_data[name] or ""))
# Not settable here, and refused rather than ignored. Every vector in
# the database was produced by this model; changing it makes all of them
# incomparable and search returns noise until 3,000 questions, every
# article and every card have been re-embedded. A change that expensive
# belongs in the environment, where making it is a deployment somebody
# decided on — not a text field on a settings page.
if "embedding_model" in settings_data:
raise HTTPException(
400,
"The embedding model is set by LITELLM_EMBEDDING_MODEL in the "
"environment. Changing it invalidates every stored vector, so it "
"is a deployment rather than a setting.")
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."}