diff --git a/backend/alembic/versions/e7f8091a2b3c_user_voice_preference.py b/backend/alembic/versions/e7f8091a2b3c_user_voice_preference.py new file mode 100644 index 0000000..5094c24 --- /dev/null +++ b/backend/alembic/versions/e7f8091a2b3c_user_voice_preference.py @@ -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") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 1cbd220..394f401 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -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 diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index b09f575..f170153 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -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() diff --git a/backend/app/routers/teach.py b/backend/app/routers/teach.py index 99dd5dc..7be0f48 100644 --- a/backend/app/routers/teach.py +++ b/backend/app/routers/teach.py @@ -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( diff --git a/backend/app/routers/tts.py b/backend/app/routers/tts.py index e434441..07daa12 100644 --- a/backend/app/routers/tts.py +++ b/backend/app/routers/tts.py @@ -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) diff --git a/backend/app/services/ai_mode_service.py b/backend/app/services/ai_mode_service.py index 47c1ec5..ace2332 100644 --- a/backend/app/services/ai_mode_service.py +++ b/backend/app/services/ai_mode_service.py @@ -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 diff --git a/docs/retrieval-thresholds.md b/docs/retrieval-thresholds.md new file mode 100644 index 0000000..6bb7ac0 --- /dev/null +++ b/docs/retrieval-thresholds.md @@ -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. diff --git a/frontend/src/components/VoiceSetting.css b/frontend/src/components/VoiceSetting.css new file mode 100644 index 0000000..43aae5f --- /dev/null +++ b/frontend/src/components/VoiceSetting.css @@ -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