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