380 lines
12 KiB
Python
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}
|