"""题库管理:题目列表、删除、按章节生成新题。""" 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"", "", 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