Tutor questions require owned selected attempts; similarity context filters eligibility before ranking. Uploads move to a permission-aware boundary with reference ACLs, canonical legacy aliases, pre-mutation attachment checks and card-aware moderator rules. Nginx stops caching media and supplies native byte ranges. Verified 37 deployed-image backend tests, 69 frontend tests/build, real pgvector/Nginx/browser checks, and two independent reviews.
203 lines
7.9 KiB
Python
203 lines
7.9 KiB
Python
"""Teach chat endpoint — AI tutor for study mode questions."""
|
|
from datetime import datetime, time, timedelta, timezone
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
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.user import User
|
|
from app.utils.quiz_access import require_question_access
|
|
from app.services.quiz_builder import bank_question_predicate
|
|
from app.utils.auth import get_current_user, check_rate_limit
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
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 []
|
|
|
|
|
|
def _build_system_prompt(question: Question, similar: list[Question]) -> str:
|
|
opts = ""
|
|
if question.options:
|
|
opts = "\n".join(f" {i}) {opt}" for i, opt in enumerate(question.options, 1))
|
|
|
|
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"
|
|
"- 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 question.correct_answer:
|
|
prompt += f"Correct Answer: {question.correct_answer}\n"
|
|
if question.explanation:
|
|
prompt += f"Explanation: {question.explanation}\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.post("/chat")
|
|
async def chat(
|
|
req: ChatRequest,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""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)
|
|
|
|
# Daily AI coach quota. Admins, moderators, and unthrottled users are exempt.
|
|
quota_day, quota_ttl = _daily_teach_limit()
|
|
check_rate_limit(
|
|
key=f"teach_chat_daily:{current_user.id}:{quota_day}",
|
|
max_calls=30,
|
|
window_seconds=quota_ttl,
|
|
detail="You've reached today's AI coach limit of 30 messages. Try again tomorrow, or contact an admin if you need the limit raised.",
|
|
user=current_user,
|
|
)
|
|
model_info = _get_teach_model(db, req.model_id)
|
|
if not model_info:
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="No teaching AI model is configured. Ask an admin to add a model with task 'teach'.",
|
|
)
|
|
model_id, api_key = model_info
|
|
|
|
similar = _find_similar_questions(db, question, current_user)
|
|
system_prompt = _build_system_prompt(question, similar)
|
|
|
|
messages = [{"role": "system", "content": system_prompt}]
|
|
for msg in req.messages:
|
|
if msg.role not in ("user", "assistant"):
|
|
continue
|
|
messages.append({"role": msg.role, "content": msg.content})
|
|
|
|
try:
|
|
import litellm
|
|
from app.config import settings
|
|
from app.services.ai_service import _proxy_model
|
|
use_model = _proxy_model(model_id)
|
|
kwargs = {
|
|
"model": use_model,
|
|
"messages": messages,
|
|
"max_tokens": 600,
|
|
"temperature": 0.4,
|
|
}
|
|
if api_key:
|
|
kwargs["api_key"] = api_key
|
|
elif settings.LITELLM_API_KEY:
|
|
kwargs["api_key"] = settings.LITELLM_API_KEY
|
|
if settings.LITELLM_API_BASE:
|
|
kwargs["api_base"] = settings.LITELLM_API_BASE
|
|
response = await litellm.acompletion(**kwargs)
|
|
raw = response.choices[0].message.content.strip()
|
|
|
|
# Parse out follow-up suggestions (lines starting with "> ")
|
|
lines = raw.splitlines()
|
|
suggestions = [l[2:].strip() for l in lines if l.startswith("> ")]
|
|
reply_lines = [l for l in lines if not l.startswith("> ")]
|
|
# Trim trailing blank lines from reply
|
|
while reply_lines and not reply_lines[-1].strip():
|
|
reply_lines.pop()
|
|
reply = "\n".join(reply_lines).strip()
|
|
|
|
return {"reply": reply, "suggestions": suggestions[:3]}
|
|
except Exception as e:
|
|
import logging
|
|
logging.getLogger(__name__).error(f"TeachChat error for user {current_user.id} model {model_id}: {e}")
|
|
raise HTTPException(status_code=502, detail="The AI tutor is temporarily unavailable. Please try again in a moment.")
|