first commit

This commit is contained in:
2026-03-05 11:50:15 +08:00
commit b1b14fd964
45 changed files with 7779 additions and 0 deletions

1
backend/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Backend package marker.

54
backend/auth.py Normal file
View File

@@ -0,0 +1,54 @@
from datetime import datetime, timedelta, timezone
import importlib.util
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
_jose_spec = importlib.util.find_spec("jose")
_jose_origin = _jose_spec.origin if _jose_spec else None
_incompatible_jose = bool(_jose_origin and _jose_origin.endswith("site-packages/jose.py"))
try:
import jwt
from jwt import InvalidTokenError as JWTError
except Exception as exc:
if not _incompatible_jose:
try:
from jose import JWTError, jwt
except Exception as fallback_exc:
raise RuntimeError(
"JWT 依赖不可用,请安装 python-jose[cryptography] 或 PyJWT"
) from fallback_exc
else:
raise RuntimeError(
"检测到不兼容依赖 jose请卸载 jose 后安装 python-jose[cryptography] 或安装 PyJWT"
) from exc
from backend.config import settings
security = HTTPBearer()
def create_access_token(subject: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
payload = {"sub": subject, "exp": expire}
return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
def verify_password(username: str, password: str) -> bool:
return username == settings.admin_username and password == settings.admin_password
def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> str:
token = credentials.credentials
try:
payload = jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
username: str | None = payload.get("sub")
if not username:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效Token")
return username
except JWTError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已过期") from exc

32
backend/config.py Normal file
View File

@@ -0,0 +1,32 @@
from pathlib import Path
from typing import Dict
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
api_key: str = ""
model_name: str = "gpt-4.1"
dmxapi_url: str = "https://www.dmxapi.cn/v1/responses"
jwt_secret_key: str = "change-me-in-env"
jwt_algorithm: str = "HS256"
access_token_expire_minutes: int = 60 * 12
admin_username: str = "admin"
admin_password: str = "admin123"
database_url: str = f"sqlite:///{Path(__file__).resolve().parent / 'problem_bank.db'}"
upload_dir: str = str(Path(__file__).resolve().parent / "uploads")
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
@property
def type_map(self) -> Dict[str, str]:
return {
"单选题": "单选",
"多选题": "多选",
"不定项选择题": "不定项",
"填空题": "填空",
"解答题": "解答",
}
settings = Settings()

23
backend/database.py Normal file
View File

@@ -0,0 +1,23 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from backend.config import settings
class Base(DeclarativeBase):
pass
engine = create_engine(
settings.database_url,
connect_args={"check_same_thread": False} if settings.database_url.startswith("sqlite") else {},
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

30
backend/main.py Normal file
View File

@@ -0,0 +1,30 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from backend.database import Base, engine
from backend.routers import auth, categories, exports, imports, practice, questions, stats
app = FastAPI(title="Problem Bank API", version="1.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
Base.metadata.create_all(bind=engine)
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(questions.router, prefix="/api/questions", tags=["questions"])
app.include_router(imports.router, prefix="/api/import", tags=["imports"])
app.include_router(exports.router, prefix="/api/export", tags=["exports"])
app.include_router(categories.router, prefix="/api/categories", tags=["categories"])
app.include_router(practice.router, prefix="/api/practice", tags=["practice"])
app.include_router(stats.router, prefix="/api/stats", tags=["stats"])
@app.get("/api/health")
def health() -> dict:
return {"status": "ok"}

54
backend/models.py Normal file
View File

@@ -0,0 +1,54 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.database import Base
class Question(Base):
__tablename__ = "questions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
chapter: Mapped[str] = mapped_column(Text, default="")
primary_knowledge: Mapped[str] = mapped_column(Text, default="")
secondary_knowledge: Mapped[str] = mapped_column(Text, default="")
question_type: Mapped[str] = mapped_column(Text, default="")
difficulty: Mapped[str] = mapped_column(Text, default="")
stem: Mapped[str] = mapped_column(Text)
option_a: Mapped[str] = mapped_column(Text, default="")
option_b: Mapped[str] = mapped_column(Text, default="")
option_c: Mapped[str] = mapped_column(Text, default="")
option_d: Mapped[str] = mapped_column(Text, default="")
answer: Mapped[str] = mapped_column(Text, default="")
explanation: Mapped[str] = mapped_column(Text, default="")
notes: Mapped[str] = mapped_column(Text, default="")
source_file: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
)
class Category(Base):
__tablename__ = "categories"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
name: Mapped[str] = mapped_column(Text, nullable=False)
parent_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id"), nullable=True)
level: Mapped[int] = mapped_column(Integer, default=1)
children: Mapped[list["Category"]] = relationship(
"Category", backref="parent", remote_side=[id], lazy="joined"
)
class ImportHistory(Base):
__tablename__ = "import_history"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
filename: Mapped[str] = mapped_column(Text, default="")
method: Mapped[str] = mapped_column(Text, default="manual")
question_count: Mapped[int] = mapped_column(Integer, default=0)
status: Mapped[str] = mapped_column(Text, default="success")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)

12
backend/requirements.txt Normal file
View File

@@ -0,0 +1,12 @@
fastapi
uvicorn[standard]
sqlalchemy
pydantic
pydantic-settings
python-jose[cryptography]
PyJWT
passlib[bcrypt]
python-multipart
requests
openpyxl
python-dotenv

View File

@@ -0,0 +1 @@
# Routers package marker.

13
backend/routers/auth.py Normal file
View File

@@ -0,0 +1,13 @@
from fastapi import APIRouter, HTTPException, status
from backend.auth import create_access_token, verify_password
from backend.schemas import LoginRequest, Token
router = APIRouter()
@router.post("/login", response_model=Token)
def login(payload: LoginRequest) -> Token:
if not verify_password(payload.username, payload.password):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误")
return Token(access_token=create_access_token(payload.username))

View File

@@ -0,0 +1,74 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func
from sqlalchemy.orm import Session
from backend.auth import get_current_user
from backend.database import get_db
from backend.models import Category, Question
from backend.schemas import CategoryCreate, CategoryOut
router = APIRouter(dependencies=[Depends(get_current_user)])
def _build_tree(rows: list[Category], counts: dict[str, int]) -> list[dict]:
nodes = {
row.id: {
"id": row.id,
"name": row.name,
"level": row.level,
"parent_id": row.parent_id,
"count": counts.get(row.name, 0),
"children": [],
}
for row in rows
}
tree = []
for node in nodes.values():
pid = node["parent_id"]
if pid and pid in nodes:
nodes[pid]["children"].append(node)
else:
tree.append(node)
return tree
@router.get("")
def list_categories(db: Session = Depends(get_db)) -> dict:
rows = db.query(Category).order_by(Category.level.asc(), Category.id.asc()).all()
counts = dict(db.query(Question.chapter, func.count(Question.id)).group_by(Question.chapter).all())
return {"items": _build_tree(rows, counts)}
@router.post("", response_model=CategoryOut)
def create_category(payload: CategoryCreate, db: Session = Depends(get_db)) -> CategoryOut:
if payload.parent_id:
parent = db.get(Category, payload.parent_id)
if not parent:
raise HTTPException(status_code=404, detail="父分类不存在")
item = Category(**payload.model_dump())
db.add(item)
db.commit()
db.refresh(item)
return CategoryOut.model_validate(item)
@router.put("/{category_id}", response_model=CategoryOut)
def update_category(category_id: int, payload: CategoryCreate, db: Session = Depends(get_db)) -> CategoryOut:
item = db.get(Category, category_id)
if not item:
raise HTTPException(status_code=404, detail="分类不存在")
for key, value in payload.model_dump().items():
setattr(item, key, value)
db.commit()
db.refresh(item)
return CategoryOut.model_validate(item)
@router.delete("/{category_id}")
def delete_category(category_id: int, db: Session = Depends(get_db)) -> dict:
item = db.get(Category, category_id)
if not item:
raise HTTPException(status_code=404, detail="分类不存在")
db.delete(item)
db.commit()
return {"ok": True}

View File

@@ -0,0 +1,68 @@
import csv
import json
from io import StringIO
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from backend.auth import get_current_user
from backend.database import get_db
from backend.models import Question
from backend.services.excel_service import export_excel_bytes
router = APIRouter(dependencies=[Depends(get_current_user)])
def _filter_questions(
db: Session, chapter: str = "", question_type: str = "", difficulty: str = ""
) -> list[Question]:
query = db.query(Question)
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)
return query.order_by(Question.id.desc()).all()
@router.get("")
def export_questions(
format: str = Query(default="json", pattern="^(json|csv|xlsx)$"),
chapter: str = "",
question_type: str = "",
difficulty: str = "",
db: Session = Depends(get_db),
) -> dict:
items = _filter_questions(db, chapter, question_type, difficulty)
rows = [
{
"id": i.id,
"chapter": i.chapter,
"primary_knowledge": i.primary_knowledge,
"secondary_knowledge": i.secondary_knowledge,
"question_type": i.question_type,
"difficulty": i.difficulty,
"stem": i.stem,
"option_a": i.option_a,
"option_b": i.option_b,
"option_c": i.option_c,
"option_d": i.option_d,
"answer": i.answer,
"explanation": i.explanation,
"notes": i.notes,
"source_file": i.source_file,
}
for i in items
]
if format == "json":
return {"format": "json", "content": json.dumps(rows, ensure_ascii=False)}
if format == "csv":
out = StringIO()
writer = csv.DictWriter(out, fieldnames=rows[0].keys() if rows else ["id"])
writer.writeheader()
for row in rows:
writer.writerow(row)
return {"format": "csv", "content": out.getvalue()}
xlsx_bytes = export_excel_bytes(rows)
return {"format": "xlsx", "content_base64": xlsx_bytes.hex()}

View File

@@ -0,0 +1,97 @@
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from sqlalchemy.orm import Session
from backend.auth import get_current_user
from backend.database import get_db
from backend.models import ImportHistory, Question
from backend.schemas import ImportHistoryOut, QuestionOut
from backend.services.excel_service import create_template_bytes, parse_excel_file
from backend.services.file_utils import save_upload
from backend.services.parser import DMXAPIService, extract_metadata
router = APIRouter(dependencies=[Depends(get_current_user)])
@router.post("/ai/parse")
def parse_by_ai(file: UploadFile = File(...)) -> dict:
path = save_upload(file)
parser = DMXAPIService()
metadata = extract_metadata(file.filename or path.name)
questions = parser.parse_file(str(path))
preview = []
for q in questions:
preview.append(
{
"chapter": metadata["chapter"],
"primary_knowledge": "",
"secondary_knowledge": metadata["secondary_knowledge"],
"question_type": metadata["question_type"],
"difficulty": metadata["difficulty"],
"stem": q.get("题干", ""),
"option_a": q.get("选项A", ""),
"option_b": q.get("选项B", ""),
"option_c": q.get("选项C", ""),
"option_d": q.get("选项D", ""),
"answer": q.get("正确答案", ""),
"explanation": q.get("解析", ""),
"notes": q.get("备注", ""),
"source_file": metadata["source_file"],
}
)
return {"filename": file.filename, "preview": preview}
@router.post("/ai/confirm", response_model=list[QuestionOut])
def confirm_ai_import(payload: list[dict], db: Session = Depends(get_db)) -> list[QuestionOut]:
if not payload:
raise HTTPException(status_code=400, detail="没有可导入数据")
items = [Question(**item) for item in payload]
db.add_all(items)
db.add(
ImportHistory(
filename=items[0].source_file if items else "",
method="ai",
question_count=len(items),
status="success",
)
)
db.commit()
for item in items:
db.refresh(item)
return [QuestionOut.model_validate(item) for item in items]
@router.post("/excel", response_model=list[QuestionOut])
def import_excel(file: UploadFile = File(...), db: Session = Depends(get_db)) -> list[QuestionOut]:
path = save_upload(file)
if Path(path).suffix.lower() not in [".xlsx", ".xlsm", ".xltx", ".xltm"]:
raise HTTPException(status_code=400, detail="仅支持 Excel 文件")
rows = parse_excel_file(Path(path))
items = [Question(**row) for row in rows]
db.add_all(items)
db.add(
ImportHistory(
filename=file.filename or "",
method="excel",
question_count=len(items),
status="success",
)
)
db.commit()
for item in items:
db.refresh(item)
return [QuestionOut.model_validate(item) for item in items]
@router.get("/template")
def download_template() -> dict:
content = create_template_bytes()
return {"filename": "question_template.xlsx", "content_base64": content.hex()}
@router.get("/history", response_model=list[ImportHistoryOut])
def import_history(db: Session = Depends(get_db)) -> list[ImportHistoryOut]:
rows = db.query(ImportHistory).order_by(ImportHistory.created_at.desc()).limit(100).all()
return [ImportHistoryOut.model_validate(r) for r in rows]

View File

@@ -0,0 +1,57 @@
import random
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from backend.auth import get_current_user
from backend.database import get_db
from backend.models import Question
from backend.schemas import PracticeCheckRequest, PracticeStartRequest
router = APIRouter(dependencies=[Depends(get_current_user)])
@router.post("/start")
def start_practice(payload: PracticeStartRequest, db: Session = Depends(get_db)) -> dict:
query = db.query(Question)
if payload.chapter:
query = query.filter(Question.chapter == payload.chapter)
if payload.secondary_knowledge:
query = query.filter(Question.secondary_knowledge == payload.secondary_knowledge)
if payload.question_type:
query = query.filter(Question.question_type == payload.question_type)
if payload.difficulty:
query = query.filter(Question.difficulty == payload.difficulty)
items = query.all()
if payload.random_mode:
random.shuffle(items)
items = items[: payload.limit]
return {
"total": len(items),
"items": [
{
"id": i.id,
"stem": i.stem,
"option_a": i.option_a,
"option_b": i.option_b,
"option_c": i.option_c,
"option_d": i.option_d,
"question_type": i.question_type,
}
for i in items
],
}
@router.post("/check")
def check_answer(payload: PracticeCheckRequest, db: Session = Depends(get_db)) -> dict:
item = db.get(Question, payload.question_id)
if not item:
return {"correct": False, "message": "题目不存在"}
user_answer = payload.user_answer.strip().upper()
right_answer = item.answer.strip().upper()
return {
"correct": user_answer == right_answer,
"right_answer": right_answer,
"explanation": item.explanation,
}

View File

@@ -0,0 +1,136 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from backend.auth import get_current_user
from backend.database import get_db
from backend.models import Question
from backend.schemas import (
BatchDeleteRequest,
BatchUpdateRequest,
QuestionCreate,
QuestionOut,
QuestionUpdate,
)
router = APIRouter(dependencies=[Depends(get_current_user)])
@router.get("")
def list_questions(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=200),
keyword: str = "",
chapter: str = "",
secondary_knowledge: str = "",
question_type: str = "",
difficulty: str = "",
db: Session = Depends(get_db),
) -> dict:
query = db.query(Question)
if keyword:
like = f"%{keyword}%"
query = query.filter(
or_(
Question.stem.like(like),
Question.option_a.like(like),
Question.option_b.like(like),
Question.option_c.like(like),
Question.option_d.like(like),
Question.explanation.like(like),
)
)
if chapter:
query = query.filter(Question.chapter == chapter)
if secondary_knowledge:
query = query.filter(Question.secondary_knowledge == secondary_knowledge)
if question_type:
query = query.filter(Question.question_type == question_type)
if difficulty:
query = query.filter(Question.difficulty == difficulty)
total = query.with_entities(func.count(Question.id)).scalar() or 0
items = (
query.order_by(Question.updated_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
return {"total": total, "items": [QuestionOut.model_validate(i).model_dump() for i in items]}
@router.get("/id/{question_id}", response_model=QuestionOut)
def get_question(question_id: int, db: Session = Depends(get_db)) -> QuestionOut:
item = db.get(Question, question_id)
if not item:
raise HTTPException(status_code=404, detail="题目不存在")
return QuestionOut.model_validate(item)
@router.post("", response_model=QuestionOut)
def create_question(payload: QuestionCreate, db: Session = Depends(get_db)) -> QuestionOut:
item = Question(**payload.model_dump())
db.add(item)
db.commit()
db.refresh(item)
return QuestionOut.model_validate(item)
@router.delete("/batch")
def batch_delete(payload: BatchDeleteRequest, db: Session = Depends(get_db)) -> dict:
if not payload.ids:
return {"deleted": 0}
deleted = db.query(Question).filter(Question.id.in_(payload.ids)).delete(synchronize_session=False)
db.commit()
return {"deleted": deleted}
@router.put("/batch/update")
def batch_update(payload: BatchUpdateRequest, db: Session = Depends(get_db)) -> dict:
if not payload.ids:
return {"updated": 0}
updates = payload.model_dump(exclude_none=True, exclude={"ids"})
if not updates:
return {"updated": 0}
updated = db.query(Question).filter(Question.id.in_(payload.ids)).update(
updates, synchronize_session=False
)
db.commit()
return {"updated": updated}
@router.get("/meta/options")
def get_filter_options(db: Session = Depends(get_db)) -> dict:
def distinct_values(column) -> list[str]:
rows = db.query(column).filter(column != "").distinct().all()
return [row[0] for row in rows]
return {
"chapters": distinct_values(Question.chapter),
"secondary_knowledge_list": distinct_values(Question.secondary_knowledge),
"question_types": distinct_values(Question.question_type),
"difficulties": distinct_values(Question.difficulty),
}
@router.put("/{question_id}", response_model=QuestionOut)
def update_question(question_id: int, payload: QuestionUpdate, db: Session = Depends(get_db)) -> QuestionOut:
item = db.get(Question, question_id)
if not item:
raise HTTPException(status_code=404, detail="题目不存在")
update_data = payload.model_dump(exclude_none=True)
for key, value in update_data.items():
setattr(item, key, value)
db.commit()
db.refresh(item)
return QuestionOut.model_validate(item)
@router.delete("/{question_id}")
def delete_question(question_id: int, db: Session = Depends(get_db)) -> dict:
item = db.get(Question, question_id)
if not item:
raise HTTPException(status_code=404, detail="题目不存在")
db.delete(item)
db.commit()
return {"ok": True}

37
backend/routers/stats.py Normal file
View File

@@ -0,0 +1,37 @@
from fastapi import APIRouter, Depends
from sqlalchemy import func
from sqlalchemy.orm import Session
from backend.auth import get_current_user
from backend.database import get_db
from backend.models import ImportHistory, Question
router = APIRouter(dependencies=[Depends(get_current_user)])
@router.get("")
def get_stats(db: Session = Depends(get_db)) -> dict:
total = db.query(func.count(Question.id)).scalar() or 0
by_type = db.query(Question.question_type, func.count(Question.id)).group_by(Question.question_type).all()
by_difficulty = db.query(Question.difficulty, func.count(Question.id)).group_by(Question.difficulty).all()
by_chapter = db.query(Question.chapter, func.count(Question.id)).group_by(Question.chapter).all()
latest_imports = (
db.query(ImportHistory).order_by(ImportHistory.created_at.desc()).limit(10).all()
)
return {
"total": total,
"by_type": [{"name": n or "未分类", "value": v} for n, v in by_type],
"by_difficulty": [{"name": n or "未分类", "value": v} for n, v in by_difficulty],
"by_chapter": [{"name": n or "未分类", "value": v} for n, v in by_chapter],
"latest_imports": [
{
"id": i.id,
"filename": i.filename,
"method": i.method,
"question_count": i.question_count,
"status": i.status,
"created_at": i.created_at.isoformat(),
}
for i in latest_imports
],
}

117
backend/schemas.py Normal file
View File

@@ -0,0 +1,117 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
class LoginRequest(BaseModel):
username: str
password: str
class QuestionBase(BaseModel):
chapter: str = ""
primary_knowledge: str = ""
secondary_knowledge: str = ""
question_type: str = ""
difficulty: str = ""
stem: str = Field(..., min_length=1)
option_a: str = ""
option_b: str = ""
option_c: str = ""
option_d: str = ""
answer: str = ""
explanation: str = ""
notes: str = ""
source_file: str = ""
class QuestionCreate(QuestionBase):
pass
class QuestionUpdate(BaseModel):
chapter: Optional[str] = None
primary_knowledge: Optional[str] = None
secondary_knowledge: Optional[str] = None
question_type: Optional[str] = None
difficulty: Optional[str] = None
stem: Optional[str] = None
option_a: Optional[str] = None
option_b: Optional[str] = None
option_c: Optional[str] = None
option_d: Optional[str] = None
answer: Optional[str] = None
explanation: Optional[str] = None
notes: Optional[str] = None
source_file: Optional[str] = None
class QuestionOut(QuestionBase):
id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class BatchDeleteRequest(BaseModel):
ids: list[int]
class BatchUpdateRequest(BaseModel):
ids: list[int]
chapter: Optional[str] = None
primary_knowledge: Optional[str] = None
secondary_knowledge: Optional[str] = None
question_type: Optional[str] = None
difficulty: Optional[str] = None
class CategoryBase(BaseModel):
name: str
parent_id: Optional[int] = None
level: int = 1
class CategoryCreate(CategoryBase):
pass
class CategoryOut(CategoryBase):
id: int
class Config:
from_attributes = True
class ImportHistoryOut(BaseModel):
id: int
filename: str
method: str
question_count: int
status: str
created_at: datetime
class Config:
from_attributes = True
class PracticeStartRequest(BaseModel):
chapter: Optional[str] = None
secondary_knowledge: Optional[str] = None
question_type: Optional[str] = None
difficulty: Optional[str] = None
random_mode: bool = False
limit: int = 20
class PracticeCheckRequest(BaseModel):
question_id: int
user_answer: str

View File

@@ -0,0 +1 @@
# Services package marker.

View File

@@ -0,0 +1,70 @@
from io import BytesIO
from pathlib import Path
from openpyxl import Workbook, load_workbook
QUESTION_COLUMNS = [
("chapter", "章节"),
("primary_knowledge", "一级知识点"),
("secondary_knowledge", "二级知识点"),
("question_type", "题目类型"),
("difficulty", "难度"),
("stem", "题干"),
("option_a", "选项A"),
("option_b", "选项B"),
("option_c", "选项C"),
("option_d", "选项D"),
("answer", "正确答案"),
("explanation", "解析"),
("notes", "备注"),
("source_file", "来源文件"),
]
def create_template_bytes() -> bytes:
wb = Workbook()
ws = wb.active
ws.title = "questions"
ws.append([col[1] for col in QUESTION_COLUMNS])
ws.append(["函数", "", "函数性质", "单选", "", "示例题干", "A", "B", "C", "D", "A", "示例解析", "", "template.xlsx"])
buf = BytesIO()
wb.save(buf)
return buf.getvalue()
def parse_excel_file(path: Path) -> list[dict]:
wb = load_workbook(path, read_only=True)
ws = wb.active
rows = list(ws.iter_rows(values_only=True))
if not rows:
return []
headers = [str(v).strip() if v is not None else "" for v in rows[0]]
key_map = {label: key for key, label in QUESTION_COLUMNS}
result: list[dict] = []
for row in rows[1:]:
if not row or all(v is None or str(v).strip() == "" for v in row):
continue
item = {key: "" for key, _ in QUESTION_COLUMNS}
for idx, val in enumerate(row):
if idx >= len(headers):
continue
label = headers[idx]
key = key_map.get(label)
if not key:
continue
item[key] = "" if val is None else str(val)
if item["stem"]:
result.append(item)
return result
def export_excel_bytes(items: list[dict]) -> bytes:
wb = Workbook()
ws = wb.active
ws.title = "questions"
ws.append([label for _, label in QUESTION_COLUMNS])
for item in items:
ws.append([item.get(key, "") for key, _ in QUESTION_COLUMNS])
buf = BytesIO()
wb.save(buf)
return buf.getvalue()

View File

@@ -0,0 +1,20 @@
import shutil
from pathlib import Path
from fastapi import UploadFile
from backend.config import settings
def ensure_upload_dir() -> Path:
target = Path(settings.upload_dir)
target.mkdir(parents=True, exist_ok=True)
return target
def save_upload(upload_file: UploadFile) -> Path:
target_dir = ensure_upload_dir()
target_path = target_dir / upload_file.filename
with target_path.open("wb") as buffer:
shutil.copyfileobj(upload_file.file, buffer)
return target_path

167
backend/services/parser.py Normal file
View File

@@ -0,0 +1,167 @@
import json
import re
import subprocess
from pathlib import Path
import requests
from backend.config import settings
class DMXAPIService:
def __init__(self) -> None:
self.api_key = settings.api_key
self.model_name = settings.model_name
self.api_url = settings.dmxapi_url
def parse_file(self, file_path: str) -> list[dict]:
path = Path(file_path)
if path.suffix.lower() != ".pdf":
pdf_path = self._convert_to_pdf(path)
else:
pdf_path = path
try:
file_url = self._upload_to_temp_host(pdf_path)
questions = self._parse_with_file_url(file_url, path.name)
finally:
if pdf_path != path and pdf_path.exists():
pdf_path.unlink(missing_ok=True)
return questions
def _upload_to_temp_host(self, path: Path) -> str:
try:
with path.open("rb") as f:
response = requests.post("https://file.io", files={"file": f}, timeout=60)
if response.status_code == 200 and response.json().get("success"):
return response.json()["link"]
except Exception:
pass
with path.open("rb") as f:
response = requests.post("https://tmpfiles.org/api/v1/upload", files={"file": f}, timeout=60)
if response.status_code != 200:
raise ValueError(f"上传失败: {response.text}")
data = response.json()
if data.get("status") != "success":
raise ValueError(f"上传失败: {data}")
return data["data"]["url"].replace("tmpfiles.org/", "tmpfiles.org/dl/")
def _convert_to_pdf(self, path: Path) -> Path:
pdf_path = path.with_suffix(".pdf")
pdf_path.unlink(missing_ok=True)
cmd = [
"pandoc",
str(path),
"-o",
str(pdf_path),
"--pdf-engine=xelatex",
"-V",
"CJKmainfont=PingFang SC",
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=90)
if result.returncode != 0:
fallback = [
"pandoc",
str(path),
"-o",
str(pdf_path),
"--pdf-engine=weasyprint",
]
result = subprocess.run(fallback, capture_output=True, text=True, timeout=90)
if result.returncode != 0:
raise ValueError(f"文件转 PDF 失败: {result.stderr}")
return pdf_path
def _parse_with_file_url(self, file_url: str, original_filename: str) -> list[dict]:
if not self.api_key:
raise ValueError("未配置 API_KEY无法调用 DMXAPI")
payload = {
"model": self.model_name,
"input": [
{
"role": "user",
"content": [
{"type": "input_file", "file_url": file_url},
{"type": "input_text", "text": self._build_instruction(original_filename)},
],
}
],
}
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
response = requests.post(
self.api_url, headers=headers, data=json.dumps(payload), timeout=180
)
if response.status_code != 200:
raise ValueError(f"DMXAPI 请求失败: {response.status_code} {response.text}")
return self._extract_questions(response.json())
def _build_instruction(self, filename: str) -> str:
return f"""请从文件\"{filename}\"中提取所有题目信息并以JSON数组格式返回。
提取字段题干、选项A、选项B、选项C、选项D、正确答案、解析、备注。
数学公式统一转为 LaTeX内联公式使用 $...$,独立公式使用 $$...$$。
答案统一大写字母,清理“答案:”等前缀。
仅返回 JSON 数组,不要返回额外说明。"""
def _extract_questions(self, response: dict) -> list[dict]:
if response.get("status") != "completed":
raise ValueError(f"响应状态异常: {response.get('status')}")
text = None
for item in response.get("output", []):
if item.get("type") == "message":
for content in item.get("content", []):
if content.get("type") == "output_text":
text = content.get("text")
break
if not text:
raise ValueError("未在响应中找到文本内容")
questions = self._parse_json(text)
for q in questions:
for field in ["选项A", "选项B", "选项C", "选项D", "解析", "备注"]:
q.setdefault(field, "")
return questions
def _parse_json(self, text: str) -> list[dict]:
start_idx = text.find("[")
end_idx = text.rfind("]")
if start_idx < 0 or end_idx < 0:
raise ValueError(f"未找到 JSON 数组: {text[:200]}")
json_str = text[start_idx : end_idx + 1]
data = json.loads(json_str)
if not isinstance(data, list):
raise ValueError("解析结果不是数组")
for index, item in enumerate(data):
if "题干" not in item or "正确答案" not in item:
raise ValueError(f"{index + 1} 题缺少题干或正确答案")
item["正确答案"] = re.sub(r"[^A-D0-9]", "", str(item.get("正确答案", "")).upper())
return data
def extract_metadata(filename: str) -> dict:
basename = Path(filename).stem
separators = ["+", " ", "-", "_"]
parts = [basename]
for sep in separators:
if sep in basename:
parts = basename.split(sep)
break
secondary = parts[0].strip() if len(parts) > 0 else ""
raw_type = parts[1].strip() if len(parts) > 1 else ""
difficulty = parts[2].strip() if len(parts) > 2 else ""
mapped_type = settings.type_map.get(raw_type, raw_type)
chapter = secondary.split("")[0] if "" in secondary else secondary
chapter = chapter or "未分类"
return {
"chapter": chapter,
"secondary_knowledge": secondary,
"question_type": mapped_type,
"difficulty": difficulty,
"source_file": filename,
}