555 lines
18 KiB
Python
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}
|