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:
parent
c59f9f936b
commit
fc4df94a6a
10 changed files with 467 additions and 4 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
51
frontend/src/pages/HandbookPage.css
Normal file
51
frontend/src/pages/HandbookPage.css
Normal 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);
|
||||
}
|
||||
209
frontend/src/pages/HandbookPage.jsx
Normal file
209
frontend/src/pages/HandbookPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
57
frontend/src/pages/HandbookPage.test.jsx
Normal file
57
frontend/src/pages/HandbookPage.test.jsx
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
Loading…
Reference in a new issue