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
754 lines
30 KiB
Python
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."}
|