🎉 Initial commit: QQuiz - 智能刷题与题库管理平台

## 功能特性

 **核心功能**
- 多文件上传与智能去重(基于 content_hash)
- 异步文档解析(支持 TXT/PDF/DOCX/XLSX)
- AI 智能题目提取与评分(OpenAI/Anthropic/Qwen)
- 断点续做与进度管理
- 自动错题本收集

 **技术栈**
- Backend: FastAPI + SQLAlchemy 2.0 + PostgreSQL
- Frontend: React 18 + Vite + Tailwind CSS
- Deployment: Docker Compose

 **项目结构**
- 53 个文件
- 完整的前后端分离架构
- Docker/源码双模部署支持

🚀 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-01 12:39:46 +08:00
commit c5ecbeaec2
53 changed files with 6211 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
"""
Routers package
"""
from . import auth, exam, question, mistake, admin
__all__ = ["auth", "exam", "question", "mistake", "admin"]

63
backend/routers/admin.py Normal file
View File

@@ -0,0 +1,63 @@
"""
Admin Router
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from database import get_db
from models import User, SystemConfig
from schemas import SystemConfigUpdate, SystemConfigResponse
from services.auth_service import get_current_admin_user
router = APIRouter()
@router.get("/config", response_model=SystemConfigResponse)
async def get_system_config(
current_admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_db)
):
"""Get system configuration (admin only)"""
# Fetch all config values
result = await db.execute(select(SystemConfig))
configs = {config.key: config.value for config in result.scalars().all()}
return {
"allow_registration": configs.get("allow_registration", "true").lower() == "true",
"max_upload_size_mb": int(configs.get("max_upload_size_mb", "10")),
"max_daily_uploads": int(configs.get("max_daily_uploads", "20")),
"ai_provider": configs.get("ai_provider", "openai")
}
@router.put("/config", response_model=SystemConfigResponse)
async def update_system_config(
config_update: SystemConfigUpdate,
current_admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_db)
):
"""Update system configuration (admin only)"""
update_data = config_update.dict(exclude_unset=True)
for key, value in update_data.items():
result = await db.execute(
select(SystemConfig).where(SystemConfig.key == key)
)
config = result.scalar_one_or_none()
if config:
config.value = str(value).lower() if isinstance(value, bool) else str(value)
else:
new_config = SystemConfig(
key=key,
value=str(value).lower() if isinstance(value, bool) else str(value)
)
db.add(new_config)
await db.commit()
# Return updated config
return await get_system_config(current_admin, db)

130
backend/routers/auth.py Normal file
View File

@@ -0,0 +1,130 @@
"""
Authentication Router
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from datetime import timedelta
from database import get_db
from models import User, SystemConfig
from schemas import UserCreate, UserLogin, Token, UserResponse
from utils import hash_password, verify_password, create_access_token
from services.auth_service import get_current_user
router = APIRouter()
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def register(
user_data: UserCreate,
db: AsyncSession = Depends(get_db)
):
"""Register a new user"""
# Check if registration is allowed
result = await db.execute(
select(SystemConfig).where(SystemConfig.key == "allow_registration")
)
config = result.scalar_one_or_none()
if config and config.value.lower() == "false":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Registration is currently disabled"
)
# Check if username already exists
result = await db.execute(
select(User).where(User.username == user_data.username)
)
existing_user = result.scalar_one_or_none()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already registered"
)
# Create new user
new_user = User(
username=user_data.username,
hashed_password=hash_password(user_data.password),
is_admin=False
)
db.add(new_user)
await db.commit()
await db.refresh(new_user)
return new_user
@router.post("/login", response_model=Token)
async def login(
user_data: UserLogin,
db: AsyncSession = Depends(get_db)
):
"""Login and get access token"""
# Find user
result = await db.execute(
select(User).where(User.username == user_data.username)
)
user = result.scalar_one_or_none()
# Verify credentials
if not user or not verify_password(user_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
# Create access token
access_token = create_access_token(
data={"sub": user.id}
)
return {
"access_token": access_token,
"token_type": "bearer"
}
@router.get("/me", response_model=UserResponse)
async def get_current_user_info(
current_user: User = Depends(get_current_user)
):
"""Get current user information"""
return current_user
@router.post("/change-password")
async def change_password(
old_password: str,
new_password: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Change user password"""
# Verify old password
if not verify_password(old_password, current_user.hashed_password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Incorrect current password"
)
# Validate new password
if len(new_password) < 6:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="New password must be at least 6 characters"
)
# Update password
current_user.hashed_password = hash_password(new_password)
await db.commit()
return {"message": "Password changed successfully"}

411
backend/routers/exam.py Normal file
View File

@@ -0,0 +1,411 @@
"""
Exam Router - Handles exam creation, file upload, and deduplication
"""
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_
from typing import List, Optional
from datetime import datetime, timedelta
import os
import aiofiles
from database import get_db
from models import User, Exam, Question, ExamStatus, SystemConfig
from schemas import (
ExamCreate, ExamResponse, ExamListResponse,
ExamUploadResponse, ParseResult, QuizProgressUpdate
)
from services.auth_service import get_current_user
from services.document_parser import document_parser
from services.llm_service import llm_service
from utils import is_allowed_file, calculate_content_hash
router = APIRouter()
async def check_upload_limits(user_id: int, file_size: int, db: AsyncSession):
"""Check if user has exceeded upload limits"""
# Get max upload size config
result = await db.execute(
select(SystemConfig).where(SystemConfig.key == "max_upload_size_mb")
)
config = result.scalar_one_or_none()
max_size_mb = int(config.value) if config else 10
# Check file size
if file_size > max_size_mb * 1024 * 1024:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"File size exceeds limit of {max_size_mb}MB"
)
# Get max daily uploads config
result = await db.execute(
select(SystemConfig).where(SystemConfig.key == "max_daily_uploads")
)
config = result.scalar_one_or_none()
max_daily = int(config.value) if config else 20
# Check daily upload count
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
result = await db.execute(
select(func.count(Exam.id)).where(
and_(
Exam.user_id == user_id,
Exam.created_at >= today_start
)
)
)
upload_count = result.scalar()
if upload_count >= max_daily:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"Daily upload limit of {max_daily} reached"
)
async def process_questions_with_dedup(
exam_id: int,
questions_data: List[dict],
db: AsyncSession
) -> ParseResult:
"""
Process parsed questions with deduplication logic.
Args:
exam_id: Target exam ID
questions_data: List of question dicts from LLM parsing
db: Database session
Returns:
ParseResult with statistics
"""
total_parsed = len(questions_data)
duplicates_removed = 0
new_added = 0
# Get existing content hashes for this exam
result = await db.execute(
select(Question.content_hash).where(Question.exam_id == exam_id)
)
existing_hashes = set(row[0] for row in result.all())
# Insert only new questions
for q_data in questions_data:
content_hash = q_data.get("content_hash")
if content_hash in existing_hashes:
duplicates_removed += 1
continue
# Create new question
new_question = Question(
exam_id=exam_id,
content=q_data["content"],
type=q_data["type"],
options=q_data.get("options"),
answer=q_data["answer"],
analysis=q_data.get("analysis"),
content_hash=content_hash
)
db.add(new_question)
existing_hashes.add(content_hash) # Add to set to prevent duplicates in current batch
new_added += 1
await db.commit()
return ParseResult(
total_parsed=total_parsed,
duplicates_removed=duplicates_removed,
new_added=new_added,
message=f"Parsed {total_parsed} questions, removed {duplicates_removed} duplicates, added {new_added} new questions"
)
async def async_parse_and_save(
exam_id: int,
file_content: bytes,
filename: str,
db_url: str
):
"""
Background task to parse document and save questions with deduplication.
"""
from database import AsyncSessionLocal
from sqlalchemy import select
async with AsyncSessionLocal() as db:
try:
# Update exam status to processing
result = await db.execute(select(Exam).where(Exam.id == exam_id))
exam = result.scalar_one()
exam.status = ExamStatus.PROCESSING
await db.commit()
# Parse document
print(f"[Exam {exam_id}] Parsing document: {filename}")
text_content = await document_parser.parse_file(file_content, filename)
if not text_content or len(text_content.strip()) < 10:
raise Exception("Document appears to be empty or too short")
# Parse questions using LLM
print(f"[Exam {exam_id}] Calling LLM to extract questions...")
questions_data = await llm_service.parse_document(text_content)
if not questions_data:
raise Exception("No questions found in document")
# Process questions with deduplication
print(f"[Exam {exam_id}] Processing questions with deduplication...")
parse_result = await process_questions_with_dedup(exam_id, questions_data, db)
# Update exam status and total questions
result = await db.execute(select(Exam).where(Exam.id == exam_id))
exam = result.scalar_one()
# Get updated question count
result = await db.execute(
select(func.count(Question.id)).where(Question.exam_id == exam_id)
)
total_questions = result.scalar()
exam.status = ExamStatus.READY
exam.total_questions = total_questions
await db.commit()
print(f"[Exam {exam_id}] ✅ {parse_result.message}")
except Exception as e:
print(f"[Exam {exam_id}] ❌ Error: {str(e)}")
# Update exam status to failed
result = await db.execute(select(Exam).where(Exam.id == exam_id))
exam = result.scalar_one()
exam.status = ExamStatus.FAILED
await db.commit()
@router.post("/create", response_model=ExamUploadResponse, status_code=status.HTTP_201_CREATED)
async def create_exam_with_upload(
background_tasks: BackgroundTasks,
title: str = Form(...),
file: UploadFile = File(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""
Create a new exam and upload the first document.
Document will be parsed asynchronously in background.
"""
# Validate file
if not file.filename or not is_allowed_file(file.filename):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid file type. Allowed: txt, pdf, doc, docx, xlsx, xls"
)
# Read file content
file_content = await file.read()
# Check upload limits
await check_upload_limits(current_user.id, len(file_content), db)
# Create exam
new_exam = Exam(
user_id=current_user.id,
title=title,
status=ExamStatus.PENDING
)
db.add(new_exam)
await db.commit()
await db.refresh(new_exam)
# Start background parsing
background_tasks.add_task(
async_parse_and_save,
new_exam.id,
file_content,
file.filename,
os.getenv("DATABASE_URL")
)
return ExamUploadResponse(
exam_id=new_exam.id,
title=new_exam.title,
status=new_exam.status.value,
message="Exam created. Document is being processed in background."
)
@router.post("/{exam_id}/append", response_model=ExamUploadResponse)
async def append_document_to_exam(
exam_id: int,
background_tasks: BackgroundTasks,
file: UploadFile = File(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""
Append a new document to an existing exam.
Questions will be parsed and deduplicated asynchronously.
"""
# Get exam and verify ownership
result = await db.execute(
select(Exam).where(
and_(Exam.id == exam_id, Exam.user_id == current_user.id)
)
)
exam = result.scalar_one_or_none()
if not exam:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Exam not found"
)
# Don't allow appending while processing
if exam.status == ExamStatus.PROCESSING:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Exam is currently being processed. Please wait."
)
# Validate file
if not file.filename or not is_allowed_file(file.filename):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid file type. Allowed: txt, pdf, doc, docx, xlsx, xls"
)
# Read file content
file_content = await file.read()
# Check upload limits
await check_upload_limits(current_user.id, len(file_content), db)
# Start background parsing (will auto-deduplicate)
background_tasks.add_task(
async_parse_and_save,
exam.id,
file_content,
file.filename,
os.getenv("DATABASE_URL")
)
return ExamUploadResponse(
exam_id=exam.id,
title=exam.title,
status=ExamStatus.PROCESSING.value,
message=f"Document '{file.filename}' is being processed. Duplicates will be automatically removed."
)
@router.get("/", response_model=ExamListResponse)
async def get_user_exams(
skip: int = 0,
limit: int = 20,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get all exams for current user"""
# Get total count
result = await db.execute(
select(func.count(Exam.id)).where(Exam.user_id == current_user.id)
)
total = result.scalar()
# Get exams
result = await db.execute(
select(Exam)
.where(Exam.user_id == current_user.id)
.order_by(Exam.created_at.desc())
.offset(skip)
.limit(limit)
)
exams = result.scalars().all()
return ExamListResponse(exams=exams, total=total)
@router.get("/{exam_id}", response_model=ExamResponse)
async def get_exam_detail(
exam_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get exam details"""
result = await db.execute(
select(Exam).where(
and_(Exam.id == exam_id, Exam.user_id == current_user.id)
)
)
exam = result.scalar_one_or_none()
if not exam:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Exam not found"
)
return exam
@router.delete("/{exam_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_exam(
exam_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Delete an exam and all its questions"""
result = await db.execute(
select(Exam).where(
and_(Exam.id == exam_id, Exam.user_id == current_user.id)
)
)
exam = result.scalar_one_or_none()
if not exam:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Exam not found"
)
await db.delete(exam)
await db.commit()
@router.put("/{exam_id}/progress", response_model=ExamResponse)
async def update_quiz_progress(
exam_id: int,
progress: QuizProgressUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Update quiz progress (current_index)"""
result = await db.execute(
select(Exam).where(
and_(Exam.id == exam_id, Exam.user_id == current_user.id)
)
)
exam = result.scalar_one_or_none()
if not exam:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Exam not found"
)
exam.current_index = progress.current_index
await db.commit()
await db.refresh(exam)
return exam

192
backend/routers/mistake.py Normal file
View File

@@ -0,0 +1,192 @@
"""
Mistake Router - Handles user mistake book (错题本)
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, func
from sqlalchemy.orm import selectinload
from database import get_db
from models import User, Question, UserMistake, Exam
from schemas import MistakeAdd, MistakeResponse, MistakeListResponse
from services.auth_service import get_current_user
router = APIRouter()
@router.get("/", response_model=MistakeListResponse)
async def get_user_mistakes(
skip: int = 0,
limit: int = 50,
exam_id: int = None, # Optional filter by exam
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get user's mistake book with optional exam filter"""
# Build query
query = (
select(UserMistake)
.options(selectinload(UserMistake.question))
.where(UserMistake.user_id == current_user.id)
.order_by(UserMistake.created_at.desc())
)
# Apply exam filter if provided
if exam_id is not None:
query = query.join(Question).where(Question.exam_id == exam_id)
# Get total count
count_query = select(func.count(UserMistake.id)).where(UserMistake.user_id == current_user.id)
if exam_id is not None:
count_query = count_query.join(Question).where(Question.exam_id == exam_id)
result = await db.execute(count_query)
total = result.scalar()
# Get mistakes
result = await db.execute(query.offset(skip).limit(limit))
mistakes = result.scalars().all()
# Format response
mistake_responses = []
for mistake in mistakes:
mistake_responses.append(
MistakeResponse(
id=mistake.id,
user_id=mistake.user_id,
question_id=mistake.question_id,
question=mistake.question,
created_at=mistake.created_at
)
)
return MistakeListResponse(mistakes=mistake_responses, total=total)
@router.post("/add", response_model=MistakeResponse, status_code=status.HTTP_201_CREATED)
async def add_to_mistakes(
mistake_data: MistakeAdd,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Manually add a question to mistake book"""
# Verify question exists and user has access to it
result = await db.execute(
select(Question)
.join(Exam)
.where(
and_(
Question.id == mistake_data.question_id,
Exam.user_id == current_user.id
)
)
)
question = result.scalar_one_or_none()
if not question:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Question not found or you don't have access"
)
# Check if already in mistake book
result = await db.execute(
select(UserMistake).where(
and_(
UserMistake.user_id == current_user.id,
UserMistake.question_id == mistake_data.question_id
)
)
)
existing = result.scalar_one_or_none()
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Question already in mistake book"
)
# Add to mistake book
new_mistake = UserMistake(
user_id=current_user.id,
question_id=mistake_data.question_id
)
db.add(new_mistake)
await db.commit()
await db.refresh(new_mistake)
# Load question relationship
result = await db.execute(
select(UserMistake)
.options(selectinload(UserMistake.question))
.where(UserMistake.id == new_mistake.id)
)
new_mistake = result.scalar_one()
return MistakeResponse(
id=new_mistake.id,
user_id=new_mistake.user_id,
question_id=new_mistake.question_id,
question=new_mistake.question,
created_at=new_mistake.created_at
)
@router.delete("/{mistake_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_from_mistakes(
mistake_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Remove a question from mistake book"""
# Get mistake and verify ownership
result = await db.execute(
select(UserMistake).where(
and_(
UserMistake.id == mistake_id,
UserMistake.user_id == current_user.id
)
)
)
mistake = result.scalar_one_or_none()
if not mistake:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Mistake record not found"
)
await db.delete(mistake)
await db.commit()
@router.delete("/question/{question_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_question_from_mistakes(
question_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Remove a question from mistake book by question ID"""
# Get mistake and verify ownership
result = await db.execute(
select(UserMistake).where(
and_(
UserMistake.question_id == question_id,
UserMistake.user_id == current_user.id
)
)
)
mistake = result.scalar_one_or_none()
if not mistake:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Question not found in mistake book"
)
await db.delete(mistake)
await db.commit()

228
backend/routers/question.py Normal file
View File

@@ -0,0 +1,228 @@
"""
Question Router - Handles quiz playing and answer checking
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, func
from typing import List, Optional
from database import get_db
from models import User, Exam, Question, UserMistake, ExamStatus, QuestionType
from schemas import (
QuestionResponse, QuestionListResponse,
AnswerSubmit, AnswerCheckResponse
)
from services.auth_service import get_current_user
from services.llm_service import llm_service
router = APIRouter()
@router.get("/exam/{exam_id}/questions", response_model=QuestionListResponse)
async def get_exam_questions(
exam_id: int,
skip: int = 0,
limit: int = 50,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get all questions for an exam"""
# Verify exam ownership
result = await db.execute(
select(Exam).where(
and_(Exam.id == exam_id, Exam.user_id == current_user.id)
)
)
exam = result.scalar_one_or_none()
if not exam:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Exam not found"
)
# Get total count
result = await db.execute(
select(func.count(Question.id)).where(Question.exam_id == exam_id)
)
total = result.scalar()
# Get questions
result = await db.execute(
select(Question)
.where(Question.exam_id == exam_id)
.order_by(Question.id)
.offset(skip)
.limit(limit)
)
questions = result.scalars().all()
return QuestionListResponse(questions=questions, total=total)
@router.get("/exam/{exam_id}/current", response_model=QuestionResponse)
async def get_current_question(
exam_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get the current question based on exam's current_index"""
# Get exam
result = await db.execute(
select(Exam).where(
and_(Exam.id == exam_id, Exam.user_id == current_user.id)
)
)
exam = result.scalar_one_or_none()
if not exam:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Exam not found"
)
if exam.status != ExamStatus.READY:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Exam is not ready. Status: {exam.status.value}"
)
# Get questions
result = await db.execute(
select(Question)
.where(Question.exam_id == exam_id)
.order_by(Question.id)
.offset(exam.current_index)
.limit(1)
)
question = result.scalar_one_or_none()
if not question:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No more questions available. You've completed this exam!"
)
return question
@router.get("/{question_id}", response_model=QuestionResponse)
async def get_question_by_id(
question_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get a specific question by ID"""
# Get question and verify access through exam ownership
result = await db.execute(
select(Question)
.join(Exam)
.where(
and_(
Question.id == question_id,
Exam.user_id == current_user.id
)
)
)
question = result.scalar_one_or_none()
if not question:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Question not found"
)
return question
@router.post("/check", response_model=AnswerCheckResponse)
async def check_answer(
answer_data: AnswerSubmit,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""
Check user's answer and return result.
For short answers, use AI to grade.
Automatically add wrong answers to mistake book.
"""
# Get question and verify access
result = await db.execute(
select(Question)
.join(Exam)
.where(
and_(
Question.id == answer_data.question_id,
Exam.user_id == current_user.id
)
)
)
question = result.scalar_one_or_none()
if not question:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Question not found"
)
user_answer = answer_data.user_answer.strip()
correct_answer = question.answer.strip()
is_correct = False
ai_score = None
ai_feedback = None
# Check answer based on question type
if question.type == QuestionType.SHORT:
# Use AI to grade short answer
grading = await llm_service.grade_short_answer(
question.content,
correct_answer,
user_answer
)
ai_score = grading["score"]
ai_feedback = grading["feedback"]
is_correct = ai_score >= 0.7 # Consider 70% as correct
elif question.type == QuestionType.MULTIPLE:
# For multiple choice, normalize answer (sort letters)
user_normalized = ''.join(sorted(user_answer.upper().replace(' ', '')))
correct_normalized = ''.join(sorted(correct_answer.upper().replace(' ', '')))
is_correct = user_normalized == correct_normalized
else:
# For single choice and judge questions
is_correct = user_answer.upper() == correct_answer.upper()
# If wrong, add to mistake book
if not is_correct:
# Check if already in mistake book
result = await db.execute(
select(UserMistake).where(
and_(
UserMistake.user_id == current_user.id,
UserMistake.question_id == question.id
)
)
)
existing_mistake = result.scalar_one_or_none()
if not existing_mistake:
new_mistake = UserMistake(
user_id=current_user.id,
question_id=question.id
)
db.add(new_mistake)
await db.commit()
return AnswerCheckResponse(
correct=is_correct,
user_answer=user_answer,
correct_answer=correct_answer,
analysis=question.analysis,
ai_score=ai_score,
ai_feedback=ai_feedback
)