graph.js 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992
  1. // C2 防护:边类型黑名单(模块级常量,参数化传入,勿拼字符串)
  2. // 普通模式:HAS_QUESTION + 能力画像全部边类型(支撑能力/考查能力/支撑维度/属于层次/对应模块)
  3. const HIDDEN_REL_TYPES = ['HAS_QUESTION', '支撑能力', '考查能力', '支撑维度', '属于层次', '对应模块'];
  4. // 能力画像模式:支撑能力仅由抽屉"展开支撑子图"专用查询(Q3a)消费,通用展开路径同样屏蔽
  5. const HIDDEN_REL_TYPES_ABILITY = ['HAS_QUESTION', '考查能力', '支撑维度', '属于层次', '对应模块'];
  6. // 边显示名:能力画像的"支撑能力/考查能力"边附加置信度(confidence 是过滤的第一口径)
  7. const formatEdgeLabel = (r_info) => {
  8. const type = r_info.type || r_info.props?.label || '相关';
  9. const c = r_info.props?.confidence;
  10. return c ? `${type}·${c}` : type;
  11. };
  12. // 节点 data 组装:能力画像属性(ability_id/level/definition 等)一并带上,
  13. // 供画布层次上色与能力项抽屉使用
  14. const toNodeData = (info) => {
  15. const p = info.props || {};
  16. return {
  17. text: p.name || '',
  18. elementId: info.elementId,
  19. labels: info.labels,
  20. ...(p.ability_id !== undefined && { ability_id: p.ability_id }),
  21. ...(p.level !== undefined && { level: p.level }),
  22. ...(p.definition !== undefined && { definition: p.definition }),
  23. ...(p.knowledge_support !== undefined && { knowledge_support: p.knowledge_support }),
  24. ...(p.star !== undefined && { star: p.star })
  25. };
  26. };
  27. export const graphService = {
  28. /**
  29. * 获取图谱的根节点
  30. * 能力画像模式:以 6 个维度(AbilityDimension)为根(维度有"支撑维度"入边,
  31. * 不能用 NOT ()-[]->(n) 判根,必须按标签直查)
  32. */
  33. async findRootNodes(neo4jInstance, includeAbility = false) {
  34. // 能力画像模式:6 个维度为根,节点大小按支撑知识点总数编码(如 877/818/672/653/637/419);
  35. // 不走 NOT ()-[]->(n) 判根(维度有"支撑维度"入边),按标签直查
  36. const cypher = includeAbility ? `
  37. MATCH (n:AbilityDimension)
  38. OPTIONAL MATCH ()-[r:支撑能力]->( )-[:包含]->(n)
  39. WITH n, count(r) AS supportCount
  40. RETURN id(n) AS id, elementId(n) AS elementId, labels(n) as labels,
  41. properties(n) AS props, supportCount
  42. ` : `
  43. MATCH (n)
  44. WHERE NOT ()-[]->(n) AND NOT n:Question
  45. AND NOT n:AbilityDimension AND NOT n:Ability AND NOT n:AbilityLevel
  46. RETURN id(n) AS id, elementId(n) AS elementId, labels(n) as labels, properties(n) AS props
  47. `;
  48. const records = await neo4jInstance.execute(cypher);
  49. // 将结果整理为清晰的数组格式
  50. return records.map(record => ({
  51. id: record.id,
  52. elementId: record.elementId,
  53. labels: record.labels,
  54. supportCount: record.supportCount ?? 0,
  55. ...record.props // 展开所有属性,比如 name, group 等
  56. }));
  57. },
  58. /**
  59. * 根据节点ID获取节点信息 WITH n,r,m LIMIT 50
  60. * */
  61. async findNodeById(neo4jInstance, nodeId, includeAbility = false) {
  62. // 能力画像模式:双向展开(Entity─支撑能力→Ability 等入边仅由 Q3a 专用查询消费,
  63. // 通用展开路径屏蔽全部能力画像边,画布只出 维度─包含→能力项);
  64. // AbilityLevel 不进画布(层次降维为 Ability 颜色属性)
  65. const cypher = includeAbility ? `
  66. MATCH (n)
  67. WHERE id(n) = ${nodeId}
  68. OPTIONAL MATCH (n)-[r1]->(m1)
  69. WHERE NOT type(r1) IN $hidden AND NOT m1:AbilityLevel
  70. AND (NOT n:Entity OR m1:Entity)
  71. OPTIONAL MATCH (m2)-[r2]->(n)
  72. WHERE NOT type(r2) IN $hidden AND NOT m2:AbilityLevel
  73. AND (NOT n:Entity OR m2:Entity)
  74. WITH n,
  75. CASE WHEN r1 IS NOT NULL AND m1 IS NOT NULL THEN {rel: r1, other: m1} ELSE null END AS outRel,
  76. CASE WHEN r2 IS NOT NULL AND m2 IS NOT NULL THEN {rel: r2, other: m2} ELSE null END AS inRel
  77. WITH n, [x IN [outRel, inRel] WHERE x IS NOT NULL] AS rels
  78. UNWIND rels AS rel
  79. WITH n, rel.rel AS r, rel.other AS m
  80. RETURN
  81. {id: id(n), elementId: elementId(n), labels: labels(n), props: properties(n)} AS n_info,
  82. {id: id(r), elementId: elementId(r), type: type(r), props: properties(r)} AS r_info,
  83. {id: id(m), elementId: elementId(m), labels: labels(m), props: properties(m)} AS m_info
  84. ` : `
  85. MATCH (n)-[r]->(m)
  86. WHERE id(n) = ${nodeId}
  87. AND NOT type(r) IN $hidden
  88. AND NOT m:AbilityDimension AND NOT m:Ability AND NOT m:AbilityLevel
  89. RETURN
  90. {id: id(n), elementId: elementId(n), labels: labels(n), props: properties(n)} AS n_info,
  91. {id: id(r), elementId: elementId(r), type: type(r), props: properties(r)} AS r_info,
  92. {id: id(m), elementId: elementId(m), labels: labels(m), props: properties(m)} AS m_info
  93. `;
  94. const records = await neo4jInstance.execute(cypher, {
  95. hidden: includeAbility ? HIDDEN_REL_TYPES_ABILITY : HIDDEN_REL_TYPES
  96. });
  97. // 将 records 转换为 G6 需要的 {nodes:[], edges:[]} 格式
  98. const nodeMap = new Map();
  99. const edgeMap = new Map();
  100. records.forEach(record => {
  101. const {
  102. n_info,
  103. r_info,
  104. m_info
  105. } = record;
  106. // 处理起始节点 n
  107. if (n_info && !nodeMap.has(n_info.id)) {
  108. // console.log(n_info.props.sources)
  109. nodeMap.set(n_info.id, {
  110. id: String(n_info.id),
  111. data: toNodeData(n_info)
  112. });
  113. }
  114. // 处理目标节点 m
  115. if (m_info && !nodeMap.has(m_info.id)) {
  116. nodeMap.set(m_info.id, {
  117. id: String(m_info.id),
  118. data: toNodeData(m_info)
  119. });
  120. }
  121. // 处理关系 r
  122. if (r_info && !edgeMap.has(r_info.id)) {
  123. edgeMap.set(r_info.id, {
  124. id: String(r_info.elementId),
  125. source: String(n_info.id),
  126. target: String(m_info.id),
  127. label: formatEdgeLabel(r_info),
  128. // sources: r_info.props?.sources?r_info.props?.sources:"[]",
  129. });
  130. }
  131. });
  132. // console.log("根据节点ID获取节点信息:",nodeMap.values(), edgeMap.values())
  133. const result = {
  134. nodes: Array.from(nodeMap.values()),
  135. edges: Array.from(edgeMap.values())
  136. };
  137. // console.log('转换后的数据:', result);
  138. return result;
  139. },
  140. /**
  141. * 根据节点ID查询节点的双向关系(网状结构)
  142. * 包括入边和出边,展示节点的完整关联路径
  143. */
  144. async findNodeRelations(neo4jInstance, nodeId, includeAbility = false) {
  145. // 普通模式剔除能力画像三类节点;能力画像模式保留但排除 AbilityLevel(层次不进画布);
  146. // 能力画像模式下展开实体时仅保留实体邻居(过滤 Mu/Section 等结构节点)
  147. const filterM = includeAbility ? ' AND NOT m:AbilityLevel AND (NOT n:Entity OR m:Entity)'
  148. : ' AND NOT m:AbilityDimension AND NOT m:Ability AND NOT m:AbilityLevel';
  149. const filterP = includeAbility ? ' AND NOT p:AbilityLevel AND (NOT n:Entity OR p:Entity)'
  150. : ' AND NOT p:AbilityDimension AND NOT p:Ability AND NOT p:AbilityLevel';
  151. const cypher = `
  152. MATCH (n)
  153. WHERE elementId(n) = $nodeId OR id(n) = toInteger($nodeId)
  154. OPTIONAL MATCH (n)-[r1]->(m)
  155. WHERE NOT type(r1) IN $hidden${filterM}
  156. OPTIONAL MATCH (p)-[r2]->(n)
  157. WHERE NOT type(r2) IN $hidden${filterP}
  158. WITH n, m, r1, p, r2
  159. WITH collect(DISTINCT n) + collect(DISTINCT m) + collect(DISTINCT p) AS allNodes,
  160. collect(DISTINCT r1) + collect(DISTINCT r2) AS allRelations
  161. UNWIND allNodes AS n1
  162. UNWIND allNodes AS n2
  163. MATCH (n1)-[r]->(n2)
  164. WHERE NOT type(r) IN $hidden AND r IS NOT NULL
  165. RETURN
  166. {id: id(n1), elementId: elementId(n1), labels: labels(n1), props: properties(n1)} AS n_info,
  167. {id: id(r), elementId: elementId(r), type: type(r), props: properties(r)} AS r_info,
  168. {id: id(n2), elementId: elementId(n2), labels: labels(n2), props: properties(n2)} AS m_info
  169. `;
  170. const records = await neo4jInstance.execute(cypher, {
  171. nodeId,
  172. hidden: includeAbility ? HIDDEN_REL_TYPES_ABILITY : HIDDEN_REL_TYPES
  173. });
  174. const nodeMap = new Map();
  175. const edgeMap = new Map();
  176. records.forEach(record => {
  177. const { n_info, r_info, m_info } = record;
  178. if (n_info && !nodeMap.has(n_info.id)) {
  179. nodeMap.set(n_info.id, {
  180. id: String(n_info.id),
  181. data: toNodeData(n_info)
  182. });
  183. }
  184. if (m_info && !nodeMap.has(m_info.id)) {
  185. nodeMap.set(m_info.id, {
  186. id: String(m_info.id),
  187. data: toNodeData(m_info)
  188. });
  189. }
  190. if (r_info && !edgeMap.has(r_info.elementId)) {
  191. edgeMap.set(r_info.elementId, {
  192. id: String(r_info.elementId),
  193. source: String(n_info.id),
  194. target: String(m_info.id),
  195. label: formatEdgeLabel(r_info),
  196. });
  197. }
  198. });
  199. if (nodeMap.size === 0) {
  200. const fallbackCypher = `
  201. MATCH (n) WHERE elementId(n) = $nodeId OR id(n) = toInteger($nodeId)
  202. RETURN {id: id(n), elementId: elementId(n), labels: labels(n), props: properties(n)} AS n_info
  203. `;
  204. const fallbackRecords = await neo4jInstance.execute(fallbackCypher, { nodeId });
  205. if (fallbackRecords.length > 0) {
  206. const { n_info } = fallbackRecords[0];
  207. nodeMap.set(n_info.id, {
  208. id: String(n_info.id),
  209. data: {
  210. text: n_info.props.name || '',
  211. elementId: n_info.elementId,
  212. labels: n_info.labels,
  213. ...(n_info.props.star !== undefined && { star: n_info.props.star })
  214. }
  215. });
  216. }
  217. }
  218. return {
  219. nodes: Array.from(nodeMap.values()),
  220. edges: Array.from(edgeMap.values())
  221. };
  222. },
  223. /**
  224. * 根据父节点ID,获取其下属的子图
  225. * (包含父节点、所有子节点,以及这个家族圈子内部的所有交叉关系)
  226. */
  227. async findSubgraphByParentId(neo4jInstance, parentNodeId, includeAbility = false) {
  228. // 1. MATCH 找到指定的父节点 n
  229. // 2. OPTIONAL MATCH (n)-[r1]->(m) 顺着箭头找出所有的子节点 m
  230. // 3. 限制数量,并将父节点与所有子节点合并为 allNodes 节点池
  231. // 4. 在节点池内部,寻找任意两点之间的所有连线 r2(从而把子节点之间的关系也查出来)
  232. // 能力画像模式排除 AbilityLevel(层次降维为颜色属性,不进画布);
  233. // 展开实体时仅保留实体邻居(过滤 Mu/Section 等结构节点及其他类型)
  234. const abilityFilter = includeAbility ? ' AND NOT m:AbilityLevel AND (NOT n:Entity OR m:Entity)'
  235. : ' AND NOT m:AbilityDimension AND NOT m:Ability AND NOT m:AbilityLevel';
  236. const cypher = `
  237. MATCH (n)
  238. WHERE elementId(n) = $parentNodeId OR id(n) = toInteger($parentNodeId)
  239. // 明确单向箭头,只找父节点指向的子节点
  240. OPTIONAL MATCH (n)-[r1]->(m)
  241. WHERE NOT type(r1) IN $hidden${abilityFilter}
  242. WITH n, m LIMIT 60
  243. // 将父节点和它所有的子节点打包成一个“家族圈子”
  244. WITH collect(distinct n) + collect(distinct m) AS allNodes
  245. // 展开圈子,寻找圈子内部成员之间的所有关系(包括子节点与子节点之间的关系)
  246. UNWIND allNodes AS n1
  247. UNWIND allNodes AS n2
  248. MATCH (n1)-[r2]->(n2)
  249. WHERE NOT type(r2) IN $hidden
  250. RETURN
  251. {id: id(n1), elementId: elementId(n1), labels: labels(n1), props: properties(n1)} AS n_info,
  252. {id: id(r2), elementId: elementId(r2), type: type(r2), props: properties(r2)} AS r_info,
  253. {id: id(n2), elementId: elementId(n2), labels: labels(n2), props: properties(n2)} AS m_info
  254. `;
  255. const records = await neo4jInstance.execute(cypher, {
  256. parentNodeId: parentNodeId,
  257. hidden: includeAbility ? HIDDEN_REL_TYPES_ABILITY : HIDDEN_REL_TYPES
  258. });
  259. const nodeMap = new Map();
  260. const edgeMap = new Map();
  261. records.forEach(record => {
  262. const { n_info, r_info, m_info } = record;
  263. // 1. 处理源节点
  264. if (n_info && !nodeMap.has(n_info.elementId)) {
  265. nodeMap.set(n_info.elementId, {
  266. id: String(n_info.elementId),
  267. data: {
  268. text: n_info.props.name || '',
  269. labels: n_info.labels,
  270. ...(n_info.props.star !== undefined && { star: n_info.props.star })
  271. }
  272. });
  273. }
  274. // 2. 处理目标节点
  275. if (m_info && !nodeMap.has(m_info.elementId)) {
  276. nodeMap.set(m_info.elementId, {
  277. id: String(m_info.elementId),
  278. data: {
  279. text: m_info.props.name || '',
  280. labels: m_info.labels,
  281. ...(m_info.props.star !== undefined && { star: m_info.props.star })
  282. }
  283. });
  284. }
  285. // 3. 处理关系(此时 r_info 包含了:父->子、子->子 的所有关系)
  286. if (r_info && !edgeMap.has(r_info.elementId)) {
  287. edgeMap.set(r_info.elementId, {
  288. id: String(r_info.elementId),
  289. source: String(n_info.elementId),
  290. target: String(m_info.elementId),
  291. label: formatEdgeLabel(r_info),
  292. });
  293. }
  294. });
  295. // 兜底机制:如果该父节点没有任何子节点,至少把父节点自身返回,避免前端画布空白
  296. if (nodeMap.size === 0) {
  297. const fallbackCypher = `
  298. MATCH (n) WHERE elementId(n) = $parentNodeId OR id(n) = toInteger($parentNodeId)
  299. RETURN {id: id(n), elementId: elementId(n), labels: labels(n), props: properties(n)} AS n_info
  300. `;
  301. const fallbackRecords = await neo4jInstance.execute(fallbackCypher, { parentNodeId: parentNodeId });
  302. if (fallbackRecords.length > 0) {
  303. const { n_info } = fallbackRecords[0];
  304. nodeMap.set(n_info.elementId, {
  305. id: String(n_info.elementId),
  306. data: {
  307. text: n_info.props.name || '',
  308. labels: n_info.labels,
  309. ...(n_info.props.star !== undefined && { star: n_info.props.star })
  310. }
  311. });
  312. }
  313. }
  314. return {
  315. nodes: Array.from(nodeMap.values()),
  316. edges: Array.from(edgeMap.values())
  317. };
  318. },
  319. /**
  320. * 查询某个父节点的直接子节点总数
  321. */
  322. async countChildren(neo4jInstance, parentNodeId, includeAbility = false) {
  323. // 能力画像模式:双向计数(出边邻居 + 入边邻居),排除 AbilityLevel;
  324. // 支撑能力等边在黑名单内,此计数反映"画布可见邻居数"
  325. const cypher = includeAbility ? `
  326. MATCH (n)
  327. WHERE elementId(n) = $parentNodeId OR id(n) = toInteger($parentNodeId)
  328. OPTIONAL MATCH (n)-[r1]->(m1)
  329. WHERE NOT type(r1) IN $hidden AND NOT m1:AbilityLevel
  330. AND (NOT n:Entity OR m1:Entity)
  331. OPTIONAL MATCH (m2)-[r2]->(n)
  332. WHERE NOT type(r2) IN $hidden AND NOT m2:AbilityLevel
  333. AND (NOT n:Entity OR m2:Entity)
  334. WITH collect(DISTINCT m1) + collect(DISTINCT m2) AS allM
  335. RETURN size([x IN allM WHERE x IS NOT NULL]) AS total
  336. ` : `
  337. MATCH (n)-[r]->(m)
  338. WHERE (elementId(n) = $parentNodeId OR id(n) = toInteger($parentNodeId))
  339. AND NOT type(r) IN $hidden
  340. AND NOT m:AbilityDimension AND NOT m:Ability AND NOT m:AbilityLevel
  341. RETURN count(DISTINCT m) AS total
  342. `;
  343. const records = await neo4jInstance.execute(cypher, {
  344. parentNodeId,
  345. hidden: includeAbility ? HIDDEN_REL_TYPES_ABILITY : HIDDEN_REL_TYPES
  346. });
  347. return records[0]?.total || 0;
  348. },
  349. /**
  350. * 能力画像:查询能力项的层次详情(唯一需要查 AbilityLevel 节点的场景)
  351. * 取 bloom 区间映射与 definition,用于抽屉展示
  352. */
  353. async findAbilityLevelDetail(neo4jInstance, abilityId) {
  354. const cypher = `
  355. MATCH (a:Ability {ability_id: $abilityId})-[:属于层次]->(l:AbilityLevel)
  356. RETURN properties(l) AS levelProps
  357. `;
  358. const records = await neo4jInstance.execute(cypher, { abilityId });
  359. return records[0]?.levelProps || null;
  360. },
  361. /**
  362. * 能力画像:本维度的能力层次分布(聚合统计小字,如"会 3 · 通 2")
  363. */
  364. async findDimensionLevelDistribution(neo4jInstance, dimensionId) {
  365. const cypher = `
  366. MATCH (d:AbilityDimension {dimension_id: $dimensionId})-[:包含]->(a:Ability)
  367. RETURN a.level AS level, count(a) AS cnt
  368. ORDER BY cnt DESC
  369. `;
  370. const records = await neo4jInstance.execute(cypher, { dimensionId });
  371. return records.map(r => ({ level: r.level, count: r.cnt }));
  372. },
  373. /**
  374. * Q1 能力全景(首页态 + 一级展开)
  375. * 每行一个能力项,带支撑统计(total / high_cnt);
  376. * 首页态按维度聚合 supportCount(节点大小编码),展开态取该维度的能力项行
  377. */
  378. async findAbilityOverview(neo4jInstance) {
  379. const cypher = `
  380. MATCH (d:AbilityDimension)-[:包含]->(a:Ability)-[:属于层次]->(l:AbilityLevel)
  381. OPTIONAL MATCH (e:Entity)-[s:支撑能力]->(a)
  382. RETURN id(d) AS d_id, elementId(d) AS d_elementId,
  383. d.dimension_id AS dimension_id, d.name AS dimension_name,
  384. id(a) AS a_id, elementId(a) AS a_elementId,
  385. a.ability_id AS ability_id, a.name AS ability_name,
  386. a.level AS level, a.definition AS definition, a.knowledge_support AS knowledge_support,
  387. l.code AS level_code, l.bloom AS level_bloom, l.definition AS level_definition,
  388. count(s) AS total,
  389. sum(CASE WHEN s.confidence='高' THEN 1 ELSE 0 END) AS high_cnt
  390. ORDER BY d.dimension_id, a.ability_id
  391. `;
  392. return await neo4jInstance.execute(cypher);
  393. },
  394. /**
  395. * Q2 能力详情:支撑知识点列表(置信度分组排序:高→中→低,star 优先)
  396. * 供能力项抽屉展示(name/type/star/confidence/reasoning)
  397. */
  398. async findAbilitySupportList(neo4jInstance, abilityId) {
  399. const cypher = `
  400. MATCH (e:Entity)-[s:支撑能力]->(a:Ability {ability_id: $abilityId})
  401. RETURN e.name AS name, e.type AS type, e.star AS star,
  402. s.confidence AS confidence, s.reasoning AS reasoning
  403. ORDER BY CASE s.confidence WHEN '高' THEN 1 WHEN '中' THEN 2 ELSE 3 END,
  404. e.star DESC
  405. `;
  406. return await neo4jInstance.execute(cypher, { abilityId });
  407. },
  408. /**
  409. * Q3a 支撑子图·节点:Entity─支撑能力→Ability,高置信优先 + star 优先,LIMIT 默认 200
  410. */
  411. async findAbilitySupportSubgraph(neo4jInstance, abilityId, limit = 200) {
  412. const cypher = `
  413. MATCH (e:Entity)-[r:支撑能力]->(a:Ability {ability_id: $abilityId})
  414. WITH e, r, a
  415. ORDER BY CASE r.confidence WHEN '高' THEN 1 WHEN '中' THEN 2 WHEN '低' THEN 3 ELSE 4 END, e.star DESC
  416. LIMIT toInteger($limit)
  417. RETURN
  418. {id: id(a), elementId: elementId(a), labels: labels(a), props: properties(a)} AS n_info,
  419. {id: id(r), elementId: elementId(r), type: type(r), props: properties(r)} AS r_info,
  420. {id: id(e), elementId: elementId(e), labels: labels(e), props: properties(e)} AS m_info
  421. `;
  422. const records = await neo4jInstance.execute(cypher, { abilityId, limit });
  423. const nodeMap = new Map();
  424. const edgeMap = new Map();
  425. records.forEach(record => {
  426. const { n_info, r_info, m_info } = record;
  427. if (n_info && !nodeMap.has(n_info.id)) {
  428. nodeMap.set(n_info.id, {
  429. id: String(n_info.id),
  430. data: toNodeData(n_info)
  431. });
  432. }
  433. if (m_info && !nodeMap.has(m_info.id)) {
  434. nodeMap.set(m_info.id, {
  435. id: String(m_info.id),
  436. data: toNodeData(m_info)
  437. });
  438. }
  439. if (r_info && !edgeMap.has(r_info.id)) {
  440. edgeMap.set(r_info.id, {
  441. id: String(r_info.elementId),
  442. source: String(m_info.id),
  443. target: String(n_info.id),
  444. label: formatEdgeLabel(r_info),
  445. });
  446. }
  447. });
  448. return {
  449. nodes: Array.from(nodeMap.values()),
  450. edges: Array.from(edgeMap.values())
  451. };
  452. },
  453. /**
  454. * Q3b 支撑子图·实体间语义边:仅在 Q3a 返回的实体名列表内,
  455. * 找 前序/关联/因果/对比 四类语义关系($names 为实体名列表)
  456. */
  457. async findEntitySemanticEdges(neo4jInstance, names) {
  458. if (!names || names.length < 2) return [];
  459. const cypher = `
  460. UNWIND $names AS n1
  461. UNWIND $names AS n2
  462. MATCH (a:Entity {name: n1})-[r]->(b:Entity {name: n2})
  463. WHERE type(r) IN ['前序', '关联', '因果', '对比']
  464. RETURN n1, n2, type(r) AS rel
  465. `;
  466. return await neo4jInstance.execute(cypher, { names });
  467. },
  468. /**
  469. * Q4 能力可选题量:考查该能力项的题目数(本阶段仅数字展示,不做跳转)
  470. */
  471. async findAbilityQuestionCount(neo4jInstance, abilityId) {
  472. const cypher = `
  473. MATCH (a:Ability {ability_id: $abilityId})<-[:考查能力]-(q:Question)
  474. RETURN count(q) AS q_cnt
  475. `;
  476. const records = await neo4jInstance.execute(cypher, { abilityId });
  477. return records[0]?.q_cnt || 0;
  478. },
  479. /**
  480. * 分页查询父节点的子图(用于子节点超过阈值时分批加载)
  481. * 行为与 findNodeById 一致(单向父子边,数字id),仅加分页
  482. */
  483. async findSubgraphByParentIdPaged(neo4jInstance, parentNodeId, skip, limit, includeAbility = false) {
  484. // 分页查询(普通模式单向父子边);能力画像模式为双向(入边邻居)
  485. const cypher = includeAbility ? `
  486. MATCH (n)
  487. WHERE (elementId(n) = $parentNodeId OR id(n) = toInteger($parentNodeId))
  488. OPTIONAL MATCH (n)-[r1]->(m1)
  489. WHERE NOT type(r1) IN $hidden AND NOT m1:AbilityLevel
  490. AND (NOT n:Entity OR m1:Entity)
  491. OPTIONAL MATCH (m2)-[r2]->(n)
  492. WHERE NOT type(r2) IN $hidden AND NOT m2:AbilityLevel
  493. AND (NOT n:Entity OR m2:Entity)
  494. WITH n,
  495. CASE WHEN r1 IS NOT NULL AND m1 IS NOT NULL THEN {rel: r1, other: m1} ELSE null END AS outRel,
  496. CASE WHEN r2 IS NOT NULL AND m2 IS NOT NULL THEN {rel: r2, other: m2} ELSE null END AS inRel
  497. WITH n, [x IN [outRel, inRel] WHERE x IS NOT NULL] AS rels
  498. UNWIND rels AS rel
  499. WITH n, rel.rel AS r, rel.other AS m
  500. ORDER BY elementId(m), elementId(r)
  501. SKIP toInteger($skip) LIMIT toInteger($limit)
  502. RETURN
  503. {id: id(n), elementId: elementId(n), labels: labels(n), props: properties(n)} AS n_info,
  504. {id: id(r), elementId: elementId(r), type: type(r), props: properties(r)} AS r_info,
  505. {id: id(m), elementId: elementId(m), labels: labels(m), props: properties(m)} AS m_info
  506. ` : `
  507. MATCH (n)-[r]->(m)
  508. WHERE (elementId(n) = $parentNodeId OR id(n) = toInteger($parentNodeId))
  509. AND NOT type(r) IN $hidden
  510. AND NOT m:AbilityDimension AND NOT m:Ability AND NOT m:AbilityLevel
  511. WITH n, r, m
  512. ORDER BY elementId(m)
  513. SKIP toInteger($skip) LIMIT toInteger($limit)
  514. RETURN
  515. {id: id(n), elementId: elementId(n), labels: labels(n), props: properties(n)} AS n_info,
  516. {id: id(r), elementId: elementId(r), type: type(r), props: properties(r)} AS r_info,
  517. {id: id(m), elementId: elementId(m), labels: labels(m), props: properties(m)} AS m_info
  518. `;
  519. const records = await neo4jInstance.execute(cypher, {
  520. parentNodeId, skip, limit,
  521. hidden: includeAbility ? HIDDEN_REL_TYPES_ABILITY : HIDDEN_REL_TYPES
  522. });
  523. const nodeMap = new Map();
  524. const edgeMap = new Map();
  525. records.forEach(record => {
  526. const { n_info, r_info, m_info } = record;
  527. // 起始节点 n
  528. if (n_info && !nodeMap.has(n_info.id)) {
  529. nodeMap.set(n_info.id, {
  530. id: String(n_info.id),
  531. data: toNodeData(n_info)
  532. });
  533. }
  534. // 目标节点 m
  535. if (m_info && !nodeMap.has(m_info.id)) {
  536. nodeMap.set(m_info.id, {
  537. id: String(m_info.id),
  538. data: toNodeData(m_info)
  539. });
  540. }
  541. // 关系 r
  542. if (r_info && !edgeMap.has(r_info.id)) {
  543. edgeMap.set(r_info.id, {
  544. id: String(r_info.elementId),
  545. source: String(n_info.id),
  546. target: String(m_info.id),
  547. label: formatEdgeLabel(r_info),
  548. });
  549. }
  550. });
  551. return {
  552. nodes: Array.from(nodeMap.values()),
  553. edges: Array.from(edgeMap.values())
  554. };
  555. },
  556. /**
  557. * 根据节点名称模糊查询节点信息
  558. * 按模式区分检索范围:
  559. * - 普通模式(智能分析/智能组卷):Book/Part/Chapter/Section/Mu 结构节点 + 全部实体节点
  560. * - 能力画像模式:维度 + 能力项(懂/会/通/创)+ 全部实体节点
  561. */
  562. async searchNodesByName(neo4jInstance, keyword, includeAbility = false) {
  563. const typeFilter = includeAbility
  564. ? `(node:AbilityDimension OR node:Ability OR node:Entity)`
  565. : `(node:Book OR node:Part OR node:Chapter OR node:Section OR node:Mu OR node:Entity)`;
  566. // 1. 同款 Cypher 改造:在数据库直接把数据组装成干净的 JSON 对象
  567. const cypher = `
  568. MATCH (node)
  569. WHERE ${typeFilter}
  570. AND node.name CONTAINS $keyword
  571. WITH collect(node) AS items
  572. WITH items[0..100] AS nodesList
  573. UNWIND nodesList AS n
  574. // 寻找这些节点之间的内部上下级/横向关系
  575. OPTIONAL MATCH (n)-[r]->(m)
  576. WHERE m IN nodesList AND NOT type(r) IN $hidden
  577. // 同款 RETURN 打包语法糖,把所有需要的字段一口气全部暴露出来
  578. RETURN
  579. {id: id(n), elementId: elementId(n), labels: labels(n), props: properties(n)} AS n_info,
  580. CASE WHEN r IS NOT NULL
  581. THEN {id: id(r), elementId: elementId(r), type: type(r), props: properties(r)}
  582. ELSE null
  583. END AS r_info,
  584. CASE WHEN m IS NOT NULL
  585. THEN {id: id(m), elementId: elementId(m), labels: labels(m), props: properties(m)}
  586. ELSE null
  587. END AS m_info
  588. `;
  589. // 2. 使用你的自定义注入插件执行查询
  590. const records = await neo4jInstance.execute(cypher, {
  591. keyword,
  592. hidden: HIDDEN_REL_TYPES
  593. });
  594. // console.log("模糊搜索原始 records:", records);
  595. // 3. 采用跟你一模一样的 Map 去重组装逻辑
  596. const nodeMap = new Map();
  597. const edgeMap = new Map();
  598. records.forEach(record => {
  599. // 解构出每一行的 JSON 数据
  600. const {
  601. n_info,
  602. r_info,
  603. m_info
  604. } = record;
  605. // ================= 处理主节点 n (匹配到名字的医院等节点) =================
  606. if (n_info && !nodeMap.has(n_info.id)) {
  607. nodeMap.set(n_info.id, {
  608. id: String(n_info.id),
  609. data: {
  610. text: n_info.props.name || '',
  611. elementId: n_info.elementId,
  612. labels: n_info.labels,
  613. ...(n_info.props.star !== undefined && { star: n_info.props.star })
  614. }
  615. });
  616. }
  617. // ================= 处理目标节点 m (存在内部关系的另一个医院节点) =================
  618. if (m_info && !nodeMap.has(m_info.id)) {
  619. nodeMap.set(m_info.id, {
  620. id: String(m_info.id),
  621. data: {
  622. text: m_info.props.name || '',
  623. elementId: m_info.elementId,
  624. labels: m_info.labels,
  625. ...(m_info.props.star !== undefined && { star: m_info.props.star })
  626. }
  627. });
  628. }
  629. // ================= 处理关系连线 r =================
  630. if (r_info && !edgeMap.has(r_info.id)) {
  631. edgeMap.set(r_info.elementId, {
  632. id: String(r_info.elementId),
  633. source: String(n_info.id), // 连线起点
  634. target: String(m_info.id), // 连线终点
  635. label: formatEdgeLabel(r_info),
  636. // sources: r_info.props?.sources ? r_info.props.sources : "[]",
  637. });
  638. }
  639. });
  640. const result = {
  641. nodes: Array.from(nodeMap.values()),
  642. edges: Array.from(edgeMap.values())
  643. };
  644. // console.log("模糊搜索内连最终结果:", result);
  645. return result;
  646. },
  647. /**
  648. * 根据节点id查询节点中的sources,返回新的结构格式
  649. * */
  650. async searchNodeSources(neo4jInstance, nodeId) {
  651. const cypher = `
  652. MATCH (node)
  653. WHERE id(node) = ${nodeId}
  654. RETURN properties(node) AS props
  655. `;
  656. const records = await neo4jInstance.execute(cypher);
  657. const props = records[0]?.props || {};
  658. // 能力画像节点(维度/能力项/层次)用 definition/knowledge_support 等属性展示
  659. const result = [{
  660. mu_id: props.mu_id || props.dimension_id || props.ability_id || props.code || '',
  661. name: props.name || '',
  662. path: props.path
  663. || [props.core_position, props.knowledge_module, props.domain_tag].filter(Boolean).join(' · ')
  664. || '',
  665. text: props.description || props.text || props.definition
  666. || [props.level && `层次:${props.level}`, props.bloom && `Bloom:${props.bloom}`, props.knowledge_support].filter(Boolean).join('\n')
  667. || '',
  668. menuVisible: false,
  669. menuStyle: {}
  670. }];
  671. return JSON.stringify(result);
  672. },
  673. /**
  674. * 根据关系id查询关系中的sources
  675. * */
  676. async searchEdgeSources(neo4jInstance, edgeId) {
  677. const cypher = `
  678. MATCH (n)-[r]->(m)
  679. WHERE elementId(r) = $edgeId
  680. RETURN r.source_sentence as source_sentence, r.slice_id as slice_id, r.chapter as chapter, r.slice_text as slice_text,
  681. properties(r) AS props, type(r) AS rel_type
  682. `;
  683. const sources = []
  684. const records = await neo4jInstance.execute(cypher, {
  685. edgeId
  686. });
  687. const rec = records[0];
  688. if (!rec) return "[]";
  689. // 知识图谱边:有原文切片属性,直接返回
  690. if (rec.source_sentence || rec.slice_id || rec.chapter || rec.slice_text) {
  691. sources.push(rec);
  692. return JSON.stringify(sources);
  693. }
  694. // 能力画像边(支撑能力/考查能力等):展示边属性
  695. // 样例: {confidence:"高", level:"会", bloom_level:"L3", knowledge_domain:"质量", reasoning:"..."}
  696. const p = rec.props || {};
  697. if (Object.keys(p).length > 0) {
  698. sources.push({
  699. mu_id: rec.rel_type,
  700. name: p.confidence ? `置信度:${p.confidence}` : '关系属性',
  701. path: [
  702. p.level && `层次:${p.level}`,
  703. p.bloom_level && `Bloom:${p.bloom_level}`,
  704. p.knowledge_domain && `领域:${p.knowledge_domain}`,
  705. p.derived === true ? 'derived' : null,
  706. p.via_entity && `经由实体:${p.via_entity}`
  707. ].filter(Boolean).join(' · '),
  708. text: p.reasoning || JSON.stringify(p)
  709. });
  710. return JSON.stringify(sources);
  711. }
  712. return "[]";
  713. },
  714. /**
  715. * 根据实体id查询MU中所有信息
  716. * */
  717. async searchMuByEntitySources(neo4jInstance, nodId) {
  718. const cypher = `
  719. MATCH (mu)-[:包含实体]->(node)
  720. WHERE id(node) = ${nodId}
  721. RETURN properties(mu) AS muProps
  722. `;
  723. const records = await neo4jInstance.execute(cypher, {
  724. nodId
  725. });
  726. const result = records.map(record => {
  727. const muProps = record.muProps || {};
  728. return {
  729. mu_id: muProps.mu_id || '',
  730. name: muProps.name || '',
  731. path: muProps.path || '',
  732. text: muProps.description || muProps.text || '',
  733. menuVisible: false,
  734. menuStyle: {}
  735. };
  736. });
  737. return JSON.stringify(result);
  738. },
  739. /**
  740. * 根据标签查询所有label的节点
  741. * */
  742. async findNodesByLabel(neo4jInstance, label) {
  743. const cypher = `
  744. MATCH(n:${label})
  745. RETURN id(n) AS id, elementId(n) AS elementId, labels(n) as labels, properties(n) AS props
  746. `;
  747. const records = await neo4jInstance.execute(cypher);
  748. // console.log("根据标签查询节点11:", records);
  749. // 将结果整理为清晰的数组格式
  750. return records.map(record => ({
  751. id: record.id,
  752. elementId: record.elementId,
  753. labels: record.labels,
  754. ...record.props // 展开所有属性,比如 name, group 等
  755. }));
  756. },
  757. /**
  758. * 根据权重查询节点
  759. * */
  760. async findNodesByWeight(neo4jInstance, weight) {
  761. const cypher = `
  762. MATCH(n)
  763. WHERE n.weight = $weight
  764. RETURN id(n) AS id, elementId(n) AS elementId, labels(n) as labels, properties(n) AS props
  765. `;
  766. const records = await neo4jInstance.execute(cypher, { weight });
  767. return records.map(record => ({
  768. id: record.id,
  769. elementId: record.elementId,
  770. labels: record.labels,
  771. ...record.props
  772. }));
  773. },
  774. /**
  775. * 查询所有的Question节点
  776. * */
  777. async findQuestionsNodes(neo4jInstance) {
  778. const cypher = `
  779. MATCH (n)
  780. WHERE NOT ()-[]->(n) OR n:Question
  781. RETURN id(n) AS id, elementId(n) AS elementId, labels(n) as labels, properties(n) AS props
  782. `;
  783. const records = await neo4jInstance.execute(cypher);
  784. // 将结果整理为清晰的数组格式
  785. return records.map(record => ({
  786. id: record.id,
  787. elementId: record.elementId,
  788. labels: record.labels,
  789. ...record.props // 展开所有属性
  790. }));
  791. },
  792. async findNodesByQuestionType(neo4jInstance, questionType) {
  793. // 1. 修改 Cypher 语句,直接比对节点的属性 n.questionType
  794. const cypher = `
  795. MATCH (n)
  796. WHERE n.question_type = $targetType
  797. RETURN id(n) AS id, elementId(n) AS elementId, labels(n) as labels, properties(n) AS props
  798. `;
  799. // 2. 将动态的 questionType 作为参数传入,防止注入并提高性能
  800. const records = await neo4jInstance.execute(cypher, {
  801. targetType: questionType
  802. });
  803. // 3. 保持原有的数据格式化逻辑
  804. return records.map(record => ({
  805. id: record.id,
  806. elementId: record.elementId,
  807. labels: record.labels,
  808. ...record.props // 展开后,questionType 也会平铺在这个对象的顶层
  809. }));
  810. },
  811. /**
  812. * 根据entity_name和question_type查询Question节点
  813. * @param {Object} neo4jInstance Neo4j 驱动实例
  814. * @param {string[]} entity_names 要查询的实体名称数组
  815. * @param {string[]} question_types 要查询的题目类型数组
  816. * @returns {Promise<QuestionNode[]>} 符合条件的Question节点数组
  817. * */
  818. async findQuestionsNodesByNameAndType(neo4jInstance, entity_names, question_types) {
  819. const cypher = `
  820. MATCH (q:Question)
  821. WHERE q.entity_name IN $entity_names
  822. AND ($question_types = [] OR q.question_type IN $question_types)
  823. RETURN
  824. id(q) AS node_db_id, // Neo4j数据库内置节点ID
  825. q.elementId AS elementId, // 节点自身属性elementId
  826. q AS node
  827. ORDER BY q.chapter ASC, q.question_type ASC
  828. `.trim();
  829. const records = await neo4jInstance.execute(cypher, {
  830. entity_names,
  831. question_types: question_types || [] // 确保如果是 null/undefined 时传空数组
  832. });
  833. console.log(records, 111)
  834. return records.map(record => {
  835. return {
  836. id: record.node_db_id,
  837. elementId: record.elementId,
  838. ...record.node
  839. }
  840. // 替换 record.get('q') 为普通的 JS 对象取值
  841. const node = record.q;
  842. if (!node) return null; // 增强健壮性,防止查空
  843. // console.log(node);
  844. // return {
  845. // id: nodeDbId,
  846. // elementId: elementId,
  847. // ...node
  848. // };
  849. }) // 过滤掉可能为 null 的数据
  850. },
  851. /**
  852. * 根据单个节点名称,获取其自身及所有子节点的名称列表
  853. * @param {Object} neo4jInstance Neo4j 驱动实例
  854. * @param {string} nodeName 当前选中的节点名称
  855. * @param {boolean} includeChildren 是否勾选了包含子节点
  856. * @returns {Promise<string[]>} 节点名称数组
  857. */
  858. async getEntityNamesWithChildren(neo4jInstance, nodeName, includeChildren = false) {
  859. if (!nodeName) return [];
  860. // 如果没有勾选包含子节点,直接返回当前节点名称
  861. if (!includeChildren) {
  862. return [nodeName];
  863. }
  864. // 如果勾选了,去图谱中查询自身及所有后代节点
  865. const cypher = `
  866. MATCH (p{name: $nodeName})-[*1..]->(descendant)
  867. RETURN collect(DISTINCT descendant.name) AS name_list
  868. `
  869. const records = await neo4jInstance.execute(cypher, {
  870. nodeName
  871. });
  872. // console.log(records)
  873. if (records.length) {
  874. return records[0].name_list
  875. } else {
  876. return []
  877. }
  878. },
  879. /**
  880. * 查询库内可展开节点总数
  881. * 按模式区分统计口径:
  882. * - 普通模式(智能分析/智能组卷):Book/Part/Chapter/Section/Mu 结构节点 + 全部实体节点
  883. * - 能力画像模式:维度 + 能力项(懂/会/通/创)+ 全部实体节点
  884. * @param {Object} neo4jInstance Neo4j 驱动实例
  885. * @param {boolean} includeAbility 是否能力画像模式
  886. * @returns {Promise<number>} 节点总数
  887. */
  888. async countAllNodes(neo4jInstance, includeAbility = false) {
  889. const cypher = includeAbility
  890. ? `MATCH (n)
  891. WHERE (n:AbilityDimension OR n:Ability OR n:Entity)
  892. RETURN count(n) AS total`
  893. : `MATCH (n)
  894. WHERE (n:Book OR n:Part OR n:Chapter OR n:Section OR n:Mu OR n:Entity)
  895. RETURN count(n) AS total`;
  896. const records = await neo4jInstance.execute(cypher);
  897. return records[0]?.total || 0;
  898. }
  899. }