okstra 0.130.4 → 0.131.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (365) hide show
  1. package/README.md +0 -1
  2. package/docs/architecture.md +2 -2
  3. package/docs/cli.md +1 -1
  4. package/docs/for-ai/README.md +0 -2
  5. package/docs/for-ai/skills/okstra-run.md +13 -2
  6. package/docs/for-ai/skills/okstra-setup.md +4 -1
  7. package/docs/project-structure-overview.md +5 -4
  8. package/docs/task-process/implementation-planning.md +13 -0
  9. package/docs/task-process/implementation.md +17 -2
  10. package/package.json +1 -1
  11. package/runtime/BUILD.json +2 -2
  12. package/runtime/agents/workers/report-writer-worker.md +7 -0
  13. package/runtime/bin/lib/okstra/usage.sh +4 -4
  14. package/runtime/prompts/lead/okstra-lead-contract.md +2 -2
  15. package/runtime/prompts/lead/plan-body-verification.md +26 -5
  16. package/runtime/prompts/lead/report-writer.md +6 -2
  17. package/runtime/prompts/profiles/_common-contract.md +2 -1
  18. package/runtime/prompts/profiles/_implementation-deliverable.md +6 -1
  19. package/runtime/prompts/profiles/_implementation-executor.md +2 -2
  20. package/runtime/prompts/profiles/_implementation-verifier.md +33 -10
  21. package/runtime/prompts/profiles/final-verification.md +21 -6
  22. package/runtime/prompts/profiles/implementation-planning.md +16 -4
  23. package/runtime/python/okstra_ctl/codex_dispatch.py +31 -0
  24. package/runtime/python/okstra_ctl/conformance.py +49 -11
  25. package/runtime/python/okstra_ctl/report_finalize.py +52 -1
  26. package/runtime/python/okstra_ctl/run.py +15 -3
  27. package/runtime/python/okstra_ctl/worker_prompt_contract.py +14 -6
  28. package/runtime/python/okstra_ctl/worker_prompt_policy.py +19 -1
  29. package/runtime/python/okstra_vendor/__init__.py +0 -44
  30. package/runtime/schemas/final-report-v1.0.schema.json +99 -5
  31. package/runtime/skills/okstra-run/SKILL.md +11 -4
  32. package/runtime/skills/okstra-setup/SKILL.md +3 -2
  33. package/runtime/skills/okstra-setup/references/project-config.md +16 -7
  34. package/runtime/templates/reports/final-report.template.md +15 -9
  35. package/runtime/templates/reports/i18n/en.json +1 -0
  36. package/runtime/templates/reports/i18n/ko.json +1 -0
  37. package/runtime/validators/lib/fixtures.sh +6 -0
  38. package/runtime/validators/validate-run.py +2019 -96
  39. package/src/cli-registry.mjs +0 -7
  40. package/src/commands/lifecycle/doctor.mjs +0 -9
  41. package/src/lib/skill-catalog.mjs +0 -1
  42. package/docs/for-ai/skills/okstra-graphify.md +0 -64
  43. package/runtime/python/okstra_ctl/graphify_cmd.py +0 -225
  44. package/runtime/python/okstra_vendor/graphify/.vendored-version +0 -1
  45. package/runtime/python/okstra_vendor/graphify/__init__.py +0 -28
  46. package/runtime/python/okstra_vendor/graphify/__main__.py +0 -1371
  47. package/runtime/python/okstra_vendor/graphify/analyze.py +0 -540
  48. package/runtime/python/okstra_vendor/graphify/benchmark.py +0 -129
  49. package/runtime/python/okstra_vendor/graphify/build.py +0 -107
  50. package/runtime/python/okstra_vendor/graphify/cache.py +0 -169
  51. package/runtime/python/okstra_vendor/graphify/cluster.py +0 -137
  52. package/runtime/python/okstra_vendor/graphify/detect.py +0 -510
  53. package/runtime/python/okstra_vendor/graphify/export.py +0 -1014
  54. package/runtime/python/okstra_vendor/graphify/extract.py +0 -3277
  55. package/runtime/python/okstra_vendor/graphify/hooks.py +0 -220
  56. package/runtime/python/okstra_vendor/graphify/ingest.py +0 -297
  57. package/runtime/python/okstra_vendor/graphify/manifest.py +0 -4
  58. package/runtime/python/okstra_vendor/graphify/report.py +0 -175
  59. package/runtime/python/okstra_vendor/graphify/security.py +0 -203
  60. package/runtime/python/okstra_vendor/graphify/serve.py +0 -373
  61. package/runtime/python/okstra_vendor/graphify/skill-aider.md +0 -1184
  62. package/runtime/python/okstra_vendor/graphify/skill-claw.md +0 -1184
  63. package/runtime/python/okstra_vendor/graphify/skill-codex.md +0 -1242
  64. package/runtime/python/okstra_vendor/graphify/skill-copilot.md +0 -1268
  65. package/runtime/python/okstra_vendor/graphify/skill-droid.md +0 -1239
  66. package/runtime/python/okstra_vendor/graphify/skill-kiro.md +0 -1183
  67. package/runtime/python/okstra_vendor/graphify/skill-opencode.md +0 -1238
  68. package/runtime/python/okstra_vendor/graphify/skill-trae.md +0 -1208
  69. package/runtime/python/okstra_vendor/graphify/skill-vscode.md +0 -253
  70. package/runtime/python/okstra_vendor/graphify/skill-windows.md +0 -1245
  71. package/runtime/python/okstra_vendor/graphify/skill.md +0 -1319
  72. package/runtime/python/okstra_vendor/graphify/transcribe.py +0 -182
  73. package/runtime/python/okstra_vendor/graphify/validate.py +0 -72
  74. package/runtime/python/okstra_vendor/graphify/watch.py +0 -188
  75. package/runtime/python/okstra_vendor/graphify/wiki.py +0 -214
  76. package/runtime/python/okstra_vendor/networkx/__init__.py +0 -62
  77. package/runtime/python/okstra_vendor/networkx/algorithms/__init__.py +0 -134
  78. package/runtime/python/okstra_vendor/networkx/algorithms/approximation/__init__.py +0 -26
  79. package/runtime/python/okstra_vendor/networkx/algorithms/approximation/clique.py +0 -259
  80. package/runtime/python/okstra_vendor/networkx/algorithms/approximation/clustering_coefficient.py +0 -71
  81. package/runtime/python/okstra_vendor/networkx/algorithms/approximation/connectivity.py +0 -412
  82. package/runtime/python/okstra_vendor/networkx/algorithms/approximation/density.py +0 -396
  83. package/runtime/python/okstra_vendor/networkx/algorithms/approximation/distance_measures.py +0 -150
  84. package/runtime/python/okstra_vendor/networkx/algorithms/approximation/dominating_set.py +0 -149
  85. package/runtime/python/okstra_vendor/networkx/algorithms/approximation/kcomponents.py +0 -369
  86. package/runtime/python/okstra_vendor/networkx/algorithms/approximation/matching.py +0 -44
  87. package/runtime/python/okstra_vendor/networkx/algorithms/approximation/maxcut.py +0 -143
  88. package/runtime/python/okstra_vendor/networkx/algorithms/approximation/ramsey.py +0 -53
  89. package/runtime/python/okstra_vendor/networkx/algorithms/approximation/steinertree.py +0 -265
  90. package/runtime/python/okstra_vendor/networkx/algorithms/approximation/traveling_salesman.py +0 -1508
  91. package/runtime/python/okstra_vendor/networkx/algorithms/approximation/treewidth.py +0 -255
  92. package/runtime/python/okstra_vendor/networkx/algorithms/approximation/vertex_cover.py +0 -83
  93. package/runtime/python/okstra_vendor/networkx/algorithms/assortativity/__init__.py +0 -5
  94. package/runtime/python/okstra_vendor/networkx/algorithms/assortativity/connectivity.py +0 -122
  95. package/runtime/python/okstra_vendor/networkx/algorithms/assortativity/correlation.py +0 -302
  96. package/runtime/python/okstra_vendor/networkx/algorithms/assortativity/mixing.py +0 -255
  97. package/runtime/python/okstra_vendor/networkx/algorithms/assortativity/neighbor_degree.py +0 -160
  98. package/runtime/python/okstra_vendor/networkx/algorithms/assortativity/pairs.py +0 -127
  99. package/runtime/python/okstra_vendor/networkx/algorithms/asteroidal.py +0 -164
  100. package/runtime/python/okstra_vendor/networkx/algorithms/bipartite/__init__.py +0 -88
  101. package/runtime/python/okstra_vendor/networkx/algorithms/bipartite/basic.py +0 -322
  102. package/runtime/python/okstra_vendor/networkx/algorithms/bipartite/centrality.py +0 -290
  103. package/runtime/python/okstra_vendor/networkx/algorithms/bipartite/cluster.py +0 -289
  104. package/runtime/python/okstra_vendor/networkx/algorithms/bipartite/covering.py +0 -57
  105. package/runtime/python/okstra_vendor/networkx/algorithms/bipartite/edgelist.py +0 -360
  106. package/runtime/python/okstra_vendor/networkx/algorithms/bipartite/extendability.py +0 -105
  107. package/runtime/python/okstra_vendor/networkx/algorithms/bipartite/generators.py +0 -603
  108. package/runtime/python/okstra_vendor/networkx/algorithms/bipartite/link_analysis.py +0 -316
  109. package/runtime/python/okstra_vendor/networkx/algorithms/bipartite/matching.py +0 -590
  110. package/runtime/python/okstra_vendor/networkx/algorithms/bipartite/matrix.py +0 -232
  111. package/runtime/python/okstra_vendor/networkx/algorithms/bipartite/projection.py +0 -526
  112. package/runtime/python/okstra_vendor/networkx/algorithms/bipartite/redundancy.py +0 -112
  113. package/runtime/python/okstra_vendor/networkx/algorithms/bipartite/spectral.py +0 -69
  114. package/runtime/python/okstra_vendor/networkx/algorithms/boundary.py +0 -168
  115. package/runtime/python/okstra_vendor/networkx/algorithms/bridges.py +0 -205
  116. package/runtime/python/okstra_vendor/networkx/algorithms/broadcasting.py +0 -164
  117. package/runtime/python/okstra_vendor/networkx/algorithms/centrality/__init__.py +0 -20
  118. package/runtime/python/okstra_vendor/networkx/algorithms/centrality/betweenness.py +0 -591
  119. package/runtime/python/okstra_vendor/networkx/algorithms/centrality/betweenness_subset.py +0 -236
  120. package/runtime/python/okstra_vendor/networkx/algorithms/centrality/closeness.py +0 -282
  121. package/runtime/python/okstra_vendor/networkx/algorithms/centrality/current_flow_betweenness.py +0 -364
  122. package/runtime/python/okstra_vendor/networkx/algorithms/centrality/current_flow_betweenness_subset.py +0 -227
  123. package/runtime/python/okstra_vendor/networkx/algorithms/centrality/current_flow_closeness.py +0 -96
  124. package/runtime/python/okstra_vendor/networkx/algorithms/centrality/degree_alg.py +0 -150
  125. package/runtime/python/okstra_vendor/networkx/algorithms/centrality/dispersion.py +0 -107
  126. package/runtime/python/okstra_vendor/networkx/algorithms/centrality/eigenvector.py +0 -357
  127. package/runtime/python/okstra_vendor/networkx/algorithms/centrality/flow_matrix.py +0 -130
  128. package/runtime/python/okstra_vendor/networkx/algorithms/centrality/group.py +0 -787
  129. package/runtime/python/okstra_vendor/networkx/algorithms/centrality/harmonic.py +0 -88
  130. package/runtime/python/okstra_vendor/networkx/algorithms/centrality/katz.py +0 -331
  131. package/runtime/python/okstra_vendor/networkx/algorithms/centrality/laplacian.py +0 -150
  132. package/runtime/python/okstra_vendor/networkx/algorithms/centrality/load.py +0 -200
  133. package/runtime/python/okstra_vendor/networkx/algorithms/centrality/percolation.py +0 -128
  134. package/runtime/python/okstra_vendor/networkx/algorithms/centrality/reaching.py +0 -209
  135. package/runtime/python/okstra_vendor/networkx/algorithms/centrality/second_order.py +0 -141
  136. package/runtime/python/okstra_vendor/networkx/algorithms/centrality/subgraph_alg.py +0 -361
  137. package/runtime/python/okstra_vendor/networkx/algorithms/centrality/trophic.py +0 -181
  138. package/runtime/python/okstra_vendor/networkx/algorithms/centrality/voterank_alg.py +0 -95
  139. package/runtime/python/okstra_vendor/networkx/algorithms/chains.py +0 -172
  140. package/runtime/python/okstra_vendor/networkx/algorithms/chordal.py +0 -443
  141. package/runtime/python/okstra_vendor/networkx/algorithms/clique.py +0 -818
  142. package/runtime/python/okstra_vendor/networkx/algorithms/cluster.py +0 -732
  143. package/runtime/python/okstra_vendor/networkx/algorithms/coloring/__init__.py +0 -4
  144. package/runtime/python/okstra_vendor/networkx/algorithms/coloring/equitable_coloring.py +0 -505
  145. package/runtime/python/okstra_vendor/networkx/algorithms/coloring/greedy_coloring.py +0 -565
  146. package/runtime/python/okstra_vendor/networkx/algorithms/communicability_alg.py +0 -163
  147. package/runtime/python/okstra_vendor/networkx/algorithms/community/__init__.py +0 -28
  148. package/runtime/python/okstra_vendor/networkx/algorithms/community/asyn_fluid.py +0 -153
  149. package/runtime/python/okstra_vendor/networkx/algorithms/community/bipartitions.py +0 -354
  150. package/runtime/python/okstra_vendor/networkx/algorithms/community/centrality.py +0 -171
  151. package/runtime/python/okstra_vendor/networkx/algorithms/community/community_utils.py +0 -30
  152. package/runtime/python/okstra_vendor/networkx/algorithms/community/divisive.py +0 -216
  153. package/runtime/python/okstra_vendor/networkx/algorithms/community/kclique.py +0 -79
  154. package/runtime/python/okstra_vendor/networkx/algorithms/community/label_propagation.py +0 -338
  155. package/runtime/python/okstra_vendor/networkx/algorithms/community/leiden.py +0 -162
  156. package/runtime/python/okstra_vendor/networkx/algorithms/community/local.py +0 -220
  157. package/runtime/python/okstra_vendor/networkx/algorithms/community/louvain.py +0 -384
  158. package/runtime/python/okstra_vendor/networkx/algorithms/community/lukes.py +0 -227
  159. package/runtime/python/okstra_vendor/networkx/algorithms/community/modularity_max.py +0 -452
  160. package/runtime/python/okstra_vendor/networkx/algorithms/community/quality.py +0 -347
  161. package/runtime/python/okstra_vendor/networkx/algorithms/components/__init__.py +0 -6
  162. package/runtime/python/okstra_vendor/networkx/algorithms/components/attracting.py +0 -115
  163. package/runtime/python/okstra_vendor/networkx/algorithms/components/biconnected.py +0 -394
  164. package/runtime/python/okstra_vendor/networkx/algorithms/components/connected.py +0 -282
  165. package/runtime/python/okstra_vendor/networkx/algorithms/components/semiconnected.py +0 -71
  166. package/runtime/python/okstra_vendor/networkx/algorithms/components/strongly_connected.py +0 -359
  167. package/runtime/python/okstra_vendor/networkx/algorithms/components/weakly_connected.py +0 -196
  168. package/runtime/python/okstra_vendor/networkx/algorithms/connectivity/__init__.py +0 -11
  169. package/runtime/python/okstra_vendor/networkx/algorithms/connectivity/connectivity.py +0 -811
  170. package/runtime/python/okstra_vendor/networkx/algorithms/connectivity/cuts.py +0 -616
  171. package/runtime/python/okstra_vendor/networkx/algorithms/connectivity/disjoint_paths.py +0 -408
  172. package/runtime/python/okstra_vendor/networkx/algorithms/connectivity/edge_augmentation.py +0 -1270
  173. package/runtime/python/okstra_vendor/networkx/algorithms/connectivity/edge_kcomponents.py +0 -592
  174. package/runtime/python/okstra_vendor/networkx/algorithms/connectivity/kcomponents.py +0 -220
  175. package/runtime/python/okstra_vendor/networkx/algorithms/connectivity/kcutsets.py +0 -235
  176. package/runtime/python/okstra_vendor/networkx/algorithms/connectivity/stoerwagner.py +0 -152
  177. package/runtime/python/okstra_vendor/networkx/algorithms/connectivity/utils.py +0 -88
  178. package/runtime/python/okstra_vendor/networkx/algorithms/core.py +0 -588
  179. package/runtime/python/okstra_vendor/networkx/algorithms/covering.py +0 -142
  180. package/runtime/python/okstra_vendor/networkx/algorithms/cuts.py +0 -416
  181. package/runtime/python/okstra_vendor/networkx/algorithms/cycles.py +0 -1234
  182. package/runtime/python/okstra_vendor/networkx/algorithms/d_separation.py +0 -677
  183. package/runtime/python/okstra_vendor/networkx/algorithms/dag.py +0 -1392
  184. package/runtime/python/okstra_vendor/networkx/algorithms/distance_measures.py +0 -1095
  185. package/runtime/python/okstra_vendor/networkx/algorithms/distance_regular.py +0 -272
  186. package/runtime/python/okstra_vendor/networkx/algorithms/dominance.py +0 -142
  187. package/runtime/python/okstra_vendor/networkx/algorithms/dominating.py +0 -268
  188. package/runtime/python/okstra_vendor/networkx/algorithms/efficiency_measures.py +0 -167
  189. package/runtime/python/okstra_vendor/networkx/algorithms/euler.py +0 -470
  190. package/runtime/python/okstra_vendor/networkx/algorithms/flow/__init__.py +0 -11
  191. package/runtime/python/okstra_vendor/networkx/algorithms/flow/boykovkolmogorov.py +0 -370
  192. package/runtime/python/okstra_vendor/networkx/algorithms/flow/capacityscaling.py +0 -407
  193. package/runtime/python/okstra_vendor/networkx/algorithms/flow/dinitz_alg.py +0 -238
  194. package/runtime/python/okstra_vendor/networkx/algorithms/flow/edmondskarp.py +0 -241
  195. package/runtime/python/okstra_vendor/networkx/algorithms/flow/gomory_hu.py +0 -178
  196. package/runtime/python/okstra_vendor/networkx/algorithms/flow/maxflow.py +0 -611
  197. package/runtime/python/okstra_vendor/networkx/algorithms/flow/mincost.py +0 -356
  198. package/runtime/python/okstra_vendor/networkx/algorithms/flow/networksimplex.py +0 -662
  199. package/runtime/python/okstra_vendor/networkx/algorithms/flow/preflowpush.py +0 -425
  200. package/runtime/python/okstra_vendor/networkx/algorithms/flow/shortestaugmentingpath.py +0 -300
  201. package/runtime/python/okstra_vendor/networkx/algorithms/flow/utils.py +0 -194
  202. package/runtime/python/okstra_vendor/networkx/algorithms/graph_hashing.py +0 -435
  203. package/runtime/python/okstra_vendor/networkx/algorithms/graphical.py +0 -483
  204. package/runtime/python/okstra_vendor/networkx/algorithms/hierarchy.py +0 -57
  205. package/runtime/python/okstra_vendor/networkx/algorithms/hybrid.py +0 -196
  206. package/runtime/python/okstra_vendor/networkx/algorithms/isolate.py +0 -107
  207. package/runtime/python/okstra_vendor/networkx/algorithms/isomorphism/__init__.py +0 -7
  208. package/runtime/python/okstra_vendor/networkx/algorithms/isomorphism/ismags.py +0 -1306
  209. package/runtime/python/okstra_vendor/networkx/algorithms/isomorphism/isomorph.py +0 -336
  210. package/runtime/python/okstra_vendor/networkx/algorithms/isomorphism/isomorphvf2.py +0 -1262
  211. package/runtime/python/okstra_vendor/networkx/algorithms/isomorphism/matchhelpers.py +0 -352
  212. package/runtime/python/okstra_vendor/networkx/algorithms/isomorphism/temporalisomorphvf2.py +0 -308
  213. package/runtime/python/okstra_vendor/networkx/algorithms/isomorphism/tree_isomorphism.py +0 -264
  214. package/runtime/python/okstra_vendor/networkx/algorithms/isomorphism/vf2pp.py +0 -1102
  215. package/runtime/python/okstra_vendor/networkx/algorithms/isomorphism/vf2userfunc.py +0 -192
  216. package/runtime/python/okstra_vendor/networkx/algorithms/link_analysis/__init__.py +0 -2
  217. package/runtime/python/okstra_vendor/networkx/algorithms/link_analysis/hits_alg.py +0 -337
  218. package/runtime/python/okstra_vendor/networkx/algorithms/link_analysis/pagerank_alg.py +0 -498
  219. package/runtime/python/okstra_vendor/networkx/algorithms/link_prediction.py +0 -687
  220. package/runtime/python/okstra_vendor/networkx/algorithms/lowest_common_ancestors.py +0 -280
  221. package/runtime/python/okstra_vendor/networkx/algorithms/matching.py +0 -1148
  222. package/runtime/python/okstra_vendor/networkx/algorithms/minors/__init__.py +0 -27
  223. package/runtime/python/okstra_vendor/networkx/algorithms/minors/contraction.py +0 -738
  224. package/runtime/python/okstra_vendor/networkx/algorithms/mis.py +0 -78
  225. package/runtime/python/okstra_vendor/networkx/algorithms/moral.py +0 -59
  226. package/runtime/python/okstra_vendor/networkx/algorithms/node_classification.py +0 -219
  227. package/runtime/python/okstra_vendor/networkx/algorithms/non_randomness.py +0 -155
  228. package/runtime/python/okstra_vendor/networkx/algorithms/operators/__init__.py +0 -4
  229. package/runtime/python/okstra_vendor/networkx/algorithms/operators/all.py +0 -324
  230. package/runtime/python/okstra_vendor/networkx/algorithms/operators/binary.py +0 -468
  231. package/runtime/python/okstra_vendor/networkx/algorithms/operators/product.py +0 -633
  232. package/runtime/python/okstra_vendor/networkx/algorithms/operators/unary.py +0 -77
  233. package/runtime/python/okstra_vendor/networkx/algorithms/perfect_graph.py +0 -73
  234. package/runtime/python/okstra_vendor/networkx/algorithms/planar_drawing.py +0 -464
  235. package/runtime/python/okstra_vendor/networkx/algorithms/planarity.py +0 -1463
  236. package/runtime/python/okstra_vendor/networkx/algorithms/polynomials.py +0 -306
  237. package/runtime/python/okstra_vendor/networkx/algorithms/reciprocity.py +0 -98
  238. package/runtime/python/okstra_vendor/networkx/algorithms/regular.py +0 -167
  239. package/runtime/python/okstra_vendor/networkx/algorithms/richclub.py +0 -138
  240. package/runtime/python/okstra_vendor/networkx/algorithms/shortest_paths/__init__.py +0 -5
  241. package/runtime/python/okstra_vendor/networkx/algorithms/shortest_paths/astar.py +0 -239
  242. package/runtime/python/okstra_vendor/networkx/algorithms/shortest_paths/dense.py +0 -264
  243. package/runtime/python/okstra_vendor/networkx/algorithms/shortest_paths/generic.py +0 -716
  244. package/runtime/python/okstra_vendor/networkx/algorithms/shortest_paths/unweighted.py +0 -625
  245. package/runtime/python/okstra_vendor/networkx/algorithms/shortest_paths/weighted.py +0 -2542
  246. package/runtime/python/okstra_vendor/networkx/algorithms/similarity.py +0 -2107
  247. package/runtime/python/okstra_vendor/networkx/algorithms/simple_paths.py +0 -966
  248. package/runtime/python/okstra_vendor/networkx/algorithms/smallworld.py +0 -404
  249. package/runtime/python/okstra_vendor/networkx/algorithms/smetric.py +0 -30
  250. package/runtime/python/okstra_vendor/networkx/algorithms/sparsifiers.py +0 -296
  251. package/runtime/python/okstra_vendor/networkx/algorithms/structuralholes.py +0 -374
  252. package/runtime/python/okstra_vendor/networkx/algorithms/summarization.py +0 -564
  253. package/runtime/python/okstra_vendor/networkx/algorithms/swap.py +0 -406
  254. package/runtime/python/okstra_vendor/networkx/algorithms/threshold.py +0 -981
  255. package/runtime/python/okstra_vendor/networkx/algorithms/time_dependent.py +0 -142
  256. package/runtime/python/okstra_vendor/networkx/algorithms/tournament.py +0 -406
  257. package/runtime/python/okstra_vendor/networkx/algorithms/traversal/__init__.py +0 -5
  258. package/runtime/python/okstra_vendor/networkx/algorithms/traversal/beamsearch.py +0 -90
  259. package/runtime/python/okstra_vendor/networkx/algorithms/traversal/breadth_first_search.py +0 -576
  260. package/runtime/python/okstra_vendor/networkx/algorithms/traversal/depth_first_search.py +0 -529
  261. package/runtime/python/okstra_vendor/networkx/algorithms/traversal/edgebfs.py +0 -185
  262. package/runtime/python/okstra_vendor/networkx/algorithms/traversal/edgedfs.py +0 -182
  263. package/runtime/python/okstra_vendor/networkx/algorithms/tree/__init__.py +0 -7
  264. package/runtime/python/okstra_vendor/networkx/algorithms/tree/branchings.py +0 -1042
  265. package/runtime/python/okstra_vendor/networkx/algorithms/tree/coding.py +0 -413
  266. package/runtime/python/okstra_vendor/networkx/algorithms/tree/decomposition.py +0 -88
  267. package/runtime/python/okstra_vendor/networkx/algorithms/tree/distance_measures.py +0 -219
  268. package/runtime/python/okstra_vendor/networkx/algorithms/tree/mst.py +0 -1281
  269. package/runtime/python/okstra_vendor/networkx/algorithms/tree/operations.py +0 -106
  270. package/runtime/python/okstra_vendor/networkx/algorithms/tree/recognition.py +0 -273
  271. package/runtime/python/okstra_vendor/networkx/algorithms/triads.py +0 -500
  272. package/runtime/python/okstra_vendor/networkx/algorithms/vitality.py +0 -76
  273. package/runtime/python/okstra_vendor/networkx/algorithms/voronoi.py +0 -86
  274. package/runtime/python/okstra_vendor/networkx/algorithms/walks.py +0 -77
  275. package/runtime/python/okstra_vendor/networkx/algorithms/wiener.py +0 -278
  276. package/runtime/python/okstra_vendor/networkx/classes/__init__.py +0 -13
  277. package/runtime/python/okstra_vendor/networkx/classes/coreviews.py +0 -435
  278. package/runtime/python/okstra_vendor/networkx/classes/digraph.py +0 -1363
  279. package/runtime/python/okstra_vendor/networkx/classes/filters.py +0 -95
  280. package/runtime/python/okstra_vendor/networkx/classes/function.py +0 -1549
  281. package/runtime/python/okstra_vendor/networkx/classes/graph.py +0 -2082
  282. package/runtime/python/okstra_vendor/networkx/classes/graphviews.py +0 -269
  283. package/runtime/python/okstra_vendor/networkx/classes/multidigraph.py +0 -977
  284. package/runtime/python/okstra_vendor/networkx/classes/multigraph.py +0 -1294
  285. package/runtime/python/okstra_vendor/networkx/classes/reportviews.py +0 -1447
  286. package/runtime/python/okstra_vendor/networkx/convert.py +0 -502
  287. package/runtime/python/okstra_vendor/networkx/convert_matrix.py +0 -1314
  288. package/runtime/python/okstra_vendor/networkx/drawing/__init__.py +0 -7
  289. package/runtime/python/okstra_vendor/networkx/drawing/layout.py +0 -2036
  290. package/runtime/python/okstra_vendor/networkx/drawing/nx_agraph.py +0 -470
  291. package/runtime/python/okstra_vendor/networkx/drawing/nx_latex.py +0 -570
  292. package/runtime/python/okstra_vendor/networkx/drawing/nx_pydot.py +0 -361
  293. package/runtime/python/okstra_vendor/networkx/drawing/nx_pylab.py +0 -2978
  294. package/runtime/python/okstra_vendor/networkx/exception.py +0 -131
  295. package/runtime/python/okstra_vendor/networkx/generators/__init__.py +0 -34
  296. package/runtime/python/okstra_vendor/networkx/generators/atlas.dat.gz +0 -0
  297. package/runtime/python/okstra_vendor/networkx/generators/atlas.py +0 -227
  298. package/runtime/python/okstra_vendor/networkx/generators/classic.py +0 -1091
  299. package/runtime/python/okstra_vendor/networkx/generators/cographs.py +0 -68
  300. package/runtime/python/okstra_vendor/networkx/generators/community.py +0 -1070
  301. package/runtime/python/okstra_vendor/networkx/generators/degree_seq.py +0 -886
  302. package/runtime/python/okstra_vendor/networkx/generators/directed.py +0 -572
  303. package/runtime/python/okstra_vendor/networkx/generators/duplication.py +0 -174
  304. package/runtime/python/okstra_vendor/networkx/generators/ego.py +0 -66
  305. package/runtime/python/okstra_vendor/networkx/generators/expanders.py +0 -499
  306. package/runtime/python/okstra_vendor/networkx/generators/geometric.py +0 -1037
  307. package/runtime/python/okstra_vendor/networkx/generators/harary_graph.py +0 -163
  308. package/runtime/python/okstra_vendor/networkx/generators/internet_as_graphs.py +0 -443
  309. package/runtime/python/okstra_vendor/networkx/generators/intersection.py +0 -125
  310. package/runtime/python/okstra_vendor/networkx/generators/interval_graph.py +0 -70
  311. package/runtime/python/okstra_vendor/networkx/generators/joint_degree_seq.py +0 -664
  312. package/runtime/python/okstra_vendor/networkx/generators/lattice.py +0 -405
  313. package/runtime/python/okstra_vendor/networkx/generators/line.py +0 -501
  314. package/runtime/python/okstra_vendor/networkx/generators/mycielski.py +0 -110
  315. package/runtime/python/okstra_vendor/networkx/generators/nonisomorphic_trees.py +0 -259
  316. package/runtime/python/okstra_vendor/networkx/generators/random_clustered.py +0 -117
  317. package/runtime/python/okstra_vendor/networkx/generators/random_graphs.py +0 -1416
  318. package/runtime/python/okstra_vendor/networkx/generators/small.py +0 -1070
  319. package/runtime/python/okstra_vendor/networkx/generators/social.py +0 -554
  320. package/runtime/python/okstra_vendor/networkx/generators/spectral_graph_forge.py +0 -120
  321. package/runtime/python/okstra_vendor/networkx/generators/stochastic.py +0 -54
  322. package/runtime/python/okstra_vendor/networkx/generators/sudoku.py +0 -131
  323. package/runtime/python/okstra_vendor/networkx/generators/time_series.py +0 -74
  324. package/runtime/python/okstra_vendor/networkx/generators/trees.py +0 -1070
  325. package/runtime/python/okstra_vendor/networkx/generators/triads.py +0 -94
  326. package/runtime/python/okstra_vendor/networkx/lazy_imports.py +0 -188
  327. package/runtime/python/okstra_vendor/networkx/linalg/__init__.py +0 -13
  328. package/runtime/python/okstra_vendor/networkx/linalg/algebraicconnectivity.py +0 -650
  329. package/runtime/python/okstra_vendor/networkx/linalg/attrmatrix.py +0 -466
  330. package/runtime/python/okstra_vendor/networkx/linalg/bethehessianmatrix.py +0 -77
  331. package/runtime/python/okstra_vendor/networkx/linalg/graphmatrix.py +0 -168
  332. package/runtime/python/okstra_vendor/networkx/linalg/laplacianmatrix.py +0 -512
  333. package/runtime/python/okstra_vendor/networkx/linalg/modularitymatrix.py +0 -166
  334. package/runtime/python/okstra_vendor/networkx/linalg/spectrum.py +0 -186
  335. package/runtime/python/okstra_vendor/networkx/readwrite/__init__.py +0 -17
  336. package/runtime/python/okstra_vendor/networkx/readwrite/adjlist.py +0 -330
  337. package/runtime/python/okstra_vendor/networkx/readwrite/edgelist.py +0 -489
  338. package/runtime/python/okstra_vendor/networkx/readwrite/gexf.py +0 -1084
  339. package/runtime/python/okstra_vendor/networkx/readwrite/gml.py +0 -879
  340. package/runtime/python/okstra_vendor/networkx/readwrite/graph6.py +0 -427
  341. package/runtime/python/okstra_vendor/networkx/readwrite/graphml.py +0 -1053
  342. package/runtime/python/okstra_vendor/networkx/readwrite/json_graph/__init__.py +0 -19
  343. package/runtime/python/okstra_vendor/networkx/readwrite/json_graph/adjacency.py +0 -156
  344. package/runtime/python/okstra_vendor/networkx/readwrite/json_graph/cytoscape.py +0 -190
  345. package/runtime/python/okstra_vendor/networkx/readwrite/json_graph/node_link.py +0 -261
  346. package/runtime/python/okstra_vendor/networkx/readwrite/json_graph/tree.py +0 -137
  347. package/runtime/python/okstra_vendor/networkx/readwrite/leda.py +0 -108
  348. package/runtime/python/okstra_vendor/networkx/readwrite/multiline_adjlist.py +0 -393
  349. package/runtime/python/okstra_vendor/networkx/readwrite/p2g.py +0 -113
  350. package/runtime/python/okstra_vendor/networkx/readwrite/pajek.py +0 -286
  351. package/runtime/python/okstra_vendor/networkx/readwrite/sparse6.py +0 -379
  352. package/runtime/python/okstra_vendor/networkx/readwrite/text.py +0 -851
  353. package/runtime/python/okstra_vendor/networkx/relabel.py +0 -285
  354. package/runtime/python/okstra_vendor/networkx/utils/__init__.py +0 -8
  355. package/runtime/python/okstra_vendor/networkx/utils/backends.py +0 -2171
  356. package/runtime/python/okstra_vendor/networkx/utils/configs.py +0 -396
  357. package/runtime/python/okstra_vendor/networkx/utils/decorators.py +0 -1233
  358. package/runtime/python/okstra_vendor/networkx/utils/heaps.py +0 -338
  359. package/runtime/python/okstra_vendor/networkx/utils/mapped_queue.py +0 -297
  360. package/runtime/python/okstra_vendor/networkx/utils/misc.py +0 -703
  361. package/runtime/python/okstra_vendor/networkx/utils/random_sequence.py +0 -198
  362. package/runtime/python/okstra_vendor/networkx/utils/rcm.py +0 -159
  363. package/runtime/python/okstra_vendor/networkx/utils/union_find.py +0 -106
  364. package/runtime/skills/okstra-graphify/SKILL.md +0 -169
  365. package/src/commands/graphify.mjs +0 -32
@@ -1,1014 +0,0 @@
1
- # write graph to HTML, JSON, SVG, GraphML, Obsidian vault, and Neo4j Cypher
2
- from __future__ import annotations
3
- import html as _html
4
- import json
5
- import math
6
- import re
7
- from collections import Counter
8
- from pathlib import Path
9
- import networkx as nx
10
- from networkx.readwrite import json_graph
11
- from graphify.security import sanitize_label
12
- from graphify.analyze import _node_community_map
13
-
14
- def _strip_diacritics(text: str) -> str:
15
- import unicodedata
16
- nfkd = unicodedata.normalize("NFKD", text)
17
- return "".join(c for c in nfkd if not unicodedata.combining(c))
18
-
19
-
20
- COMMUNITY_COLORS = [
21
- "#4E79A7", "#F28E2B", "#E15759", "#76B7B2", "#59A14F",
22
- "#EDC948", "#B07AA1", "#FF9DA7", "#9C755F", "#BAB0AC",
23
- ]
24
-
25
- MAX_NODES_FOR_VIZ = 5_000
26
-
27
-
28
- def _html_styles() -> str:
29
- return """<style>
30
- * { box-sizing: border-box; margin: 0; padding: 0; }
31
- body { background: #0f0f1a; color: #e0e0e0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; display: flex; height: 100vh; overflow: hidden; }
32
- #graph { flex: 1; }
33
- #sidebar { width: 280px; background: #1a1a2e; border-left: 1px solid #2a2a4e; display: flex; flex-direction: column; overflow: hidden; }
34
- #search-wrap { padding: 12px; border-bottom: 1px solid #2a2a4e; }
35
- #search { width: 100%; background: #0f0f1a; border: 1px solid #3a3a5e; color: #e0e0e0; padding: 7px 10px; border-radius: 6px; font-size: 13px; outline: none; }
36
- #search:focus { border-color: #4E79A7; }
37
- #search-results { max-height: 140px; overflow-y: auto; padding: 4px 12px; border-bottom: 1px solid #2a2a4e; display: none; }
38
- .search-item { padding: 4px 6px; cursor: pointer; border-radius: 4px; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
39
- .search-item:hover { background: #2a2a4e; }
40
- #info-panel { padding: 14px; border-bottom: 1px solid #2a2a4e; min-height: 140px; }
41
- #info-panel h3 { font-size: 13px; color: #aaa; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.05em; }
42
- #info-content { font-size: 13px; color: #ccc; line-height: 1.6; }
43
- #info-content .field { margin-bottom: 5px; }
44
- #info-content .field b { color: #e0e0e0; }
45
- #info-content .empty { color: #555; font-style: italic; }
46
- .neighbor-link { display: block; padding: 2px 6px; margin: 2px 0; border-radius: 3px; cursor: pointer; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; border-left: 3px solid #333; }
47
- .neighbor-link:hover { background: #2a2a4e; }
48
- #neighbors-list { max-height: 160px; overflow-y: auto; margin-top: 4px; }
49
- #legend-wrap { flex: 1; overflow-y: auto; padding: 12px; }
50
- #legend-wrap h3 { font-size: 13px; color: #aaa; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.05em; }
51
- .legend-item { display: flex; align-items: center; gap: 8px; padding: 4px 0; cursor: pointer; border-radius: 4px; font-size: 12px; }
52
- .legend-item:hover { background: #2a2a4e; padding-left: 4px; }
53
- .legend-item.dimmed { opacity: 0.35; }
54
- .legend-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
55
- .legend-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
56
- .legend-count { color: #666; font-size: 11px; }
57
- #stats { padding: 10px 14px; border-top: 1px solid #2a2a4e; font-size: 11px; color: #555; }
58
- </style>"""
59
-
60
-
61
- def _hyperedge_script(hyperedges_json: str) -> str:
62
- return f"""<script>
63
- // Render hyperedges as shaded regions
64
- const hyperedges = {hyperedges_json};
65
- // afterDrawing passes ctx already transformed to network coordinate space.
66
- // Draw node positions raw — no manual pan/zoom/DPR math needed.
67
- network.on('afterDrawing', function(ctx) {{
68
- hyperedges.forEach(h => {{
69
- const positions = h.nodes
70
- .map(nid => network.getPositions([nid])[nid])
71
- .filter(p => p !== undefined);
72
- if (positions.length < 2) return;
73
- ctx.save();
74
- ctx.globalAlpha = 0.12;
75
- ctx.fillStyle = '#6366f1';
76
- ctx.strokeStyle = '#6366f1';
77
- ctx.lineWidth = 2;
78
- ctx.beginPath();
79
- // Centroid and expanded hull in network coordinates
80
- const cx = positions.reduce((s, p) => s + p.x, 0) / positions.length;
81
- const cy = positions.reduce((s, p) => s + p.y, 0) / positions.length;
82
- const expanded = positions.map(p => ({{
83
- x: cx + (p.x - cx) * 1.15,
84
- y: cy + (p.y - cy) * 1.15
85
- }}));
86
- ctx.moveTo(expanded[0].x, expanded[0].y);
87
- expanded.slice(1).forEach(p => ctx.lineTo(p.x, p.y));
88
- ctx.closePath();
89
- ctx.fill();
90
- ctx.globalAlpha = 0.4;
91
- ctx.stroke();
92
- // Label
93
- ctx.globalAlpha = 0.8;
94
- ctx.fillStyle = '#4f46e5';
95
- ctx.font = 'bold 11px sans-serif';
96
- ctx.textAlign = 'center';
97
- ctx.fillText(h.label, cx, cy - 5);
98
- ctx.restore();
99
- }});
100
- }});
101
- </script>"""
102
-
103
-
104
- def _html_script(nodes_json: str, edges_json: str, legend_json: str) -> str:
105
- return f"""<script>
106
- const RAW_NODES = {nodes_json};
107
- const RAW_EDGES = {edges_json};
108
- const LEGEND = {legend_json};
109
-
110
- // HTML-escape helper — prevents XSS when injecting graph data into innerHTML
111
- function esc(s) {{
112
- return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
113
- }}
114
-
115
- // Build vis datasets
116
- const nodesDS = new vis.DataSet(RAW_NODES.map(n => ({{
117
- id: n.id, label: n.label, color: n.color, size: n.size,
118
- font: n.font, title: n.title,
119
- _community: n.community, _community_name: n.community_name,
120
- _source_file: n.source_file, _file_type: n.file_type, _degree: n.degree,
121
- }})));
122
-
123
- const edgesDS = new vis.DataSet(RAW_EDGES.map((e, i) => ({{
124
- id: i, from: e.from, to: e.to,
125
- label: '',
126
- title: e.title,
127
- dashes: e.dashes,
128
- width: e.width,
129
- color: e.color,
130
- arrows: {{ to: {{ enabled: true, scaleFactor: 0.5 }} }},
131
- }})));
132
-
133
- const container = document.getElementById('graph');
134
- const network = new vis.Network(container, {{ nodes: nodesDS, edges: edgesDS }}, {{
135
- physics: {{
136
- enabled: true,
137
- solver: 'forceAtlas2Based',
138
- forceAtlas2Based: {{
139
- gravitationalConstant: -60,
140
- centralGravity: 0.005,
141
- springLength: 120,
142
- springConstant: 0.08,
143
- damping: 0.4,
144
- avoidOverlap: 0.8,
145
- }},
146
- stabilization: {{ iterations: 200, fit: true }},
147
- }},
148
- interaction: {{
149
- hover: true,
150
- tooltipDelay: 100,
151
- hideEdgesOnDrag: true,
152
- navigationButtons: false,
153
- keyboard: false,
154
- }},
155
- nodes: {{ shape: 'dot', borderWidth: 1.5 }},
156
- edges: {{ smooth: {{ type: 'continuous', roundness: 0.2 }}, selectionWidth: 3 }},
157
- }});
158
-
159
- network.once('stabilizationIterationsDone', () => {{
160
- network.setOptions({{ physics: {{ enabled: false }} }});
161
- }});
162
-
163
- function showInfo(nodeId) {{
164
- const n = nodesDS.get(nodeId);
165
- if (!n) return;
166
- const neighborIds = network.getConnectedNodes(nodeId);
167
- const neighborItems = neighborIds.map(nid => {{
168
- const nb = nodesDS.get(nid);
169
- const color = nb ? nb.color.background : '#555';
170
- return `<span class="neighbor-link" style="border-left-color:${{esc(color)}}" onclick="focusNode(${{JSON.stringify(nid)}})">${{esc(nb ? nb.label : nid)}}</span>`;
171
- }}).join('');
172
- document.getElementById('info-content').innerHTML = `
173
- <div class="field"><b>${{esc(n.label)}}</b></div>
174
- <div class="field">Type: ${{esc(n._file_type || 'unknown')}}</div>
175
- <div class="field">Community: ${{esc(n._community_name)}}</div>
176
- <div class="field">Source: ${{esc(n._source_file || '-')}}</div>
177
- <div class="field">Degree: ${{n._degree}}</div>
178
- ${{neighborIds.length ? `<div class="field" style="margin-top:8px;color:#aaa;font-size:11px">Neighbors (${{neighborIds.length}})</div><div id="neighbors-list">${{neighborItems}}</div>` : ''}}
179
- `;
180
- }}
181
-
182
- function focusNode(nodeId) {{
183
- network.focus(nodeId, {{ scale: 1.4, animation: true }});
184
- network.selectNodes([nodeId]);
185
- showInfo(nodeId);
186
- }}
187
-
188
- // Track hovered node — hover detection is more reliable than click params
189
- let hoveredNodeId = null;
190
- network.on('hoverNode', params => {{
191
- hoveredNodeId = params.node;
192
- container.style.cursor = 'pointer';
193
- }});
194
- network.on('blurNode', () => {{
195
- hoveredNodeId = null;
196
- container.style.cursor = 'default';
197
- }});
198
- container.addEventListener('click', () => {{
199
- if (hoveredNodeId !== null) {{
200
- showInfo(hoveredNodeId);
201
- network.selectNodes([hoveredNodeId]);
202
- }}
203
- }});
204
- network.on('click', params => {{
205
- if (params.nodes.length > 0) {{
206
- showInfo(params.nodes[0]);
207
- }} else if (hoveredNodeId === null) {{
208
- document.getElementById('info-content').innerHTML = '<span class="empty">Click a node to inspect it</span>';
209
- }}
210
- }});
211
-
212
- const searchInput = document.getElementById('search');
213
- const searchResults = document.getElementById('search-results');
214
- searchInput.addEventListener('input', () => {{
215
- const q = searchInput.value.toLowerCase().trim();
216
- searchResults.innerHTML = '';
217
- if (!q) {{ searchResults.style.display = 'none'; return; }}
218
- const matches = RAW_NODES.filter(n => n.label.toLowerCase().includes(q)).slice(0, 20);
219
- if (!matches.length) {{ searchResults.style.display = 'none'; return; }}
220
- searchResults.style.display = 'block';
221
- matches.forEach(n => {{
222
- const el = document.createElement('div');
223
- el.className = 'search-item';
224
- el.textContent = n.label;
225
- el.style.borderLeft = `3px solid ${{n.color.background}}`;
226
- el.style.paddingLeft = '8px';
227
- el.onclick = () => {{
228
- network.focus(n.id, {{ scale: 1.5, animation: true }});
229
- network.selectNodes([n.id]);
230
- showInfo(n.id);
231
- searchResults.style.display = 'none';
232
- searchInput.value = '';
233
- }};
234
- searchResults.appendChild(el);
235
- }});
236
- }});
237
- document.addEventListener('click', e => {{
238
- if (!searchResults.contains(e.target) && e.target !== searchInput)
239
- searchResults.style.display = 'none';
240
- }});
241
-
242
- const hiddenCommunities = new Set();
243
- const legendEl = document.getElementById('legend');
244
- LEGEND.forEach(c => {{
245
- const item = document.createElement('div');
246
- item.className = 'legend-item';
247
- item.innerHTML = `<div class="legend-dot" style="background:${{c.color}}"></div>
248
- <span class="legend-label">${{c.label}}</span>
249
- <span class="legend-count">${{c.count}}</span>`;
250
- item.onclick = () => {{
251
- if (hiddenCommunities.has(c.cid)) {{
252
- hiddenCommunities.delete(c.cid);
253
- item.classList.remove('dimmed');
254
- }} else {{
255
- hiddenCommunities.add(c.cid);
256
- item.classList.add('dimmed');
257
- }}
258
- const updates = RAW_NODES
259
- .filter(n => n.community === c.cid)
260
- .map(n => ({{ id: n.id, hidden: hiddenCommunities.has(c.cid) }}));
261
- nodesDS.update(updates);
262
- }};
263
- legendEl.appendChild(item);
264
- }});
265
- </script>"""
266
-
267
-
268
- _CONFIDENCE_SCORE_DEFAULTS = {"EXTRACTED": 1.0, "INFERRED": 0.5, "AMBIGUOUS": 0.2}
269
-
270
-
271
- def attach_hyperedges(G: nx.Graph, hyperedges: list) -> None:
272
- """Store hyperedges in the graph's metadata dict."""
273
- existing = G.graph.get("hyperedges", [])
274
- seen_ids = {h["id"] for h in existing}
275
- for h in hyperedges:
276
- if h.get("id") and h["id"] not in seen_ids:
277
- existing.append(h)
278
- seen_ids.add(h["id"])
279
- G.graph["hyperedges"] = existing
280
-
281
-
282
- def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str) -> None:
283
- node_community = _node_community_map(communities)
284
- try:
285
- data = json_graph.node_link_data(G, edges="links")
286
- except TypeError:
287
- data = json_graph.node_link_data(G)
288
- for node in data["nodes"]:
289
- node["community"] = node_community.get(node["id"])
290
- node["norm_label"] = _strip_diacritics(node.get("label", "")).lower()
291
- for link in data["links"]:
292
- if "confidence_score" not in link:
293
- conf = link.get("confidence", "EXTRACTED")
294
- link["confidence_score"] = _CONFIDENCE_SCORE_DEFAULTS.get(conf, 1.0)
295
- data["hyperedges"] = getattr(G, "graph", {}).get("hyperedges", [])
296
- with open(output_path, "w", encoding="utf-8") as f:
297
- json.dump(data, f, indent=2)
298
-
299
-
300
- def prune_dangling_edges(graph_data: dict) -> tuple[dict, int]:
301
- """Remove edges whose source or target node is not in the node set.
302
-
303
- Returns the cleaned graph_data dict and the number of pruned edges.
304
- """
305
- node_ids = {n["id"] for n in graph_data["nodes"]}
306
- links_key = "links" if "links" in graph_data else "edges"
307
- before = len(graph_data[links_key])
308
- graph_data[links_key] = [
309
- e for e in graph_data[links_key]
310
- if e["source"] in node_ids and e["target"] in node_ids
311
- ]
312
- return graph_data, before - len(graph_data[links_key])
313
-
314
-
315
- def _cypher_escape(s: str) -> str:
316
- """Escape a string for safe embedding in a Cypher single-quoted literal."""
317
- return s.replace("\\", "\\\\").replace("'", "\\'")
318
-
319
-
320
- def to_cypher(G: nx.Graph, output_path: str) -> None:
321
- lines = ["// Neo4j Cypher import - generated by /graphify", ""]
322
- for node_id, data in G.nodes(data=True):
323
- label = _cypher_escape(data.get("label", node_id))
324
- node_id_esc = _cypher_escape(node_id)
325
- _ft = re.sub(r"[^A-Za-z0-9_]", "", data.get("file_type", "unknown").capitalize())
326
- ftype = (_ft if _ft and _ft[0].isalpha() else "Entity")
327
- lines.append(f"MERGE (n:{ftype} {{id: '{node_id_esc}', label: '{label}'}});")
328
- lines.append("")
329
- for u, v, data in G.edges(data=True):
330
- rel = re.sub(r"[^A-Za-z0-9_]", "_", data.get("relation", "RELATES_TO").upper())
331
- conf = _cypher_escape(data.get("confidence", "EXTRACTED"))
332
- u_esc = _cypher_escape(u)
333
- v_esc = _cypher_escape(v)
334
- lines.append(
335
- f"MATCH (a {{id: '{u_esc}'}}), (b {{id: '{v_esc}'}}) "
336
- f"MERGE (a)-[:{rel} {{confidence: '{conf}'}}]->(b);"
337
- )
338
- with open(output_path, "w", encoding="utf-8") as f:
339
- f.write("\n".join(lines))
340
-
341
-
342
- def to_html(
343
- G: nx.Graph,
344
- communities: dict[int, list[str]],
345
- output_path: str,
346
- community_labels: dict[int, str] | None = None,
347
- ) -> None:
348
- """Generate an interactive vis.js HTML visualization of the graph.
349
-
350
- Features: node size by degree, click-to-inspect panel, search box,
351
- community filter, physics clustering by community, confidence-styled edges.
352
- Raises ValueError if graph exceeds MAX_NODES_FOR_VIZ.
353
- """
354
- if G.number_of_nodes() > MAX_NODES_FOR_VIZ:
355
- raise ValueError(
356
- f"Graph has {G.number_of_nodes()} nodes - too large for HTML viz. "
357
- f"Use --no-viz or reduce input size."
358
- )
359
-
360
- node_community = _node_community_map(communities)
361
- degree = dict(G.degree())
362
- max_deg = max(degree.values(), default=1) or 1
363
-
364
- # Build nodes list for vis.js
365
- vis_nodes = []
366
- for node_id, data in G.nodes(data=True):
367
- cid = node_community.get(node_id, 0)
368
- color = COMMUNITY_COLORS[cid % len(COMMUNITY_COLORS)]
369
- label = sanitize_label(data.get("label", node_id))
370
- deg = degree.get(node_id, 1)
371
- size = 10 + 30 * (deg / max_deg)
372
- # Only show label for high-degree nodes by default; others show on hover
373
- font_size = 12 if deg >= max_deg * 0.15 else 0
374
- vis_nodes.append({
375
- "id": node_id,
376
- "label": label,
377
- "color": {"background": color, "border": color, "highlight": {"background": "#ffffff", "border": color}},
378
- "size": round(size, 1),
379
- "font": {"size": font_size, "color": "#ffffff"},
380
- "title": _html.escape(label),
381
- "community": cid,
382
- "community_name": sanitize_label((community_labels or {}).get(cid, f"Community {cid}")),
383
- "source_file": sanitize_label(data.get("source_file", "")),
384
- "file_type": data.get("file_type", ""),
385
- "degree": deg,
386
- })
387
-
388
- # Build edges list
389
- vis_edges = []
390
- for u, v, data in G.edges(data=True):
391
- confidence = data.get("confidence", "EXTRACTED")
392
- relation = data.get("relation", "")
393
- vis_edges.append({
394
- "from": u,
395
- "to": v,
396
- "label": relation,
397
- "title": _html.escape(f"{relation} [{confidence}]"),
398
- "dashes": confidence != "EXTRACTED",
399
- "width": 2 if confidence == "EXTRACTED" else 1,
400
- "color": {"opacity": 0.7 if confidence == "EXTRACTED" else 0.35},
401
- "confidence": confidence,
402
- })
403
-
404
- # Build community legend data
405
- legend_data = []
406
- for cid in sorted((community_labels or {}).keys()):
407
- color = COMMUNITY_COLORS[cid % len(COMMUNITY_COLORS)]
408
- lbl = _html.escape(sanitize_label((community_labels or {}).get(cid, f"Community {cid}")))
409
- n = len(communities.get(cid, []))
410
- legend_data.append({"cid": cid, "color": color, "label": lbl, "count": n})
411
-
412
- # Escape </script> sequences so embedded JSON cannot break out of the script tag
413
- def _js_safe(obj) -> str:
414
- return json.dumps(obj).replace("</", "<\\/")
415
-
416
- nodes_json = _js_safe(vis_nodes)
417
- edges_json = _js_safe(vis_edges)
418
- legend_json = _js_safe(legend_data)
419
- hyperedges_json = _js_safe(getattr(G, "graph", {}).get("hyperedges", []))
420
- title = _html.escape(sanitize_label(str(output_path)))
421
- stats = f"{G.number_of_nodes()} nodes &middot; {G.number_of_edges()} edges &middot; {len(communities)} communities"
422
-
423
- html = f"""<!DOCTYPE html>
424
- <html lang="en">
425
- <head>
426
- <meta charset="UTF-8">
427
- <title>graphify - {title}</title>
428
- <script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
429
- {_html_styles()}
430
- </head>
431
- <body>
432
- <div id="graph"></div>
433
- <div id="sidebar">
434
- <div id="search-wrap">
435
- <input id="search" type="text" placeholder="Search nodes..." autocomplete="off">
436
- <div id="search-results"></div>
437
- </div>
438
- <div id="info-panel">
439
- <h3>Node Info</h3>
440
- <div id="info-content"><span class="empty">Click a node to inspect it</span></div>
441
- </div>
442
- <div id="legend-wrap">
443
- <h3>Communities</h3>
444
- <div id="legend"></div>
445
- </div>
446
- <div id="stats">{stats}</div>
447
- </div>
448
- {_html_script(nodes_json, edges_json, legend_json)}
449
- {_hyperedge_script(hyperedges_json)}
450
- </body>
451
- </html>"""
452
-
453
- Path(output_path).write_text(html, encoding="utf-8")
454
-
455
-
456
- # Keep backward-compatible alias - skill.md calls generate_html
457
- generate_html = to_html
458
-
459
-
460
- def to_obsidian(
461
- G: nx.Graph,
462
- communities: dict[int, list[str]],
463
- output_dir: str,
464
- community_labels: dict[int, str] | None = None,
465
- cohesion: dict[int, float] | None = None,
466
- ) -> int:
467
- """Export graph as an Obsidian vault - one .md file per node with [[wikilinks]],
468
- plus one _COMMUNITY_name.md overview note per community (sorted to top by underscore prefix).
469
-
470
- Open the output directory as a vault in Obsidian to get an interactive
471
- graph view with community colors and full-text search over node metadata.
472
-
473
- Returns the number of node notes + community notes written.
474
- """
475
- out = Path(output_dir)
476
- out.mkdir(parents=True, exist_ok=True)
477
-
478
- node_community = _node_community_map(communities)
479
-
480
- # Map node_id → safe filename so wikilinks stay consistent.
481
- # Deduplicate: if two nodes produce the same filename, append a numeric suffix.
482
- def safe_name(label: str) -> str:
483
- cleaned = re.sub(r'[\\/*?:"<>|#^[\]]', "", label.replace("\r\n", " ").replace("\r", " ").replace("\n", " ")).strip()
484
- # Strip trailing .md/.mdx/.markdown so "CLAUDE.md" doesn't become "CLAUDE.md.md"
485
- cleaned = re.sub(r"\.(md|mdx|markdown)$", "", cleaned, flags=re.IGNORECASE)
486
- return cleaned or "unnamed"
487
-
488
- node_filename: dict[str, str] = {}
489
- seen_names: dict[str, int] = {}
490
- for node_id, data in G.nodes(data=True):
491
- base = safe_name(data.get("label", node_id))
492
- if base in seen_names:
493
- seen_names[base] += 1
494
- node_filename[node_id] = f"{base}_{seen_names[base]}"
495
- else:
496
- seen_names[base] = 0
497
- node_filename[node_id] = base
498
-
499
- # Helper: compute dominant confidence for a node across all its edges
500
- def _dominant_confidence(node_id: str) -> str:
501
- confs = []
502
- for u, v, edata in G.edges(node_id, data=True):
503
- confs.append(edata.get("confidence", "EXTRACTED"))
504
- if not confs:
505
- return "EXTRACTED"
506
- return Counter(confs).most_common(1)[0][0]
507
-
508
- # Map file_type → graphify tag
509
- _FTYPE_TAG = {
510
- "code": "graphify/code",
511
- "document": "graphify/document",
512
- "paper": "graphify/paper",
513
- "image": "graphify/image",
514
- }
515
-
516
- # Write one .md file per node
517
- for node_id, data in G.nodes(data=True):
518
- label = data.get("label", node_id)
519
- cid = node_community.get(node_id)
520
- community_name = (
521
- community_labels.get(cid, f"Community {cid}")
522
- if community_labels and cid is not None
523
- else f"Community {cid}"
524
- )
525
-
526
- # Build tags for this node
527
- ftype = data.get("file_type", "")
528
- ftype_tag = _FTYPE_TAG.get(ftype, f"graphify/{ftype}" if ftype else "graphify/document")
529
- dom_conf = _dominant_confidence(node_id)
530
- conf_tag = f"graphify/{dom_conf}"
531
- comm_tag = f"community/{community_name.replace(' ', '_')}"
532
- node_tags = [ftype_tag, conf_tag, comm_tag]
533
-
534
- lines: list[str] = []
535
-
536
- # YAML frontmatter - readable in Obsidian's properties panel
537
- lines += [
538
- "---",
539
- f'source_file: "{data.get("source_file", "")}"',
540
- f'type: "{ftype}"',
541
- f'community: "{community_name}"',
542
- ]
543
- if data.get("source_location"):
544
- lines.append(f'location: "{data["source_location"]}"')
545
- # Add tags list to frontmatter
546
- lines.append("tags:")
547
- for tag in node_tags:
548
- lines.append(f" - {tag}")
549
- lines += ["---", "", f"# {label}", ""]
550
-
551
- # Outgoing edges as wikilinks
552
- neighbors = list(G.neighbors(node_id))
553
- if neighbors:
554
- lines.append("## Connections")
555
- for neighbor in sorted(neighbors, key=lambda n: G.nodes[n].get("label", n)):
556
- edge_data = G.edges[node_id, neighbor]
557
- neighbor_label = node_filename[neighbor]
558
- relation = edge_data.get("relation", "")
559
- confidence = edge_data.get("confidence", "EXTRACTED")
560
- lines.append(f"- [[{neighbor_label}]] - `{relation}` [{confidence}]")
561
- lines.append("")
562
-
563
- # Inline tags at bottom of note body (for Obsidian tag panel)
564
- inline_tags = " ".join(f"#{t}" for t in node_tags)
565
- lines.append(inline_tags)
566
-
567
- fname = node_filename[node_id] + ".md"
568
- (out / fname).write_text("\n".join(lines), encoding="utf-8")
569
-
570
- # Write one _COMMUNITY_name.md overview note per community
571
- # Build inter-community edge counts for "Connections to other communities"
572
- inter_community_edges: dict[int, dict[int, int]] = {}
573
- for cid in communities:
574
- inter_community_edges[cid] = {}
575
- for u, v in G.edges():
576
- cu = node_community.get(u)
577
- cv = node_community.get(v)
578
- if cu is not None and cv is not None and cu != cv:
579
- inter_community_edges.setdefault(cu, {})
580
- inter_community_edges.setdefault(cv, {})
581
- inter_community_edges[cu][cv] = inter_community_edges[cu].get(cv, 0) + 1
582
- inter_community_edges[cv][cu] = inter_community_edges[cv].get(cu, 0) + 1
583
-
584
- # Precompute per-node community reach (number of distinct communities a node connects to)
585
- def _community_reach(node_id: str) -> int:
586
- neighbor_cids = {
587
- node_community[nb]
588
- for nb in G.neighbors(node_id)
589
- if nb in node_community and node_community[nb] != node_community.get(node_id)
590
- }
591
- return len(neighbor_cids)
592
-
593
- community_notes_written = 0
594
- for cid, members in communities.items():
595
- community_name = (
596
- community_labels.get(cid, f"Community {cid}")
597
- if community_labels and cid is not None
598
- else f"Community {cid}"
599
- )
600
- n_members = len(members)
601
- coh_value = cohesion.get(cid) if cohesion else None
602
-
603
- lines: list[str] = []
604
-
605
- # YAML frontmatter
606
- lines.append("---")
607
- lines.append("type: community")
608
- if coh_value is not None:
609
- lines.append(f"cohesion: {coh_value:.2f}")
610
- lines.append(f"members: {n_members}")
611
- lines.append("---")
612
- lines.append("")
613
- lines.append(f"# {community_name}")
614
- lines.append("")
615
-
616
- # Cohesion + member count summary
617
- if coh_value is not None:
618
- cohesion_desc = (
619
- "tightly connected" if coh_value >= 0.7
620
- else "moderately connected" if coh_value >= 0.4
621
- else "loosely connected"
622
- )
623
- lines.append(f"**Cohesion:** {coh_value:.2f} - {cohesion_desc}")
624
- lines.append(f"**Members:** {n_members} nodes")
625
- lines.append("")
626
-
627
- # Members section
628
- lines.append("## Members")
629
- for node_id in sorted(members, key=lambda n: G.nodes[n].get("label", n)):
630
- data = G.nodes[node_id]
631
- node_label = node_filename[node_id]
632
- ftype = data.get("file_type", "")
633
- source = data.get("source_file", "")
634
- entry = f"- [[{node_label}]]"
635
- if ftype:
636
- entry += f" - {ftype}"
637
- if source:
638
- entry += f" - {source}"
639
- lines.append(entry)
640
- lines.append("")
641
-
642
- # Dataview live query (improvement 2)
643
- comm_tag_name = community_name.replace(" ", "_")
644
- lines.append("## Live Query (requires Dataview plugin)")
645
- lines.append("")
646
- lines.append("```dataview")
647
- lines.append(f"TABLE source_file, type FROM #community/{comm_tag_name}")
648
- lines.append("SORT file.name ASC")
649
- lines.append("```")
650
- lines.append("")
651
-
652
- # Connections to other communities
653
- cross = inter_community_edges.get(cid, {})
654
- if cross:
655
- lines.append("## Connections to other communities")
656
- for other_cid, edge_count in sorted(cross.items(), key=lambda x: -x[1]):
657
- other_name = (
658
- community_labels.get(other_cid, f"Community {other_cid}")
659
- if community_labels and other_cid is not None
660
- else f"Community {other_cid}"
661
- )
662
- other_safe = safe_name(other_name)
663
- lines.append(f"- {edge_count} edge{'s' if edge_count != 1 else ''} to [[_COMMUNITY_{other_safe}]]")
664
- lines.append("")
665
-
666
- # Top bridge nodes - highest degree nodes that connect to other communities
667
- bridge_nodes = [
668
- (node_id, G.degree(node_id), _community_reach(node_id))
669
- for node_id in members
670
- if _community_reach(node_id) > 0
671
- ]
672
- bridge_nodes.sort(key=lambda x: (-x[2], -x[1]))
673
- top_bridges = bridge_nodes[:5]
674
- if top_bridges:
675
- lines.append("## Top bridge nodes")
676
- for node_id, degree, reach in top_bridges:
677
- node_label = node_filename[node_id]
678
- lines.append(
679
- f"- [[{node_label}]] - degree {degree}, connects to {reach} "
680
- f"{'community' if reach == 1 else 'communities'}"
681
- )
682
-
683
- community_safe = safe_name(community_name)
684
- fname = f"_COMMUNITY_{community_safe}.md"
685
- (out / fname).write_text("\n".join(lines), encoding="utf-8")
686
- community_notes_written += 1
687
-
688
- # Improvement 4: write .obsidian/graph.json to color nodes by community in graph view
689
- obsidian_dir = out / ".obsidian"
690
- obsidian_dir.mkdir(exist_ok=True)
691
- graph_config = {
692
- "colorGroups": [
693
- {
694
- "query": f"tag:#community/{label.replace(' ', '_')}",
695
- "color": {"a": 1, "rgb": int(COMMUNITY_COLORS[cid % len(COMMUNITY_COLORS)].lstrip('#'), 16)}
696
- }
697
- for cid, label in sorted((community_labels or {}).items())
698
- ]
699
- }
700
- (obsidian_dir / "graph.json").write_text(json.dumps(graph_config, indent=2), encoding="utf-8")
701
-
702
- return G.number_of_nodes() + community_notes_written
703
-
704
-
705
- def to_canvas(
706
- G: nx.Graph,
707
- communities: dict[int, list[str]],
708
- output_path: str,
709
- community_labels: dict[int, str] | None = None,
710
- node_filenames: dict[str, str] | None = None,
711
- ) -> None:
712
- """Export graph as an Obsidian Canvas file - communities as groups, nodes as cards.
713
-
714
- Generates a structured layout: communities arranged in a grid, nodes within
715
- each community arranged in rows. Edges shown between connected nodes.
716
- Opens in Obsidian as an infinite canvas with community groupings visible.
717
- """
718
- # Obsidian canvas color codes (cycle through for communities)
719
- CANVAS_COLORS = ["1", "2", "3", "4", "5", "6"] # red, orange, yellow, green, cyan, purple
720
-
721
- def safe_name(label: str) -> str:
722
- cleaned = re.sub(r'[\\/*?:"<>|#^[\]]', "", label.replace("\r\n", " ").replace("\r", " ").replace("\n", " ")).strip()
723
- cleaned = re.sub(r"\.(md|mdx|markdown)$", "", cleaned, flags=re.IGNORECASE)
724
- return cleaned or "unnamed"
725
-
726
- # Build node_filenames if not provided (same dedup logic as to_obsidian)
727
- if node_filenames is None:
728
- node_filenames = {}
729
- seen_names: dict[str, int] = {}
730
- for node_id, data in G.nodes(data=True):
731
- base = safe_name(data.get("label", node_id))
732
- if base in seen_names:
733
- seen_names[base] += 1
734
- node_filenames[node_id] = f"{base}_{seen_names[base]}"
735
- else:
736
- seen_names[base] = 0
737
- node_filenames[node_id] = base
738
-
739
- num_communities = len(communities)
740
- cols = math.ceil(math.sqrt(num_communities)) if num_communities > 0 else 1
741
- rows = math.ceil(num_communities / cols) if num_communities > 0 else 1
742
-
743
- canvas_nodes: list[dict] = []
744
- canvas_edges: list[dict] = []
745
-
746
- # Lay out communities in a grid
747
- gap = 80
748
- group_x_offsets: list[int] = []
749
- group_y_offsets: list[int] = []
750
-
751
- # Precompute group sizes so we can calculate offsets
752
- sorted_cids = sorted(communities.keys())
753
- group_sizes: dict[int, tuple[int, int]] = {}
754
- for cid in sorted_cids:
755
- members = communities[cid]
756
- n = len(members)
757
- w = max(600, 220 * math.ceil(math.sqrt(n)) if n > 0 else 600)
758
- h = max(400, 100 * math.ceil(n / 3) + 120 if n > 0 else 400)
759
- group_sizes[cid] = (w, h)
760
-
761
- # Compute cumulative row heights and col widths for grid placement
762
- # Each grid cell uses the max width/height in its col/row
763
- col_widths: list[int] = []
764
- row_heights: list[int] = []
765
- for col_idx in range(cols):
766
- max_w = 0
767
- for row_idx in range(rows):
768
- linear = row_idx * cols + col_idx
769
- if linear < len(sorted_cids):
770
- cid = sorted_cids[linear]
771
- w, _ = group_sizes[cid]
772
- max_w = max(max_w, w)
773
- col_widths.append(max_w)
774
-
775
- for row_idx in range(rows):
776
- max_h = 0
777
- for col_idx in range(cols):
778
- linear = row_idx * cols + col_idx
779
- if linear < len(sorted_cids):
780
- cid = sorted_cids[linear]
781
- _, h = group_sizes[cid]
782
- max_h = max(max_h, h)
783
- row_heights.append(max_h)
784
-
785
- # Map from cid → (group_x, group_y, group_w, group_h)
786
- group_layout: dict[int, tuple[int, int, int, int]] = {}
787
- for idx, cid in enumerate(sorted_cids):
788
- col_idx = idx % cols
789
- row_idx = idx // cols
790
- gx = sum(col_widths[:col_idx]) + col_idx * gap
791
- gy = sum(row_heights[:row_idx]) + row_idx * gap
792
- gw, gh = group_sizes[cid]
793
- group_layout[cid] = (gx, gy, gw, gh)
794
-
795
- # Build set of all node_ids in canvas for edge filtering
796
- all_canvas_nodes: set[str] = set()
797
- for members in communities.values():
798
- all_canvas_nodes.update(members)
799
-
800
- # Generate group and node canvas entries
801
- for idx, cid in enumerate(sorted_cids):
802
- members = communities[cid]
803
- community_name = (
804
- community_labels.get(cid, f"Community {cid}")
805
- if community_labels and cid is not None
806
- else f"Community {cid}"
807
- )
808
- gx, gy, gw, gh = group_layout[cid]
809
- canvas_color = CANVAS_COLORS[idx % len(CANVAS_COLORS)]
810
-
811
- # Group node
812
- canvas_nodes.append({
813
- "id": f"g{cid}",
814
- "type": "group",
815
- "label": community_name,
816
- "x": gx,
817
- "y": gy,
818
- "width": gw,
819
- "height": gh,
820
- "color": canvas_color,
821
- })
822
-
823
- # Node cards inside the group - rows of 3
824
- sorted_members = sorted(members, key=lambda n: G.nodes[n].get("label", n))
825
- for m_idx, node_id in enumerate(sorted_members):
826
- col = m_idx % 3
827
- row = m_idx // 3
828
- nx_x = gx + 20 + col * (180 + 20)
829
- nx_y = gy + 80 + row * (60 + 20)
830
- fname = node_filenames.get(node_id, safe_name(G.nodes[node_id].get("label", node_id)))
831
- canvas_nodes.append({
832
- "id": f"n_{node_id}",
833
- "type": "file",
834
- "file": f"graphify/obsidian/{fname}.md",
835
- "x": nx_x,
836
- "y": nx_y,
837
- "width": 180,
838
- "height": 60,
839
- })
840
-
841
- # Generate edges - only between nodes both in canvas, cap at 200 highest-weight
842
- all_edges_weighted: list[tuple[float, str, str, str]] = []
843
- for u, v, edata in G.edges(data=True):
844
- if u in all_canvas_nodes and v in all_canvas_nodes:
845
- weight = edata.get("weight", 1.0)
846
- relation = edata.get("relation", "")
847
- conf = edata.get("confidence", "EXTRACTED")
848
- label = f"{relation} [{conf}]" if relation else f"[{conf}]"
849
- all_edges_weighted.append((weight, u, v, label))
850
-
851
- all_edges_weighted.sort(key=lambda x: -x[0])
852
- for weight, u, v, label in all_edges_weighted[:200]:
853
- canvas_edges.append({
854
- "id": f"e_{u}_{v}",
855
- "fromNode": f"n_{u}",
856
- "toNode": f"n_{v}",
857
- "label": label,
858
- })
859
-
860
- canvas_data = {"nodes": canvas_nodes, "edges": canvas_edges}
861
- Path(output_path).write_text(json.dumps(canvas_data, indent=2), encoding="utf-8")
862
-
863
-
864
- def push_to_neo4j(
865
- G: nx.Graph,
866
- uri: str,
867
- user: str,
868
- password: str,
869
- communities: dict[int, list[str]] | None = None,
870
- ) -> dict[str, int]:
871
- """Push graph directly to a running Neo4j instance via the Python driver.
872
-
873
- Requires: pip install neo4j
874
-
875
- Uses MERGE so re-running is safe - nodes and edges are upserted, not duplicated.
876
- Returns a dict with counts of nodes and edges pushed.
877
- """
878
- try:
879
- from neo4j import GraphDatabase
880
- except ImportError as e:
881
- raise ImportError(
882
- "neo4j driver not installed. Run: pip install neo4j"
883
- ) from e
884
-
885
- node_community = _node_community_map(communities) if communities else {}
886
-
887
- def _safe_rel(relation: str) -> str:
888
- return re.sub(r"[^A-Z0-9_]", "_", relation.upper().replace(" ", "_").replace("-", "_")) or "RELATED_TO"
889
-
890
- def _safe_label(label: str) -> str:
891
- """Sanitize a Neo4j node label to prevent Cypher injection."""
892
- sanitized = re.sub(r"[^A-Za-z0-9_]", "", label)
893
- return sanitized if sanitized else "Entity"
894
-
895
- driver = GraphDatabase.driver(uri, auth=(user, password))
896
- nodes_pushed = 0
897
- edges_pushed = 0
898
-
899
- with driver.session() as session:
900
- for node_id, data in G.nodes(data=True):
901
- props = {k: v for k, v in data.items() if isinstance(v, (str, int, float, bool))}
902
- props["id"] = node_id
903
- cid = node_community.get(node_id)
904
- if cid is not None:
905
- props["community"] = cid
906
- ftype = _safe_label(data.get("file_type", "Entity").capitalize())
907
- session.run(
908
- f"MERGE (n:{ftype} {{id: $id}}) SET n += $props",
909
- id=node_id,
910
- props=props,
911
- )
912
- nodes_pushed += 1
913
-
914
- for u, v, data in G.edges(data=True):
915
- rel = _safe_rel(data.get("relation", "RELATED_TO"))
916
- props = {k: v for k, v in data.items() if isinstance(v, (str, int, float, bool))}
917
- session.run(
918
- f"MATCH (a {{id: $src}}), (b {{id: $tgt}}) "
919
- f"MERGE (a)-[r:{rel}]->(b) SET r += $props",
920
- src=u,
921
- tgt=v,
922
- props=props,
923
- )
924
- edges_pushed += 1
925
-
926
- driver.close()
927
- return {"nodes": nodes_pushed, "edges": edges_pushed}
928
-
929
-
930
- def to_graphml(
931
- G: nx.Graph,
932
- communities: dict[int, list[str]],
933
- output_path: str,
934
- ) -> None:
935
- """Export graph as GraphML - opens in Gephi, yEd, and any GraphML-compatible tool.
936
-
937
- Community IDs are written as a node attribute so Gephi can colour by community.
938
- Edge confidence (EXTRACTED/INFERRED/AMBIGUOUS) is preserved as an edge attribute.
939
- """
940
- H = G.copy()
941
- node_community = _node_community_map(communities)
942
- for node_id in H.nodes():
943
- H.nodes[node_id]["community"] = node_community.get(node_id, -1)
944
- nx.write_graphml(H, output_path)
945
-
946
-
947
- def to_svg(
948
- G: nx.Graph,
949
- communities: dict[int, list[str]],
950
- output_path: str,
951
- community_labels: dict[int, str] | None = None,
952
- figsize: tuple[int, int] = (20, 14),
953
- ) -> None:
954
- """Export graph as an SVG file using matplotlib + spring layout.
955
-
956
- Lightweight and embeddable - works in Obsidian notes, Notion, GitHub READMEs,
957
- and any markdown renderer. No JavaScript required.
958
-
959
- Node size scales with degree. Community colors match the HTML output.
960
- """
961
- try:
962
- import matplotlib
963
- matplotlib.use("Agg")
964
- import matplotlib.pyplot as plt
965
- import matplotlib.patches as mpatches
966
- except ImportError as e:
967
- raise ImportError("matplotlib not installed. Run: pip install matplotlib") from e
968
-
969
- node_community = _node_community_map(communities)
970
-
971
- fig, ax = plt.subplots(figsize=figsize, facecolor="#1a1a2e")
972
- ax.set_facecolor("#1a1a2e")
973
- ax.axis("off")
974
-
975
- pos = nx.spring_layout(G, seed=42, k=2.0 / (G.number_of_nodes() ** 0.5 + 1))
976
-
977
- degree = dict(G.degree())
978
- max_deg = max(degree.values(), default=1) or 1
979
-
980
- node_colors = [COMMUNITY_COLORS[node_community.get(n, 0) % len(COMMUNITY_COLORS)] for n in G.nodes()]
981
- node_sizes = [300 + 1200 * (degree.get(n, 1) / max_deg) for n in G.nodes()]
982
-
983
- # Draw edges - dashed for non-EXTRACTED
984
- for u, v, data in G.edges(data=True):
985
- conf = data.get("confidence", "EXTRACTED")
986
- style = "solid" if conf == "EXTRACTED" else "dashed"
987
- alpha = 0.6 if conf == "EXTRACTED" else 0.3
988
- x0, y0 = pos[u]
989
- x1, y1 = pos[v]
990
- ax.plot([x0, x1], [y0, y1], color="#aaaaaa", linewidth=0.8,
991
- linestyle=style, alpha=alpha, zorder=1)
992
-
993
- nx.draw_networkx_nodes(G, pos, ax=ax, node_color=node_colors,
994
- node_size=node_sizes, alpha=0.9)
995
- nx.draw_networkx_labels(G, pos, ax=ax,
996
- labels={n: G.nodes[n].get("label", n) for n in G.nodes()},
997
- font_size=7, font_color="white")
998
-
999
- # Legend
1000
- if community_labels:
1001
- patches = [
1002
- mpatches.Patch(
1003
- color=COMMUNITY_COLORS[cid % len(COMMUNITY_COLORS)],
1004
- label=f"{label} ({len(communities.get(cid, []))})",
1005
- )
1006
- for cid, label in sorted(community_labels.items())
1007
- ]
1008
- ax.legend(handles=patches, loc="upper left", framealpha=0.7,
1009
- facecolor="#2a2a4e", labelcolor="white", fontsize=8)
1010
-
1011
- plt.tight_layout()
1012
- plt.savefig(output_path, format="svg", bbox_inches="tight",
1013
- facecolor=fig.get_facecolor())
1014
- plt.close(fig)