from fastapi import FastAPI, HTTPException, Depends, UploadFile, File, status, Request from fastapi.responses import FileResponse from fastapi.middleware.cors import CORSMiddleware from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.staticfiles import StaticFiles from sqlalchemy import create_engine, Column, Integer, String, Float, DateTime, Boolean, Text, JSON, func from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker, Session from pydantic import BaseModel from typing import List, Optional import pandas as pd import jwt import bcrypt from datetime import datetime, timedelta, date import json import io import csv import shutil import tempfile import uuid from pdf_splitter_hybrid import PDFSplitterHybrid as PDFSplitter from pdf_exam_parser_enhanced_v2 import PDFExamParserEnhancedV2 from pdf_image_extractor import PDFImageExtractor from utils.question_formatter import QuestionFormatter from claude_exam_parser import ClaudeExamParser import uvicorn import uuid import os import logging from dotenv import load_dotenv # 加载环境变量 load_dotenv() # 配置日志 logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) # 从环境变量读取配置 DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/wordmaster") SECRET_KEY = os.getenv("SECRET_KEY", "your-super-secret-and-secure-key-change-me") ALGORITHM = os.getenv("ALGORITHM", "HS256") ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "30")) # 数据库配置 engine = create_engine(DATABASE_URL) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) Base = declarative_base() # 数据库模型 class User(Base): __tablename__ = "users" id = Column(Integer, primary_key=True, index=True) username = Column(String, unique=True, index=True) email = Column(String, unique=True, index=True) hashed_password = Column(String) created_at = Column(DateTime, default=datetime.utcnow) class Word(Base): __tablename__ = "words" id = Column(Integer, primary_key=True, index=True) word = Column(String, index=True) meaning = Column(String) example = Column(Text, nullable=True) phrases = Column(Text, nullable=True) # JSON string level = Column(String, default="middle_school") # elementary, middle_school, high_school created_at = Column(DateTime, default=datetime.utcnow) class UserWordProgress(Base): __tablename__ = "user_word_progress" id = Column(Integer, primary_key=True, index=True) user_id = Column(Integer, index=True) word_id = Column(Integer, index=True) ef = Column(Float, default=2.5) # SM-2 ease factor interval = Column(Integer, default=0) # days reps = Column(Integer, default=0) # repetitions due = Column(DateTime, default=datetime.utcnow) correct_count = Column(Integer, default=0) wrong_count = Column(Integer, default=0) streak_wrong = Column(Integer, default=0) is_leech = Column(Boolean, default=False) last_reviewed = Column(DateTime, nullable=True) created_at = Column(DateTime, default=datetime.utcnow) class Paper(Base): __tablename__ = "papers" id = Column(Integer, primary_key=True, index=True) user_id = Column(Integer, index=True) title = Column(String, index=True) subject = Column(String, index=True) # math, chemistry, physics, english grade = Column(String) # grade7, grade8, grade9, etc. exam_type = Column(String) # midterm, final, monthly, practice exam_date = Column(DateTime) total_score = Column(Float) achieved_score = Column(Float, nullable=True) file_key = Column(String) # 文件存储路径 pdf_key = Column(String, nullable=True) # PDF文件路径 thumb_key = Column(String, nullable=True) # 缩略图路径 tags = Column(JSON, nullable=True) # 标签数组 ocr_text = Column(Text, nullable=True) # OCR提取的文本 created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) class Mistake(Base): __tablename__ = "mistakes" id = Column(Integer, primary_key=True, index=True) user_id = Column(Integer, index=True) paper_id = Column(Integer, index=True) page = Column(Integer) # 页码 bbox = Column(JSON) # 边界框 {x, y, width, height} image_key = Column(String, nullable=True) # 错题图片路径 subject = Column(String, index=True) knowledge_points = Column(JSON) # 知识点数组 reason_tag = Column(String) # concept, calc, careless, memory, steps, reading difficulty = Column(Integer, default=3) # 1-5 review_level = Column(Integer, default=1) # Leitner盒子级别 1-5 next_review_date = Column(DateTime, default=datetime.utcnow) last_score = Column(Integer, nullable=True) # 最后一次复习评分 1-5 answer = Column(Text, nullable=True) # 正确答案 analysis = Column(Text, nullable=True) # 解析 notes = Column(Text, nullable=True) # 家长/学生备注 created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) class ReviewLog(Base): __tablename__ = "review_logs" id = Column(Integer, primary_key=True, index=True) user_id = Column(Integer, index=True) mistake_id = Column(Integer, index=True) reviewed_at = Column(DateTime, default=datetime.utcnow) rating = Column(Integer) # 1-5评分 next_review_date = Column(DateTime) comment = Column(Text, nullable=True) time_spent = Column(Integer, nullable=True) # 秒 class Question(Base): __tablename__ = "questions" id = Column(Integer, primary_key=True, index=True) user_id = Column(Integer, index=True) paper_id = Column(Integer, index=True, nullable=True) # 来源试卷 question_type = Column(String) # choice, fill_blank, essay, calculation subject = Column(String, index=True) # math, chemistry, physics, english grade = Column(String) # grade7, grade8, etc. chapter = Column(String, nullable=True) # 章节 knowledge_points = Column(JSON) # 知识点数组 difficulty = Column(Integer, default=3) # 1-5难度 question_content = Column(Text) # 题目内容 options = Column(JSON, nullable=True) # 选择题选项 {A, B, C, D} correct_answer = Column(Text) # 正确答案 explanation = Column(Text, nullable=True) # 解析 image_key = Column(String, nullable=True) # 题目图片 tags = Column(JSON, nullable=True) # 标签数组 # 拆题功能相关字段 page_spans = Column(JSON, nullable=True) # 页面位置信息 [{"page":1,"bbox":[x,y,w,h]}] source_text = Column(Text, nullable=True) # OCR原始文本 images = Column(JSON, nullable=True) # 题目中的图片信息 [{"page":1,"bbox":[...],"key":"..."}] formula = Column(JSON, nullable=True) # 公式信息 [{"latex":"...","bbox":[...]}] score = Column(Integer, nullable=True) # 分值 question_number = Column(String, nullable=True) # 题号 (1, 2, 3 等) auto_extracted = Column(Boolean, default=False) # 是否自动提取 created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) class QuestionCollection(Base): __tablename__ = "question_collections" id = Column(Integer, primary_key=True, index=True) user_id = Column(Integer, index=True) name = Column(String) # 题集名称 description = Column(Text, nullable=True) # 描述 subject = Column(String, index=True) question_ids = Column(JSON) # 题目ID数组 is_public = Column(Boolean, default=False) # 是否公开 created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) # 创建表 Base.metadata.create_all(bind=engine) # Pydantic模型 class UserCreate(BaseModel): username: str email: str password: str class UserLogin(BaseModel): username: str password: str class Token(BaseModel): access_token: str token_type: str class WordCreate(BaseModel): word: str meaning: str example: Optional[str] = None phrases: Optional[List[str]] = None level: str = "middle_school" class WordResponse(BaseModel): id: int word: str meaning: str example: Optional[str] phrases: Optional[List[str]] level: str class Config: from_attributes = True class StudySession(BaseModel): word_id: int quality: int # 0-5 for SM-2 time_spent: int # seconds class StatsResponse(BaseModel): total_words: int due_today: int completed_today: int accuracy_rate: float streak_days: int class PaperCreate(BaseModel): title: str subject: str # math, chemistry, physics, english grade: str exam_type: str exam_date: datetime total_score: float achieved_score: Optional[float] = None file_key: str tags: Optional[List[str]] = None class PaperResponse(BaseModel): id: int title: str subject: str grade: str exam_type: str exam_date: datetime total_score: float achieved_score: Optional[float] file_key: str pdf_key: Optional[str] thumb_key: Optional[str] tags: Optional[List[str]] created_at: datetime class Config: from_attributes = True class MistakeCreate(BaseModel): paper_id: int page: int bbox: dict # {x, y, width, height} subject: str knowledge_points: List[str] reason_tag: str difficulty: int = 3 answer: Optional[str] = None analysis: Optional[str] = None notes: Optional[str] = None class MistakeResponse(BaseModel): id: int paper_id: int page: int bbox: dict image_key: Optional[str] subject: str knowledge_points: List[str] reason_tag: str difficulty: int review_level: int next_review_date: datetime last_score: Optional[int] answer: Optional[str] analysis: Optional[str] notes: Optional[str] created_at: datetime class Config: from_attributes = True class ReviewSubmit(BaseModel): mistake_id: int rating: int # 1-5 comment: Optional[str] = None time_spent: Optional[int] = None class FileUploadRequest(BaseModel): filename: str content_type: str class FileUploadResponse(BaseModel): file_key: str upload_url: str class DashboardStats(BaseModel): papers_count: int mistakes_count: int due_reviews: int weak_subjects: List[dict] recent_accuracy: float subject_stats: dict # 按学科分类的统计数据 class QuestionCreate(BaseModel): paper_id: Optional[int] = None question_type: str # choice, fill_blank, essay, calculation subject: str grade: str chapter: Optional[str] = None knowledge_points: List[str] difficulty: int = 3 question_content: str options: Optional[dict] = None # {A: "option1", B: "option2", ...} correct_answer: str explanation: Optional[str] = None tags: Optional[List[str]] = None class QuestionResponse(BaseModel): id: int paper_id: Optional[int] question_type: str subject: str grade: str chapter: Optional[str] knowledge_points: List[str] difficulty: int question_content: str options: Optional[dict] correct_answer: str explanation: Optional[str] image_key: Optional[str] images: Optional[List[dict]] # 题目关联的图片信息 question_number: Optional[str] = None # 添加题号字段,设为可选且有默认值 tags: Optional[List[str]] created_at: datetime class Config: from_attributes = True class QuestionCollectionCreate(BaseModel): name: str description: Optional[str] = None subject: str question_ids: List[int] is_public: bool = False class QuestionCollectionResponse(BaseModel): id: int name: str description: Optional[str] subject: str question_ids: List[int] is_public: bool created_at: datetime class Config: from_attributes = True # 拆题相关模型 class SplitRequest(BaseModel): paper_id: int class SplitQuestionResult(BaseModel): number: str type: str text: str options: Optional[dict] = None bbox: List[int] page: int confidence: float image_filename: Optional[str] = None class SplitResponse(BaseModel): success: bool paper_id: int total_questions: int questions: List[SplitQuestionResult] stats: dict error: Optional[str] = None # 导入图片API路由 from api_images import router as images_router # FastAPI应用 app = FastAPI(title="学习助手 Pro API", version="1.0.0", description="智能试卷管理与学习跟踪系统API") # 注册图片API路由 app.include_router(images_router) # CORS中间件 app.add_middleware( CORSMiddleware, allow_origins=["*"], # 生产环境中应该限制具体域名 allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # 静态文件服务 - 提供图片访问 app.mount("/images", StaticFiles(directory="uploads/images"), name="images") # 依赖 def get_db(): db = SessionLocal() try: yield db finally: db.close() security = HTTPBearer() def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db)): try: payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM]) username: str = payload.get("sub") if username is None: raise HTTPException(status_code=401, detail="Invalid token") except jwt.PyJWTError: raise HTTPException(status_code=401, detail="Invalid token") user = db.query(User).filter(User.username == username).first() if user is None: raise HTTPException(status_code=401, detail="User not found") return user # 工具函数 def verify_password(plain_password: str, hashed_password: str) -> bool: return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8')) def get_password_hash(password: str) -> str: return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): to_encode = data.copy() if expires_delta: expire = datetime.utcnow() + expires_delta else: expire = datetime.utcnow() + timedelta(minutes=15) to_encode.update({"exp": expire}) encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) return encoded_jwt def sm2_schedule(ef: float, interval: int, reps: int, quality: int) -> tuple: """SM-2算法实现""" ef = max(1.3, ef + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02))) if quality < 3: reps = 0 interval = 1 else: reps += 1 if reps == 1: interval = 1 elif reps == 2: interval = 3 else: interval = round(interval * ef) return ef, interval, reps # API路由 @app.post("/api/auth/register", response_model=Token) async def register(user: UserCreate, db: Session = Depends(get_db)): # 检查用户名是否存在 db_user = db.query(User).filter(User.username == user.username).first() if db_user: raise HTTPException(status_code=400, detail="Username already registered") # 检查邮箱是否存在 db_user = db.query(User).filter(User.email == user.email).first() if db_user: raise HTTPException(status_code=400, detail="Email already registered") # 创建用户 hashed_password = get_password_hash(user.password) db_user = User(username=user.username, email=user.email, hashed_password=hashed_password) db.add(db_user) db.commit() db.refresh(db_user) # 生成令牌 access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) access_token = create_access_token( data={"sub": user.username}, expires_delta=access_token_expires ) return {"access_token": access_token, "token_type": "bearer"} @app.post("/api/auth/login", response_model=Token) async def login(user: UserLogin, db: Session = Depends(get_db)): db_user = db.query(User).filter(User.username == user.username).first() if not db_user or not verify_password(user.password, db_user.hashed_password): raise HTTPException(status_code=401, detail="Incorrect username or password") access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) access_token = create_access_token( data={"sub": user.username}, expires_delta=access_token_expires ) return {"access_token": access_token, "token_type": "bearer"} @app.get("/api/words", response_model=List[WordResponse]) async def get_words(skip: int = 0, limit: int = 100, level: Optional[str] = None, db: Session = Depends(get_db)): query = db.query(Word) if level: query = query.filter(Word.level == level) words = query.offset(skip).limit(limit).all() # 处理phrases字段 result = [] for word in words: word_dict = { "id": word.id, "word": word.word, "meaning": word.meaning, "example": word.example, "level": word.level, "phrases": json.loads(word.phrases) if word.phrases else [] } result.append(word_dict) return result @app.post("/api/words", response_model=WordResponse) async def create_word(word: WordCreate, db: Session = Depends(get_db)): phrases_json = json.dumps(word.phrases) if word.phrases else None db_word = Word( word=word.word, meaning=word.meaning, example=word.example, phrases=phrases_json, level=word.level ) db.add(db_word) db.commit() db.refresh(db_word) return { "id": db_word.id, "word": db_word.word, "meaning": db_word.meaning, "example": db_word.example, "phrases": json.loads(db_word.phrases) if db_word.phrases else [], "level": db_word.level } @app.post("/api/words/import") async def import_words(file: UploadFile = File(...), db: Session = Depends(get_db)): if not file.filename.endswith(('.csv', '.json')): raise HTTPException(status_code=400, detail="Only CSV and JSON files are supported") content = await file.read() imported_count = 0 try: if file.filename.endswith('.csv'): # 处理CSV文件 csv_content = content.decode('utf-8') csv_reader = csv.DictReader(io.StringIO(csv_content)) for row in csv_reader: if not row.get('word') or not row.get('meaning'): continue phrases = [] if row.get('phrases'): phrases = [p.strip() for p in row['phrases'].split(';') if p.strip()] phrases_json = json.dumps(phrases) if phrases else None db_word = Word( word=row['word'].strip(), meaning=row['meaning'].strip(), example=row.get('example', '').strip() or None, phrases=phrases_json, level=row.get('level', 'middle_school').strip() ) db.add(db_word) imported_count += 1 elif file.filename.endswith('.json'): # 处理JSON文件 json_data = json.loads(content.decode('utf-8')) for item in json_data: if not item.get('word') or not item.get('meaning'): continue phrases_json = json.dumps(item.get('phrases', [])) if item.get('phrases') else None db_word = Word( word=item['word'].strip(), meaning=item['meaning'].strip(), example=item.get('example', '').strip() or None, phrases=phrases_json, level=item.get('level', 'middle_school').strip() ) db.add(db_word) imported_count += 1 db.commit() return {"message": f"Successfully imported {imported_count} words"} except Exception as e: db.rollback() raise HTTPException(status_code=400, detail=f"Import failed: {str(e)}") @app.get("/api/study/due") async def get_due_words(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): """获取今日待学习的单词""" now = datetime.utcnow() # 获取用户的学习进度 progress_query = db.query(UserWordProgress).filter( UserWordProgress.user_id == current_user.id, UserWordProgress.due <= now ) due_progress = progress_query.all() # 如果没有待学习的单词,添加新单词 if len(due_progress) < 10: # 保持至少10个待学习单词 # 获取用户尚未学习的单词 learned_word_ids = db.query(UserWordProgress.word_id).filter( UserWordProgress.user_id == current_user.id ).subquery() new_words = db.query(Word).filter( ~Word.id.in_(learned_word_ids) ).limit(10 - len(due_progress)).all() # 为新单词创建学习进度 for word in new_words: progress = UserWordProgress( user_id=current_user.id, word_id=word.id, due=now ) db.add(progress) due_progress.append(progress) db.commit() # 获取完整的单词信息 word_ids = [p.word_id for p in due_progress] words = db.query(Word).filter(Word.id.in_(word_ids)).all() result = [] for word in words: progress = next(p for p in due_progress if p.word_id == word.id) result.append({ "id": word.id, "word": word.word, "meaning": word.meaning, "example": word.example, "phrases": json.loads(word.phrases) if word.phrases else [], "level": word.level, "progress": { "ef": progress.ef, "interval": progress.interval, "reps": progress.reps, "correct_count": progress.correct_count, "wrong_count": progress.wrong_count, "is_leech": progress.is_leech } }) return result @app.post("/api/study/submit") async def submit_study_session( session: StudySession, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): """提交学习结果""" progress = db.query(UserWordProgress).filter( UserWordProgress.user_id == current_user.id, UserWordProgress.word_id == session.word_id ).first() if not progress: raise HTTPException(status_code=404, detail="Word progress not found") # 更新统计信息 is_correct = session.quality >= 3 if is_correct: progress.correct_count += 1 progress.streak_wrong = 0 if progress.is_leech and progress.correct_count % 3 == 0: progress.is_leech = False progress.ef = min(2.5, progress.ef + 0.1) else: progress.wrong_count += 1 progress.streak_wrong += 1 if progress.streak_wrong >= 3: progress.is_leech = True progress.ef = max(1.3, progress.ef - 0.2) # 使用SM-2算法计算下次复习时间 ef, interval, reps = sm2_schedule(progress.ef, progress.interval, progress.reps, session.quality) progress.ef = ef progress.interval = interval progress.reps = reps progress.due = datetime.utcnow() + timedelta(days=interval) progress.last_reviewed = datetime.utcnow() db.commit() return {"message": "Study session submitted successfully", "next_due": progress.due} @app.get("/api/stats", response_model=StatsResponse) async def get_stats(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): """获取学习统计""" now = datetime.utcnow() today_start = datetime(now.year, now.month, now.day) # 总词汇量 total_words = db.query(UserWordProgress).filter( UserWordProgress.user_id == current_user.id ).count() # 今日待复习 due_today = db.query(UserWordProgress).filter( UserWordProgress.user_id == current_user.id, UserWordProgress.due <= now ).count() # 今日已完成 completed_today = db.query(UserWordProgress).filter( UserWordProgress.user_id == current_user.id, UserWordProgress.last_reviewed >= today_start ).count() # 总体正确率 all_progress = db.query(UserWordProgress).filter( UserWordProgress.user_id == current_user.id ).all() total_correct = sum(p.correct_count for p in all_progress) total_attempts = sum(p.correct_count + p.wrong_count for p in all_progress) accuracy_rate = (total_correct / total_attempts * 100) if total_attempts > 0 else 0 # 连续学习天数(简化计算) streak_days = 1 # 这里可以实现更复杂的连续天数计算 return StatsResponse( total_words=total_words, due_today=due_today, completed_today=completed_today, accuracy_rate=accuracy_rate, streak_days=streak_days ) @app.get("/api/words/leech") async def get_leech_words(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): """获取顽固词汇""" leech_progress = db.query(UserWordProgress).filter( UserWordProgress.user_id == current_user.id, UserWordProgress.is_leech == True ).all() word_ids = [p.word_id for p in leech_progress] words = db.query(Word).filter(Word.id.in_(word_ids)).all() result = [] for word in words: progress = next(p for p in leech_progress if p.word_id == word.id) result.append({ "id": word.id, "word": word.word, "meaning": word.meaning, "wrong_count": progress.wrong_count, "streak_wrong": progress.streak_wrong }) return result # 文件上传相关 @app.post("/api/file/presign", response_model=FileUploadResponse) async def create_presigned_url( request: FileUploadRequest, current_user: User = Depends(get_current_user) ): """生成预签名上传URL""" # 简化版本:直接使用本地存储 file_key = f"uploads/{current_user.id}/{uuid.uuid4()}_{request.filename}" upload_dir = os.path.dirname(file_key) os.makedirs(upload_dir, exist_ok=True) return FileUploadResponse( file_key=file_key, upload_url=f"http://localhost:8888/api/file/upload/{file_key}" ) @app.post("/api/file/upload/") async def upload_file_direct( file: UploadFile = File(...), current_user: User = Depends(get_current_user) ): """直接文件上传""" try: # 检查文件名 if not file.filename or file.filename == "": return {"error": "No filename provided", "filename": repr(file.filename)} # 安全的文件名处理 safe_filename = file.filename.replace(" ", "_").replace("/", "_") file_key = f"uploads/{current_user.id}/{uuid.uuid4()}_{safe_filename}" # 确保目录存在 upload_dir = os.path.dirname(file_key) if upload_dir: os.makedirs(upload_dir, exist_ok=True) # 读取并保存文件 content = await file.read() with open(file_key, "wb") as buffer: buffer.write(content) return { "message": "File uploaded successfully", "file_key": file_key, "original_filename": file.filename, "file_size": len(content) } except Exception as e: return {"error": str(e), "filename": getattr(file, 'filename', 'unknown')} @app.post("/api/file/upload/{file_path:path}") async def upload_file(file_path: str, file: UploadFile = File(...)): """文件上传""" try: with open(file_path, "wb") as buffer: content = await file.read() buffer.write(content) return {"message": "File uploaded successfully", "file_key": file_path} except Exception as e: raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}") # 试卷管理 @app.post("/api/papers", response_model=PaperResponse) async def create_paper( paper: PaperCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): """创建试卷记录""" db_paper = Paper( user_id=current_user.id, title=paper.title, subject=paper.subject, grade=paper.grade, exam_type=paper.exam_type, exam_date=paper.exam_date, total_score=paper.total_score, achieved_score=paper.achieved_score, file_key=paper.file_key, tags=paper.tags ) db.add(db_paper) db.commit() db.refresh(db_paper) return PaperResponse( id=db_paper.id, title=db_paper.title, subject=db_paper.subject, grade=db_paper.grade, exam_type=db_paper.exam_type, exam_date=db_paper.exam_date, total_score=db_paper.total_score, achieved_score=db_paper.achieved_score, file_key=db_paper.file_key, pdf_key=db_paper.pdf_key, thumb_key=db_paper.thumb_key, tags=db_paper.tags, created_at=db_paper.created_at ) @app.get("/api/papers", response_model=List[PaperResponse]) async def get_papers( subject: Optional[str] = None, grade: Optional[str] = None, exam_type: Optional[str] = None, skip: int = 0, limit: int = 50, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): """获取试卷列表""" query = db.query(Paper).filter(Paper.user_id == current_user.id) if subject: query = query.filter(Paper.subject == subject) if grade: query = query.filter(Paper.grade == grade) if exam_type: query = query.filter(Paper.exam_type == exam_type) papers = query.offset(skip).limit(limit).all() return [PaperResponse( id=paper.id, title=paper.title, subject=paper.subject, grade=paper.grade, exam_type=paper.exam_type, exam_date=paper.exam_date, total_score=paper.total_score, achieved_score=paper.achieved_score, file_key=paper.file_key, pdf_key=paper.pdf_key, thumb_key=paper.thumb_key, tags=paper.tags, created_at=paper.created_at ) for paper in papers] @app.get("/api/papers/{paper_id}", response_model=PaperResponse) async def get_paper( paper_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): """获取单个试卷详情""" paper = db.query(Paper).filter( Paper.id == paper_id, Paper.user_id == current_user.id ).first() if not paper: raise HTTPException(status_code=404, detail="Paper not found") return PaperResponse( id=paper.id, title=paper.title, subject=paper.subject, grade=paper.grade, exam_type=paper.exam_type, exam_date=paper.exam_date, total_score=paper.total_score, achieved_score=paper.achieved_score, file_key=paper.file_key, pdf_key=paper.pdf_key, thumb_key=paper.thumb_key, tags=paper.tags, created_at=paper.created_at ) # 错题管理 @app.post("/api/mistakes", response_model=MistakeResponse) async def create_mistake( mistake: MistakeCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): """创建错题记录""" # 验证试卷是否属于当前用户 paper = db.query(Paper).filter( Paper.id == mistake.paper_id, Paper.user_id == current_user.id ).first() if not paper: raise HTTPException(status_code=404, detail="Paper not found") db_mistake = Mistake( user_id=current_user.id, paper_id=mistake.paper_id, page=mistake.page, bbox=mistake.bbox, subject=mistake.subject, knowledge_points=mistake.knowledge_points, reason_tag=mistake.reason_tag, difficulty=mistake.difficulty, answer=mistake.answer, analysis=mistake.analysis, notes=mistake.notes ) db.add(db_mistake) db.commit() db.refresh(db_mistake) return MistakeResponse( id=db_mistake.id, paper_id=db_mistake.paper_id, page=db_mistake.page, bbox=db_mistake.bbox, image_key=db_mistake.image_key, subject=db_mistake.subject, knowledge_points=db_mistake.knowledge_points, reason_tag=db_mistake.reason_tag, difficulty=db_mistake.difficulty, review_level=db_mistake.review_level, next_review_date=db_mistake.next_review_date, last_score=db_mistake.last_score, answer=db_mistake.answer, analysis=db_mistake.analysis, notes=db_mistake.notes, created_at=db_mistake.created_at ) @app.get("/api/mistakes", response_model=List[MistakeResponse]) async def get_mistakes( subject: Optional[str] = None, due: Optional[str] = None, # today, overdue, upcoming skip: int = 0, limit: int = 50, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): """获取错题列表""" query = db.query(Mistake).filter(Mistake.user_id == current_user.id) if subject: query = query.filter(Mistake.subject == subject) if due == "today": today = datetime.utcnow().date() query = query.filter(Mistake.next_review_date <= datetime.combine(today, datetime.min.time()) + timedelta(days=1)) elif due == "overdue": query = query.filter(Mistake.next_review_date < datetime.utcnow()) mistakes = query.offset(skip).limit(limit).all() return [MistakeResponse( id=mistake.id, paper_id=mistake.paper_id, page=mistake.page, bbox=mistake.bbox, image_key=mistake.image_key, subject=mistake.subject, knowledge_points=mistake.knowledge_points, reason_tag=mistake.reason_tag, difficulty=mistake.difficulty, review_level=mistake.review_level, next_review_date=mistake.next_review_date, last_score=mistake.last_score, answer=mistake.answer, analysis=mistake.analysis, notes=mistake.notes, created_at=mistake.created_at ) for mistake in mistakes] @app.post("/api/mistakes/{mistake_id}/review") async def submit_review( mistake_id: int, review: ReviewSubmit, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): """提交错题复习结果""" mistake = db.query(Mistake).filter( Mistake.id == mistake_id, Mistake.user_id == current_user.id ).first() if not mistake: raise HTTPException(status_code=404, detail="Mistake not found") # 计算下次复习时间 (Leitner算法) def calculate_next_review(level: int, rating: int, today: date) -> tuple: buckets = {1: 1, 2: 2, 3: 4, 4: 7, 5: 14} if rating <= 2: return today + timedelta(days=1), 1 new_level = min(5, level + (1 if rating >= 4 else 0)) return today + timedelta(days=buckets[new_level]), new_level today = datetime.utcnow().date() next_date, new_level = calculate_next_review(mistake.review_level, review.rating, today) # 更新错题记录 mistake.last_score = review.rating mistake.review_level = new_level mistake.next_review_date = datetime.combine(next_date, datetime.min.time()) # 创建复习记录 review_log = ReviewLog( user_id=current_user.id, mistake_id=mistake_id, rating=review.rating, next_review_date=mistake.next_review_date, comment=review.comment, time_spent=review.time_spent ) db.add(review_log) db.commit() return {"message": "Review submitted successfully", "next_review_date": mistake.next_review_date} @app.get("/api/dashboard", response_model=DashboardStats) async def get_dashboard_stats( current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): """获取仪表盘统计数据""" # 试卷总数 papers_count = db.query(Paper).filter(Paper.user_id == current_user.id).count() # 错题总数 mistakes_count = db.query(Mistake).filter(Mistake.user_id == current_user.id).count() # 今日待复习错题数 today = datetime.utcnow().date() due_reviews = db.query(Mistake).filter( Mistake.user_id == current_user.id, Mistake.next_review_date <= datetime.combine(today, datetime.min.time()) + timedelta(days=1) ).count() # 薄弱学科分析 from sqlalchemy import func weak_subjects = db.query( Mistake.subject, func.count(Mistake.id).label('count'), func.avg(Mistake.last_score).label('avg_score') ).filter( Mistake.user_id == current_user.id ).group_by(Mistake.subject).all() weak_subjects_data = [ {"subject": ws.subject, "mistake_count": ws.count, "avg_score": ws.avg_score or 0} for ws in weak_subjects ] # 最近准确率 recent_reviews = db.query(ReviewLog).filter( ReviewLog.user_id == current_user.id, ReviewLog.reviewed_at >= datetime.utcnow() - timedelta(days=7) ).all() recent_accuracy = 0.0 if recent_reviews: good_reviews = sum(1 for r in recent_reviews if r.rating >= 3) recent_accuracy = (good_reviews / len(recent_reviews)) * 100 # 按学科统计试卷、错题和待复习数量 subject_stats = {} # 统计各学科试卷数量 papers_by_subject = db.query( Paper.subject, func.count(Paper.id).label('count') ).filter( Paper.user_id == current_user.id ).group_by(Paper.subject).all() # 统计各学科错题数量 mistakes_by_subject = db.query( Mistake.subject, func.count(Mistake.id).label('count') ).filter( Mistake.user_id == current_user.id ).group_by(Mistake.subject).all() # 统计各学科待复习数量 due_by_subject = db.query( Mistake.subject, func.count(Mistake.id).label('count') ).filter( Mistake.user_id == current_user.id, Mistake.next_review_date <= datetime.combine(today, datetime.min.time()) + timedelta(days=1) ).group_by(Mistake.subject).all() # 初始化所有学科数据 all_subjects = set() for item in papers_by_subject: all_subjects.add(item.subject) for item in mistakes_by_subject: all_subjects.add(item.subject) for item in due_by_subject: all_subjects.add(item.subject) # 构建学科统计字典 for subject in all_subjects: subject_stats[subject] = { 'papers': 0, 'mistakes': 0, 'due': 0 } # 填充试卷数据 for item in papers_by_subject: subject_stats[item.subject]['papers'] = item.count # 填充错题数据 for item in mistakes_by_subject: subject_stats[item.subject]['mistakes'] = item.count # 填充待复习数据 for item in due_by_subject: subject_stats[item.subject]['due'] = item.count return DashboardStats( papers_count=papers_count, mistakes_count=mistakes_count, due_reviews=due_reviews, weak_subjects=weak_subjects_data, recent_accuracy=recent_accuracy, subject_stats=subject_stats ) # 题目管理 @app.post("/api/questions", response_model=QuestionResponse) async def create_question( question: QuestionCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): """创建题目""" db_question = Question( user_id=current_user.id, paper_id=question.paper_id, question_type=question.question_type, subject=question.subject, grade=question.grade, chapter=question.chapter, knowledge_points=question.knowledge_points, difficulty=question.difficulty, question_content=question.question_content, options=question.options, correct_answer=question.correct_answer, explanation=question.explanation, tags=question.tags ) db.add(db_question) db.commit() db.refresh(db_question) return QuestionResponse( id=db_question.id, paper_id=db_question.paper_id, question_type=db_question.question_type, subject=db_question.subject, grade=db_question.grade, chapter=db_question.chapter, knowledge_points=db_question.knowledge_points, difficulty=db_question.difficulty, question_content=db_question.question_content, options=db_question.options, correct_answer=db_question.correct_answer, explanation=db_question.explanation, image_key=db_question.image_key, tags=db_question.tags, created_at=db_question.created_at ) @app.get("/api/questions", response_model=List[QuestionResponse]) async def get_questions( paper_id: Optional[int] = None, subject: Optional[str] = None, grade: Optional[str] = None, chapter: Optional[str] = None, question_type: Optional[str] = None, difficulty: Optional[int] = None, search: Optional[str] = None, skip: int = 0, limit: int = 50, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): """获取题目列表""" query = db.query(Question).filter(Question.user_id == current_user.id) if paper_id: query = query.filter(Question.paper_id == paper_id) if subject: query = query.filter(Question.subject == subject) if grade: query = query.filter(Question.grade == grade) if chapter: query = query.filter(Question.chapter == chapter) if question_type: query = query.filter(Question.question_type == question_type) if difficulty: query = query.filter(Question.difficulty == difficulty) if search: query = query.filter(Question.question_content.contains(search)) questions = query.offset(skip).limit(limit).all() return [QuestionResponse( id=q.id, paper_id=q.paper_id, question_type=q.question_type, subject=q.subject, grade=q.grade, chapter=q.chapter, knowledge_points=q.knowledge_points, difficulty=q.difficulty, question_content=q.question_content, options=q.options, correct_answer=q.correct_answer, explanation=q.explanation, image_key=q.image_key, images=q.images, tags=q.tags, created_at=q.created_at ) for q in questions] @app.get("/api/questions/{question_id}", response_model=QuestionResponse) async def get_question( question_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): """获取单个题目详情""" question = db.query(Question).filter( Question.id == question_id, Question.user_id == current_user.id ).first() if not question: raise HTTPException(status_code=404, detail="Question not found") return QuestionResponse( id=question.id, paper_id=question.paper_id, question_type=question.question_type, subject=question.subject, grade=question.grade, chapter=question.chapter, knowledge_points=question.knowledge_points, difficulty=question.difficulty, question_content=question.question_content, options=question.options, correct_answer=question.correct_answer, explanation=question.explanation, image_key=question.image_key, images=question.images, tags=question.tags, created_at=question.created_at ) @app.delete("/api/questions/{question_id}") async def delete_question( question_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): """删除题目""" question = db.query(Question).filter( Question.id == question_id, Question.user_id == current_user.id ).first() if not question: raise HTTPException(status_code=404, detail="Question not found") db.delete(question) db.commit() return {"message": "Question deleted successfully"} @app.get("/api/papers/{paper_id}/file") @app.options("/api/papers/{paper_id}/file") async def get_paper_file( request: Request, paper_id: int, access_token: Optional[str] = None, credentials: Optional[HTTPAuthorizationCredentials] = Depends(HTTPBearer(auto_error=False)), db: Session = Depends(get_db) ): """获取试卷文件 - 支持token查询参数或Authorization头""" # 处理OPTIONS预检请求 if request.method == "OPTIONS": from fastapi.responses import Response return Response( headers={ "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, OPTIONS", "Access-Control-Allow-Headers": "Authorization" } ) # 尝试从查询参数获取token token = access_token # 如果没有查询参数token,尝试从Authorization头获取 if not token and credentials: token = credentials.credentials if not token: raise HTTPException(status_code=401, detail="No authentication token provided") # 验证token并获取用户 try: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) username: str = payload.get("sub") if username is None: raise HTTPException(status_code=401, detail="Invalid token") except jwt.PyJWTError: raise HTTPException(status_code=401, detail="Invalid token") user = db.query(User).filter(User.username == username).first() if user is None: raise HTTPException(status_code=401, detail="User not found") # 获取试卷 paper = db.query(Paper).filter( Paper.id == paper_id, Paper.user_id == user.id ).first() if not paper: raise HTTPException(status_code=404, detail="Paper not found") from fastapi.responses import FileResponse import os if os.path.exists(paper.file_key): # 根据文件扩展名设置content type content_type = "application/octet-stream" filename = os.path.basename(paper.file_key) if filename.lower().endswith('.pdf'): content_type = "application/pdf" elif filename.lower().endswith(('.jpg', '.jpeg')): content_type = "image/jpeg" elif filename.lower().endswith('.png'): content_type = "image/png" # 处理中文文件名编码 import urllib.parse encoded_filename = urllib.parse.quote(filename.encode('utf-8')) return FileResponse( paper.file_key, media_type=content_type, headers={ "Content-Disposition": f"inline; filename*=UTF-8''{encoded_filename}", "Access-Control-Allow-Origin": "*" } ) else: raise HTTPException(status_code=404, detail="File not found") @app.get("/api/files/paper/{paper_id}") async def get_paper_file_public( paper_id: int, access_token: str, db: Session = Depends(get_db) ): """公开访问试卷文件(通过token验证)""" # 验证token try: payload = jwt.decode(access_token, SECRET_KEY, algorithms=[ALGORITHM]) username: str = payload.get("sub") if username is None: raise HTTPException(status_code=401, detail="Invalid token") except jwt.PyJWTError: raise HTTPException(status_code=401, detail="Invalid token") # 获取用户 user = db.query(User).filter(User.username == username).first() if user is None: raise HTTPException(status_code=401, detail="User not found") # 获取试卷 paper = db.query(Paper).filter( Paper.id == paper_id, Paper.user_id == user.id ).first() if not paper: raise HTTPException(status_code=404, detail="Paper not found") from fastapi.responses import FileResponse import os if os.path.exists(paper.file_key): # 根据文件扩展名设置content type content_type = "application/octet-stream" filename = os.path.basename(paper.file_key) if filename.lower().endswith('.pdf'): content_type = "application/pdf" elif filename.lower().endswith(('.jpg', '.jpeg')): content_type = "image/jpeg" elif filename.lower().endswith('.png'): content_type = "image/png" return FileResponse( paper.file_key, media_type=content_type, headers={ "Content-Disposition": f"inline; filename={filename}", "Cache-Control": "private, max-age=3600" } ) else: raise HTTPException(status_code=404, detail="File not found") # 静态文件服务 - 用于直接访问上传的文件 @app.get("/uploads/{user_id}/{filename}") async def serve_uploaded_file( user_id: int, filename: str, current_user: User = Depends(get_current_user) ): """直接访问上传的文件""" # 只允许用户访问自己的文件 if current_user.id != user_id: raise HTTPException(status_code=403, detail="Access denied") file_path = f"uploads/{user_id}/{filename}" from fastapi.responses import FileResponse import os if os.path.exists(file_path): # 根据文件扩展名设置content type content_type = "application/octet-stream" if filename.lower().endswith('.pdf'): content_type = "application/pdf" elif filename.lower().endswith(('.jpg', '.jpeg')): content_type = "image/jpeg" elif filename.lower().endswith('.png'): content_type = "image/png" return FileResponse( file_path, media_type=content_type, headers={"Content-Disposition": f"inline; filename={filename}"} ) else: raise HTTPException(status_code=404, detail="File not found") # PDF拆题API - 使用 Claude Sonnet 4.5 智能解析 @app.post("/api/papers/{paper_id}/split", response_model=SplitResponse) async def split_paper( paper_id: int, use_claude: bool = True, # 默认使用 Claude 解析 current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): """ 拆解试卷PDF,自动提取题目 Args: paper_id: 试卷ID use_claude: 是否使用 Claude Sonnet 4.5 智能解析(默认 True) Returns: 解析结果,包含题目列表和统计信息 """ # 检查试卷是否存在 paper = db.query(Paper).filter( Paper.id == paper_id, Paper.user_id == current_user.id ).first() if not paper: raise HTTPException(status_code=404, detail="试卷不存在") # 检查文件是否存在 file_path = paper.file_key if not file_path or not os.path.exists(file_path): raise HTTPException(status_code=404, detail="试卷文件不存在") try: if use_claude: # 使用 Claude Sonnet 4.5 智能解析器 logger.info(f"使用 Claude Sonnet 4.5 解析试卷: {file_path}") parser = ClaudeExamParser() result = parser.parse_exam_file( file_path, subject=paper.subject, grade=paper.grade ) if not result['success']: logger.error(f"Claude 解析失败: {result.get('error')}") return SplitResponse( success=False, paper_id=paper_id, error=result.get('error', 'Claude 解析失败') ) # 转换 Claude 的输出格式为系统格式 claude_result = result['result'] questions_data = [] for q in claude_result.get('questions', []): # 映射题型 type_mapping = { 'choice': 'choice', 'fill_blank': 'fill_blank', 'calculation': 'calculation', 'essay': 'essay' } question_data = { 'number': q.get('number'), 'type': type_mapping.get(q.get('type', 'choice'), 'choice'), 'question': q.get('question', ''), 'options': q.get('options', []), 'answer': q.get('answer', ''), 'analysis': q.get('analysis', ''), 'difficulty': q.get('difficulty', 3), 'knowledge_points': q.get('knowledge_points', []), 'page': q.get('page', 1), 'has_image': q.get('has_image', False), 'image_description': q.get('image_description', ''), 'formulas': q.get('formulas', []), 'confidence': 0.95 # Claude 高置信度 } questions_data.append(question_data) # 更新试卷学科(如果 AI 识别出来了) if claude_result.get('subject') and not paper.subject: paper.subject = claude_result['subject'] db.commit() stats = claude_result.get('stats', {}) logger.info(f"Claude 解析成功,共 {len(questions_data)} 道题目") return SplitResponse( success=True, paper_id=paper_id, questions=questions_data, stats={ 'total_questions': claude_result.get('total_questions', len(questions_data)), 'choice_count': stats.get('choice_count', 0), 'fill_blank_count': stats.get('fill_blank_count', 0), 'calculation_count': stats.get('calculation_count', 0), 'essay_count': stats.get('essay_count', 0), 'parser_version': 'claude-sonnet-4.5' } ) else: # 使用传统解析器 logger.info(f"使用传统解析器: {file_path}") parser = PDFExamParserEnhancedV2() # 创建临时目录(如果需要的话) temp_dir = tempfile.mkdtemp(prefix="pdf_split_") try: # 处理PDF logger.info(f"开始处理PDF: {file_path}") result = parser.process_pdf(file_path) logger.info(f"PDF处理结果: {result}") if not result['success']: logger.error(f"PDF解析失败: {result.get('error')}") return SplitResponse( success=False, paper_id=paper_id, total_questions=0, questions=[], stats={}, error=result.get('error', '未知错误') ) questions = result['questions'] stats = result['stats'] logger.info(f"解析到题目数量: {len(questions)}") # 提取图片并与题目关联 image_extractor = PDFImageExtractor() extracted_images = image_extractor.extract_images_from_pdf(file_path) logger.info(f"提取到图片数量: {len(extracted_images)}") # 将图片关联到题目(基于页面位置) for img_info in extracted_images: img_page = img_info.get('page', 1) logger.info(f"处理图片: 页面{img_page}, 路径{img_info.get('path', 'unknown')}") # 找到同一页面的题目,将图片关联到最近的题目 for i, question in enumerate(questions): q_page = question.get('page', 1) logger.info(f"检查题目{question.get('number')}: 页面{q_page}") if q_page == img_page: if 'images' not in question: question['images'] = [] question['images'].append(img_info) logger.info(f"✅ 图片关联到题目{question.get('number')} (页面{img_page})") break # 每张图片只关联一个题目 else: logger.warning(f"⚠️ 无法关联图片到页面{img_page}的题目") # 清理该试卷的现有题目,避免重复累加 existing_questions = db.query(Question).filter(Question.paper_id == paper_id).all() if existing_questions: logger.info(f"清理现有题目:删除 {len(existing_questions)} 道旧题目") for q in existing_questions: db.delete(q) db.commit() # 保存提取的题目到数据库 saved_questions = [] for i, question_data in enumerate(questions): # 从question_data中获取已关联的图片信息 question_images = question_data.get('images', []) # 如果没有图片但有image_path,移动图片到uploads目录 image_key = None if not question_images and question_data.get('image_path') and os.path.exists(question_data['image_path']): # 生成新的文件名 file_extension = os.path.splitext(question_data['image_filename'])[1] new_filename = f"{uuid.uuid4()}{file_extension}" dest_dir = os.path.join("uploads", str(current_user.id)) os.makedirs(dest_dir, exist_ok=True) dest_path = os.path.join(dest_dir, new_filename) try: shutil.copy2(question_data['image_path'], dest_path) image_key = dest_path # 创建图片对象 question_images = [{ 'page': question_data.get('page', 1), 'bbox': question_data.get('bbox', []), 'key': os.path.basename(dest_path) # 只保存文件名 }] except Exception as e: print(f"移动题目图片失败: {e}") # 创建题目记录 db_question = Question( user_id=current_user.id, paper_id=paper_id, question_type=question_data.get('type', 'unknown'), subject=paper.subject, grade=paper.grade, knowledge_points=[], # 后续可以通过AI分析填充 difficulty=3, # 默认难度 question_content=question_data.get('text', ''), options=question_data.get('options'), correct_answer='', # 需要手动填写或AI识别 explanation=None, image_key=image_key, # 拆题相关字段 page_spans=question_data.get('page_spans', [{'page': question_data.get('page', 1), 'bbox': question_data.get('bbox', [])}]), source_text=question_data.get('text', ''), images=question_images, # 使用从PDFSplitterHybrid获取的图片信息 formula=[], # 后续可以添加公式识别 score=None, # 需要手动填写 question_number=str(question_data.get('number', i+1)), auto_extracted=True ) db.add(db_question) # 调试信息 q_number = str(question_data.get('number', i+1)) logger.info(f"保存题目: number='{q_number}', type='{question_data.get('type')}'") # 构建返回数据 saved_questions.append(SplitQuestionResult( number=q_number, type=question_data.get('type', 'unknown'), text=question_data.get('text', ''), options=question_data.get('options'), bbox=question_data.get('bbox', []), page=question_data.get('page', 1), confidence=question_data.get('confidence', 0.0), image_filename=new_filename if image_key else None )) # 提交数据库更改 db.commit() return SplitResponse( success=True, paper_id=paper_id, total_questions=len(saved_questions), questions=saved_questions, stats=stats ) finally: # 清理临时目录 if os.path.exists(temp_dir): shutil.rmtree(temp_dir) except Exception as e: db.rollback() return SplitResponse( success=False, paper_id=paper_id, total_questions=0, questions=[], stats={}, error=f"处理失败: {str(e)}" ) # 新增API端点:导出格式化的题目数据 @app.get("/api/papers/{paper_id}/export/json") async def export_paper_questions_json( paper_id: int, current_user: User = Depends(get_current_user), include_validation: bool = True, include_statistics: bool = True ): """ 导出试卷的格式化JSON数据 Args: paper_id: 试卷ID include_validation: 是否包含验证信息 include_statistics: 是否包含统计信息 Returns: 格式化的JSON数据 """ try: # 获取试卷信息 paper = db.query(Paper).filter( Paper.id == paper_id, Paper.user_id == current_user.id ).first() if not paper: raise HTTPException(status_code=404, detail="试卷不存在") # 获取题目列表 questions = db.query(Question).filter( Question.paper_id == paper_id, Question.user_id == current_user.id ).all() if not questions: return { "success": True, "data": { "paper_info": { "id": paper.id, "title": paper.title, "subject": paper.subject, "grade": paper.grade, "exam_type": paper.exam_type, "exam_date": paper.exam_date.isoformat() if paper.exam_date else None, "total_score": paper.total_score, "created_at": paper.created_at.isoformat() }, "questions": [], "statistics": { "total_questions": 0, "message": "该试卷暂无题目数据" } } } # 转换为字典格式 questions_data = [] for q in questions: question_dict = { "id": q.id, "question_type": q.question_type, "subject": q.subject, "grade": q.grade, "chapter": q.chapter, "difficulty": q.difficulty, "question_content": q.question_content, "options": q.options, "correct_answer": q.correct_answer, "explanation": q.explanation, "image_key": q.image_key, "knowledge_points": q.knowledge_points, "score": q.score, "question_number": q.question_number, "auto_extracted": q.auto_extracted, "images": q.images, "page_spans": q.page_spans, "source_text": q.source_text, "created_at": q.created_at, "updated_at": q.updated_at } questions_data.append(question_dict) # 使用格式化器 formatter = QuestionFormatter() # 构建完整数据结构 raw_data = { "paper_id": paper.id, "title": paper.title, "subject": paper.subject, "grade": paper.grade, "questions": questions_data } # 格式化数据 formatted_data = formatter.format_paper_questions(raw_data) # 移除不需要的验证和统计信息 if not include_validation: for question in formatted_data["questions"]: question.pop("validation", None) if not include_statistics: formatted_data.pop("statistics", None) return { "success": True, "data": formatted_data } except HTTPException: raise except Exception as e: logger.error(f"导出JSON失败: {e}") raise HTTPException(status_code=500, detail=f"导出失败: {str(e)}") # 新增API端点:获取格式化的单个题目 @app.get("/api/questions/{question_id}/format") async def get_formatted_question( question_id: int, current_user: User = Depends(get_current_user) ): """获取格式化的单个题目数据""" try: # 获取题目 question = db.query(Question).filter( Question.id == question_id, Question.user_id == current_user.id ).first() if not question: raise HTTPException(status_code=404, detail="题目不存在") # 获取关联的试卷信息 paper = None if question.paper_id: paper = db.query(Paper).filter( Paper.id == question.paper_id ).first() paper_info = None if paper: paper_info = { "id": paper.id, "title": paper.title, "subject": paper.subject, "grade": paper.grade } # 转换为字典格式 question_dict = { "id": question.id, "question_type": question.question_type, "subject": question.subject, "grade": question.grade, "chapter": question.chapter, "difficulty": question.difficulty, "question_content": question.question_content, "options": question.options, "correct_answer": question.correct_answer, "explanation": question.explanation, "image_key": question.image_key, "knowledge_points": question.knowledge_points, "score": question.score, "question_number": question.question_number, "auto_extracted": question.auto_extracted, "images": question.images, "page_spans": question.page_spans, "source_text": question.source_text, "created_at": question.created_at, "updated_at": question.updated_at, "view_count": 0, # 可以添加访问统计 "attempt_count": 0, "correct_count": 0 } # 格式化 formatter = QuestionFormatter() formatted_question = formatter.format_question(question_dict, paper_info) return { "success": True, "data": formatted_question } except HTTPException: raise except Exception as e: logger.error(f"获取格式化题目失败: {e}") raise HTTPException(status_code=500, detail=f"获取失败: {str(e)}") # 新增API端点:批量导出题目数据 @app.post("/api/questions/batch/export") async def batch_export_questions( request: dict, current_user: User = Depends(get_current_user) ): """ 批量导出题目数据 Request body: { "question_ids": [1, 2, 3], "format": "json", "include_validation": true, "include_statistics": true } """ try: question_ids = request.get("question_ids", []) include_validation = request.get("include_validation", True) include_statistics = request.get("include_statistics", True) if not question_ids: raise HTTPException(status_code=400, detail="请提供题目ID列表") # 获取题目列表 questions = db.query(Question).filter( Question.id.in_(question_ids), Question.user_id == current_user.id ).all() if not questions: return { "success": True, "data": { "questions": [], "statistics": {"total_questions": 0} } } # 转换为字典格式 questions_data = [] for q in questions: question_dict = { "id": q.id, "question_type": q.question_type, "subject": q.subject, "grade": q.grade, "chapter": q.chapter, "difficulty": q.difficulty, "question_content": q.question_content, "options": q.options, "correct_answer": q.correct_answer, "explanation": q.explanation, "image_key": q.image_key, "knowledge_points": q.knowledge_points, "score": q.score, "question_number": q.question_number, "auto_extracted": q.auto_extracted, "images": q.images, "page_spans": q.page_spans, "source_text": q.source_text, "created_at": q.created_at, "updated_at": q.updated_at } questions_data.append(question_dict) # 使用格式化器 formatter = QuestionFormatter() formatted_questions = [] for question_dict in questions_data: formatted = formatter.format_question(question_dict) if not include_validation: formatted.pop("validation", None) formatted_questions.append(formatted) # 计算统计信息 stats = {} if include_statistics: raw_data = { "paper_id": None, "title": "批量导出", "subject": "混合", "grade": "", "questions": questions_data } stats = formatter.format_paper_questions(raw_data)["statistics"] return { "success": True, "data": { "questions": formatted_questions, "statistics": stats, "export_info": { "exported_at": datetime.now().isoformat(), "total_questions": len(formatted_questions), "requested_ids": question_ids, "found_ids": [q.id for q in questions] } } } except HTTPException: raise except Exception as e: logger.error(f"批量导出失败: {e}") raise HTTPException(status_code=500, detail=f"批量导出失败: {str(e)}") # 挂载静态文件服务 app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads") app.mount("/api/uploads", StaticFiles(directory="uploads"), name="api-uploads") if __name__ == "__main__": uvicorn.run(app, host="0.0.0.0", port=8888)