| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331 |
- """
- Neo4j 图数据库客户端:知识点/类别节点、CONTAINS 与 PREREQUISITE 关系的查询与维护。
- """
- from __future__ import annotations
- from dataclasses import dataclass
- from typing import Any, Optional
- from neo4j import GraphDatabase
- from neo4j.exceptions import Neo4jError
- from app.core.config import settings
- @dataclass(frozen=True)
- class Neo4jClient:
- driver: Any
- def _session(self):
- return self.driver.session(database=settings.neo4j_database)
- def close(self) -> None:
- self.driver.close()
- def ensure_constraints(self) -> None:
- """创建唯一约束(KnowledgePoint.code、KnowledgeCategory.key),可重复调用。"""
- kp_cypher = """
- CREATE CONSTRAINT kp_code_unique IF NOT EXISTS
- FOR (k:KnowledgePoint) REQUIRE k.code IS UNIQUE
- """
- kc_cypher = """
- CREATE CONSTRAINT kc_key_unique IF NOT EXISTS
- FOR (c:KnowledgeCategory) REQUIRE c.key IS UNIQUE
- """
- with self._session() as session:
- session.run(kp_cypher)
- session.run(kc_cypher)
- def list_all_knowledge_points(self) -> list[dict[str, Any]]:
- """图中全部 KnowledgePoint,用于与前端目录对齐的下拉选项。"""
- cypher = """
- MATCH (k:KnowledgePoint)
- RETURN k.code AS code, k.name AS name, k.grade AS grade
- ORDER BY k.code
- """
- with self._session() as session:
- rows = session.run(cypher).data()
- out: list[dict[str, Any]] = []
- for row in rows:
- code = row.get("code")
- if not code:
- continue
- out.append(
- {
- "code": str(code),
- "name": row.get("name"),
- "grade": row.get("grade"),
- }
- )
- return out
- def list_knowledge_catalog(self) -> dict[str, dict[str, list[dict[str, Any]]]]:
- """
- 返回 Neo4j 中的知识点目录树:{grade: {category: [{code,name}, ...]}}
- 与前端 KnowledgePage 的下拉层级一致。
- """
- cypher = """
- MATCH (c:KnowledgeCategory)-[:CONTAINS]->(k:KnowledgePoint)
- RETURN c.grade AS grade, c.name AS category, k.code AS code, k.name AS name
- ORDER BY grade, category, code
- """
- with self._session() as session:
- rows = session.run(cypher).data()
- out: dict[str, dict[str, list[dict[str, Any]]]] = {}
- for row in rows:
- grade = row.get("grade")
- category = row.get("category")
- code = row.get("code")
- name = row.get("name")
- if not grade or not category or not code:
- continue
- grade_s = str(grade)
- category_s = str(category)
- code_s = str(code)
- name_s = str(name) if name is not None else code_s
- out.setdefault(grade_s, {}).setdefault(category_s, []).append({"code": code_s, "name": name_s})
- return out
- def get_knowledge_point(self, code: str) -> Optional[dict[str, Any]]:
- cypher = """
- MATCH (k:KnowledgePoint {code: $code})
- OPTIONAL MATCH (c:KnowledgeCategory)-[:CONTAINS]->(k)
- RETURN k{ .code, .name, .definition, .grade, .subject } AS kp, c.name AS category
- """
- with self._session() as session:
- rec = session.run(cypher, code=code).single()
- if not rec:
- return None
- kp = dict(rec["kp"])
- kp["category"] = rec.get("category")
- return kp
- def list_sibling_knowledge_points(self, code: str, limit: int = 8) -> list[str]:
- """
- 同类别下其它知识点(用于图预览 related),不含自身。
- 返回格式:名称(编码)
- """
- cypher = """
- MATCH (k:KnowledgePoint {code: $code})
- OPTIONAL MATCH (c:KnowledgeCategory)-[:CONTAINS]->(k)
- WITH k, c
- WHERE c IS NOT NULL
- MATCH (c)-[:CONTAINS]->(other:KnowledgePoint)
- WHERE other.code <> k.code
- RETURN other.name AS name, other.code AS ocode
- ORDER BY ocode
- LIMIT $limit
- """
- with self._session() as session:
- rows = session.run(cypher, code=code, limit=limit).data()
- out: list[str] = []
- for row in rows:
- name = row.get("name") or row.get("ocode") or ""
- ocode = row.get("ocode") or ""
- if ocode:
- out.append(f"{name}({ocode})")
- return out
- def link_prerequisite(self, prerequisite_code: str, dependent_code: str) -> None:
- """
- (前置知识点)-[:PREREQUISITE]->(后置知识点)
- 表示必须先掌握前置,再学后置。
- """
- pre = (prerequisite_code or "").strip()
- dep = (dependent_code or "").strip()
- if not pre or not dep:
- raise ValueError("前置与后置编码不能为空")
- if pre == dep:
- raise ValueError("前置与后置不能相同")
- if self.get_knowledge_point(pre) is None or self.get_knowledge_point(dep) is None:
- raise ValueError("前置或后置知识点在图中不存在,请先同步目录或写入知识点")
- cypher = """
- MATCH (pre:KnowledgePoint {code: $pre})
- MATCH (dep:KnowledgePoint {code: $dep})
- MERGE (pre)-[:PREREQUISITE]->(dep)
- """
- with self._session() as session:
- result = session.run(cypher, pre=pre, dep=dep)
- result.consume()
- def unlink_prerequisite(self, prerequisite_code: str, dependent_code: str) -> int:
- """删除一条 PREREQUISITE 边,返回删除条数(0 或 1)。"""
- pre = (prerequisite_code or "").strip()
- dep = (dependent_code or "").strip()
- if not pre or not dep:
- raise ValueError("前置与后置编码不能为空")
- cypher = """
- MATCH (pre:KnowledgePoint {code: $pre})-[r:PREREQUISITE]->(dep:KnowledgePoint {code: $dep})
- DELETE r
- RETURN 1 AS deleted
- """
- with self._session() as session:
- rec = session.run(cypher, pre=pre, dep=dep).single()
- return 1 if rec else 0
- def list_prerequisite_edges(self, code: str) -> tuple[list[str], list[str]]:
- """
- 返回 (必须先掌握的前置列表, 以当前点为前置的后续列表),展示格式:名称(编码)
- """
- code = (code or "").strip()
- if not code:
- return [], []
- inc_cypher = """
- MATCH (pre:KnowledgePoint)-[:PREREQUISITE]->(k:KnowledgePoint {code: $code})
- RETURN pre.name AS name, pre.code AS pcode
- ORDER BY pcode
- """
- out_cypher = """
- MATCH (k:KnowledgePoint {code: $code})-[:PREREQUISITE]->(nxt:KnowledgePoint)
- RETURN nxt.name AS name, nxt.code AS ncode
- ORDER BY ncode
- """
- incoming: list[str] = []
- outgoing: list[str] = []
- with self._session() as session:
- for row in session.run(inc_cypher, code=code).data():
- name = row.get("name") or row.get("pcode") or ""
- pcode = row.get("pcode") or ""
- if pcode:
- incoming.append(f"{name}({pcode})")
- for row in session.run(out_cypher, code=code).data():
- name = row.get("name") or row.get("ncode") or ""
- ncode = row.get("ncode") or ""
- if ncode:
- outgoing.append(f"{name}({ncode})")
- return incoming, outgoing
- def upsert_knowledge_point(self, code: str, name: str, grade: str | None = None, category: str | None = None) -> dict[str, Any]:
- if category:
- category_key = f"{grade or 'unknown'}::{category}"
- cypher = """
- MERGE (k:KnowledgePoint {code: $code})
- SET k.name = $name, k.grade = $grade
- MERGE (c:KnowledgeCategory {key: $category_key})
- SET c.name = $category, c.grade = $grade
- MERGE (c)-[:CONTAINS]->(k)
- RETURN k{ .code, .name, .definition, .grade, .subject } AS kp, c.name AS category
- """
- params = {"code": code, "name": name, "grade": grade, "category": category, "category_key": category_key}
- else:
- cypher = """
- MERGE (k:KnowledgePoint {code: $code})
- SET k.name = $name, k.grade = $grade
- RETURN k{ .code, .name, .definition, .grade, .subject } AS kp, null AS category
- """
- params = {"code": code, "name": name, "grade": grade}
- with self._session() as session:
- rec = session.run(cypher, **params).single()
- if not rec:
- raise RuntimeError("Neo4j upsert failed")
- kp = dict(rec["kp"])
- kp["category"] = rec.get("category")
- return kp
- def rename_category(self, grade: str, old_name: str, new_name: str) -> None:
- old_key = f"{grade}::{old_name}"
- new_key = f"{grade}::{new_name}"
- cypher = """
- MATCH (c:KnowledgeCategory {key: $old_key})
- SET c.name = $new_name, c.key = $new_key, c.grade = $grade
- RETURN c.key AS key
- """
- with self._session() as session:
- rec = session.run(cypher, old_key=old_key, new_key=new_key, new_name=new_name, grade=grade).single()
- if not rec:
- raise RuntimeError("Category not found")
- def delete_category(self, grade: str, name: str) -> None:
- key = f"{grade}::{name}"
- cypher = """
- MATCH (c:KnowledgeCategory {key: $key})-[:CONTAINS]->(k:KnowledgePoint)
- DETACH DELETE c, k
- """
- with self._session() as session:
- session.run(cypher, key=key)
- def rename_knowledge_point(self, code: str, new_name: str) -> None:
- cypher = """
- MATCH (k:KnowledgePoint {code: $code})
- SET k.name = $new_name
- RETURN k.code AS code
- """
- with self._session() as session:
- rec = session.run(cypher, code=code, new_name=new_name).single()
- if not rec:
- raise RuntimeError("Knowledge point not found")
- def delete_knowledge_point(self, code: str) -> None:
- cypher = """
- MATCH (k:KnowledgePoint {code: $code})
- DETACH DELETE k
- """
- with self._session() as session:
- session.run(cypher, code=code)
- def backfill_category_relations(self) -> dict[str, int]:
- fetch_cypher = """
- MATCH (k:KnowledgePoint)
- OPTIONAL MATCH (c:KnowledgeCategory)-[:CONTAINS]->(k)
- WITH k, c
- WHERE c IS NULL
- RETURN k.code AS code, k.name AS name
- """
- rule_map = {
- "J7-EQ": ("七年级", "代数与方程"),
- "J7-GEO": ("七年级", "几何基础"),
- "J8-FUNC": ("八年级", "代数与函数"),
- "J8-ROOT": ("八年级", "代数与函数"),
- "J8-GEO": ("八年级", "几何证明"),
- "J9-EQ": ("九年级", "代数进阶"),
- "J9-FUNC": ("九年级", "代数进阶"),
- "J9-CIRCLE": ("九年级", "圆与统计"),
- "J9-STAT": ("九年级", "圆与统计"),
- }
- def _infer(code: str) -> tuple[str, str]:
- for prefix, mapping in rule_map.items():
- if code.startswith(prefix):
- return mapping
- if code.startswith("J7"):
- return ("七年级", "未分类")
- if code.startswith("J8"):
- return ("八年级", "未分类")
- if code.startswith("J9"):
- return ("九年级", "未分类")
- return ("未知年级", "未分类")
- with self._session() as session:
- rows = session.run(fetch_cypher).data()
- total = len(rows)
- updated = 0
- for row in rows:
- code = row.get("code")
- name = row.get("name") or code or "未命名知识点"
- if not code:
- continue
- grade, category = _infer(code)
- self.upsert_knowledge_point(code=code, name=name, grade=grade, category=category)
- updated += 1
- return {"total": total, "updated": updated, "skipped": total - updated}
- _client: Neo4jClient | None = None
- def get_neo4j_client() -> Neo4jClient:
- global _client
- if _client is None:
- driver = GraphDatabase.driver(
- settings.neo4j_uri,
- auth=(settings.neo4j_user, settings.neo4j_password),
- )
- _client = Neo4jClient(driver=driver)
- try:
- _client.ensure_constraints()
- except Neo4jError:
- pass
- return _client
|