🎉 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,13 @@
"""
Services package
"""
from .auth_service import get_current_user, get_current_admin_user
from .llm_service import llm_service
from .document_parser import document_parser
__all__ = [
"get_current_user",
"get_current_admin_user",
"llm_service",
"document_parser"
]

View File

@@ -0,0 +1,78 @@
"""
Authentication Service
"""
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import Optional
from models import User
from database import get_db
from utils import decode_access_token
security = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db)
) -> User:
"""
Get current authenticated user from JWT token.
"""
token = credentials.credentials
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
# Decode token
payload = decode_access_token(token)
if payload is None:
raise credentials_exception
user_id: int = payload.get("sub")
if user_id is None:
raise credentials_exception
# Get user from database
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise credentials_exception
return user
async def get_current_admin_user(
current_user: User = Depends(get_current_user)
) -> User:
"""
Get current user and verify admin permissions.
"""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
return current_user
async def get_optional_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
db: AsyncSession = Depends(get_db)
) -> Optional[User]:
"""
Get current user if token is provided, otherwise return None.
Useful for endpoints that work for both authenticated and anonymous users.
"""
if credentials is None:
return None
try:
return await get_current_user(credentials, db)
except HTTPException:
return None

View File

@@ -0,0 +1,121 @@
"""
Document Parser Service
Supports: TXT, PDF, DOCX, XLSX
"""
import io
from typing import Optional
import PyPDF2
from docx import Document
import openpyxl
class DocumentParser:
"""Parse various document formats to extract text content"""
@staticmethod
async def parse_txt(file_content: bytes) -> str:
"""Parse TXT file"""
try:
return file_content.decode('utf-8')
except UnicodeDecodeError:
try:
return file_content.decode('gbk')
except:
return file_content.decode('utf-8', errors='ignore')
@staticmethod
async def parse_pdf(file_content: bytes) -> str:
"""Parse PDF file"""
try:
pdf_file = io.BytesIO(file_content)
pdf_reader = PyPDF2.PdfReader(pdf_file)
text_content = []
for page in pdf_reader.pages:
text = page.extract_text()
if text:
text_content.append(text)
return '\n\n'.join(text_content)
except Exception as e:
raise Exception(f"Failed to parse PDF: {str(e)}")
@staticmethod
async def parse_docx(file_content: bytes) -> str:
"""Parse DOCX file"""
try:
docx_file = io.BytesIO(file_content)
doc = Document(docx_file)
text_content = []
for paragraph in doc.paragraphs:
if paragraph.text.strip():
text_content.append(paragraph.text)
# Also extract text from tables
for table in doc.tables:
for row in table.rows:
row_text = ' | '.join(cell.text.strip() for cell in row.cells)
if row_text.strip():
text_content.append(row_text)
return '\n\n'.join(text_content)
except Exception as e:
raise Exception(f"Failed to parse DOCX: {str(e)}")
@staticmethod
async def parse_xlsx(file_content: bytes) -> str:
"""Parse XLSX file"""
try:
xlsx_file = io.BytesIO(file_content)
workbook = openpyxl.load_workbook(xlsx_file, data_only=True)
text_content = []
for sheet_name in workbook.sheetnames:
sheet = workbook[sheet_name]
text_content.append(f"=== Sheet: {sheet_name} ===")
for row in sheet.iter_rows(values_only=True):
row_text = ' | '.join(str(cell) if cell is not None else '' for cell in row)
if row_text.strip(' |'):
text_content.append(row_text)
return '\n\n'.join(text_content)
except Exception as e:
raise Exception(f"Failed to parse XLSX: {str(e)}")
@staticmethod
async def parse_file(file_content: bytes, filename: str) -> str:
"""
Parse file based on extension.
Args:
file_content: File content as bytes
filename: Original filename
Returns:
Extracted text content
Raises:
Exception: If file format is unsupported or parsing fails
"""
extension = filename.rsplit('.', 1)[-1].lower() if '.' in filename else ''
parsers = {
'txt': DocumentParser.parse_txt,
'pdf': DocumentParser.parse_pdf,
'docx': DocumentParser.parse_docx,
'doc': DocumentParser.parse_docx, # Try to parse DOC as DOCX
'xlsx': DocumentParser.parse_xlsx,
'xls': DocumentParser.parse_xlsx, # Try to parse XLS as XLSX
}
parser = parsers.get(extension)
if not parser:
raise Exception(f"Unsupported file format: {extension}")
return await parser(file_content)
# Singleton instance
document_parser = DocumentParser()

View File

@@ -0,0 +1,216 @@
"""
LLM Service for AI-powered question parsing and grading
"""
import os
import json
from typing import List, Dict, Any, Optional
from openai import AsyncOpenAI
from anthropic import AsyncAnthropic
import httpx
from models import QuestionType
from utils import calculate_content_hash
class LLMService:
"""Service for interacting with various LLM providers"""
def __init__(self):
self.provider = os.getenv("AI_PROVIDER", "openai")
if self.provider == "openai":
self.client = AsyncOpenAI(
api_key=os.getenv("OPENAI_API_KEY"),
base_url=os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
)
self.model = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
elif self.provider == "anthropic":
self.client = AsyncAnthropic(
api_key=os.getenv("ANTHROPIC_API_KEY")
)
self.model = os.getenv("ANTHROPIC_MODEL", "claude-3-haiku-20240307")
elif self.provider == "qwen":
self.client = AsyncOpenAI(
api_key=os.getenv("QWEN_API_KEY"),
base_url=os.getenv("QWEN_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1")
)
self.model = os.getenv("QWEN_MODEL", "qwen-plus")
else:
raise ValueError(f"Unsupported AI provider: {self.provider}")
async def parse_document(self, content: str) -> List[Dict[str, Any]]:
"""
Parse document content and extract questions.
Returns a list of dictionaries with question data:
[
{
"content": "Question text",
"type": "single/multiple/judge/short",
"options": ["A. Option1", "B. Option2", ...], # For choice questions
"answer": "Correct answer",
"analysis": "Explanation"
},
...
]
"""
prompt = """You are a professional question parser. Parse the given document and extract all questions.
For each question, identify:
1. Question content (the question text)
2. Question type: single (单选), multiple (多选), judge (判断), short (简答)
3. Options (for choice questions only, format: ["A. Option1", "B. Option2", ...])
4. Correct answer
5. Analysis/Explanation (if available)
Return ONLY a JSON array of questions, with no additional text:
[
{
"content": "question text",
"type": "single",
"options": ["A. Option1", "B. Option2", "C. Option3", "D. Option4"],
"answer": "A",
"analysis": "explanation"
},
...
]
Document content:
---
{content}
---
IMPORTANT: Return ONLY the JSON array, no markdown code blocks or explanations."""
try:
if self.provider == "anthropic":
response = await self.client.messages.create(
model=self.model,
max_tokens=4096,
messages=[
{"role": "user", "content": prompt.format(content=content)}
]
)
result = response.content[0].text
else: # OpenAI or Qwen
response = await self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": "You are a professional question parser. Return only JSON."},
{"role": "user", "content": prompt.format(content=content)}
],
temperature=0.3,
)
result = response.choices[0].message.content
# Clean result and parse JSON
result = result.strip()
if result.startswith("```json"):
result = result[7:]
if result.startswith("```"):
result = result[3:]
if result.endswith("```"):
result = result[:-3]
result = result.strip()
questions = json.loads(result)
# Add content hash to each question
for q in questions:
q["content_hash"] = calculate_content_hash(q["content"])
return questions
except Exception as e:
print(f"Error parsing document: {e}")
raise Exception(f"Failed to parse document: {str(e)}")
async def grade_short_answer(
self,
question: str,
correct_answer: str,
user_answer: str
) -> Dict[str, Any]:
"""
Grade a short answer question using AI.
Returns:
{
"score": 0.0-1.0,
"feedback": "Detailed feedback"
}
"""
prompt = f"""Grade the following short answer question.
Question: {question}
Standard Answer: {correct_answer}
Student Answer: {user_answer}
Provide a score from 0.0 to 1.0 (where 1.0 is perfect) and detailed feedback.
Return ONLY a JSON object:
{{
"score": 0.85,
"feedback": "Your detailed feedback here"
}}
Be fair but strict. Consider:
1. Correctness of key points
2. Completeness of answer
3. Clarity of expression
Return ONLY the JSON object, no markdown or explanations."""
try:
if self.provider == "anthropic":
response = await self.client.messages.create(
model=self.model,
max_tokens=1024,
messages=[
{"role": "user", "content": prompt}
]
)
result = response.content[0].text
else: # OpenAI or Qwen
response = await self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": "You are a fair and strict grader. Return only JSON."},
{"role": "user", "content": prompt}
],
temperature=0.5,
)
result = response.choices[0].message.content
# Clean and parse JSON
result = result.strip()
if result.startswith("```json"):
result = result[7:]
if result.startswith("```"):
result = result[3:]
if result.endswith("```"):
result = result[:-3]
result = result.strip()
grading = json.loads(result)
return {
"score": float(grading.get("score", 0.0)),
"feedback": grading.get("feedback", "")
}
except Exception as e:
print(f"Error grading answer: {e}")
# Return default grading on error
return {
"score": 0.0,
"feedback": "Unable to grade answer due to an error."
}
# Singleton instance
llm_service = LLMService()