"""知识图谱:节点 / 关系浏览与管理员维护。""" 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}