feat: per-option explanations

Questions support an explanation per option, edited in the question dialog and shown in study feedback. Keys must match current options. Migration j3e4f5a6b708. 60 backend and 95 frontend tests pass.
This commit is contained in:
Daniel 2026-09-08 19:29:21 +02:00
parent a1e9340004
commit 179e51d143
12 changed files with 199 additions and 11 deletions

View file

@ -0,0 +1,19 @@
"""Per-option explanations.
Revision ID: j3e4f5a6b708
Revises: i2d3e4f5a607
"""
from alembic import op
revision = "j3e4f5a6b708"
down_revision = "i2d3e4f5a607"
branch_labels = None
depends_on = None
def upgrade():
op.execute("ALTER TABLE questions ADD COLUMN IF NOT EXISTS option_explanations JSON")
def downgrade():
op.execute("ALTER TABLE questions DROP COLUMN IF EXISTS option_explanations")

View file

@ -24,6 +24,7 @@ class Question(Base):
page_reference = Column(Integer, nullable=True)
image_path = Column(String, nullable=True)
explanation_image_path = Column(String, nullable=True)
option_explanations = Column(JSON, nullable=True) # {option_text: explanation}
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
is_shared = Column(Integer, default=1) # 1 = visible in bank, 0 = private (only owner sees it)
embedding = deferred(Column(Vector(1024), nullable=True)) # semantic search vector — deferred: not loaded in standard queries

View file

@ -17,6 +17,7 @@ from sqlalchemy.orm import Session
from app.config import settings
from app.database import get_db
from app.utils.upload_access import validate_image_attachments, stored_upload_path
from app.utils.quiz_questions import validate_option_explanations
from app.models.question import Question
from app.models.question_category import QuestionCategory, QuestionCategoryLink
from app.models.quiz import Quiz
@ -68,6 +69,7 @@ class QuestionEdit(BaseModel):
explanation: str | None = None
question_category_id: int | None = None
additional_category_ids: list[int] | None = None # Full set of extra (non-primary) categories.
option_explanations: dict | None = None
image_path: str | None = None
explanation_image_path: str | None = None
@ -87,6 +89,10 @@ def edit_question(
if question.user_id != current_user.id and not is_mod:
raise HTTPException(status_code=403, detail="Not authorized to edit this question")
values = validate_image_attachments(db, current_user, data.model_dump(exclude_unset=True))
explanations = values.pop("option_explanations", None)
if explanations is not None:
values["option_explanations"] = validate_option_explanations(
values.get("options", question.options), explanations)
extra_ids = values.pop("additional_category_ids", None)
if extra_ids is not None:
extra_ids = list(dict.fromkeys(extra_ids))
@ -357,6 +363,7 @@ def get_question_bank(
"explanation": qu.explanation,
"image_path": qu.image_path,
"explanation_image_path": qu.explanation_image_path,
"option_explanations": qu.option_explanations,
"user_id": qu.user_id,
"is_shared": qu.is_shared if qu.is_shared is not None else 1,
})
@ -384,6 +391,7 @@ class ManualQuestionCreate(BaseModel):
correct_answer: str
explanation: str | None = None
question_category_id: int | None = None
option_explanations: dict | None = None
image_path: str | None = None
explanation_image_path: str | None = None
@ -406,6 +414,7 @@ def create_question_manually(
raise HTTPException(status_code=400, detail="Correct answer must be one of the options")
images = validate_image_attachments(db, current_user, data.model_dump())
option_explanations = validate_option_explanations(data.options, data.option_explanations)
question = Question(
question_text=q_text,
question_type=data.question_type,
@ -415,6 +424,7 @@ def create_question_manually(
question_category_id=data.question_category_id,
image_path=images["image_path"],
explanation_image_path=images["explanation_image_path"],
option_explanations=option_explanations,
user_id=current_user.id,
is_shared=1,
)

View file

@ -5,6 +5,7 @@ from sqlalchemy import cast, String, or_, and_, func
from sqlalchemy.orm import Session
from app.utils.upload_access import validate_image_attachments
from app.utils.quiz_questions import validate_option_explanations
from app.database import get_db
from app.models.quiz import Quiz
from app.models.question import Question as QuestionModel
@ -452,8 +453,11 @@ def update_question(
if not question:
raise HTTPException(status_code=404, detail="Question not found")
allowed = {"question_text", "options", "correct_answer", "explanation", "question_type", "image_path", "explanation_image_path"}
allowed = {"question_text", "options", "correct_answer", "explanation", "question_type", "image_path", "explanation_image_path", "option_explanations"}
data = validate_image_attachments(db, current_user, data)
if "option_explanations" in data:
data["option_explanations"] = validate_option_explanations(
data.get("options", question.options), data["option_explanations"])
for key, value in data.items():
if key in allowed:
setattr(question, key, value)

View file

@ -42,6 +42,7 @@ class QuestionWithAnswer(QuestionResponse):
explanation: str | None
explanation_image_path: str | None = None
page_reference: int | None
option_explanations: dict | None = None
class QuizResponse(BaseModel):

View file

@ -6,6 +6,21 @@ from app.models.question import Question
from app.models.quiz_question_link import QuizQuestionLink
def validate_option_explanations(options, explanations):
"""Per-option explanations must be a {option_text: explanation} map within options."""
if explanations is None:
return None
if not isinstance(explanations, dict):
raise HTTPException(400, "Option explanations must be an object keyed by option text")
allowed = set(options or [])
for key, value in explanations.items():
if not isinstance(key, str) or key not in allowed:
raise HTTPException(400, "Option explanation keys must match existing options")
if not isinstance(value, str) or len(value) > 2000:
raise HTTPException(400, "Option explanations are strings up to 2000 characters")
return {key: value for key, value in explanations.items() if value.strip()}
def get_quiz_questions(db: Session, quiz_id: int) -> list[Question]:
"""Fetch questions for a quiz in position order via junction table."""
links = (

View file

@ -0,0 +1,68 @@
"""Per-option explanation validation and serialization."""
import unittest
import test_quiz_builder as fixtures
from app.models.question import Question
from app.routers import questions, quizzes
class OptionExplanationTests(unittest.TestCase):
def setUp(self):
self.bank = fixtures.BuilderTests()
self.bank.setUp()
self.client = self.bank.client
def tearDown(self):
self.bank.tearDown()
def create_payload(self, **overrides):
payload = {"question_text": "Explained", "question_type": "mcq",
"options": ["yes", "no"], "correct_answer": "yes",
"option_explanations": {"yes": "Right because", "no": "Wrong because"}}
payload.update(overrides)
return payload
def test_create_validates_and_serializes_explanations(self):
self.bank.user = self.bank.owner
created = self.client.post('/questions/create', json=self.create_payload())
self.assertIn(created.status_code, (200, 201), created.text)
question_id = created.json()['id']
bank = self.client.get('/questions/bank', params={'q': 'Explained'}).json()
row = next(r for r in bank['questions'] if r['id'] == question_id)
self.assertEqual(row['option_explanations'], {"yes": "Right because", "no": "Wrong because"})
for bad in (
{"option_explanations": {"nope": "x"}},
{"option_explanations": "not-a-dict"},
{"option_explanations": {"yes": "x" * 2001}},
):
response = self.client.post('/questions/create', json=self.create_payload(**bad))
self.assertIn(response.status_code, (400, 422), bad)
def test_edit_requires_keys_to_match_current_options(self):
self.bank.user = self.bank.owner
response = self.client.patch('/questions/3', json={"option_explanations": {"yes": "Correct path", "no": "Incorrect path"}})
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(self.bank.db.get(Question, 3).option_explanations, {"yes": "Correct path", "no": "Incorrect path"})
# Changing options without updating keys is rejected atomically.
response = self.client.patch('/questions/3', json={"options": ["a", "b"], "correct_answer": "a",
"option_explanations": {"yes": "stale"}})
self.assertEqual(response.status_code, 400, response.text)
self.assertEqual(self.bank.db.get(Question, 3).options, ["yes", "no"])
def test_quiz_reveal_and_editor_route_carry_explanations(self):
self.bank.user = self.bank.mod
self.client.patch('/questions/1', json={"option_explanations": {"yes": "Preferred response", "no": "Distractor"}})
self.bank.user = self.bank.owner
attempt = self.client.post('/attempts/start?quiz_id=1&mode=study').json()['id']
detail = self.client.get(f'/quizzes/1?attempt_id={attempt}').json()
self.assertEqual(detail['questions'][0]['option_explanations'], {"yes": "Preferred response", "no": "Distractor"})
self.bank.user = self.bank.mod
response = self.client.patch('/quizzes/1/questions/1', json={"option_explanations": {"nope": "bad"}})
self.assertEqual(response.status_code, 400, response.text)
response = self.client.patch('/quizzes/1/questions/1', json={"option_explanations": {"no": "Updated distractor"}})
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(self.bank.db.get(Question, 1).option_explanations, {"no": "Updated distractor"})
if __name__ == '__main__':
unittest.main()

View file

@ -230,6 +230,7 @@ function QuestionEditModal({ question, categories, onSaved, onClose }) {
explanation: question.explanation || '',
question_category_id: question.question_category_id || '',
extraCategoryIds: (question.category_ids || []).filter(id => id !== (question.question_category_id || null)),
option_explanations: { ...(question.option_explanations || {}) },
})
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
@ -237,8 +238,25 @@ function QuestionEditModal({ question, categories, onSaved, onClose }) {
const setOption = (i, val) => {
const updated = [...form.options]
const wasCorrect = form.options[i] === form.correct_answer
const oldValue = updated[i]
updated[i] = val
setForm(f => ({ ...f, options: updated, correct_answer: wasCorrect ? val : f.correct_answer }))
setForm(f => {
const explanations = { ...f.option_explanations }
if (oldValue in explanations) {
explanations[val] = explanations[oldValue]
delete explanations[oldValue]
}
return { ...f, options: updated, correct_answer: wasCorrect ? val : f.correct_answer, option_explanations: explanations }
})
}
const setOptionExplanation = (option, value) => {
setForm(f => {
const explanations = { ...f.option_explanations }
if (value.trim()) explanations[option] = value
else delete explanations[option]
return { ...f, option_explanations: explanations }
})
}
const toggleExtra = (categoryId) => {
@ -261,6 +279,7 @@ function QuestionEditModal({ question, categories, onSaved, onClose }) {
...form,
question_category_id: primary,
additional_category_ids: form.extraCategoryIds.filter(id => id !== primary),
option_explanations: Object.keys(form.option_explanations).length ? form.option_explanations : null,
}
const res = await api.patch(`/questions/${question.id}`, payload)
onSaved({ ...question, ...res.data,
@ -292,15 +311,26 @@ function QuestionEditModal({ question, categories, onSaved, onClose }) {
<label>Options select the correct one</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{form.options.map((opt, i) => (
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input type="radio" name="correct" checked={form.correct_answer === opt}
onChange={() => setForm(f => ({ ...f, correct_answer: opt }))}
style={{ width: 'auto', accentColor: 'var(--primary)' }} />
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 24, height: 24, borderRadius: '50%', flexShrink: 0, fontSize: '0.72rem', fontWeight: 700,
background: form.correct_answer === opt ? 'var(--correct-fg)' : 'var(--border)',
color: form.correct_answer === opt ? 'white' : 'var(--text-muted)' }}>{LETTERS[i]}</span>
<input type="text" value={opt} onChange={e => setOption(i, e.target.value)}
style={{ flex: 1, padding: '7px 12px', border: `1.5px solid ${form.correct_answer === opt ? 'var(--correct-bd)' : 'var(--border)'}`, borderRadius: 6, fontSize: '0.875rem', background: form.correct_answer === opt ? 'var(--correct-bg)' : 'var(--input-bg)', color: 'var(--text)', fontFamily: 'inherit' }} />
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input type="radio" name="correct" checked={form.correct_answer === opt}
onChange={() => setForm(f => ({ ...f, correct_answer: opt }))}
style={{ width: 'auto', accentColor: 'var(--primary)' }} />
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 24, height: 24, borderRadius: '50%', flexShrink: 0, fontSize: '0.72rem', fontWeight: 700,
background: form.correct_answer === opt ? 'var(--correct-fg)' : 'var(--border)',
color: form.correct_answer === opt ? 'white' : 'var(--text-muted)' }}>{LETTERS[i]}</span>
<input type="text" value={opt} onChange={e => setOption(i, e.target.value)}
style={{ flex: 1, padding: '7px 12px', border: `1.5px solid ${form.correct_answer === opt ? 'var(--correct-bd)' : 'var(--border)'}`, borderRadius: 6, fontSize: '0.875rem', background: form.correct_answer === opt ? 'var(--correct-bg)' : 'var(--input-bg)', color: 'var(--text)', fontFamily: 'inherit' }} />
</div>
<details open={!!form.option_explanations[opt]} style={{ marginLeft: 32 }}>
<summary style={{ fontSize: '0.74rem', color: 'var(--text-muted)', cursor: 'pointer' }}>
Explain this option{form.option_explanations[opt] ? ' ✓' : ''}
</summary>
<textarea rows={2} placeholder="Why this option is wrong or right — shown in study mode"
value={form.option_explanations[opt] || ''} aria-label={`Explanation for option ${opt}`}
onChange={e => setOptionExplanation(opt, e.target.value)}
style={{ width: '100%', marginTop: 4, padding: '7px 10px', border: '1px solid var(--border)', borderRadius: 6, fontSize: '0.82rem', background: 'var(--input-bg)', color: 'var(--text)', fontFamily: 'inherit' }} />
</details>
</div>
))}
</div>

View file

@ -229,4 +229,23 @@ describe('QuestionBankPage edit modal multi-category', () => {
question_category_id: 1,
})))
})
it('saves per-option explanations keyed by option text', async () => {
const cats = [
{ id: 1, name: 'Neonatology', parent_id: null, breadcrumbs: [{ id: 1, name: 'Neonatology' }] },
]
const question = { id: 7, question_text: 'Edit me', question_type: 'mcq', options: ['A', 'B'], correct_answer: 'A', explanation: '', question_category_id: 1, category_ids: [1] }
const initialGet = api.get.getMockImplementation()
api.get.mockImplementation(url => url === '/question-categories/' ? Promise.resolve({ data: cats })
: url === '/questions/bank' ? Promise.resolve({ data: { questions: [question], total: 1 } }) : initialGet(url))
api.patch = vi.fn().mockResolvedValue({ data: {} })
renderPage()
await userEvent.click(await screen.findByRole('button', { name: 'Edit' }))
await userEvent.click(screen.getAllByText('Explain this option')[0])
await userEvent.type(screen.getByLabelText('Explanation for option A'), 'Right because of this')
await userEvent.click(screen.getByRole('button', { name: 'Save Changes' }))
await waitFor(() => expect(api.patch).toHaveBeenCalledWith('/questions/7', expect.objectContaining({
option_explanations: { A: 'Right because of this' },
})))
})
})

View file

@ -1213,6 +1213,9 @@ const timerStarted = timeLeft !== null
</span>
{showCorrect && <span className="option-status option-status-correct"> Correct</span>}
{showWrong && <span className="option-status option-status-wrong"> Wrong</span>}
{hasAnswered && current.option_explanations?.[opt] && (
<span className="quiz-option-explanation">{current.option_explanations[opt]}</span>
)}
</button>
)
})}

View file

@ -147,11 +147,28 @@ describe('quiz player', () => {
expect(await screen.findByText(/Full explanation, preserved without shortening/)).toBeInTheDocument()
expect(await screen.findByText('8/10')).toBeInTheDocument()
expect(screen.getByText('80%')).toBeInTheDocument()
expect(screen.getByText(/Based on 10 recorded answers/)).toBeInTheDocument()
expect(screen.getByText(/10 recorded answers/)).toBeInTheDocument()
fireEvent.keyDown(window, { key: 'b' })
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/favorites', { question_id: 1 }))
})
it('shows per-option explanations in study feedback', async () => {
const originalGet = api.get.getMockImplementation()
api.get.mockImplementation(async (url, ...args) => {
const res = await originalGet(url, ...args)
if (res.data?.questions && url.includes('attempt_id=')) {
res.data.questions[0] = { ...res.data.questions[0], option_explanations: { 'First answer': 'Preferred because of this', 'Second answer': 'Distractor reason' } }
}
return res
})
await begin()
fireEvent.keyDown(window, { key: '1' })
fireEvent.keyDown(window, { key: 'Enter' })
expect(await screen.findByText('Preferred because of this')).toBeInTheDocument()
expect(screen.getByText('Distractor reason')).toBeInTheDocument()
})
it('never fetches response statistics in exam mode', async () => {
mode = 'exam'
await begin(false)

View file

@ -76,3 +76,4 @@
.question-reading strong { display: block; margin-bottom: 6px; font-size: .9rem; }
.question-reading ul { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 4px; }
.question-reading a { color: var(--primary); font-size: .86rem; }
.quiz-option-explanation { display: block; width: 100%; margin-top: 6px; padding: 6px 10px; border-radius: 6px; background: var(--input-bg); border: 1px solid var(--border); font-size: .8rem; color: var(--text); text-align: left; }