feat: a reading voice in Settings, and a tutor that reads every figure

The voice picker was a dropdown in the quiz player, beside the question — the
one control on that screen with nothing to do with answering it, and one a
learner sets once and never touches. It is a setting now, on the user rather
than in a Redis blob, with a play button beside each voice because a voice is
worth hearing before it is chosen. Choosing nothing stays a real choice: it
means whatever an administrator marked default, so a site that changes its
default reaches everybody without a row being edited.

The tutor reads figures from `question_media` rather than the two legacy path
columns. Those agree exactly today, so nothing was being lost — the first
question given a second figure in the editor would have been the one that
broke it, silently and only for the tutor. The legacy columns remain as a
fallback for anything not projected into that table yet.

And the retrieval thresholds are written down in docs/retrieval-thresholds.md:
the three answers, the sixteen queries they were measured against, why they are
deliberately not the retrieval floor, and how to re-measure when the corpus
grows. Worth keeping the headline in mind — "discuss love" scores 0.491,
alongside "tell me a joke". A number in the 0.4s is noise, not a weak signal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-12 16:48:09 +02:00
parent 6203c3a92f
commit 3f57bda6aa
10 changed files with 303 additions and 15 deletions

View file

@ -0,0 +1,25 @@
"""A learner's own reading voice.
It was a dropdown in the quiz player, beside the question the one control on
that screen with nothing to do with answering, and one a learner would set once
and never touch again. That is a setting.
Revision ID: e7f8091a2b3c
Revises: d6e7f8091a2b
"""
import sqlalchemy as sa
from alembic import op
revision = "e7f8091a2b3c"
down_revision = "d6e7f8091a2b"
branch_labels = None
depends_on = None
def upgrade():
if "tts_voice" not in {c["name"] for c in sa.inspect(op.get_bind()).get_columns("users")}:
op.add_column("users", sa.Column("tts_voice", sa.String(), nullable=True))
def downgrade():
op.drop_column("users", "tts_voice")

View file

@ -20,6 +20,12 @@ class User(Base):
active_exam_id = Column(Integer, ForeignKey("exams.id", ondelete="SET NULL"), nullable=True)
is_unthrottled = Column(Integer, default=0) # 1 = exempt from rate limits
reminders_disabled = Column(Boolean, default=False, nullable=False, server_default=sa_false())
# Which voice reads a question aloud. A setting, not a decision to retake at
# the top of every session — it used to be a dropdown in the player, beside
# the question, where it was the only control on screen that had nothing to
# do with answering. Null means whichever voice an administrator marked
# default, so a learner who never opens Settings still gets a working one.
tts_voice = Column(String, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
# passive_deletes leaves the child rows to the database, which already

View file

@ -278,8 +278,10 @@ def get_user_settings(
except Exception as e:
import logging; logging.getLogger(__name__).warning(f"Failed to load user settings: {e}")
# Canonical opt-out preference comes from the DB
# Canonical preferences come from the DB. Redis holds the rest, and a cache
# is not where a choice somebody made once should live.
data["reminders_disabled"] = bool(current_user.reminders_disabled)
data["tts_voice"] = current_user.tts_voice
return data
@ -292,8 +294,15 @@ def save_user_settings(
"""Save user settings. reminders_disabled is persisted to Postgres;
other keys go to Redis."""
# Persist opt-out preference to DB (canonical source for the scheduler)
changed = False
if "reminders_disabled" in settings_data:
current_user.reminders_disabled = bool(settings_data.get("reminders_disabled"))
changed = True
if "tts_voice" in settings_data:
voice = (settings_data.get("tts_voice") or "").strip()
current_user.tts_voice = voice or None
changed = True
if changed:
db.add(current_user)
db.commit()

View file

@ -13,7 +13,7 @@ from app.models.ai_model_config import AIModelConfig
from app.models.attempt import QuizAttempt
from app.models.user import User
from app.utils.quiz_access import require_question_access
from app.services import site_settings, vision_service
from app.services import question_figures, site_settings, vision_service
from app.services.quiz_builder import bank_question_predicate
from app.utils.auth import check_rate_limit, get_current_user, require_moderator
@ -80,22 +80,44 @@ def _find_similar_questions(db: Session, question: Question, user: User, limit:
return []
def _figures(question: Question) -> list[vision_service.Image]:
#: What a figure's role is called when it is described to the tutor. The two
#: are not interchangeable: "the figure in the explanation" and "the figure in
#: the stem" mean different things to something that has been told the answer.
FIGURE_CAPTIONS = {
"stem": "the figure printed with the question stem",
"explanation": "the figure printed with the explanation",
}
def _figures(db: Session, question: Question) -> list[vision_service.Image]:
"""The pictures printed with this question, loaded from storage.
The learner is looking at them. Until now the tutor was not: it was handed
the stem, the options and the answer key as text and left to teach around a
radiograph it had never seen, which produces confident sentences about a
finding nobody described. Captions say which is which, because "the figure
in the explanation" and "the figure in the stem" mean different things to a
tutor that has been told the answer.
finding nobody described.
Read from `question_media`, which is where figures live a question may
carry several, and the two legacy path columns can hold only one each.
They agree today, so nothing was being lost yet; the first question given a
second figure in the editor would have been the one that broke it, silently
and only for the tutor.
"""
wanted = [
(question.image_path, "the figure printed with the question stem"),
(question.explanation_image_path, "the figure printed with the explanation"),
rows = question_figures.figures_for(db, question.id)
images = [
vision_service.load_image(row["path"],
FIGURE_CAPTIONS.get(row.get("role"), "a figure printed with this question"))
for row in rows if row.get("path")
]
loaded = [vision_service.load_image(path, caption) for path, caption in wanted if path]
return [image for image in loaded if image]
if not images:
# Nothing projected into `question_media` yet — a question written
# before that table existed, or one whose backfill has not run.
legacy = [
(question.image_path, FIGURE_CAPTIONS["stem"]),
(question.explanation_image_path, FIGURE_CAPTIONS["explanation"]),
]
images = [vision_service.load_image(path, caption) for path, caption in legacy if path]
return [image for image in images if image]
def _option_label(index: int) -> str:
@ -300,7 +322,7 @@ async def chat(
# async endpoint, so it goes to a worker thread rather than stopping every
# other request on this uvicorn worker.
handoff = None
figures = await run_in_threadpool(_figures, question)
figures = await run_in_threadpool(_figures, db, question)
if figures:
try:
parts, handoff = await run_in_threadpool(

View file

@ -78,11 +78,16 @@ def text_to_speech(
text = request.text[:2000]
if request.voice and request.voice.startswith("local-"):
# The player no longer asks, so the learner's own setting is what decides —
# falling through to whatever an administrator marked default when they have
# never chosen one. A caller may still name a voice, which is how a preview
# in Settings plays the one being considered rather than the one in force.
wanted = request.voice or current_user.tts_voice
if wanted and wanted.startswith("local-"):
config = db.query(AIModelConfig).filter(
AIModelConfig.task == "tts",
AIModelConfig.is_active == True,
AIModelConfig.model_id == request.voice,
AIModelConfig.model_id == wanted,
).first()
if not config:
model_id, api_key = _default_tts_model(db)

View file

@ -206,7 +206,8 @@ def sources_block(sources: list[dict]) -> str:
# to come from the library, and a wrong claim costs them their trust in every
# other answer.
#
# Worth re-measuring when the corpus changes size or subject. Anything else is
# Worth re-measuring when the corpus changes size or subject; the method and
# the full measurement are in docs/retrieval-thresholds.md. Anything else is
# tuning by feel against numbers nobody wrote down.
STRONG_MATCH = 0.55
ADJACENT_MATCH = 0.50

View file

@ -0,0 +1,86 @@
# What "close enough" means
AI Mode gives one of three answers, and which one is decided by a number rather
than by asking the model to work out its own situation. This is where that
number comes from, what it was measured against, and how to re-measure it.
## Why a number and not a longer prompt
Retrieval could not say "nothing". `hybrid_ids()` fuses a lexical ranker and a
semantic one by reciprocal rank, throws the distances away, and returns the
*union* — so the shortlist was never empty. A question about photosynthesis came
back with six paediatric sources and an instruction to answer only from them.
Adding scenarios to the prompt would have asked the model to classify, in prose,
a situation the data already knows. Classification-by-prose is the part that does
not work, and it is also the part that makes prompts long. So the branch lives in
code: `ai_mode_service.answer_mode()`.
## The three answers
| closeness | mode | what the learner gets |
|---|---|---|
| `>= STRONG_MATCH` | `sourced` | answered from the library, cited |
| `>= ADJACENT_MATCH` | `adjacent` | "nothing covers this directly; the closest is…", then an answer marking which parts came from where |
| below | `open` | one line saying the library does not cover it, then an answer from general knowledge, citing nothing |
| unmeasurable (`None`) | `sourced` | see below |
**Unmeasurable is not low.** No vector database, or a downed encoder, returns
`None`. Retrieval still found its rows by other means, so they are still cited;
dropping every citation because the ruler is missing is the worse failure.
## The numbers, and the measurement behind them
In `backend/app/services/ai_mode_service.py`:
```python
STRONG_MATCH = 0.55
ADJACENT_MATCH = 0.50
```
Measured on 2026-09-12, against the corpus with article bodies embedded — eight
clearly on-topic questions and eight clearly off-topic, scored with
`search_service.top_similarity()` over the article and section corpora:
| | range | examples |
|---|---|---|
| off-topic | **0.339 0.499** | the French revolution 0.339 · how do I bake sourdough 0.410 · quantum entanglement 0.448 · javascript closures 0.452 · tell me a joke 0.455 · what is a mortgage 0.462 · discuss love 0.491 · photosynthesis 0.499 |
| on-topic | **0.586 0.740** | neonatal jaundice phototherapy 0.586 · what causes croup 0.594 · febrile seizure workup 0.638 · Kawasaki disease 0.660 · testicular torsion 0.713 · bronchiolitis in an infant 0.716 · iron deficiency in a toddler 0.733 · posterior urethral valves 0.740 |
The thresholds sit in the gap between those two bands.
Worth noticing that **"discuss love" scores 0.491, alongside "tell me a joke"**.
It feels adjacent to a paediatrics library — attachment, behaviour — and it is
not: 0.49 is where anything written in English lands against any corpus. That is
the reading to keep in mind. A number in the 0.4s is noise, not a weak signal.
## Why these are not `SEMANTIC_FLOOR`
`search_service.SEMANTIC_FLOOR` (0.45) decides what is worth putting in a list,
where a weak hit costs a reader one glance. These decide whether an answer
*claims to come from the library*, and a wrong claim there costs a learner their
trust in every other answer. Different jobs, different numbers, and tying them
together would mean one could not be tuned without moving the other.
## Re-measuring
Re-measure when the corpus changes size or subject — a much larger library
raises the floor for everything, because there is more for any query to be
vaguely near.
```
docker compose exec -T backend python -c "
from app.database import SessionLocal
from app.services.search_service import top_similarity
db = SessionLocal()
for q in ['the French revolution', 'tell me a joke', 'discuss love',
'what causes croup', 'bronchiolitis in an infant']:
print(f'{top_similarity(db, q):.3f} {q}')
"
```
Pick your own on- and off-topic sets, run both, and put the thresholds in the
gap. If there is no gap, the embeddings are wrong before the thresholds are —
that is what happened before the article bodies were indexed, when 98% of the
corpus was embedded on its title and summary alone and every distance was
measuring the wrong thing.

View file

@ -0,0 +1,28 @@
/* A list rather than a dropdown: every voice is worth hearing before it is
chosen, and a play button inside a <select> is not a thing. */
.vset-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.vset-list li { display: flex; align-items: stretch; gap: 6px; }
.vset-pick {
flex: 1; display: flex; align-items: center; gap: 10px;
padding: 11px 14px; font: inherit; font-size: 0.9rem; text-align: left; cursor: pointer;
color: var(--text); background: var(--card-bg);
border: 1.5px solid var(--border); border-radius: 9px;
}
.vset-pick:hover { border-color: var(--text-subtle); }
.vset-pick.is-on { border-color: var(--primary); background: var(--option-sel-bg, #e5ecf8); font-weight: 600; }
.vset-pick small {
margin-left: auto; font-size: 0.72rem; font-weight: 600;
letter-spacing: 0.04em; text-transform: uppercase; color: var(--text-subtle);
}
.vset-hear {
flex: none; width: 42px; cursor: pointer;
font-size: 0.95rem; color: var(--text-muted);
background: var(--card-bg); border: 1.5px solid var(--border); border-radius: 9px;
}
.vset-hear:hover:not(:disabled) { color: var(--primary); border-color: var(--primary); }
.vset-hear:disabled { opacity: 0.5; cursor: default; }
.vset-error { margin: 0 0 10px; font-size: 0.85rem; color: var(--wrong-fg); }
.vset-none { margin: 0; font-size: 0.88rem; color: var(--text-muted); }

View file

@ -0,0 +1,98 @@
import { useEffect, useRef, useState } from 'react'
import api from '../api/client'
import './VoiceSetting.css'
/**
* Which voice reads a question aloud.
*
* It was a dropdown in the quiz player, sitting beside the question the one
* control on that screen with nothing to do with answering it, and one a
* learner sets once and then never touches. That is a setting.
*
* Choosing nothing is a real choice and stays available: it means whichever
* voice an administrator marked default, so a learner who never opens this
* page still gets a working one, and a site that changes its default reaches
* them without anybody editing a row.
*/
export default function VoiceSetting() {
const [voices, setVoices] = useState([])
const [chosen, setChosen] = useState('')
const [state, setState] = useState('loading') // loading | ready | saving | failed
const [playing, setPlaying] = useState('')
const audio = useRef(null)
useEffect(() => {
let live = true
Promise.all([
api.get('/tts/voices').catch(() => ({ data: [] })),
api.get('/auth/me/settings').catch(() => ({ data: {} })),
]).then(([list, settings]) => {
if (!live) return
setVoices(list.data || [])
setChosen(settings.data?.tts_voice || '')
setState('ready')
})
// Nothing should still be talking after this page closes.
return () => { live = false; audio.current?.pause() }
}, [])
const choose = async (value) => {
const previous = chosen
setChosen(value)
setState('saving')
try {
await api.put('/auth/me/settings', { tts_voice: value || null })
setState('ready')
} catch {
// Put it back rather than showing a choice that was not saved.
setChosen(previous)
setState('failed')
}
}
/** Hear the one being considered, which is not necessarily the one in force. */
const preview = async (voice) => {
audio.current?.pause()
setPlaying(voice)
try {
const res = await api.post('/tts/speak',
{ text: 'A four-year-old is brought in with a barking cough.', voice: voice || null },
{ responseType: 'blob' })
const player = new Audio(URL.createObjectURL(res.data))
audio.current = player
player.onended = () => setPlaying('')
await player.play()
} catch { setPlaying('') }
}
if (state === 'loading') return <div className="loading"><div className="spinner" /></div>
if (!voices.length) {
return <p className="vset-none">No reading voices are configured for this site yet.</p>
}
return (
<div className="vset">
{state === 'failed' && (
<p className="vset-error" role="alert">That could not be saved. Try again.</p>
)}
<ul className="vset-list" role="radiogroup" aria-label="Reading voice">
{[{ id: '', name: 'Site default' }, ...voices].map(voice => (
<li key={voice.id || 'default'}>
<button type="button" role="radio" aria-checked={chosen === voice.id}
className={`vset-pick${chosen === voice.id ? ' is-on' : ''}`}
disabled={state === 'saving'}
onClick={() => choose(voice.id)}>
{voice.name}
{voice.is_default && voice.id && <small>site default</small>}
</button>
<button type="button" className="vset-hear"
aria-label={`Hear ${voice.name}`} disabled={!!playing}
onClick={() => preview(voice.id)}>
{playing === voice.id ? '♪' : '▶'}
</button>
</li>
))}
</ul>
</div>
)
}

View file

@ -9,6 +9,7 @@ import ExamAdmin from '../components/ExamAdmin'
import PeopleAdmin from '../components/PeopleAdmin'
import ModelsAdmin from '../components/ModelsAdmin'
import './SettingsPage.css'
import VoiceSetting from '../components/VoiceSetting'
function Section({ title, description, children }) {
return (
@ -413,6 +414,13 @@ export default function SettingsPage() {
render: () => <StudySection /> },
{ key: 'appearance', group: 'You', icon: '🎨', label: 'Appearance',
render: () => <AppearanceSection /> },
{ key: 'voice', group: 'You', icon: '🔊', label: 'Reading voice',
render: () => (
<Section title="Reading voice"
description="Which voice reads a question aloud. Hear one before you choose it.">
<VoiceSetting />
</Section>
) },
{ key: 'data', group: 'You', icon: '🗄️', label: 'Your data',
render: () => <DataSection /> },
...(isModerator ? [