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