Quizify / backend /main.py
hetsheta's picture
feat: add root GET route for health checks
867a83a
Raw
History Blame Contribute Delete
5.43 kB
from fastapi import FastAPI, UploadFile, File
from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from core.cache import get_quiz, store_quiz
from quiz.semantic import is_semantically_correct
from rag.parser import parse_pdf
from quiz.generator import generate_quiz
from models.schemas import QuizRequest, SubmitRequest
app = FastAPI(title="RAG Quiz Agent")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/")
async def root():
return {"status": "healthy", "message": "Quizify API is running"}
def resolve_full_answer(answer: str, options: list) -> str:
"""
LLMs often return just a letter like "B" or "B)" as the answer.
This resolves it to the full matching option text e.g. "B) When current is high."
Falls back to the raw answer if no match found.
"""
if not options or not answer:
return answer
answer_letter = answer.strip().rstrip(")").rstrip(".").upper() # "B" or "B)" -> "B"
for option in options:
# Match options like "A) ...", "A. ...", "A ..."
option_stripped = option.strip()
if option_stripped and option_stripped[0].upper() == answer_letter:
return option # Return the full option string
# If the answer already is a full option text
if answer in options:
return answer
return answer # Fallback
def normalize_for_comparison(text: str, options: list = None) -> str:
"""
Reduce both user answer and stored answer to the same letter
so comparison works regardless of whether text is "B" or "B) full text".
"""
if not text:
return ""
text = text.strip()
if options:
# If text matches a full option, extract its leading letter
for option in options:
if text == option and option.strip():
return option.strip()[0].upper()
# If text is already just a letter (possibly with ) or .)
cleaned = text.rstrip(")").rstrip(".").strip()
if len(cleaned) == 1 and cleaned.isalpha():
return cleaned.upper()
return text.lower()
@app.post("/parse-document")
async def parse_document(file: UploadFile = File(...)):
await parse_pdf(file)
return {"status": "success"}
@app.post("/generate-quiz")
async def generate(request: QuizRequest):
quiz = await generate_quiz(request)
quiz_id = store_quiz(quiz)
# Send questions + options to frontend, but NOT answers/explanations
questions_for_client = []
for q in quiz["questions"]:
client_q = {"question": q["question"]}
if "options" in q:
client_q["options"] = q["options"]
questions_for_client.append(client_q)
return {
"quiz_id": quiz_id,
"questions": questions_for_client
}
@app.post("/submit-quiz")
async def submit(request: SubmitRequest):
quiz = get_quiz(request.quiz_id)
if not quiz:
return JSONResponse(
status_code=404,
content={"error": "Quiz not found or expired. Please generate a new quiz."}
)
stored_questions = quiz["questions"]
score = 0
results = []
for item in request.answers:
i = item.question_index
if i >= len(stored_questions):
continue
stored_q = stored_questions[i]
raw_correct = stored_q["answer"] # e.g. "B" or "B) full text"
user_answer = item.user_answer # e.g. "B) full option text" (what user clicked)
options = stored_q.get("options", [])
# Determine question type
if options:
if options == ["True", "False"]:
question_type = "True/False"
else:
question_type = "MCQ"
else:
question_type = "Short Answer"
# Resolve the FULL correct answer text for display
if question_type == "MCQ":
full_correct_answer = resolve_full_answer(raw_correct, options)
else:
full_correct_answer = raw_correct
# ─── VALIDATION ───
if question_type in ["MCQ", "True/False"]:
user_norm = normalize_for_comparison(user_answer, options)
correct_norm = normalize_for_comparison(raw_correct, options)
# Letter match OR direct full-text match
is_correct = (user_norm == correct_norm) or (
user_answer.strip().lower() == full_correct_answer.strip().lower()
)
similarity = 1.0 if is_correct else 0.0
else:
is_correct, similarity = is_semantically_correct(user_answer, raw_correct)
if is_correct:
score += 1
# Explanation from cache — zero extra API calls
explanation = None
if not is_correct:
explanation = stored_q.get("explanation", "No explanation available.")
results.append({
"question": stored_q["question"],
"user_answer": user_answer,
"correct_answer": full_correct_answer, # Full text, not just "B"
"correct": bool(is_correct),
"similarity_score": round(float(similarity), 3),
"explanation": explanation,
"concept": stored_q.get("concept", "")
})
return {
"score": score,
"total": len(results),
"results": results
}