nex_math/backend/routers/catalog.py

555 lines
18 KiB
Python

"""教材 / 课程目录:读取对登录用户开放,增删改仅管理员。"""
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}