neo4j.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. """
  2. Neo4j 图数据库客户端:知识点/类别节点、CONTAINS 与 PREREQUISITE 关系的查询与维护。
  3. """
  4. from __future__ import annotations
  5. from dataclasses import dataclass
  6. from typing import Any, Optional
  7. from neo4j import GraphDatabase
  8. from neo4j.exceptions import Neo4jError
  9. from app.core.config import settings
  10. @dataclass(frozen=True)
  11. class Neo4jClient:
  12. driver: Any
  13. def _session(self):
  14. return self.driver.session(database=settings.neo4j_database)
  15. def close(self) -> None:
  16. self.driver.close()
  17. def ensure_constraints(self) -> None:
  18. """创建唯一约束(KnowledgePoint.code、KnowledgeCategory.key),可重复调用。"""
  19. kp_cypher = """
  20. CREATE CONSTRAINT kp_code_unique IF NOT EXISTS
  21. FOR (k:KnowledgePoint) REQUIRE k.code IS UNIQUE
  22. """
  23. kc_cypher = """
  24. CREATE CONSTRAINT kc_key_unique IF NOT EXISTS
  25. FOR (c:KnowledgeCategory) REQUIRE c.key IS UNIQUE
  26. """
  27. with self._session() as session:
  28. session.run(kp_cypher)
  29. session.run(kc_cypher)
  30. def list_all_knowledge_points(self) -> list[dict[str, Any]]:
  31. """图中全部 KnowledgePoint,用于与前端目录对齐的下拉选项。"""
  32. cypher = """
  33. MATCH (k:KnowledgePoint)
  34. RETURN k.code AS code, k.name AS name, k.grade AS grade
  35. ORDER BY k.code
  36. """
  37. with self._session() as session:
  38. rows = session.run(cypher).data()
  39. out: list[dict[str, Any]] = []
  40. for row in rows:
  41. code = row.get("code")
  42. if not code:
  43. continue
  44. out.append(
  45. {
  46. "code": str(code),
  47. "name": row.get("name"),
  48. "grade": row.get("grade"),
  49. }
  50. )
  51. return out
  52. def list_knowledge_catalog(self) -> dict[str, dict[str, list[dict[str, Any]]]]:
  53. """
  54. 返回 Neo4j 中的知识点目录树:{grade: {category: [{code,name}, ...]}}
  55. 与前端 KnowledgePage 的下拉层级一致。
  56. """
  57. cypher = """
  58. MATCH (c:KnowledgeCategory)-[:CONTAINS]->(k:KnowledgePoint)
  59. RETURN c.grade AS grade, c.name AS category, k.code AS code, k.name AS name
  60. ORDER BY grade, category, code
  61. """
  62. with self._session() as session:
  63. rows = session.run(cypher).data()
  64. out: dict[str, dict[str, list[dict[str, Any]]]] = {}
  65. for row in rows:
  66. grade = row.get("grade")
  67. category = row.get("category")
  68. code = row.get("code")
  69. name = row.get("name")
  70. if not grade or not category or not code:
  71. continue
  72. grade_s = str(grade)
  73. category_s = str(category)
  74. code_s = str(code)
  75. name_s = str(name) if name is not None else code_s
  76. out.setdefault(grade_s, {}).setdefault(category_s, []).append({"code": code_s, "name": name_s})
  77. return out
  78. def get_knowledge_point(self, code: str) -> Optional[dict[str, Any]]:
  79. cypher = """
  80. MATCH (k:KnowledgePoint {code: $code})
  81. OPTIONAL MATCH (c:KnowledgeCategory)-[:CONTAINS]->(k)
  82. RETURN k{ .code, .name, .definition, .grade, .subject } AS kp, c.name AS category
  83. """
  84. with self._session() as session:
  85. rec = session.run(cypher, code=code).single()
  86. if not rec:
  87. return None
  88. kp = dict(rec["kp"])
  89. kp["category"] = rec.get("category")
  90. return kp
  91. def list_sibling_knowledge_points(self, code: str, limit: int = 8) -> list[str]:
  92. """
  93. 同类别下其它知识点(用于图预览 related),不含自身。
  94. 返回格式:名称(编码)
  95. """
  96. cypher = """
  97. MATCH (k:KnowledgePoint {code: $code})
  98. OPTIONAL MATCH (c:KnowledgeCategory)-[:CONTAINS]->(k)
  99. WITH k, c
  100. WHERE c IS NOT NULL
  101. MATCH (c)-[:CONTAINS]->(other:KnowledgePoint)
  102. WHERE other.code <> k.code
  103. RETURN other.name AS name, other.code AS ocode
  104. ORDER BY ocode
  105. LIMIT $limit
  106. """
  107. with self._session() as session:
  108. rows = session.run(cypher, code=code, limit=limit).data()
  109. out: list[str] = []
  110. for row in rows:
  111. name = row.get("name") or row.get("ocode") or ""
  112. ocode = row.get("ocode") or ""
  113. if ocode:
  114. out.append(f"{name}({ocode})")
  115. return out
  116. def link_prerequisite(self, prerequisite_code: str, dependent_code: str) -> None:
  117. """
  118. (前置知识点)-[:PREREQUISITE]->(后置知识点)
  119. 表示必须先掌握前置,再学后置。
  120. """
  121. pre = (prerequisite_code or "").strip()
  122. dep = (dependent_code or "").strip()
  123. if not pre or not dep:
  124. raise ValueError("前置与后置编码不能为空")
  125. if pre == dep:
  126. raise ValueError("前置与后置不能相同")
  127. if self.get_knowledge_point(pre) is None or self.get_knowledge_point(dep) is None:
  128. raise ValueError("前置或后置知识点在图中不存在,请先同步目录或写入知识点")
  129. cypher = """
  130. MATCH (pre:KnowledgePoint {code: $pre})
  131. MATCH (dep:KnowledgePoint {code: $dep})
  132. MERGE (pre)-[:PREREQUISITE]->(dep)
  133. """
  134. with self._session() as session:
  135. result = session.run(cypher, pre=pre, dep=dep)
  136. result.consume()
  137. def unlink_prerequisite(self, prerequisite_code: str, dependent_code: str) -> int:
  138. """删除一条 PREREQUISITE 边,返回删除条数(0 或 1)。"""
  139. pre = (prerequisite_code or "").strip()
  140. dep = (dependent_code or "").strip()
  141. if not pre or not dep:
  142. raise ValueError("前置与后置编码不能为空")
  143. cypher = """
  144. MATCH (pre:KnowledgePoint {code: $pre})-[r:PREREQUISITE]->(dep:KnowledgePoint {code: $dep})
  145. DELETE r
  146. RETURN 1 AS deleted
  147. """
  148. with self._session() as session:
  149. rec = session.run(cypher, pre=pre, dep=dep).single()
  150. return 1 if rec else 0
  151. def list_prerequisite_edges(self, code: str) -> tuple[list[str], list[str]]:
  152. """
  153. 返回 (必须先掌握的前置列表, 以当前点为前置的后续列表),展示格式:名称(编码)
  154. """
  155. code = (code or "").strip()
  156. if not code:
  157. return [], []
  158. inc_cypher = """
  159. MATCH (pre:KnowledgePoint)-[:PREREQUISITE]->(k:KnowledgePoint {code: $code})
  160. RETURN pre.name AS name, pre.code AS pcode
  161. ORDER BY pcode
  162. """
  163. out_cypher = """
  164. MATCH (k:KnowledgePoint {code: $code})-[:PREREQUISITE]->(nxt:KnowledgePoint)
  165. RETURN nxt.name AS name, nxt.code AS ncode
  166. ORDER BY ncode
  167. """
  168. incoming: list[str] = []
  169. outgoing: list[str] = []
  170. with self._session() as session:
  171. for row in session.run(inc_cypher, code=code).data():
  172. name = row.get("name") or row.get("pcode") or ""
  173. pcode = row.get("pcode") or ""
  174. if pcode:
  175. incoming.append(f"{name}({pcode})")
  176. for row in session.run(out_cypher, code=code).data():
  177. name = row.get("name") or row.get("ncode") or ""
  178. ncode = row.get("ncode") or ""
  179. if ncode:
  180. outgoing.append(f"{name}({ncode})")
  181. return incoming, outgoing
  182. def upsert_knowledge_point(self, code: str, name: str, grade: str | None = None, category: str | None = None) -> dict[str, Any]:
  183. if category:
  184. category_key = f"{grade or 'unknown'}::{category}"
  185. cypher = """
  186. MERGE (k:KnowledgePoint {code: $code})
  187. SET k.name = $name, k.grade = $grade
  188. MERGE (c:KnowledgeCategory {key: $category_key})
  189. SET c.name = $category, c.grade = $grade
  190. MERGE (c)-[:CONTAINS]->(k)
  191. RETURN k{ .code, .name, .definition, .grade, .subject } AS kp, c.name AS category
  192. """
  193. params = {"code": code, "name": name, "grade": grade, "category": category, "category_key": category_key}
  194. else:
  195. cypher = """
  196. MERGE (k:KnowledgePoint {code: $code})
  197. SET k.name = $name, k.grade = $grade
  198. RETURN k{ .code, .name, .definition, .grade, .subject } AS kp, null AS category
  199. """
  200. params = {"code": code, "name": name, "grade": grade}
  201. with self._session() as session:
  202. rec = session.run(cypher, **params).single()
  203. if not rec:
  204. raise RuntimeError("Neo4j upsert failed")
  205. kp = dict(rec["kp"])
  206. kp["category"] = rec.get("category")
  207. return kp
  208. def rename_category(self, grade: str, old_name: str, new_name: str) -> None:
  209. old_key = f"{grade}::{old_name}"
  210. new_key = f"{grade}::{new_name}"
  211. cypher = """
  212. MATCH (c:KnowledgeCategory {key: $old_key})
  213. SET c.name = $new_name, c.key = $new_key, c.grade = $grade
  214. RETURN c.key AS key
  215. """
  216. with self._session() as session:
  217. rec = session.run(cypher, old_key=old_key, new_key=new_key, new_name=new_name, grade=grade).single()
  218. if not rec:
  219. raise RuntimeError("Category not found")
  220. def delete_category(self, grade: str, name: str) -> None:
  221. key = f"{grade}::{name}"
  222. cypher = """
  223. MATCH (c:KnowledgeCategory {key: $key})-[:CONTAINS]->(k:KnowledgePoint)
  224. DETACH DELETE c, k
  225. """
  226. with self._session() as session:
  227. session.run(cypher, key=key)
  228. def rename_knowledge_point(self, code: str, new_name: str) -> None:
  229. cypher = """
  230. MATCH (k:KnowledgePoint {code: $code})
  231. SET k.name = $new_name
  232. RETURN k.code AS code
  233. """
  234. with self._session() as session:
  235. rec = session.run(cypher, code=code, new_name=new_name).single()
  236. if not rec:
  237. raise RuntimeError("Knowledge point not found")
  238. def delete_knowledge_point(self, code: str) -> None:
  239. cypher = """
  240. MATCH (k:KnowledgePoint {code: $code})
  241. DETACH DELETE k
  242. """
  243. with self._session() as session:
  244. session.run(cypher, code=code)
  245. def backfill_category_relations(self) -> dict[str, int]:
  246. fetch_cypher = """
  247. MATCH (k:KnowledgePoint)
  248. OPTIONAL MATCH (c:KnowledgeCategory)-[:CONTAINS]->(k)
  249. WITH k, c
  250. WHERE c IS NULL
  251. RETURN k.code AS code, k.name AS name
  252. """
  253. rule_map = {
  254. "J7-EQ": ("七年级", "代数与方程"),
  255. "J7-GEO": ("七年级", "几何基础"),
  256. "J8-FUNC": ("八年级", "代数与函数"),
  257. "J8-ROOT": ("八年级", "代数与函数"),
  258. "J8-GEO": ("八年级", "几何证明"),
  259. "J9-EQ": ("九年级", "代数进阶"),
  260. "J9-FUNC": ("九年级", "代数进阶"),
  261. "J9-CIRCLE": ("九年级", "圆与统计"),
  262. "J9-STAT": ("九年级", "圆与统计"),
  263. }
  264. def _infer(code: str) -> tuple[str, str]:
  265. for prefix, mapping in rule_map.items():
  266. if code.startswith(prefix):
  267. return mapping
  268. if code.startswith("J7"):
  269. return ("七年级", "未分类")
  270. if code.startswith("J8"):
  271. return ("八年级", "未分类")
  272. if code.startswith("J9"):
  273. return ("九年级", "未分类")
  274. return ("未知年级", "未分类")
  275. with self._session() as session:
  276. rows = session.run(fetch_cypher).data()
  277. total = len(rows)
  278. updated = 0
  279. for row in rows:
  280. code = row.get("code")
  281. name = row.get("name") or code or "未命名知识点"
  282. if not code:
  283. continue
  284. grade, category = _infer(code)
  285. self.upsert_knowledge_point(code=code, name=name, grade=grade, category=category)
  286. updated += 1
  287. return {"total": total, "updated": updated, "skipped": total - updated}
  288. _client: Neo4jClient | None = None
  289. def get_neo4j_client() -> Neo4jClient:
  290. global _client
  291. if _client is None:
  292. driver = GraphDatabase.driver(
  293. settings.neo4j_uri,
  294. auth=(settings.neo4j_user, settings.neo4j_password),
  295. )
  296. _client = Neo4jClient(driver=driver)
  297. try:
  298. _client.ensure_constraints()
  299. except Neo4jError:
  300. pass
  301. return _client