Files
gaoandQwen-Coder 2dc75d6a63 feat: Add Claude AI exam parser and enhance dashboard UI
- Implement Claude Sonnet 4.5 based exam parser with multimodal understanding
- Create enhanced dashboard with analytics, AI insights and knowledge tree
- Add new UI components (AIAnalysisPanel, KnowledgeTreePanel, etc.)
- Improve paper viewer with question extraction and preview panels
- Add .env.example with configuration settings

Co-authored-by: Qwen-Coder <[email protected]>
2025-11-12 22:04:30 +08:00

2106 lines
71 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)