pdf-quiz-generator/backend/app/utils/quiz_questions.py
Daniel 91ec6a501c feat: key points smart links, difficulty tags, adaptive sessions, educator-only question management
Key points on questions link into article sections (AMBOSS-style) with samples; difficulty tagging with builder/bank filters; adaptive session algorithm prefers unanswered questions then recycles older incorrect ones, weakest categories first with damping; question create/edit is now admin/educator only; expired exams no longer auto-submit on resume; exam suspend messaging updated. Migrations k4f5a6b7c819, l5a6b7c8d920, m6a7b8c9d031. 63 backend and 97 frontend tests pass.
2026-09-09 02:26:45 +02:00

108 lines
4.8 KiB
Python

"""Helpers for managing the quiz ↔ question junction table."""
from fastapi import HTTPException
from sqlalchemy.orm import Session
from app.models.question import Question
from app.models.quiz_question_link import QuizQuestionLink
def validate_key_points(key_points, db=None):
"""Key points are short text items that may smart-link to an article section."""
if key_points is None:
return None
if not isinstance(key_points, list) or len(key_points) > 12:
raise HTTPException(400, "Key points must be a list of up to 12 items")
from app.models.article import Article
result = []
for item in key_points:
if not isinstance(item, dict) or not isinstance(item.get("text"), str) or not item["text"].strip():
raise HTTPException(400, "Each key point needs text")
text = item["text"].strip()
if len(text) > 300:
raise HTTPException(400, "Key point text is too long (max 300 characters)")
article_id = item.get("article_id")
section_id = item.get("article_section_id")
if article_id is not None:
article = db.get(Article, int(article_id)) if db else None
if not article:
raise HTTPException(400, "Linked article not found")
if section_id and section_id not in {s["id"] for s in (article.sections or [])}:
raise HTTPException(400, "Linked article section not found")
result.append({"text": text, "article_id": int(article_id) if article_id is not None else None,
"article_section_id": section_id or None})
return result
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 = (
db.query(QuizQuestionLink)
.filter(QuizQuestionLink.quiz_id == quiz_id)
.order_by(QuizQuestionLink.position)
.all()
)
if not links:
return []
q_map = {
q.id: q
for q in db.query(Question).filter(Question.id.in_([l.question_id for l in links])).all()
}
return [q_map[l.question_id] for l in links if l.question_id in q_map]
def grade_quiz_answers(questions, answers, selected_ids=None):
"""Grade each selected question once; omissions are recorded as incorrect."""
question_map = {q.id: q for q in questions}
ids = list(question_map) if selected_ids is None else selected_ids
if not ids or len(ids) != len(set(ids)) or set(ids) - question_map.keys():
raise HTTPException(400, "Question selection is invalid or no longer available")
submitted = dict(answers)
if len(submitted) != len(answers) or submitted.keys() - set(ids) or any(not isinstance(value, str) for value in submitted.values()):
raise HTTPException(400, "Answers contain duplicate or unselected question IDs")
grades = []
for qid in ids:
question = question_map[qid]
answer = submitted.get(qid, "")
correct = bool(answer.strip()) and bool(question.correct_answer) and answer.strip().lower() == question.correct_answer.strip().lower()
grades.append((question, answer, correct))
return grades
def add_questions_to_quiz(db: Session, quiz_id: int, question_ids: list[int], start_pos: int = 0):
"""Add question links to a quiz (skips duplicates)."""
existing = {
l.question_id
for l in db.query(QuizQuestionLink).filter(QuizQuestionLink.quiz_id == quiz_id).all()
}
for i, qid in enumerate(question_ids):
if qid not in existing:
db.add(QuizQuestionLink(quiz_id=quiz_id, question_id=qid, position=start_pos + i))
def question_in_quiz(db: Session, quiz_id: int, question_id: int) -> bool:
return db.query(QuizQuestionLink).filter(
QuizQuestionLink.quiz_id == quiz_id,
QuizQuestionLink.question_id == question_id,
).first() is not None
def remove_question_from_quiz(db: Session, quiz_id: int, question_id: int):
db.query(QuizQuestionLink).filter(
QuizQuestionLink.quiz_id == quiz_id,
QuizQuestionLink.question_id == question_id,
).delete()