nex_math/backend/routers/question_bank.py

318 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""题库管理:题目列表、删除、按章节生成新题。"""
from __future__ import annotations
import json
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func
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 (
BankSummaryOut,
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.get("/summary", response_model=BankSummaryOut)
def summary(
db: Session = Depends(get_db),
_: User = Depends(require_permission("question-bank:manage")),
):
"题库侧栏计数:总数、未挂章节、AI 生成、已被使用,一次算完。"
def count(*conditions) -> int:
query = db.query(func.count(Question.id))
for condition in conditions:
query = query.filter(condition)
return int(query.scalar() or 0)
return BankSummaryOut(
total=count(),
unattached=count(Question.chapter_id.is_(None)),
generated=count(Question.is_generated.is_(True)),
used=count(Question.used_count > 0),
)
@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