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."}