diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py
index b97c779..a5d1918 100644
--- a/backend/app/routers/admin.py
+++ b/backend/app/routers/admin.py
@@ -1,6 +1,8 @@
-from fastapi import APIRouter, Depends, HTTPException, Query
+import logging
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
@@ -11,7 +13,7 @@ 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 invites, site_settings
+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
@@ -146,6 +148,13 @@ class LiteLLMSearchRequest(BaseModel):
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."
+
+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.
@@ -308,10 +317,17 @@ def test_model(
raise HTTPException(status_code=404, detail="Model config not found")
if model.task == "tts":
- # Not a failure. A voice is tested by hearing it, and the page offers
- # that; saying so in red taught administrators to distrust a working
- # configuration.
- return {"message": f"{model.model_id} is a voice — press Preview to hear it."}
+ # 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")
@@ -325,15 +341,33 @@ def test_model(
status_code=404,
detail=f"{model.model_id} is not served by the proxy"
+ (" as a transcription model" if by_mode else ""))
- return {"message": f"✓ {model.model_id} is served by the proxy" + (
- " for speech transcription" if by_mode
- # The route that says what a model is for is not open to this
- # key, so this is presence, not suitability.
- else " — the proxy would not say whether it transcribes")}
except HTTPException:
raise
except Exception as e:
- raise HTTPException(status_code=502, detail=str(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()}”"}
try:
import litellm
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 7bbdeba..20e776a 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,6 +1,7 @@
import { Suspense } from 'react'
import { BrowserRouter, Routes, Route, Navigate, Outlet, Link, useLocation, useParams } from 'react-router-dom'
import { AuthProvider, useAuth } from './context/AuthContext'
+import { SessionDrawerProvider } from './context/SessionDrawer'
import { ThemeProvider } from './context/ThemeContext'
import Navbar from './components/Navbar'
import SiteFooter from './components/SiteFooter'
@@ -238,7 +239,9 @@ export default function App() {
+
+
diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx
index 9f61b8a..4bf5b47 100644
--- a/frontend/src/components/Navbar.jsx
+++ b/frontend/src/components/Navbar.jsx
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { Link, useLocation } from 'react-router-dom'
import ScrollStrip from './ScrollStrip'
import { useAuth } from '../context/AuthContext'
+import { useSessionDrawer } from '../context/SessionDrawer'
import api from '../api/client'
import ExamSwitcher from './ExamSwitcher'
import GlobalSearch from './GlobalSearch'
@@ -185,6 +186,8 @@ export default function Navbar({ onSignIn, onRegister }) {
// The section bar is wanted when you decide to go elsewhere and in the way
// every other moment, so it leaves on the way down and returns on the way up.
const sectionBarHidden = useHidingBar()
+ // Registered by the quiz player while it is on screen without a rail.
+ const sessionDrawer = useSessionDrawer()
const [jobs, setJobs] = useState([])
const location = useLocation()
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
@@ -258,11 +261,16 @@ export default function Navbar({ onSignIn, onRegister }) {
+ {/* While a session is open on a narrow screen, this button
+ belongs to the session: it opens the list of questions, and
+ the site menu is a tab inside that drawer. Two menu buttons
+ an inch apart, one of which leaves the session you are
+ sitting, is the wrong offer. */}