""" 知识图谱路由:Neo4j 查询与维护、目录、习题 CSV/JSON 批量导入。 """ import csv import json from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile from sqlmodel import Session from app.db import get_session from app.deps import require_role from app.integrations.neo4j import Neo4jClient, get_neo4j_client from app.models import Exercise, UserRole from app.data.knowledge_catalog import iter_catalog_topics from app.utils.exercise_constants import normalize_resolution_for_store from app.utils.neo4j_exercise import enrich_exercise_from_neo4j from app.schemas import ( ExerciseImportPreviewItem, ExerciseImportPreviewResponse, ExerciseImportSummary, GraphBackfillResponse, GraphSeedResponse, KnowledgePointInGraph, KnowledgeQueryResponse, PrerequisiteLinkRequest, ) router = APIRouter(prefix="/knowledge-graph", tags=["knowledge-graph"]) def _knowledge_to_response(neo4j: Neo4jClient, knowledge_code: str, kp: dict) -> KnowledgeQueryResponse: code = str(kp.get("code") or knowledge_code) try: related = neo4j.list_sibling_knowledge_points(code) except Exception: related = [] try: prerequisites, next_steps = neo4j.list_prerequisite_edges(code) except Exception: prerequisites, next_steps = [], [] return KnowledgeQueryResponse( knowledge_code=code, name=kp.get("name"), grade=kp.get("grade"), category=kp.get("category"), definition=kp.get("definition"), related=related, prerequisites=prerequisites, next_steps=next_steps, ) def _parse_difficulty(raw: object, row_index: int) -> int | None: if raw in (None, ""): return None try: diff = int(str(raw).strip()) except ValueError as exc: raise ValueError(f"第{row_index}行 difficulty 不是整数") from exc if diff < 1 or diff > 5: raise ValueError(f"第{row_index}行 difficulty 必须在 1-5 之间") return diff def _parse_weight(raw: object, row_index: int) -> int | None: if raw in (None, ""): return None try: w = int(str(raw).strip()) except ValueError as exc: raise ValueError(f"第{row_index}行 weight 不是整数") from exc if w < 0: raise ValueError(f"第{row_index}行 weight 不能小于 0") return w def _normalize_row(row: dict[str, object], row_index: int, fallback_knowledge_code: str | None) -> Exercise: title = str(row.get("title") or "").strip() body = str(row.get("body") or "").strip() if not title or not body: raise ValueError(f"第{row_index}行缺少 title 或 body") row_code = str(row.get("knowledge_code") or "").strip() knowledge_code = row_code or (fallback_knowledge_code or "").strip() or None difficulty = _parse_difficulty(row.get("difficulty"), row_index) weight = _parse_weight(row.get("weight"), row_index) question_type = str(row.get("question_type") or "").strip() or None correct_answer = str(row.get("correct_answer") or "").strip() or None resolution = str(row.get("resolution") or "").strip() or None return Exercise( title=title, body=body, knowledge_code=knowledge_code, difficulty=difficulty, question_type=question_type, correct_answer=correct_answer, resolution=normalize_resolution_for_store(resolution), weight=weight or 0, ) def _parse_upload_rows(raw: bytes, filename: str, fallback_knowledge_code: str | None) -> tuple[list[Exercise], list[str]]: exercises: list[Exercise] = [] errors: list[str] = [] if filename.endswith(".json"): payload = json.loads(raw.decode("utf-8")) if not isinstance(payload, list): raise HTTPException(status_code=400, detail="JSON 文件必须是数组") for idx, item in enumerate(payload, start=1): if not isinstance(item, dict): errors.append(f"第{idx}项不是对象") continue try: exercises.append(_normalize_row(item, idx, fallback_knowledge_code)) except ValueError as err: errors.append(str(err)) elif filename.endswith(".csv"): text = raw.decode("utf-8-sig") reader = csv.DictReader(text.splitlines()) for idx, row in enumerate(reader, start=2): try: row_map: dict[str, object] = dict(row) exercises.append(_normalize_row(row_map, idx, fallback_knowledge_code)) except ValueError as err: errors.append(str(err)) else: raise HTTPException(status_code=400, detail="仅支持 .csv 或 .json 文件") return exercises, errors @router.get("/knowledge-points", response_model=list[KnowledgePointInGraph]) def list_knowledge_points_in_graph(neo4j: Neo4jClient = Depends(get_neo4j_client)): """返回 Neo4j 中当前全部知识点(与「写入图」一致,不含仅存在于前端目录的项)。""" rows = neo4j.list_all_knowledge_points() return [KnowledgePointInGraph(code=r["code"], name=r.get("name"), grade=r.get("grade")) for r in rows] @router.get("/catalog") def list_knowledge_catalog(neo4j: Neo4jClient = Depends(get_neo4j_client)): """ 返回 Neo4j 中完整知识点目录树: { [grade]: { [category]: [{ code, name }, ...] } } """ return neo4j.list_knowledge_catalog() @router.get("/knowledge/{knowledge_code}", response_model=KnowledgeQueryResponse) def get_knowledge(knowledge_code: str, neo4j: Neo4jClient = Depends(get_neo4j_client)): kp = neo4j.get_knowledge_point(knowledge_code) if not kp: raise HTTPException(status_code=404, detail="Knowledge point not found") return _knowledge_to_response(neo4j, knowledge_code, kp) @router.post("/knowledge/{knowledge_code}", response_model=KnowledgeQueryResponse) def upsert_knowledge( knowledge_code: str, name: str, grade: str | None = None, category: str | None = None, neo4j: Neo4jClient = Depends(get_neo4j_client), _=Depends(require_role(UserRole.teacher)), ): kp = neo4j.upsert_knowledge_point(knowledge_code, name, grade=grade, category=category) return _knowledge_to_response(neo4j, knowledge_code, kp) @router.post("/prerequisite") def link_prerequisite_edge( payload: PrerequisiteLinkRequest, neo4j: Neo4jClient = Depends(get_neo4j_client), _=Depends(require_role(UserRole.teacher)), ): try: neo4j.link_prerequisite(payload.prerequisite_code, payload.dependent_code) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) from e return {"ok": True} @router.delete("/prerequisite") def unlink_prerequisite_edge( prerequisite_code: str = Query(..., description="前置知识点编码"), dependent_code: str = Query(..., description="后置知识点编码"), neo4j: Neo4jClient = Depends(get_neo4j_client), _=Depends(require_role(UserRole.teacher)), ): try: deleted = neo4j.unlink_prerequisite(prerequisite_code, dependent_code) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) from e return {"ok": True, "deleted": deleted} @router.patch("/categories/rename") def rename_category( grade: str, old_name: str, new_name: str, neo4j: Neo4jClient = Depends(get_neo4j_client), _=Depends(require_role(UserRole.teacher)), ): if not grade.strip() or not old_name.strip() or not new_name.strip(): raise HTTPException(status_code=400, detail="grade, old_name, new_name 均不能为空") neo4j.rename_category(grade.strip(), old_name.strip(), new_name.strip()) return {"ok": True} @router.delete("/categories") def delete_category( grade: str, name: str, neo4j: Neo4jClient = Depends(get_neo4j_client), _=Depends(require_role(UserRole.teacher)), ): if not grade.strip() or not name.strip(): raise HTTPException(status_code=400, detail="grade 和 name 不能为空") neo4j.delete_category(grade.strip(), name.strip()) return {"ok": True} @router.patch("/knowledge/{knowledge_code}/rename") def rename_knowledge_point( knowledge_code: str, new_name: str, neo4j: Neo4jClient = Depends(get_neo4j_client), _=Depends(require_role(UserRole.teacher)), ): if not new_name.strip(): raise HTTPException(status_code=400, detail="new_name 不能为空") neo4j.rename_knowledge_point(knowledge_code, new_name.strip()) return {"ok": True} @router.delete("/knowledge/{knowledge_code}") def delete_knowledge_point( knowledge_code: str, neo4j: Neo4jClient = Depends(get_neo4j_client), _=Depends(require_role(UserRole.teacher)), ): neo4j.delete_knowledge_point(knowledge_code) return {"ok": True} @router.post("/maintenance/backfill-categories", response_model=GraphBackfillResponse) def backfill_categories( neo4j: Neo4jClient = Depends(get_neo4j_client), _=Depends(require_role(UserRole.teacher)), ): result = neo4j.backfill_category_relations() return GraphBackfillResponse(**result) @router.post("/maintenance/seed-catalog", response_model=GraphSeedResponse) def seed_default_catalog( neo4j: Neo4jClient = Depends(get_neo4j_client), _=Depends(require_role(UserRole.teacher)), ): """ 将默认初中数学知识点目录(与前端下拉框一致)全部 MERGE 进 Neo4j。 可重复执行,用于“补完”图数据或重装后恢复。 """ n = 0 for grade, category, code, name in iter_catalog_topics(): neo4j.upsert_knowledge_point(code, name, grade=grade, category=category) n += 1 return GraphSeedResponse(upserted=n) @router.post("/exercises/import", response_model=ExerciseImportSummary) async def import_exercises( file: UploadFile = File(...), knowledge_code: str | None = None, session: Session = Depends(get_session), neo4j: Neo4jClient = Depends(get_neo4j_client), _=Depends(require_role(UserRole.teacher)), ): filename = (file.filename or "").lower() raw = await file.read() if not raw: raise HTTPException(status_code=400, detail="上传文件为空") try: exercises, errors = _parse_upload_rows(raw, filename, knowledge_code) except UnicodeDecodeError as exc: raise HTTPException(status_code=400, detail="文件编码错误,请使用 UTF-8") from exc except json.JSONDecodeError as exc: raise HTTPException(status_code=400, detail=f"JSON 解析失败: {exc.msg}") from exc if not exercises: return ExerciseImportSummary(total=0, created=0, skipped=len(errors), errors=errors) for ex in exercises: enrich_exercise_from_neo4j(ex, neo4j) session.add(ex) session.commit() return ExerciseImportSummary( total=len(exercises) + len(errors), created=len(exercises), skipped=len(errors), errors=errors, ) @router.post("/exercises/import/preview", response_model=ExerciseImportPreviewResponse) async def preview_exercises_import( file: UploadFile = File(...), knowledge_code: str | None = None, neo4j: Neo4jClient = Depends(get_neo4j_client), _=Depends(require_role(UserRole.teacher)), ): filename = (file.filename or "").lower() raw = await file.read() if not raw: raise HTTPException(status_code=400, detail="上传文件为空") try: exercises, errors = _parse_upload_rows(raw, filename, knowledge_code) except UnicodeDecodeError as exc: raise HTTPException(status_code=400, detail="文件编码错误,请使用 UTF-8") from exc except json.JSONDecodeError as exc: raise HTTPException(status_code=400, detail=f"JSON 解析失败: {exc.msg}") from exc sample = [] for ex in exercises[:20]: enrich_exercise_from_neo4j(ex, neo4j) sample.append( ExerciseImportPreviewItem( title=ex.title, body=ex.body, knowledge_code=ex.knowledge_code, difficulty=ex.difficulty, question_type=ex.question_type, correct_answer=ex.correct_answer, resolution=ex.resolution, ) ) total = len(exercises) + len(errors) return ExerciseImportPreviewResponse( total=total, valid=len(exercises), invalid=len(errors), sample=sample, errors=errors, )