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:
parent
6203c3a92f
commit
3f57bda6aa
10 changed files with 303 additions and 15 deletions
|
|
@ -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")
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
86
docs/retrieval-thresholds.md
Normal file
86
docs/retrieval-thresholds.md
Normal 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.
|
||||
28
frontend/src/components/VoiceSetting.css
Normal file
28
frontend/src/components/VoiceSetting.css
Normal 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); }
|
||||
98
frontend/src/components/VoiceSetting.jsx
Normal file
98
frontend/src/components/VoiceSetting.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 ? [
|
||||
|
|
|
|||
Loading…
Reference in a new issue