"""教材 / 课程目录:读取对登录用户开放,增删改仅管理员。""" from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import func from sqlalchemy.orm import Session from database import get_db from dependencies import get_current_user, require_permission from models import ( Chapter, EbookProgress, ChapterKnowledge, Knowledge, LlmSetting, Question, Textbook, User, ) from schemas import ( ChapterCreate, EbookProgressOut, ChapterOut, ChapterUpdate, ChaptersGenerateIn, ChaptersGenerateOut, ChaptersOrganizeIn, TextbookCreate, TextbookOut, TextbookUpdate, ) from services import ebooks as ebook_store from services.catalog_generator import ( generate_chapters, organize_reference_chapters, ) from services.isbn_lookup import fetch_isbn_context from services.llm_config import get_default_channel from services.knowledge_service import ( set_chapter_knowledge_names, set_chapter_knowledge_ids, ) router = APIRouter(prefix="/textbooks", tags=["catalog"]) def _chapter_out(db: Session, chapter: Chapter) -> ChapterOut: question_count = ( db.query(func.count(Question.id)) .filter(Question.chapter_id == chapter.id) .scalar() or 0 ) knowledge_rows = ( db.query(Knowledge.id, Knowledge.name) .join(ChapterKnowledge, ChapterKnowledge.knowledge_id == Knowledge.id) .filter(ChapterKnowledge.chapter_id == chapter.id) .order_by(ChapterKnowledge.id.asc()) .all() ) knowledge_names = [name for _, name in knowledge_rows] return ChapterOut( id=chapter.id, textbook_id=chapter.textbook_id, name=chapter.name, position=chapter.position, summary=chapter.summary, ebook_page=chapter.ebook_page, question_count=question_count, knowledge_names=[str(name) for name in knowledge_names], knowledge_ids=[kid for kid, _ in knowledge_rows], ) def _textbook_out( db: Session, textbook: Textbook, user: User | None = None ) -> TextbookOut: chapters = ( db.query(Chapter) .filter(Chapter.textbook_id == textbook.id) .order_by(Chapter.position.asc(), Chapter.id.asc()) .all() ) return TextbookOut( id=textbook.id, name=textbook.name, author=textbook.author, publisher=textbook.publisher, isbn=textbook.isbn, edition=textbook.edition, grade=textbook.grade, link=textbook.link, description=textbook.description, position=textbook.position, chapters=[_chapter_out(db, chapter) for chapter in chapters], ebook_file=textbook.ebook_file, ebook_format=textbook.ebook_format, ebook_name=textbook.ebook_name, ebook_size=textbook.ebook_size, ebook_pages=textbook.ebook_pages, ebook_uploaded_at=textbook.ebook_uploaded_at, ebook_progress=_ebook_progress_out(db, textbook, user), ) def _ebook_progress_out( db: Session, textbook: Textbook, user: User | None ) -> EbookProgressOut | None: if user is None or not textbook.ebook_file: return None row = ( db.query(EbookProgress) .filter( EbookProgress.user_id == user.id, EbookProgress.textbook_id == textbook.id, ) .first() ) if row is None: return None return EbookProgressOut.model_validate(row) def _load_textbook(db: Session, textbook_id: int) -> Textbook: textbook = db.get(Textbook, textbook_id) if textbook is None: raise HTTPException(status_code=404, detail="教材不存在") return textbook def _resolve_channel(db: Session, channel_id: int | None) -> LlmSetting: if channel_id is not None: channel = db.get(LlmSetting, channel_id) if channel is None: raise HTTPException(status_code=404, detail="指定的模型通道不存在") return channel channel = get_default_channel(db) if channel is None: raise HTTPException( status_code=400, detail="尚未配置模型通道,请先在“模型配置”新增并设为默认", ) return channel def _book_graph_context(db: Session, textbook: Textbook) -> list[str]: name = textbook.name category_hints: list[str] = [] if "代数" in name: category_hints = ["数与式", "方程与不等式", "函数与图像", "数列与证明"] elif "几何" in name: category_hints = ["平面几何"] elif "坐标" in name: category_hints = ["坐标与解析几何"] elif "三角" in name: category_hints = ["三角函数"] elif "函数" in name or "图像" in name: category_hints = ["函数与图像", "坐标与解析几何"] if category_hints: return [ node_name for (node_name,) in db.query(Knowledge.name) .filter(Knowledge.category.in_(category_hints)) .all() ] # 兜底:给常见初等图谱节点 return [ node_name for (node_name,) in db.query(Knowledge.name) .filter(Knowledge.domain == "初等", Knowledge.category != "总纲") .all() ] @router.get("", response_model=list[TextbookOut]) def list_textbooks( db: Session = Depends(get_db), user: User = Depends(get_current_user), ): textbooks = ( db.query(Textbook) .order_by(Textbook.position.asc(), Textbook.id.asc()) .all() ) return [_textbook_out(db, textbook, user) for textbook in textbooks] @router.get("/{textbook_id}/chapters", response_model=list[ChapterOut]) def list_chapters( textbook_id: int, db: Session = Depends(get_db), _: User = Depends(get_current_user), ): textbook = _load_textbook(db, textbook_id) chapters = ( db.query(Chapter) .filter(Chapter.textbook_id == textbook.id) .order_by(Chapter.position.asc(), Chapter.id.asc()) .all() ) return [_chapter_out(db, chapter) for chapter in chapters] @router.post("", response_model=TextbookOut) def create_textbook( payload: TextbookCreate, db: Session = Depends(get_db), _: User = Depends(require_permission("catalog:manage")), ): if db.query(Textbook).filter(Textbook.name == payload.name).first(): raise HTTPException(status_code=400, detail="同名教材已存在") max_position = db.query(Textbook).count() textbook = Textbook( name=payload.name, author=payload.author, publisher=payload.publisher, isbn=payload.isbn, edition=payload.edition, grade=payload.grade, link=payload.link, description=payload.description, position=max_position, ) db.add(textbook) db.commit() return _textbook_out(db, textbook) @router.put("/{textbook_id}", response_model=TextbookOut) def update_textbook( textbook_id: int, payload: TextbookUpdate, db: Session = Depends(get_db), _: User = Depends(require_permission("catalog:manage")), ): textbook = _load_textbook(db, textbook_id) fields = payload.model_dump(exclude_unset=True) if "name" in fields and fields["name"] != textbook.name: exists = ( db.query(Textbook) .filter(Textbook.name == fields["name"], Textbook.id != textbook.id) .first() ) if exists: raise HTTPException(status_code=400, detail="同名教材已存在") for field, value in fields.items(): setattr(textbook, field, value) db.commit() return _textbook_out(db, textbook) @router.delete("/{textbook_id}") def delete_textbook( textbook_id: int, db: Session = Depends(get_db), _: User = Depends(require_permission("catalog:manage")), ): textbook = _load_textbook(db, textbook_id) question_count = ( db.query(func.count(Question.id)) .join(Chapter, Chapter.id == Question.chapter_id) .filter(Chapter.textbook_id == textbook.id) .scalar() or 0 ) if question_count > 0: raise HTTPException( status_code=400, detail=f"该书下还有 {question_count} 道题目,不能删除教材,只能修改", ) db.query(Chapter).filter(Chapter.textbook_id == textbook.id).delete() ebook_store.drop_progress(db, textbook.id) ebook_store.drop_book_files(textbook.id) db.delete(textbook) db.commit() return {"deleted": textbook_id} @router.post("/{textbook_id}/chapters", response_model=ChapterOut) def create_chapter( textbook_id: int, payload: ChapterCreate, db: Session = Depends(get_db), _: User = Depends(require_permission("catalog:manage")), ): textbook = _load_textbook(db, textbook_id) chapter = Chapter( textbook_id=textbook.id, name=payload.name, position=db.query(Chapter).filter(Chapter.textbook_id == textbook.id).count(), summary=payload.summary, ) db.add(chapter) db.flush() if payload.knowledge_ids: set_chapter_knowledge_ids(db, chapter.id, payload.knowledge_ids) else: set_chapter_knowledge_names(db, chapter.id, payload.knowledge_names) db.commit() return _chapter_out(db, chapter) @router.post("/{textbook_id}/chapters/generate", response_model=ChaptersGenerateOut) def generate_catalog_chapters( textbook_id: int, payload: ChaptersGenerateIn, db: Session = Depends(get_db), _: User = Depends(require_permission("catalog:manage")), ): textbook = _load_textbook(db, textbook_id) existing = ( db.query(Chapter) .filter(Chapter.textbook_id == textbook.id) .order_by(Chapter.position.asc(), Chapter.id.asc()) .all() ) existing_names = {chapter.name for chapter in existing} external_context: dict = {} lookup_titles: list[str] = [] if textbook.isbn: external_context = fetch_isbn_context(textbook.isbn) lookup_titles = external_context.get("chapter_titles") or [] fixed_titles: list[str] | None = None generation_count = payload.count if payload.count is not None else 60 source_note = "已结合书名 / ISBN / 作者信息自动获取章节" if lookup_titles: missing_titles = [ title for title in lookup_titles if title not in existing_names ] if missing_titles: fixed_titles = missing_titles[:12] generation_count = len(fixed_titles) source_note = "已按 ISBN 查询到的正式章节名生成摘要" selected_names: list[str] = [] if payload.knowledge_ids: rows = ( db.query(Knowledge.name) .filter(Knowledge.id.in_(payload.knowledge_ids)) .all() ) selected_names = [name for (name,) in rows] else: selected_names = _book_graph_context(db, textbook) channel = _resolve_channel(db, payload.channel_id) if not channel.api_key or not channel.base_url or not channel.model: raise HTTPException( status_code=400, detail="模型通道未完整配置,请先补全并测试" ) try: proposed = generate_chapters( setting=channel, textbook=textbook, count=generation_count, existing_names=sorted(existing_names), instructions=( f"可挂接的知识图谱节点参考:{'、'.join(selected_names)}。\n" if selected_names else "" ) + payload.instructions.strip(), fixed_titles=fixed_titles, external_context=external_context, graph_context=selected_names or None, ) except ValueError as exc: raise HTTPException(status_code=400, detail=f"生成失败:{exc}") from exc max_position = ( db.query(func.max(Chapter.position)) .filter(Chapter.textbook_id == textbook.id) .scalar() ) position = (max_position if max_position is not None else -1) + 1 inserted: list[Chapter] = [] skipped: list[str] = [] for item in proposed: if item["name"] in existing_names: skipped.append(item["name"]) continue existing_names.add(item["name"]) chapter = Chapter( textbook_id=textbook.id, name=item["name"], summary=item["summary"], position=position, ) db.add(chapter) db.flush() if payload.knowledge_ids: try: set_chapter_knowledge_ids( db, chapter.id, payload.knowledge_ids ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc else: item_names = item.get("knowledge_names") or [] valid_names = [ node_name for (node_name,) in ( db.query(Knowledge.name) .filter(Knowledge.name.in_(item_names)) .all() ) ] if item_names else [] if valid_names: set_chapter_knowledge_names(db, chapter.id, valid_names) inserted.append(chapter) position += 1 db.flush() created = [_chapter_out(db, chapter) for chapter in inserted] db.commit() message = f"{source_note}:已新增 {len(created)} 个章节" if source_note else f"已新增 {len(created)} 个章节" if skipped: message += f",跳过重复章节 {len(skipped)} 个" return ChaptersGenerateOut( created=created, skipped_duplicates=skipped, message=message, ) @router.post("/{textbook_id}/chapters/organize", response_model=ChaptersGenerateOut) def organize_textbook_chapters( textbook_id: int, payload: ChaptersOrganizeIn, db: Session = Depends(get_db), _: User = Depends(require_permission("catalog:manage")), ): textbook = _load_textbook(db, textbook_id) existing = ( db.query(Chapter) .filter(Chapter.textbook_id == textbook.id) .order_by(Chapter.position.asc(), Chapter.id.asc()) .all() ) existing_names = {chapter.name for chapter in existing} channel = _resolve_channel(db, payload.channel_id) if not channel.api_key or not channel.base_url or not channel.model: raise HTTPException( status_code=400, detail="模型通道未完整配置,请先补全并测试" ) graph_context = _book_graph_context(db, textbook) try: proposed = organize_reference_chapters( setting=channel, textbook=textbook, reference_text=payload.reference_text.strip(), existing_names=sorted(existing_names), graph_context=graph_context, ) except ValueError as exc: raise HTTPException(status_code=400, detail=f"整理失败:{exc}") from exc max_position = ( db.query(func.max(Chapter.position)) .filter(Chapter.textbook_id == textbook.id) .scalar() ) position = (max_position if max_position is not None else -1) + 1 inserted: list[Chapter] = [] skipped: list[str] = [] for item in proposed: if item["name"] in existing_names: skipped.append(item["name"]) continue existing_names.add(item["name"]) chapter = Chapter( textbook_id=textbook.id, name=item["name"], summary=item["summary"], position=position, ) db.add(chapter) db.flush() item_names = item.get("knowledge_names") or [] valid_names = ( [ node_name for (node_name,) in ( db.query(Knowledge.name) .filter(Knowledge.name.in_(item_names)) .all() ) ] if item_names else [] ) if valid_names: set_chapter_knowledge_names(db, chapter.id, valid_names) inserted.append(chapter) position += 1 created = [_chapter_out(db, chapter) for chapter in inserted] db.commit() message = f"AI 整理完成:已新增 {len(created)} 个章节并关联知识图谱" if skipped: message += f",跳过重复 {len(skipped)} 个" return ChaptersGenerateOut( created=created, skipped_duplicates=skipped, message=message, ) @router.put("/chapters/{chapter_id}", response_model=ChapterOut) def update_chapter( chapter_id: int, payload: ChapterUpdate, db: Session = Depends(get_db), _: User = Depends(require_permission("catalog:manage")), ): chapter = db.get(Chapter, chapter_id) if chapter is None: raise HTTPException(status_code=404, detail="章节不存在") update_fields = payload.model_dump(exclude_unset=True) knowledge_names = update_fields.pop("knowledge_names", None) knowledge_ids = update_fields.pop("knowledge_ids", None) for field, value in update_fields.items(): setattr(chapter, field, value) if knowledge_ids is not None: try: set_chapter_knowledge_ids(db, chapter.id, knowledge_ids) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc elif knowledge_names is not None: set_chapter_knowledge_names(db, chapter.id, knowledge_names) db.commit() return _chapter_out(db, chapter) @router.delete("/chapters/{chapter_id}") def delete_chapter( chapter_id: int, db: Session = Depends(get_db), _: User = Depends(require_permission("catalog:manage")), ): chapter = db.get(Chapter, chapter_id) if chapter is None: raise HTTPException(status_code=404, detail="章节不存在") question_count = ( db.query(func.count(Question.id)) .filter(Question.chapter_id == chapter.id) .scalar() or 0 ) if question_count > 0: raise HTTPException( status_code=400, detail=f"该章节下有 {question_count} 道题目,不能删除,只能修改章节", ) db.delete(chapter) db.commit() return {"deleted": chapter_id}