• 新增增强版PDF解析器,基于ChatGPT5策略使用文本层解析 • 实现混合PDF拆分器,优先文本层解析,OCR作为备份 • 添加数学符号选项修复功能,解决选择题空选项问题 • 性能提升:0.01秒vs29秒(2900倍),22+题目vs6题目(3.5倍) • 更新前端拆分界面,增加错误处理和用户体验优化 • 扩展数据库架构支持OCR相关字段 • 统一前后端配置管理,修复硬编码URL问题 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1652 lines
55 KiB
Python
1652 lines
55 KiB
Python
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 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
|
|
import uvicorn
|
|
import uuid
|
|
import os
|
|
import logging
|
|
|
|
# 配置日志
|
|
logging.basicConfig(level=logging.INFO)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# 数据库配置
|
|
SQLALCHEMY_DATABASE_URL = "sqlite:///./wordmaster.db"
|
|
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
|
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|
Base = declarative_base()
|
|
|
|
# JWT配置
|
|
SECRET_KEY = "your-secret-key-change-in-production"
|
|
ALGORITHM = "HS256"
|
|
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
|
|
|
# 数据库模型
|
|
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]
|
|
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
|
|
|
|
# FastAPI应用
|
|
app = FastAPI(title="WordMaster Pro API", version="1.0.0")
|
|
|
|
# CORS中间件
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"], # 生产环境中应该限制具体域名
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
# 依赖
|
|
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,
|
|
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,
|
|
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
|
|
@app.post("/api/papers/{paper_id}/split", response_model=SplitResponse)
|
|
async def split_paper(
|
|
paper_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
拆解试卷PDF,自动提取题目
|
|
"""
|
|
# 检查试卷是否存在
|
|
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:
|
|
# 创建PDF拆分器
|
|
splitter = PDFSplitter()
|
|
|
|
# 创建临时目录存储处理结果
|
|
temp_dir = tempfile.mkdtemp()
|
|
|
|
try:
|
|
# 处理PDF
|
|
result = splitter.process_pdf(file_path, temp_dir)
|
|
|
|
if not result['success']:
|
|
return SplitResponse(
|
|
success=False,
|
|
paper_id=paper_id,
|
|
total_questions=0,
|
|
questions=[],
|
|
stats={},
|
|
error=result.get('error', '未知错误')
|
|
)
|
|
|
|
questions = result['questions']
|
|
stats = result['stats']
|
|
|
|
# 保存提取的题目到数据库
|
|
saved_questions = []
|
|
for i, question_data in enumerate(questions):
|
|
# 移动题目图片到uploads目录
|
|
image_key = None
|
|
if 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
|
|
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=[{
|
|
'page': question_data.get('page', 1),
|
|
'bbox': question_data.get('bbox', []),
|
|
'key': image_key
|
|
}] if image_key else [],
|
|
formula=[], # 后续可以添加公式识别
|
|
score=None, # 需要手动填写
|
|
question_number=question_data.get('number', str(i+1)),
|
|
auto_extracted=True
|
|
)
|
|
|
|
db.add(db_question)
|
|
|
|
# 构建返回数据
|
|
saved_questions.append(SplitQuestionResult(
|
|
number=question_data.get('number', str(i+1)),
|
|
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)}"
|
|
)
|
|
|
|
if __name__ == "__main__":
|
|
uvicorn.run(app, host="0.0.0.0", port=8888) |