"""Teach chat endpoint — AI tutor for study mode questions.""" import logging from datetime import datetime, time, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException from fastapi.concurrency import run_in_threadpool from pydantic import BaseModel 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 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 router = APIRouter() log = logging.getLogger(__name__) def _daily_teach_limit() -> tuple[str, int]: now = datetime.now(timezone.utc) reset_at = datetime.combine(now.date() + timedelta(days=1), time.min, tzinfo=timezone.utc) ttl = max(60, int((reset_at - now).total_seconds()) + 300) return now.date().isoformat(), ttl class ChatMessage(BaseModel): role: str # "user" | "assistant" content: str class ChatRequest(BaseModel): model_config = {"protected_namespaces": ()} question_id: int attempt_id: int | None = None messages: list[ChatMessage] model_id: int | None = None # AIModelConfig.id — if None, use default def _get_teach_model(db: Session, model_config_id: int | None = None): """Return (model_id, api_key) for the requested (or default) teach model, or None.""" if model_config_id: m = db.query(AIModelConfig).filter( AIModelConfig.id == model_config_id, AIModelConfig.task == "teach", AIModelConfig.is_active == True, ).first() if m: return (m.model_id, m.api_key or None) # Fall back to default, then any active m = db.query(AIModelConfig).filter( AIModelConfig.task == "teach", AIModelConfig.is_active == True, AIModelConfig.is_default == True, ).first() if not m: m = db.query(AIModelConfig).filter( AIModelConfig.task == "teach", AIModelConfig.is_active == True, ).first() return (m.model_id, m.api_key or None) if m else None def _find_similar_questions(db: Session, question: Question, user: User, limit: int = 4) -> list[Question]: """Filter eligible context in SQL before similarity ranking and LIMIT.""" if question.embedding is None: return [] try: distance = Question.embedding.cosine_distance(question.embedding) return db.query(Question).filter( bank_question_predicate(user), Question.id != question.id, Question.embedding.isnot(None), distance <= 0.65, ).order_by(distance).limit(limit).all() except Exception: return [] #: 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. 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. """ 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") ] 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: """A, B, C … matching what the learner has on screen. The player letters its options. A tutor told they are numbered will say "option 3" about the thing the student is looking at as C, and the student is then reconciling two labellings of the same five lines. """ return chr(65 + index) if index < 26 else str(index + 1) def _build_system_prompt(question: Question, similar: list[Question]) -> str: correct = (question.correct_answer or "").strip() opts = "" if question.options: # The keyed answer is marked against its own option rather than only # quoted underneath. Given the text alone the model has to match a # string back to a line, and when two options start the same way it # matches the wrong one and teaches from it. opts = "\n".join( f" {_option_label(i)}) {opt}" + (" <-- CORRECT ANSWER" if correct and str(opt).strip() == correct else "") for i, opt in enumerate(question.options) ) prompt = ( "You are a medical education tutor. A student is studying the question below.\n" "Rules:\n" "- Answer the student's question directly. Do NOT ask clarifying questions.\n" "- You may reveal and explain the correct answer and why wrong options are wrong.\n" "- Refer to options by their letter, exactly as they are lettered below.\n" "- Use markdown formatting: bold key terms, bullet lists for comparisons.\n" "- Keep responses under 200 words unless a detailed explanation is needed.\n" "- Never ask 'what would you like to know?' — just explain.\n\n" f"=== Question ===\n{question.question_text}\n" ) if opts: prompt += f"Options:\n{opts}\n" if correct: prompt += f"Correct Answer: {correct}\n" if question.explanation: prompt += f"Explanation: {question.explanation}\n" if correct: # Said as a rule, not as data. Without it a model that would have # answered differently argues its own way and tells the student the # marked answer is wrong — which is the one thing a tutor sitting # beside a marked question must never do. prompt += ( "\nThe answer above is this question's answer key. It is authoritative: " "teach the reasoning that leads to it, and never tell the student a " "different option is correct. If you believe the key is genuinely " "contestable, explain the case for it first and say in one sentence " "that the point is debated.\n" ) if similar: prompt += "\n=== Related Questions (for broader context) ===\n" for i, sq in enumerate(similar, 1): prompt += f"{i}. {sq.question_text}" if sq.correct_answer: prompt += f" → Answer: {sq.correct_answer}" prompt += "\n" prompt += ( "\nAnswer the student's question directly. Explain the correct answer, " "the underlying concept, and why wrong options are incorrect if relevant. " "Do not ask what they want to know — just teach.\n\n" "After your explanation, suggest exactly 3 follow-up questions the student might want to ask next. " "Put them at the very end, each on its own line starting with '> '. Example:\n" "> Why is dopamine not the first-line treatment here?\n" "> What are the other causes of this presentation?\n" "> How does this differ in neonates vs older children?" ) return prompt @router.get("/models") def list_teach_models( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Return available teach models for the frontend to display.""" models = db.query(AIModelConfig).filter( AIModelConfig.task == "teach", AIModelConfig.is_active == True, ).all() 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="", options=["