mirror of
https://github.com/handsomezhuzhu/QQuiz.git
synced 2026-02-20 20:10:14 +00:00
🎉 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:
6
backend/routers/__init__.py
Normal file
6
backend/routers/__init__.py
Normal 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
63
backend/routers/admin.py
Normal 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
130
backend/routers/auth.py
Normal 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
411
backend/routers/exam.py
Normal 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
192
backend/routers/mistake.py
Normal 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
228
backend/routers/question.py
Normal 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
|
||||
)
|
||||
Reference in New Issue
Block a user