nex_math/backend/routers/question_bank.py

291 lines
9.8 KiB
Python

"""题库管理:题目列表、删除、按章节生成新题。"""
from __future__ import annotations
import json
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from database import get_db
from database import REPO_ROOT
from dependencies import require_permission
from models import (
Chapter,
Knowledge,
LlmSetting,
Question,
QuestionKnowledge,
Textbook,
User,
)
from schemas import GenerateIn, QuestionBankItemOut, QuestionUpdate
from services.question_generator import generate_questions
from services.llm_config import get_default_channel
from services.knowledge_service import (
question_knowledge_names,
set_question_knowledge_names,
)
router = APIRouter(prefix="/question-bank", tags=["question-bank"])
IMAGE_UPLOAD_DIR = REPO_ROOT / "data" / "question_images"
def _store_generated_svg(svg: str) -> str:
"""把模型生成的 SVG 存成题目独立图片,返回可访问的相对 URL。"""
import re
clean = re.sub(r"<script[\s\S]*?</script>", "", svg, flags=re.I)
clean = re.sub(r"\son\w+=[\"'][^\"']*[\"']", "", clean, flags=re.I)
IMAGE_UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
filename = f"generated_{uuid.uuid4().hex}.svg"
(IMAGE_UPLOAD_DIR / filename).write_text(clean, encoding="utf-8")
return f"/api/question-images/{filename}"
def _resolve_selected_knowledge(
db: Session,
knowledge_ids: list[int],
knowledge_names: list[str],
fallback: str,
) -> list[str]:
if knowledge_ids:
rows = (
db.query(Knowledge)
.filter(Knowledge.id.in_(knowledge_ids))
.all()
)
by_id = {row.id: row for row in rows}
missing = [kid for kid in knowledge_ids if kid not in by_id]
if missing:
raise HTTPException(
status_code=400, detail="部分所选知识点不存在,请刷新知识图谱后重试"
)
return [by_id[kid].name for kid in knowledge_ids]
names = [
name.strip() for name in knowledge_names if name.strip()
]
if names:
return names
return [fallback.strip()] if fallback.strip() else []
def _to_item(
db: Session,
question: Question,
chapter: Chapter | None,
textbook: Textbook | None,
):
knowledge_rows = (
db.query(Knowledge.id, Knowledge.name)
.join(
QuestionKnowledge,
QuestionKnowledge.knowledge_id == Knowledge.id,
)
.filter(QuestionKnowledge.question_id == question.id)
.order_by(QuestionKnowledge.id.asc())
.all()
)
if not knowledge_rows and question.knowledge_name:
knowledge_rows = [
(0, question.knowledge_name),
]
knowledge_names = [name for _, name in knowledge_rows]
return QuestionBankItemOut(
id=question.id,
chapter_id=question.chapter_id,
chapter_name=chapter.name if chapter else "",
textbook_name=textbook.name if textbook else "",
knowledge_name=question.knowledge_name,
stem=question.stem,
options=json.loads(question.options),
correct_index=question.correct_index,
explanation=question.explanation,
difficulty=question.difficulty,
is_generated=bool(question.is_generated),
used_count=question.used_count or 0,
knowledge_names=[str(name) for name in knowledge_names],
knowledge_ids=[kid for kid, _ in knowledge_rows if kid],
)
def _query(db: Session):
return (
db.query(Question, Chapter, Textbook)
.outerjoin(Chapter, Question.chapter_id == Chapter.id)
.outerjoin(Textbook, Chapter.textbook_id == Textbook.id)
)
@router.get("/questions", response_model=list[QuestionBankItemOut])
def list_questions(
textbook_id: int | None = None,
chapter_id: int | None = None,
knowledge_name: str = "",
generated: bool | None = None,
db: Session = Depends(get_db),
_: User = Depends(require_permission("question-bank:manage")),
):
query = _query(db)
if textbook_id:
query = query.filter(Textbook.id == textbook_id)
if chapter_id:
query = query.filter(Question.chapter_id == chapter_id)
if knowledge_name:
query = query.filter(Question.knowledge_name == knowledge_name)
if generated is not None:
query = query.filter(Question.is_generated == generated)
rows = query.order_by(Question.id.desc()).all()
return [_to_item(db, question, chapter, textbook) for question, chapter, textbook in rows]
@router.delete("/questions/{question_id}")
def delete_question(
question_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_permission("question-bank:manage")),
):
question = db.get(Question, question_id)
if question is None:
raise HTTPException(status_code=404, detail="题目不存在")
used_count = question.used_count or 0
if used_count > 0:
raise HTTPException(
status_code=400,
detail=f"该题目已被使用 {used_count} 次,不能删除;只能编辑题目内容",
)
db.delete(question)
db.commit()
return {"deleted": question_id}
@router.put("/questions/{question_id}", response_model=QuestionBankItemOut)
def update_question(
question_id: int,
payload: QuestionUpdate,
db: Session = Depends(get_db),
_: User = Depends(require_permission("question-bank:manage")),
):
question = db.get(Question, question_id)
if question is None:
raise HTTPException(status_code=404, detail="题目不存在")
if payload.chapter_id is not None:
chapter = db.get(Chapter, payload.chapter_id)
if chapter is None:
raise HTTPException(status_code=404, detail="章节不存在")
question.chapter_id = chapter.id
else:
question.chapter_id = None
options = [option.strip() for option in payload.options]
if any(not option for option in options) or len(set(options)) != 4:
raise HTTPException(status_code=400, detail="选项必须是 4 个非空且不重复的内容")
question.stem = payload.stem.strip()
question.options = json.dumps(options, ensure_ascii=False)
question.correct_index = payload.correct_index
question.explanation = payload.explanation.strip()
chosen = _resolve_selected_knowledge(
db, payload.knowledge_ids, payload.knowledge_names, payload.knowledge_name
)
if not chosen:
raise HTTPException(status_code=400, detail="请至少选择一个知识点")
question.knowledge_name = chosen[0]
set_question_knowledge_names(
db, question, chosen, attach_to_chapter=True
)
question.difficulty = payload.difficulty
db.commit()
chapter = (
db.query(Chapter).filter(Chapter.id == question.chapter_id).first()
if question.chapter_id is not None
else None
)
textbook = (
db.query(Textbook).filter(Textbook.id == chapter.textbook_id).first()
if chapter is not None
else None
)
return _to_item(db, question, chapter, textbook)
@router.post("/generate", response_model=list[QuestionBankItemOut])
def generate(
payload: GenerateIn,
db: Session = Depends(get_db),
_: User = Depends(require_permission("question-bank:manage")),
):
chapter = db.get(Chapter, payload.chapter_id)
if chapter is None:
raise HTTPException(status_code=404, detail="章节不存在")
textbook = db.get(Textbook, chapter.textbook_id)
if textbook is None:
raise HTTPException(status_code=404, detail="教材/课程不存在")
if payload.channel_id is not None:
setting = db.get(LlmSetting, payload.channel_id)
if setting is None:
raise HTTPException(status_code=404, detail="指定的模型通道不存在")
else:
setting = get_default_channel(db)
if setting is None:
raise HTTPException(
status_code=400,
detail="尚未配置模型通道,请先到“模型配置”新增并设为默认",
)
try:
raw_items = generate_questions(
db=db,
setting=setting,
chapter=chapter,
textbook=textbook,
count=payload.count,
difficulty=payload.difficulty,
knowledge_name=(
"、".join(
name.strip()
for name in payload.knowledge_names
if name.strip()
)
or payload.knowledge_name.strip()
),
instructions=payload.instructions.strip(),
need_figure=payload.need_figure,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
inserted: list[Question] = []
for item in raw_items:
chosen = _resolve_selected_knowledge(
db,
payload.knowledge_ids,
payload.knowledge_names,
item["knowledge_name"],
)
if not chosen:
chosen = [item["knowledge_name"]]
stem = item["stem"]
if payload.need_figure and item.get("image_svg"):
image_url = _store_generated_svg(item["image_svg"])
stem = f"{stem}\n\n![AI 配图]({image_url})"
question = Question(
stem=stem,
options=json.dumps(item["options"], ensure_ascii=False),
correct_index=item["correct_index"],
knowledge_name=chosen[0],
explanation=item["explanation"],
chapter_id=chapter.id,
difficulty=item["difficulty"],
is_generated=True,
)
db.add(question)
inserted.append(question)
db.flush()
for question in inserted:
set_question_knowledge_names(db, question, chosen, attach_to_chapter=True)
result = [_to_item(db, question, chapter, textbook) for question in inserted]
db.commit()
return result