nex_math/backend/routers/knowledge_graph.py

380 lines
12 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 require_learning_user, require_permission
from models import (
ChapterKnowledge,
Course,
Knowledge,
KnowledgeRelation,
KnowledgeResource,
QuestionKnowledge,
Textbook,
User,
UserKnowledge,
)
from schemas import (
KnowledgeGraphEdge,
KnowledgeGraphNode,
KnowledgeGraphOut,
KnowledgeMapNode,
KnowledgeMapOut,
KnowledgeNodeCreate,
KnowledgeNodeUpdate,
KnowledgeRelationIn,
KnowledgeResourceIn,
KnowledgeResourceOut,
)
from services.mastery_engine import status_for
from services.knowledge_service import knowledge_resources
router = APIRouter(prefix="/knowledge", tags=["knowledge-graph"])
RELATION_TYPES = {"包含", "前置", "相关"}
def _node_out(db: Session, node: Knowledge) -> KnowledgeGraphNode:
chapter_count = (
db.query(func.count(ChapterKnowledge.id))
.filter(ChapterKnowledge.knowledge_id == node.id)
.scalar()
or 0
)
question_count = (
db.query(func.count(QuestionKnowledge.id))
.filter(QuestionKnowledge.knowledge_id == node.id)
.scalar()
or 0
)
return KnowledgeGraphNode(
id=node.id,
name=node.name,
domain=node.domain,
category=node.category,
description=node.description,
chapter_count=chapter_count,
question_count=question_count,
resources=[
KnowledgeResourceOut(
id=row.id,
kind=row.kind,
title=row.title,
url=row.url,
source_name=row.title,
)
for row in knowledge_resources(db, node.id)
],
)
def _unique_name(db: Session, name: str, exclude_id: int | None = None):
query = db.query(Knowledge).filter(Knowledge.name == name)
if exclude_id is not None:
query = query.filter(Knowledge.id != exclude_id)
if query.first() is not None:
raise HTTPException(status_code=400, detail="知识点名称已存在")
@router.get("/graph", response_model=KnowledgeGraphOut)
def get_graph(
db: Session = Depends(get_db),
_: User = Depends(require_permission("knowledge:manage")),
):
nodes = db.query(Knowledge).order_by(Knowledge.domain, Knowledge.category, Knowledge.id).all()
edges = db.query(KnowledgeRelation).order_by(KnowledgeRelation.id).all()
return KnowledgeGraphOut(
nodes=[_node_out(db, node) for node in nodes],
edges=[
KnowledgeGraphEdge(
source=edge.source_id,
target=edge.target_id,
relation_type=edge.relation_type,
)
for edge in edges
],
)
@router.get("/map", response_model=KnowledgeMapOut)
def get_knowledge_map(
db: Session = Depends(get_db),
user: User = Depends(require_learning_user),
):
"""学生视角的完整知识图谱:全量结构 + 本人掌握度。
/knowledge 只返回练过的知识点,画不出一张完整的地图;
这里一次给全量节点(含未练过的)与全部关系,计数用分组查询,避免逐点 N+1。
"""
nodes = (
db.query(Knowledge)
.order_by(Knowledge.domain, Knowledge.category, Knowledge.id)
.all()
)
edges = db.query(KnowledgeRelation).order_by(KnowledgeRelation.id).all()
mastery = {
row.knowledge_id: row.mastery
for row in db.query(UserKnowledge)
.filter(UserKnowledge.user_id == user.id)
.all()
}
question_counts = dict(
db.query(QuestionKnowledge.knowledge_id, func.count(QuestionKnowledge.id))
.group_by(QuestionKnowledge.knowledge_id)
.all()
)
chapter_counts = dict(
db.query(ChapterKnowledge.knowledge_id, func.count(ChapterKnowledge.id))
.group_by(ChapterKnowledge.knowledge_id)
.all()
)
def count(mapping: dict, node_id: int) -> int:
return int(mapping.get(node_id, 0) or 0)
return KnowledgeMapOut(
nodes=[
KnowledgeMapNode(
id=node.id,
name=node.name,
domain=node.domain,
category=node.category,
description=node.description,
mastery=mastery.get(node.id, 0),
status=status_for(mastery.get(node.id, 0)),
practiced=node.id in mastery,
question_count=count(question_counts, node.id),
chapter_count=count(chapter_counts, node.id),
)
for node in nodes
],
edges=[
KnowledgeGraphEdge(
source=edge.source_id,
target=edge.target_id,
relation_type=edge.relation_type,
)
for edge in edges
],
)
@router.post("/nodes", response_model=KnowledgeGraphNode)
def create_node(
payload: KnowledgeNodeCreate,
db: Session = Depends(get_db),
_: User = Depends(require_permission("knowledge:manage")),
):
name = payload.name.strip()
_unique_name(db, name)
node = Knowledge(
name=name,
domain=payload.domain.strip() or "初等",
category=payload.category.strip(),
description=payload.description.strip(),
)
db.add(node)
db.commit()
return _node_out(db, node)
@router.put("/nodes/{node_id}", response_model=KnowledgeGraphNode)
def update_node(
node_id: int,
payload: KnowledgeNodeUpdate,
db: Session = Depends(get_db),
_: User = Depends(require_permission("knowledge:manage")),
):
node = db.get(Knowledge, node_id)
if node is None:
raise HTTPException(status_code=404, detail="知识点不存在")
fields = payload.model_dump(exclude_unset=True)
if fields.get("name"):
name = fields["name"].strip()
_unique_name(db, name, exclude_id=node.id)
node.name = name
if fields.get("domain"):
node.domain = fields["domain"].strip()
if "category" in fields:
node.category = (fields["category"] or "").strip()
if "description" in fields:
node.description = (fields["description"] or "").strip()
db.commit()
return _node_out(db, node)
@router.delete("/nodes/{node_id}")
def delete_node(
node_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_permission("knowledge:manage")),
):
node = db.get(Knowledge, node_id)
if node is None:
raise HTTPException(status_code=404, detail="知识点不存在")
usage = (
db.query(func.count(ChapterKnowledge.id))
.filter(ChapterKnowledge.knowledge_id == node.id)
.scalar()
or 0
) + (
db.query(func.count(QuestionKnowledge.id))
.filter(QuestionKnowledge.knowledge_id == node.id)
.scalar()
or 0
)
if usage > 0:
raise HTTPException(
status_code=400,
detail=f"该知识点已关联 {usage} 个章节/题目,不能删除;可改名或先解除引用",
)
db.query(KnowledgeRelation).filter(
(KnowledgeRelation.source_id == node.id)
| (KnowledgeRelation.target_id == node.id)
).delete(synchronize_session=False)
db.query(UserKnowledge).filter(UserKnowledge.knowledge_id == node.id).delete(
synchronize_session=False
)
db.delete(node)
db.commit()
return {"deleted": node_id}
@router.post("/nodes/{node_id}/resources", response_model=KnowledgeResourceOut)
def add_resource(
node_id: int,
payload: KnowledgeResourceIn,
db: Session = Depends(get_db),
_: User = Depends(require_permission("knowledge:manage")),
):
node = db.get(Knowledge, node_id)
if node is None:
raise HTTPException(status_code=404, detail="知识点不存在")
kind = payload.kind.strip() or "video"
title = payload.title.strip()
url = payload.url.strip()
if payload.course_id is not None:
course = db.get(Course, payload.course_id)
if course is None:
raise HTTPException(status_code=404, detail="课程不存在")
title = title or course.name
url = url or course.url
elif payload.textbook_id is not None:
textbook = db.get(Textbook, payload.textbook_id)
if textbook is None:
raise HTTPException(status_code=404, detail="教材不存在")
title = title or textbook.name
url = url or textbook.link
if not title or not url:
raise HTTPException(status_code=400, detail="资源标题与链接不能为空")
exists = (
db.query(KnowledgeResource)
.filter(
KnowledgeResource.knowledge_id == node.id,
KnowledgeResource.kind == kind,
KnowledgeResource.url == url,
)
.first()
)
if exists is not None:
raise HTTPException(status_code=400, detail="该资源已关联到本知识点")
row = KnowledgeResource(
knowledge_id=node.id,
kind=kind,
title=title,
url=url,
textbook_id=payload.textbook_id,
course_id=payload.course_id,
)
db.add(row)
db.commit()
return KnowledgeResourceOut(
id=row.id,
kind=row.kind,
title=row.title,
url=row.url,
source_name=row.title,
)
@router.delete("/resources/{resource_id}")
def remove_resource(
resource_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_permission("knowledge:manage")),
):
row = db.get(KnowledgeResource, resource_id)
if row is None:
raise HTTPException(status_code=404, detail="资源链接不存在")
db.delete(row)
db.commit()
return {"deleted": resource_id}
@router.post("/relations", response_model=KnowledgeGraphEdge)
def add_relation(
payload: KnowledgeRelationIn,
db: Session = Depends(get_db),
_: User = Depends(require_permission("knowledge:manage")),
):
if payload.relation_type not in RELATION_TYPES:
raise HTTPException(status_code=400, detail="关系类型仅支持 包含/前置/相关")
if payload.source_id == payload.target_id:
raise HTTPException(status_code=400, detail="不能连接到自身")
source = db.get(Knowledge, payload.source_id)
target = db.get(Knowledge, payload.target_id)
if source is None or target is None:
raise HTTPException(status_code=404, detail="知识点不存在")
exists = (
db.query(KnowledgeRelation)
.filter(
KnowledgeRelation.source_id == source.id,
KnowledgeRelation.target_id == target.id,
KnowledgeRelation.relation_type == payload.relation_type,
)
.first()
)
if exists is not None:
raise HTTPException(status_code=400, detail="该关系已存在")
relation = KnowledgeRelation(
source_id=source.id,
target_id=target.id,
relation_type=payload.relation_type,
)
db.add(relation)
db.commit()
return KnowledgeGraphEdge(
source=relation.source_id,
target=relation.target_id,
relation_type=relation.relation_type,
)
@router.delete("/relations")
def remove_relation(
source_id: int,
target_id: int,
relation_type: str = "前置",
db: Session = Depends(get_db),
_: User = Depends(require_permission("knowledge:manage")),
):
relation = (
db.query(KnowledgeRelation)
.filter(
KnowledgeRelation.source_id == source_id,
KnowledgeRelation.target_id == target_id,
KnowledgeRelation.relation_type == relation_type,
)
.first()
)
if relation is None:
raise HTTPException(status_code=404, detail="关系不存在")
db.delete(relation)
db.commit()
return {"deleted": relation.id}