feat: the tutor is an administrator's to allow, and a handbook explains the rest

The tutor is handed the correct answer and the explanation and told it
may reveal them, which is why it has never been offered during a running
exam — require_question_access already refuses that, whatever anyone
sets. What was missing is the other half: an administrator can now
withhold it from study sessions too.

Enforced on the server rather than by hiding a button, because hiding a
button does not stop a request. Reviewing a finished attempt is not
"during" and is unaffected; the answers are shown by then anyway. If
Redis is unreachable the tutor stays on — nothing is revealed that study
mode does not already show, so the permissive direction is the safe one
here.

GET /teach/prompt renders the instructions against a stand-in question,
so an educator answering "why did the tutor say that?" can read them
rather than infer them.

And a handbook at /handbook, for anyone who maintains questions or
articles whatever access they hold. It answers the things that were only
in the code: that a question links to an article three different ways —
a further-reading row, a key point carrying an article and section, and
a [[id|label]] marker in prose keyed by id so renaming does not break it
— what the tutor is told, why a blueprint shapes a paper, why deleting a
question hides it, and why changing the embedding model invalidates
every vector.

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-11 21:13:38 +02:00
parent c59f9f936b
commit fc4df94a6a
10 changed files with 467 additions and 4 deletions

View file

@ -8,10 +8,12 @@ from sqlalchemy.orm import Session
from app.database import get_db
from app.models.question import Question
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
from app.services.quiz_builder import bank_question_predicate
from app.utils.auth import get_current_user, check_rate_limit
from app.utils.auth import check_rate_limit, get_current_user, require_moderator
router = APIRouter()
@ -131,6 +133,84 @@ def list_teach_models(
return [{"id": m.id, "name": m.name, "model_id": m.model_id, "is_default": m.is_default} for m in models]
@router.get("/policy")
def tutor_policy(current_user: User = Depends(get_current_user)):
"""Whether the tutor may be opened during a session.
Asked before the quiz offers it, so a switch an administrator has thrown
reads as "not there" rather than as a button that fails when pressed.
Study mode is a separate rule the interface already applies and the server
enforces regardless of this answer.
"""
return {"in_quiz": site_settings.get_flag("tutor_in_quiz")}
@router.get("/prompt")
def show_prompt(
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""What the tutor is actually told, for anyone who maintains questions.
An educator answering "why did the tutor say that?" should be able to read
the instructions rather than infer them, and the answer matters: the tutor
is handed the correct answer and the explanation, and is told it may
reveal them. That is the reason it is never offered during a running exam.
Rendered against a stand-in question so the shape is visible without
naming any real one.
"""
example = Question(
id=0,
question_text="<the question the learner is on>",
options=["<option 1>", "<option 2>"],
correct_answer="<the correct option>",
explanation="<the explanation stored on the question>",
question_type="mcq",
)
return {
"prompt": _build_system_prompt(example, []),
"sends_correct_answer": True,
"sends_explanation": True,
"similar_questions": 4,
"daily_limit": 30,
"notes": [
"The tutor is given the correct answer and the explanation, and is"
" told it may reveal them. It is therefore never offered while an"
" exam-mode attempt is still running.",
"Up to four semantically similar questions are added for context,"
" with their answers. They are chosen by embedding, not by hand.",
"An administrator can turn the tutor off for study sessions too,"
" under Settings, Access and joining.",
"Editing this text means changing _build_system_prompt in"
" backend/app/routers/teach.py — it is code, not a setting, so a"
" change is reviewed and deployed like any other.",
],
}
def _require_tutor_allowed(db: Session, attempt_id: int | None, user: User) -> None:
"""Refuse the tutor mid-session when an administrator has turned it off.
Enforced here rather than only in the interface, because hiding a button
does not stop a request. Two rules, and the first is not a setting:
* Never during an exam that is still running. The prompt below is given
the correct answer and told it may explain it, so an exam-mode tutor is
an answer key. `require_question_access` already refuses this.
* Optionally not during a study session either, which is what this flag
decides. Reviewing a finished attempt is not "during", so it is
unaffected the answers are already shown by then.
"""
if attempt_id is None:
return
attempt = db.query(QuizAttempt).filter_by(id=attempt_id, user_id=user.id).first()
if attempt is None or attempt.completed_at is not None:
return
if not site_settings.get_flag("tutor_in_quiz"):
raise HTTPException(403, "The tutor is turned off while a session is being sat")
@router.post("/chat")
async def chat(
req: ChatRequest,
@ -140,6 +220,7 @@ async def chat(
"""Send a message to the teach AI with full question context."""
question = db.query(Question).filter(Question.id == req.question_id).first()
require_question_access(db, question, current_user, req.attempt_id, review=True)
_require_tutor_allowed(db, req.attempt_id, current_user)
# Daily AI coach quota. Admins, moderators, and unthrottled users are exempt.
quota_day, quota_ttl = _daily_teach_limit()

View file

@ -16,6 +16,11 @@ FLAGS: dict[str, bool] = {
"sharing_enabled": True,
#: Whether registering requires an invite code issued by an administrator.
"invite_only": False,
#: Whether the AI tutor may be opened while a session is being sat. It is
#: only ever offered in study mode — the tutor is given the correct answer
#: and told it may reveal it, so during an exam it would simply hand it
#: over. This switch decides whether even study mode gets it.
"tutor_in_quiz": True,
}

View file

@ -97,3 +97,29 @@ class InviteTests(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
class TutorFlagTests(unittest.TestCase):
"""Whether the tutor may be opened during a session.
The rule that is not a setting: the tutor is handed the correct answer and
told it may explain it, so an exam-mode tutor is an answer key. That is
refused whatever the flag says. The flag only decides study mode.
"""
def test_the_tutor_is_allowed_by_default(self):
with patch.dict(sys.modules, {"redis": fake_redis({})}):
self.assertTrue(site_settings.get_flag("tutor_in_quiz"))
def test_turning_it_off_reads_back(self):
store = {}
with patch.dict(sys.modules, {"redis": fake_redis(store)}):
site_settings.set_flag("tutor_in_quiz", False)
self.assertFalse(site_settings.get_flag("tutor_in_quiz"))
def test_redis_being_down_leaves_the_tutor_on(self):
# The safe direction here is the permissive one: losing Redis should
# not silently remove a study aid. Nothing is revealed that study mode
# does not already show.
with patch.dict(sys.modules, {"redis": fake_redis({}, broken=True)}):
self.assertTrue(site_settings.get_flag("tutor_in_quiz"))

View file

@ -37,6 +37,7 @@ const AiModePage = lazyPage(() => import('./pages/AiModePage'))
const MediaPage = lazyPage(() => import('./pages/MediaPage'))
const EditorialPage = lazyPage(() => import('./pages/EditorialPage'))
const AccessPage = lazyPage(() => import('./pages/AccessPage'))
const HandbookPage = lazyPage(() => import('./pages/HandbookPage'))
const StudyPlansPage = lazyPage(() => import('./pages/StudyPlansPage'))
const StudyPlanPage = lazyPage(() => import('./pages/StudyPlanPage'))
const StudyPlanBlockPage = lazyPage(() => import('./pages/StudyPlanBlockPage'))
@ -157,6 +158,10 @@ function AppRoutes() {
Settings now; the old address still works for anyone who
bookmarked it. */}
<Route path="/admin" element={<Navigate to="/settings?s=people" replace />} />
{/* How the site works, for anyone who maintains it an educator
with one branch needs it as much as an administrator does, so
it is not moderator-only. */}
<Route path="/handbook" element={<HandbookPage />} />
</Route>
</Route>

View file

@ -15,7 +15,7 @@ const when = (value) => (value ? new Date(value).toLocaleDateString(undefined,
* something a learner has already handed to someone it stops new ones.
*/
export default function SitePolicy() {
const [flags, setFlags] = useState({ sharing_enabled: true, invite_only: false })
const [flags, setFlags] = useState({ sharing_enabled: true, invite_only: false, tutor_in_quiz: true })
const [codes, setCodes] = useState([])
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
@ -29,6 +29,7 @@ export default function SitePolicy() {
setFlags({
sharing_enabled: settings.data.sharing_enabled !== false,
invite_only: settings.data.invite_only === true,
tutor_in_quiz: settings.data.tutor_in_quiz !== false,
})
setCodes(invites.data || [])
})
@ -97,6 +98,20 @@ export default function SitePolicy() {
</span>
</label>
<label className="sp-switch">
<input type="checkbox" checked={flags.tutor_in_quiz} disabled={busy}
onChange={e => toggle('tutor_in_quiz', e.target.checked)} />
<span>
<strong>Allow the AI tutor during a session</strong>
<small>
Study mode only, always. The tutor is given the correct answer and
told it may explain it, so it is never offered during an exam
that is not a setting. Turning this off removes it from study
sessions as well; reviewing a finished attempt is unaffected.
</small>
</span>
</label>
{flags.invite_only && (
<section className="sp-codes">
<h3>Invite codes <small>{open.length} unused</small></h3>

View file

@ -0,0 +1,51 @@
/* The handbook reads like a document, so it is set like one: one column,
measured line length, and headings that carry the structure. */
.hb { max-width: 760px; margin: 0 auto; padding-bottom: 64px; }
.hb-head { margin-bottom: 32px; }
.hb-head h1 { margin: 0 0 8px; font-size: 1.5rem; font-weight: 700; }
.hb-head > p { margin: 0 0 16px; font-size: 0.92rem; color: var(--text-muted); line-height: 1.65; }
.hb-head nav { display: flex; flex-wrap: wrap; gap: 8px; }
.hb-head nav a {
padding: 5px 11px; font-size: 0.8rem; font-weight: 600; text-decoration: none;
color: var(--text-muted); background: var(--bg);
border: 1px solid var(--border); border-radius: 999px;
}
.hb-head nav a:hover { color: var(--primary); border-color: var(--primary); }
.hb section { margin-bottom: 40px; scroll-margin-top: 100px; }
.hb h2 {
margin: 0 0 12px; padding-bottom: 8px;
font-size: 1.12rem; font-weight: 700;
border-bottom: 1px solid var(--border);
}
.hb h3 { margin: 22px 0 8px; font-size: 0.95rem; font-weight: 650; }
.hb p { margin: 0 0 12px; font-size: 0.9rem; line-height: 1.7; }
.hb code {
font-size: 0.85em; padding: 1px 5px; border-radius: 4px;
background: var(--bg); color: var(--text);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.hb-facts { margin: 0 0 14px; padding-left: 20px; }
.hb-facts li { margin-bottom: 7px; font-size: 0.88rem; line-height: 1.65; }
/* An aside that qualifies, and one that warns. */
.hb-note, .hb-warn {
padding: 10px 13px; border-radius: 8px;
font-size: 0.86rem; line-height: 1.6;
}
.hb-note { background: var(--bg); border-left: 3px solid var(--border); color: var(--text-muted); }
.hb-warn { background: var(--wrong-bg); border-left: 3px solid var(--wrong-fg); color: var(--wrong-fg); }
.hb-details { margin-top: 14px; }
.hb-details summary { cursor: pointer; font-size: 0.86rem; font-weight: 600; color: var(--primary); }
.hb-details pre {
margin: 12px 0 0; padding: 14px;
/* The prompt is long and its line breaks are meaningful, so it wraps rather
than scrolling sideways. */
white-space: pre-wrap; word-break: break-word;
font-size: 0.78rem; line-height: 1.6;
background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
color: var(--text);
}

View file

@ -0,0 +1,209 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../api/client'
import './HandbookPage.css'
/**
* How this site works, for the people who maintain it.
*
* Written because the answers were only in the code: how a question comes to
* link to an article, what the tutor is told, why a paper has the shape it
* has. An educator with access to one branch of the taxonomy needs these as
* much as an administrator does, so nothing here is gated on being one
* except the tutor's own prompt, which the server will only show a moderator.
*/
const SECTIONS = [
{ id: 'links', label: 'Article links' },
{ id: 'tutor', label: 'The AI tutor' },
{ id: 'blueprint', label: 'Exams and blueprints' },
{ id: 'deleting', label: 'Deleting things' },
{ id: 'models', label: 'Models' },
{ id: 'joining', label: 'Joining and sharing' },
]
function Prompt() {
const [state, setState] = useState({ loading: true })
useEffect(() => {
api.get('/teach/prompt')
.then(res => setState({ data: res.data }))
.catch(err => setState({ denied: err?.response?.status === 403 }))
}, [])
if (state.loading) return <div className="loading"><div className="spinner" /></div>
if (state.denied) {
return <p className="hb-note">The exact prompt is shown to moderators and administrators.</p>
}
if (!state.data) return <p className="hb-note">The prompt could not be loaded.</p>
return (
<>
<ul className="hb-facts">
{state.data.notes?.map(note => <li key={note}>{note}</li>)}
</ul>
<details className="hb-details">
<summary>The exact instructions the tutor is given</summary>
<pre>{state.data.prompt}</pre>
</details>
</>
)
}
export default function HandbookPage() {
return (
<div className="hb">
<div className="hb-head">
<h1>Handbook</h1>
<p>
How the parts of this site that are not obvious actually work. Written
for anyone who maintains questions or articles, whatever they have
access to.
</p>
<nav aria-label="Handbook sections">
{SECTIONS.map(section => (
<a key={section.id} href={`#${section.id}`}>{section.label}</a>
))}
</nav>
</div>
<section id="links">
<h2>How a question comes to link to an article</h2>
<p>
There are three mechanisms and they are not interchangeable. They
exist because they answer different questions: <em>what should I read
next</em>, <em>what is this one point about</em>, and <em>what does
this word mean here</em>.
</p>
<h3>1. Further reading a link between the two records</h3>
<p>
A row in <code>question_article_links</code>, joining one question to
one article, and optionally to <strong>one section of it</strong>
rather than the whole thing. Made from the article's own page, where
you find the question and link it; the same pair can only be linked
once, so pressing it twice does nothing.
</p>
<p className="hb-note">
A section link points at <code>Article.sections[].id</code>, which is
a stable identifier rather than a position renaming or reordering a
section does not break it, and deleting that section does.
</p>
<h3>2. Key points the smart links on the question itself</h3>
<p>
Up to twelve short lines stored on the question in{' '}
<code>key_points</code>. Each is a sentence of teaching, and each may
carry an article and a section, which is what makes it a link. Edited
with the question, not with the article.
</p>
<p>
A key point is at most 300 characters. The server refuses one whose
article does not exist, or whose section is not in that article so a
broken key-point link cannot be saved, only created by later deleting
what it pointed at.
</p>
<h3>3. Cross-references inside article prose</h3>
<p>
Written in the article body as <code>[[214|congenital heart disease]]</code>{' '}
the number is the article's id and the text is what the reader sees.
The id is used rather than the title <strong>so that renaming an
article does not break every link to it</strong>.
</p>
<p className="hb-warn">
Write the marker exactly as shown. The editor escapes anything that
merely looks like one. If you write a label that differs from the
article's title, the automatic linker leaves your wording alone
that is how it tells a deliberate phrasing from one of its own.
</p>
</section>
<section id="tutor">
<h2>The AI tutor</h2>
<p>
The assistant a learner can open beside a question in study mode. What
it is told is code, not configuration, which means a change to it is
reviewed and deployed like any other change.
</p>
<Prompt />
</section>
<section id="blueprint">
<h2>Exams and blueprints</h2>
<p>
An exam is a study objective. A question belongs to one through a link
row, so the same question can count towards more than one; an article
belongs through its own link table, because the same article can be in
a basic-science exam and a clinical one showing different views.
</p>
<p>
A <strong>blueprint</strong> is an examining board's published
content outline its domains, its numbering, and the percentage of a
paper each domain accounts for. It is mapped onto our categories
rather than replacing them: their outline is arranged for examining
and ours for studying, and both are right.
</p>
<p>
A session built to a blueprint draws each domain's share rather than
sampling evenly. If a domain cannot supply its share, the shortfall is
spread over the domains that can the session keeps its length and
loses accuracy, and the working is reported rather than hidden. A
domain with no categories mapped to it contributes nothing, which is
why <Link to="/settings?s=exams">the exam screen</Link> names them.
</p>
</section>
<section id="deleting">
<h2>Deleting things</h2>
<p>
Deleting a question hides it. The row stays, because its id is
referenced by attempts, quiz membership, exam membership, media,
article links, notes, favourites and feedback fourteen tables and
ids come from a sequence that never reissues a number. Erasing the row
takes all of that with it and nothing can put it back.
</p>
<p>
So a deleted question leaves the bank, the builder, search and every
share path, and sits in <Link to="/trash">the trash</Link> until it is
restored with the same id, so everything that pointed at it still
does or erased for good, which only a moderator may do and only from
the trash.
</p>
<p className="hb-note">
Deleted tests are not kept. A test is a selection you can make again.
</p>
</section>
<section id="models">
<h2>Models</h2>
<p>
Two separate decisions. Which models this site may use at all is an
allow-list. Which one does a particular job tutoring, extraction,
drafting articles, reading aloud is one choice per job. A job with
one allowed model offers no choice, because there is none.
</p>
<p className="hb-warn">
The embedding model is different: vectors made by one model are not
comparable with another's. Changing it silently degrades search and
recommendations until everything is re-embedded, so regenerate after
saving.
</p>
</section>
<section id="joining">
<h2>Joining and sharing</h2>
<p>
Registration can require an invite code issued by an administrator.
Turning that on does not affect anyone who already has an account.
</p>
<p>
A learner can make a public link to a session. Turning sharing off
stops new links being made; one already handed to somebody keeps
working, because breaking it would break something a learner has
already given away.
</p>
</section>
</div>
)
}

View file

@ -0,0 +1,57 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import HandbookPage from './HandbookPage'
import api from '../api/client'
vi.mock('../api/client', () => ({ default: { get: vi.fn() } }))
const mount = () => render(<MemoryRouter><HandbookPage /></MemoryRouter>)
beforeEach(() => {
vi.clearAllMocks()
api.get.mockResolvedValue({ data: {
prompt: 'You are a medical education tutor.\nCorrect Answer: <the correct option>',
notes: ['The tutor is given the correct answer and the explanation.'],
} })
})
describe('handbook', () => {
it('explains all three ways a question links to an article', async () => {
mount()
expect(await screen.findByRole('heading', { name: /Further reading/ })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: /Key points/ })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: /Cross-references inside article prose/ })).toBeInTheDocument()
// The marker is by id, and the handbook says why.
expect(screen.getByText(/\[\[214\|congenital heart disease\]\]/)).toBeInTheDocument()
expect(screen.getByText(/renaming an\s+article does not break/)).toBeInTheDocument()
})
it('shows what the tutor is told, folded away', async () => {
mount()
expect(await screen.findByText(/given the correct answer/)).toBeInTheDocument()
const details = screen.getByText('The exact instructions the tutor is given')
await userEvent.click(details)
expect(screen.getByText(/You are a medical education tutor/)).toBeInTheDocument()
})
it('says where to look when the prompt is not yours to see', async () => {
api.get.mockRejectedValue({ response: { status: 403 } })
mount()
// An educator without moderator rights still gets the rest of the page.
expect(await screen.findByText(/shown to moderators and administrators/)).toBeInTheDocument()
expect(screen.getByRole('heading', { name: /Key points/ })).toBeInTheDocument()
})
it('warns that changing the embedding model invalidates every vector', async () => {
mount()
expect(await screen.findByText(/not\s+comparable with another/)).toBeInTheDocument()
})
it('explains why deleting a question hides it rather than erasing it', async () => {
mount()
expect(await screen.findByText(/fourteen tables/)).toBeInTheDocument()
expect(screen.getByText(/never reissues a number/)).toBeInTheDocument()
})
})

View file

@ -513,6 +513,16 @@ export default function QuizPage() {
// Suspending is not abandoning: it ends on the session's own analysis, where
// what has been answered so far is scored and the Resume button sits. A
// course quiz still returns to the course it belongs to.
const [tutorAllowed, setTutorAllowed] = useState(true)
useEffect(() => {
let live = true
api.get('/teach/policy')
.then(res => { if (live) setTutorAllowed(res.data?.in_quiz !== false) })
.catch(() => {})
return () => { live = false }
}, [])
const exitTarget = () => returnTo || (attemptId ? `/sessions/${attemptId}` : '/')
const questions = quiz?.questions || []
const current = questions[currentIdx]
@ -1654,8 +1664,11 @@ const timerStarted = timeLeft !== null
)}
</div>
{/* AI tutor — only in study mode, lazy-loaded */}
{isStudy && current && (
{/* The tutor is study-mode only it is given the correct answer and
told it may explain it and an administrator can withhold it from
study sessions too. Both are enforced on the server; this only
decides whether the button is there to press. */}
{isStudy && tutorAllowed && current && (
<Suspense fallback={null}>
<TeachChat question={current} attemptId={attemptId} />
</Suspense>

View file

@ -226,6 +226,7 @@ function ToolsSection() {
<Section title="Tools" description="Editing surfaces that have a page of their own.">
<div className="set-cards">
{[
{ to: '/handbook', icon: '📖', label: 'Handbook', desc: 'How the parts that are not obvious work' },
{ to: '/upload', icon: '📄', label: 'Upload PDF', desc: 'Add new documents' },
{ to: '/categories', icon: '🗂️', label: 'Taxonomy', desc: 'Topics, systems, symptoms, diseases' },
{ to: '/editorial', icon: '✍️', label: 'Editorial', desc: 'Article drafts and references' },