knowledge_graph.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  1. """
  2. 知识图谱路由:Neo4j 查询与维护、目录、习题 CSV/JSON 批量导入。
  3. """
  4. import csv
  5. import json
  6. from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
  7. from sqlmodel import Session
  8. from app.db import get_session
  9. from app.deps import require_role
  10. from app.integrations.neo4j import Neo4jClient, get_neo4j_client
  11. from app.models import Exercise, UserRole
  12. from app.data.knowledge_catalog import iter_catalog_topics
  13. from app.utils.exercise_constants import normalize_resolution_for_store
  14. from app.utils.neo4j_exercise import enrich_exercise_from_neo4j
  15. from app.schemas import (
  16. ExerciseImportPreviewItem,
  17. ExerciseImportPreviewResponse,
  18. ExerciseImportSummary,
  19. GraphBackfillResponse,
  20. GraphSeedResponse,
  21. KnowledgePointInGraph,
  22. KnowledgeQueryResponse,
  23. PrerequisiteLinkRequest,
  24. )
  25. router = APIRouter(prefix="/knowledge-graph", tags=["knowledge-graph"])
  26. def _knowledge_to_response(neo4j: Neo4jClient, knowledge_code: str, kp: dict) -> KnowledgeQueryResponse:
  27. code = str(kp.get("code") or knowledge_code)
  28. try:
  29. related = neo4j.list_sibling_knowledge_points(code)
  30. except Exception:
  31. related = []
  32. try:
  33. prerequisites, next_steps = neo4j.list_prerequisite_edges(code)
  34. except Exception:
  35. prerequisites, next_steps = [], []
  36. return KnowledgeQueryResponse(
  37. knowledge_code=code,
  38. name=kp.get("name"),
  39. grade=kp.get("grade"),
  40. category=kp.get("category"),
  41. definition=kp.get("definition"),
  42. related=related,
  43. prerequisites=prerequisites,
  44. next_steps=next_steps,
  45. )
  46. def _parse_difficulty(raw: object, row_index: int) -> int | None:
  47. if raw in (None, ""):
  48. return None
  49. try:
  50. diff = int(str(raw).strip())
  51. except ValueError as exc:
  52. raise ValueError(f"第{row_index}行 difficulty 不是整数") from exc
  53. if diff < 1 or diff > 5:
  54. raise ValueError(f"第{row_index}行 difficulty 必须在 1-5 之间")
  55. return diff
  56. def _parse_weight(raw: object, row_index: int) -> int | None:
  57. if raw in (None, ""):
  58. return None
  59. try:
  60. w = int(str(raw).strip())
  61. except ValueError as exc:
  62. raise ValueError(f"第{row_index}行 weight 不是整数") from exc
  63. if w < 0:
  64. raise ValueError(f"第{row_index}行 weight 不能小于 0")
  65. return w
  66. def _normalize_row(row: dict[str, object], row_index: int, fallback_knowledge_code: str | None) -> Exercise:
  67. title = str(row.get("title") or "").strip()
  68. body = str(row.get("body") or "").strip()
  69. if not title or not body:
  70. raise ValueError(f"第{row_index}行缺少 title 或 body")
  71. row_code = str(row.get("knowledge_code") or "").strip()
  72. knowledge_code = row_code or (fallback_knowledge_code or "").strip() or None
  73. difficulty = _parse_difficulty(row.get("difficulty"), row_index)
  74. weight = _parse_weight(row.get("weight"), row_index)
  75. question_type = str(row.get("question_type") or "").strip() or None
  76. correct_answer = str(row.get("correct_answer") or "").strip() or None
  77. resolution = str(row.get("resolution") or "").strip() or None
  78. return Exercise(
  79. title=title,
  80. body=body,
  81. knowledge_code=knowledge_code,
  82. difficulty=difficulty,
  83. question_type=question_type,
  84. correct_answer=correct_answer,
  85. resolution=normalize_resolution_for_store(resolution),
  86. weight=weight or 0,
  87. )
  88. def _parse_upload_rows(raw: bytes, filename: str, fallback_knowledge_code: str | None) -> tuple[list[Exercise], list[str]]:
  89. exercises: list[Exercise] = []
  90. errors: list[str] = []
  91. if filename.endswith(".json"):
  92. payload = json.loads(raw.decode("utf-8"))
  93. if not isinstance(payload, list):
  94. raise HTTPException(status_code=400, detail="JSON 文件必须是数组")
  95. for idx, item in enumerate(payload, start=1):
  96. if not isinstance(item, dict):
  97. errors.append(f"第{idx}项不是对象")
  98. continue
  99. try:
  100. exercises.append(_normalize_row(item, idx, fallback_knowledge_code))
  101. except ValueError as err:
  102. errors.append(str(err))
  103. elif filename.endswith(".csv"):
  104. text = raw.decode("utf-8-sig")
  105. reader = csv.DictReader(text.splitlines())
  106. for idx, row in enumerate(reader, start=2):
  107. try:
  108. row_map: dict[str, object] = dict(row)
  109. exercises.append(_normalize_row(row_map, idx, fallback_knowledge_code))
  110. except ValueError as err:
  111. errors.append(str(err))
  112. else:
  113. raise HTTPException(status_code=400, detail="仅支持 .csv 或 .json 文件")
  114. return exercises, errors
  115. @router.get("/knowledge-points", response_model=list[KnowledgePointInGraph])
  116. def list_knowledge_points_in_graph(neo4j: Neo4jClient = Depends(get_neo4j_client)):
  117. """返回 Neo4j 中当前全部知识点(与「写入图」一致,不含仅存在于前端目录的项)。"""
  118. rows = neo4j.list_all_knowledge_points()
  119. return [KnowledgePointInGraph(code=r["code"], name=r.get("name"), grade=r.get("grade")) for r in rows]
  120. @router.get("/catalog")
  121. def list_knowledge_catalog(neo4j: Neo4jClient = Depends(get_neo4j_client)):
  122. """
  123. 返回 Neo4j 中完整知识点目录树:
  124. { [grade]: { [category]: [{ code, name }, ...] } }
  125. """
  126. return neo4j.list_knowledge_catalog()
  127. @router.get("/knowledge/{knowledge_code}", response_model=KnowledgeQueryResponse)
  128. def get_knowledge(knowledge_code: str, neo4j: Neo4jClient = Depends(get_neo4j_client)):
  129. kp = neo4j.get_knowledge_point(knowledge_code)
  130. if not kp:
  131. raise HTTPException(status_code=404, detail="Knowledge point not found")
  132. return _knowledge_to_response(neo4j, knowledge_code, kp)
  133. @router.post("/knowledge/{knowledge_code}", response_model=KnowledgeQueryResponse)
  134. def upsert_knowledge(
  135. knowledge_code: str,
  136. name: str,
  137. grade: str | None = None,
  138. category: str | None = None,
  139. neo4j: Neo4jClient = Depends(get_neo4j_client),
  140. _=Depends(require_role(UserRole.teacher)),
  141. ):
  142. kp = neo4j.upsert_knowledge_point(knowledge_code, name, grade=grade, category=category)
  143. return _knowledge_to_response(neo4j, knowledge_code, kp)
  144. @router.post("/prerequisite")
  145. def link_prerequisite_edge(
  146. payload: PrerequisiteLinkRequest,
  147. neo4j: Neo4jClient = Depends(get_neo4j_client),
  148. _=Depends(require_role(UserRole.teacher)),
  149. ):
  150. try:
  151. neo4j.link_prerequisite(payload.prerequisite_code, payload.dependent_code)
  152. except ValueError as e:
  153. raise HTTPException(status_code=400, detail=str(e)) from e
  154. return {"ok": True}
  155. @router.delete("/prerequisite")
  156. def unlink_prerequisite_edge(
  157. prerequisite_code: str = Query(..., description="前置知识点编码"),
  158. dependent_code: str = Query(..., description="后置知识点编码"),
  159. neo4j: Neo4jClient = Depends(get_neo4j_client),
  160. _=Depends(require_role(UserRole.teacher)),
  161. ):
  162. try:
  163. deleted = neo4j.unlink_prerequisite(prerequisite_code, dependent_code)
  164. except ValueError as e:
  165. raise HTTPException(status_code=400, detail=str(e)) from e
  166. return {"ok": True, "deleted": deleted}
  167. @router.patch("/categories/rename")
  168. def rename_category(
  169. grade: str,
  170. old_name: str,
  171. new_name: str,
  172. neo4j: Neo4jClient = Depends(get_neo4j_client),
  173. _=Depends(require_role(UserRole.teacher)),
  174. ):
  175. if not grade.strip() or not old_name.strip() or not new_name.strip():
  176. raise HTTPException(status_code=400, detail="grade, old_name, new_name 均不能为空")
  177. neo4j.rename_category(grade.strip(), old_name.strip(), new_name.strip())
  178. return {"ok": True}
  179. @router.delete("/categories")
  180. def delete_category(
  181. grade: str,
  182. name: str,
  183. neo4j: Neo4jClient = Depends(get_neo4j_client),
  184. _=Depends(require_role(UserRole.teacher)),
  185. ):
  186. if not grade.strip() or not name.strip():
  187. raise HTTPException(status_code=400, detail="grade 和 name 不能为空")
  188. neo4j.delete_category(grade.strip(), name.strip())
  189. return {"ok": True}
  190. @router.patch("/knowledge/{knowledge_code}/rename")
  191. def rename_knowledge_point(
  192. knowledge_code: str,
  193. new_name: str,
  194. neo4j: Neo4jClient = Depends(get_neo4j_client),
  195. _=Depends(require_role(UserRole.teacher)),
  196. ):
  197. if not new_name.strip():
  198. raise HTTPException(status_code=400, detail="new_name 不能为空")
  199. neo4j.rename_knowledge_point(knowledge_code, new_name.strip())
  200. return {"ok": True}
  201. @router.delete("/knowledge/{knowledge_code}")
  202. def delete_knowledge_point(
  203. knowledge_code: str,
  204. neo4j: Neo4jClient = Depends(get_neo4j_client),
  205. _=Depends(require_role(UserRole.teacher)),
  206. ):
  207. neo4j.delete_knowledge_point(knowledge_code)
  208. return {"ok": True}
  209. @router.post("/maintenance/backfill-categories", response_model=GraphBackfillResponse)
  210. def backfill_categories(
  211. neo4j: Neo4jClient = Depends(get_neo4j_client),
  212. _=Depends(require_role(UserRole.teacher)),
  213. ):
  214. result = neo4j.backfill_category_relations()
  215. return GraphBackfillResponse(**result)
  216. @router.post("/maintenance/seed-catalog", response_model=GraphSeedResponse)
  217. def seed_default_catalog(
  218. neo4j: Neo4jClient = Depends(get_neo4j_client),
  219. _=Depends(require_role(UserRole.teacher)),
  220. ):
  221. """
  222. 将默认初中数学知识点目录(与前端下拉框一致)全部 MERGE 进 Neo4j。
  223. 可重复执行,用于“补完”图数据或重装后恢复。
  224. """
  225. n = 0
  226. for grade, category, code, name in iter_catalog_topics():
  227. neo4j.upsert_knowledge_point(code, name, grade=grade, category=category)
  228. n += 1
  229. return GraphSeedResponse(upserted=n)
  230. @router.post("/exercises/import", response_model=ExerciseImportSummary)
  231. async def import_exercises(
  232. file: UploadFile = File(...),
  233. knowledge_code: str | None = None,
  234. session: Session = Depends(get_session),
  235. neo4j: Neo4jClient = Depends(get_neo4j_client),
  236. _=Depends(require_role(UserRole.teacher)),
  237. ):
  238. filename = (file.filename or "").lower()
  239. raw = await file.read()
  240. if not raw:
  241. raise HTTPException(status_code=400, detail="上传文件为空")
  242. try:
  243. exercises, errors = _parse_upload_rows(raw, filename, knowledge_code)
  244. except UnicodeDecodeError as exc:
  245. raise HTTPException(status_code=400, detail="文件编码错误,请使用 UTF-8") from exc
  246. except json.JSONDecodeError as exc:
  247. raise HTTPException(status_code=400, detail=f"JSON 解析失败: {exc.msg}") from exc
  248. if not exercises:
  249. return ExerciseImportSummary(total=0, created=0, skipped=len(errors), errors=errors)
  250. for ex in exercises:
  251. enrich_exercise_from_neo4j(ex, neo4j)
  252. session.add(ex)
  253. session.commit()
  254. return ExerciseImportSummary(
  255. total=len(exercises) + len(errors),
  256. created=len(exercises),
  257. skipped=len(errors),
  258. errors=errors,
  259. )
  260. @router.post("/exercises/import/preview", response_model=ExerciseImportPreviewResponse)
  261. async def preview_exercises_import(
  262. file: UploadFile = File(...),
  263. knowledge_code: str | None = None,
  264. neo4j: Neo4jClient = Depends(get_neo4j_client),
  265. _=Depends(require_role(UserRole.teacher)),
  266. ):
  267. filename = (file.filename or "").lower()
  268. raw = await file.read()
  269. if not raw:
  270. raise HTTPException(status_code=400, detail="上传文件为空")
  271. try:
  272. exercises, errors = _parse_upload_rows(raw, filename, knowledge_code)
  273. except UnicodeDecodeError as exc:
  274. raise HTTPException(status_code=400, detail="文件编码错误,请使用 UTF-8") from exc
  275. except json.JSONDecodeError as exc:
  276. raise HTTPException(status_code=400, detail=f"JSON 解析失败: {exc.msg}") from exc
  277. sample = []
  278. for ex in exercises[:20]:
  279. enrich_exercise_from_neo4j(ex, neo4j)
  280. sample.append(
  281. ExerciseImportPreviewItem(
  282. title=ex.title,
  283. body=ex.body,
  284. knowledge_code=ex.knowledge_code,
  285. difficulty=ex.difficulty,
  286. question_type=ex.question_type,
  287. correct_answer=ex.correct_answer,
  288. resolution=ex.resolution,
  289. )
  290. )
  291. total = len(exercises) + len(errors)
  292. return ExerciseImportPreviewResponse(
  293. total=total,
  294. valid=len(exercises),
  295. invalid=len(errors),
  296. sample=sample,
  297. errors=errors,
  298. )