feat(graph): in-app corpus citation graph (/graph) — Phase 1 #113
387
web-ui/package-lock.json
generated
387
web-ui/package-lock.json
generated
@@ -20,6 +20,7 @@
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-dropzone": "^15.0.0",
|
||||
"react-force-graph-2d": "^1.29.1",
|
||||
"react-hook-form": "^7.72.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
@@ -3903,6 +3904,12 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@tweenjs/tween.js": {
|
||||
"version": "25.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz",
|
||||
"integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||
@@ -4606,6 +4613,15 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/accessor-fn": {
|
||||
"version": "1.5.3",
|
||||
"resolved": "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.5.3.tgz",
|
||||
"integrity": "sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
@@ -5022,6 +5038,16 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bezier-js": {
|
||||
"version": "6.1.4",
|
||||
"resolved": "https://registry.npmjs.org/bezier-js/-/bezier-js-6.1.4.tgz",
|
||||
"integrity": "sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://github.com/Pomax/bezierjs/blob/master/FUNDING.md"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
||||
@@ -5203,6 +5229,18 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/canvas-color-tracker": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/canvas-color-tracker/-/canvas-color-tracker-1.3.2.tgz",
|
||||
"integrity": "sha512-ryQkDX26yJ3CXzb3hxUVNlg1NKE4REc5crLBq661Nxzr8TNd236SaEf2ffYLXyI5tSABSeguHLqcVq4vf9L3Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinycolor2": "^1.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ccount": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
|
||||
@@ -5608,6 +5646,222 @@
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-binarytree": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz",
|
||||
"integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dispatch": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-drag": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
||||
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-selection": "3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-force-3d": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.6.tgz",
|
||||
"integrity": "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"d3-binarytree": "1",
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-octree": "1",
|
||||
"d3-quadtree": "1 - 3",
|
||||
"d3-timer": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-octree": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz",
|
||||
"integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-quadtree": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
|
||||
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale-chromatic": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
|
||||
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-interpolate": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-selection": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-transition": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
||||
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-ease": "1 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-timer": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"d3-selection": "2 - 3"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-zoom": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
||||
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-drag": "2 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-selection": "2 - 3",
|
||||
"d3-transition": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/damerau-levenshtein": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||
@@ -6949,6 +7203,20 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/float-tooltip": {
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://registry.npmjs.org/float-tooltip/-/float-tooltip-1.7.5.tgz",
|
||||
"integrity": "sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"d3-selection": "2 - 3",
|
||||
"kapsule": "^1.16",
|
||||
"preact": "10"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/for-each": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||
@@ -6965,6 +7233,32 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/force-graph": {
|
||||
"version": "1.51.4",
|
||||
"resolved": "https://registry.npmjs.org/force-graph/-/force-graph-1.51.4.tgz",
|
||||
"integrity": "sha512-TdJ2KbkoiDQ7NIRx8IPGD0mAXXpLhamS7c+b7W98b0MHG7lphnda1VOQX/98UDTsttIAdH4TcP0l0MauSnLK8w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tweenjs/tween.js": "18 - 25",
|
||||
"accessor-fn": "1",
|
||||
"bezier-js": "3 - 6",
|
||||
"canvas-color-tracker": "^1.3",
|
||||
"d3-array": "1 - 3",
|
||||
"d3-drag": "2 - 3",
|
||||
"d3-force-3d": "2 - 3",
|
||||
"d3-scale": "1 - 4",
|
||||
"d3-scale-chromatic": "1 - 3",
|
||||
"d3-selection": "2 - 3",
|
||||
"d3-zoom": "2 - 3",
|
||||
"float-tooltip": "^1.7",
|
||||
"index-array-by": "1",
|
||||
"kapsule": "^1.16",
|
||||
"lodash-es": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/formdata-polyfill": {
|
||||
"version": "4.0.10",
|
||||
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
||||
@@ -7537,6 +7831,15 @@
|
||||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/index-array-by": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/index-array-by/-/index-array-by-1.4.2.tgz",
|
||||
"integrity": "sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/index-to-position": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz",
|
||||
@@ -7577,6 +7880,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
@@ -8241,6 +8553,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/jerrypick": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/jerrypick/-/jerrypick-1.1.2.tgz",
|
||||
"integrity": "sha512-YKnxXEekXKzhpf7CLYA0A+oDP8V0OhICNCr5lv96FvSsDEmrb0GKM776JgQvHTMjr7DTTPEVv/1Ciaw0uEWzBA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||
@@ -8373,6 +8694,18 @@
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/kapsule": {
|
||||
"version": "1.16.3",
|
||||
"resolved": "https://registry.npmjs.org/kapsule/-/kapsule-1.16.3.tgz",
|
||||
"integrity": "sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash-es": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -8709,6 +9042,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
|
||||
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
@@ -10649,6 +10988,16 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/preact": {
|
||||
"version": "10.29.2",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz",
|
||||
"integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/preact"
|
||||
}
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
@@ -10914,6 +11263,23 @@
|
||||
"react": ">= 16.8 || 18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-force-graph-2d": {
|
||||
"version": "1.29.1",
|
||||
"resolved": "https://registry.npmjs.org/react-force-graph-2d/-/react-force-graph-2d-1.29.1.tgz",
|
||||
"integrity": "sha512-1Rl/1Z3xy2iTHKj6a0jRXGyiI86xUti81K+jBQZ+Oe46csaMikp47L5AjrzA9hY9fNGD63X8ffrqnvaORukCuQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"force-graph": "^1.51",
|
||||
"prop-types": "15",
|
||||
"react-kapsule": "^2.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.72.1",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.72.1.tgz",
|
||||
@@ -10936,6 +11302,21 @@
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-kapsule": {
|
||||
"version": "2.5.7",
|
||||
"resolved": "https://registry.npmjs.org/react-kapsule/-/react-kapsule-2.5.7.tgz",
|
||||
"integrity": "sha512-kifAF4ZPD77qZKc4CKLmozq6GY1sBzPEJTIJb0wWFK6HsePJatK3jXplZn2eeAt3x67CDozgi7/rO8fNQ/AL7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jerrypick": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-markdown": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
|
||||
@@ -12171,6 +12552,12 @@
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinycolor2": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz",
|
||||
"integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.16",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-dropzone": "^15.0.0",
|
||||
"react-force-graph-2d": "^1.29.1",
|
||||
"react-hook-form": "^7.72.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
|
||||
29
web-ui/src/app/graph/page.tsx
Normal file
29
web-ui/src/app/graph/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { GraphView } from "@/components/graph/graph-view";
|
||||
|
||||
export default function GraphPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<section className="space-y-6">
|
||||
<header>
|
||||
<nav className="text-[0.78rem] text-ink-muted mb-1">
|
||||
<Link href="/" className="hover:text-gold-deep">
|
||||
בית
|
||||
</Link>
|
||||
<span aria-hidden> · </span>
|
||||
<span className="text-navy">מפת הקורפוס</span>
|
||||
</nav>
|
||||
<h1 className="text-navy mb-0">מפת הקורפוס</h1>
|
||||
<p className="text-ink-muted text-sm mt-1 max-w-3xl">
|
||||
רשת הציטוטים של ספריית הפסיקה — כל נקודה היא פסיקה או נושא, וקו מציין ציטוט או שיוך.
|
||||
גודל הנקודה משקף כמה פעמים הפסיקה צוטטה. לחצו על נקודה כדי להתמקד בשכניה.
|
||||
</p>
|
||||
</header>
|
||||
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
|
||||
<GraphView />
|
||||
</section>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -50,6 +50,7 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
id: "knowledge",
|
||||
items: [
|
||||
{ href: "/precedents", label: "ספריית פסיקה" },
|
||||
{ href: "/graph", label: "מפת הקורפוס" },
|
||||
{ href: "/digests", label: "יומונים" },
|
||||
{ href: "/missing-precedents", label: "פסיקה חסרה" },
|
||||
{ href: "/goldset", label: "מדגם-זהב" },
|
||||
|
||||
233
web-ui/src/components/graph/graph-canvas.tsx
Normal file
233
web-ui/src/components/graph/graph-canvas.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Force-directed canvas for the corpus graph (Obsidian-graph-view-like).
|
||||
*
|
||||
* Uses react-force-graph-2d (HTML5 canvas + d3-force) — the live physics give
|
||||
* the "alive" Obsidian feel, and `nodeCanvasObject` gives full control over
|
||||
* node radius (size = citation count) and Hebrew/RTL label rendering. Loaded
|
||||
* via next/dynamic with ssr:false because the canvas needs the DOM (Next 16).
|
||||
*/
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { CorpusGraph, GraphNode } from "@/lib/api/graph";
|
||||
|
||||
// react-force-graph-2d's default export is a forwardRef component; it touches
|
||||
// `window` at module load, so it must be client-only. Cast to a loose type:
|
||||
// next/dynamic's wrapper doesn't surface the lib's ref/prop types cleanly, and
|
||||
// the node/link callbacks below are independently typed (FGNode/FGLink).
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const ForceGraph2D: any = dynamic(() => import("react-force-graph-2d"), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="grid h-full w-full place-items-center text-sm text-ink-muted">
|
||||
טוען גרף…
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
// Internal shapes the canvas works with (force-graph mutates these).
|
||||
type FGNode = GraphNode & { x?: number; y?: number };
|
||||
type FGLink = {
|
||||
source: string;
|
||||
target: string;
|
||||
type: string;
|
||||
treatment: string | null;
|
||||
};
|
||||
|
||||
const NODE_COLORS: Record<string, string> = {
|
||||
precedent: "#1e3a5f", // navy
|
||||
halacha: "#b45309", // amber
|
||||
topic: "#a97d3a", // gold — hubs stand out
|
||||
practice_area: "#475569", // slate
|
||||
};
|
||||
|
||||
const TREATMENT_COLORS: Record<string, string> = {
|
||||
overrule: "#b91c1c",
|
||||
overruled: "#b91c1c",
|
||||
distinguish: "#d97706",
|
||||
distinguished: "#d97706",
|
||||
};
|
||||
|
||||
function nodeRadius(n: GraphNode): number {
|
||||
if (n.type === "topic" || n.type === "practice_area") return 5;
|
||||
return Math.min(22, 3 + Math.sqrt(Math.max(0, n.size)) * 1.7);
|
||||
}
|
||||
|
||||
function useElementSize<T extends HTMLElement>() {
|
||||
const ref = useRef<T>(null);
|
||||
const [size, setSize] = useState({ width: 0, height: 0 });
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const r = entries[0]?.contentRect;
|
||||
if (r) setSize({ width: Math.floor(r.width), height: Math.floor(r.height) });
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
return { ref, size };
|
||||
}
|
||||
|
||||
export function GraphCanvas({
|
||||
data,
|
||||
selectedId,
|
||||
onNodeClick,
|
||||
}: {
|
||||
data: CorpusGraph | undefined;
|
||||
selectedId: string | null;
|
||||
onNodeClick: (node: GraphNode) => void;
|
||||
}) {
|
||||
const { ref, size } = useElementSize<HTMLDivElement>();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const fgRef = useRef<any>(null);
|
||||
const [hoverId, setHoverId] = useState<string | null>(null);
|
||||
|
||||
// Fresh objects each time `data` changes so force-graph can attach x/y
|
||||
// without mutating the TanStack Query cache.
|
||||
const graphData = useMemo(() => {
|
||||
if (!data) return { nodes: [] as FGNode[], links: [] as FGLink[] };
|
||||
return {
|
||||
nodes: data.nodes.map((n) => ({ ...n })) as FGNode[],
|
||||
links: data.edges.map((e) => ({
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
type: e.type,
|
||||
treatment: e.treatment,
|
||||
})) as FGLink[],
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
// Adjacency for hover/selection highlighting (computed once per data change).
|
||||
const adjacency = useMemo(() => {
|
||||
const map = new Map<string, Set<string>>();
|
||||
for (const e of graphData.links) {
|
||||
if (!map.has(e.source)) map.set(e.source, new Set());
|
||||
if (!map.has(e.target)) map.set(e.target, new Set());
|
||||
map.get(e.source)!.add(e.target);
|
||||
map.get(e.target)!.add(e.source);
|
||||
}
|
||||
return map;
|
||||
}, [graphData]);
|
||||
|
||||
const activeId = hoverId ?? selectedId;
|
||||
const activeNeighbors = activeId ? adjacency.get(activeId) : undefined;
|
||||
|
||||
const isDimmed = useCallback(
|
||||
(id: string) => {
|
||||
if (!activeId) return false;
|
||||
if (id === activeId) return false;
|
||||
return !(activeNeighbors && activeNeighbors.has(id));
|
||||
},
|
||||
[activeId, activeNeighbors],
|
||||
);
|
||||
|
||||
// Zoom-to-fit once physics settle.
|
||||
const handleEngineStop = useCallback(() => {
|
||||
fgRef.current?.zoomToFit?.(400, 60);
|
||||
}, []);
|
||||
|
||||
const drawNode = useCallback(
|
||||
(node: FGNode, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
const r = nodeRadius(node);
|
||||
const dimmed = isDimmed(node.id);
|
||||
const color = NODE_COLORS[node.type] ?? "#64748b";
|
||||
ctx.globalAlpha = dimmed ? 0.18 : 1;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x ?? 0, node.y ?? 0, r, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
if (node.id === activeId) {
|
||||
ctx.lineWidth = 2 / globalScale;
|
||||
ctx.strokeStyle = "#a97d3a";
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Labels: hubs always; precedents when zoomed in, important, or active.
|
||||
const isHub = node.type === "topic" || node.type === "practice_area";
|
||||
const showLabel =
|
||||
!dimmed &&
|
||||
(isHub || node.id === activeId || node.size >= 3 || globalScale >= 1.6);
|
||||
if (showLabel && node.label) {
|
||||
const fontSize = Math.max(2.5, (isHub ? 4.5 : 3.6) / Math.sqrt(globalScale)) +
|
||||
(isHub ? 1 : 0);
|
||||
ctx.font = `${fontSize + 6}px Heebo, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "top";
|
||||
ctx.direction = "rtl";
|
||||
ctx.fillStyle = isHub ? "#7a5a26" : "#1a1a2e";
|
||||
const label =
|
||||
node.label.length > 28 ? `${node.label.slice(0, 27)}…` : node.label;
|
||||
ctx.fillText(label, node.x ?? 0, (node.y ?? 0) + r + 1);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
},
|
||||
[activeId, isDimmed],
|
||||
);
|
||||
|
||||
const drawPointerArea = useCallback(
|
||||
(node: FGNode, color: string, ctx: CanvasRenderingContext2D) => {
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x ?? 0, node.y ?? 0, nodeRadius(node) + 2, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const linkColor = useCallback(
|
||||
(link: FGLink) => {
|
||||
const s = typeof link.source === "object" ? (link.source as FGNode).id : link.source;
|
||||
const t = typeof link.target === "object" ? (link.target as FGNode).id : link.target;
|
||||
const active = activeId && (s === activeId || t === activeId);
|
||||
if (active) return "rgba(169,125,58,0.85)";
|
||||
if (activeId) return "rgba(120,130,150,0.06)";
|
||||
if (link.treatment && TREATMENT_COLORS[link.treatment]) {
|
||||
return TREATMENT_COLORS[link.treatment];
|
||||
}
|
||||
if (link.type === "tagged" || link.type === "in_area") {
|
||||
return "rgba(169,125,58,0.16)";
|
||||
}
|
||||
return "rgba(80,90,110,0.22)";
|
||||
},
|
||||
[activeId],
|
||||
);
|
||||
|
||||
if (!size.width && !data) {
|
||||
return <div ref={ref} className="h-full w-full" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className="h-full w-full">
|
||||
{size.width > 0 && (
|
||||
<ForceGraph2D
|
||||
ref={fgRef}
|
||||
width={size.width}
|
||||
height={size.height}
|
||||
graphData={graphData}
|
||||
nodeId="id"
|
||||
nodeLabel={(n: FGNode) => n.label}
|
||||
nodeCanvasObject={drawNode}
|
||||
nodePointerAreaPaint={drawPointerArea}
|
||||
linkColor={linkColor}
|
||||
linkWidth={(l: FGLink) => {
|
||||
const s = typeof l.source === "object" ? (l.source as FGNode).id : l.source;
|
||||
const t = typeof l.target === "object" ? (l.target as FGNode).id : l.target;
|
||||
return activeId && (s === activeId || t === activeId) ? 1.6 : 0.6;
|
||||
}}
|
||||
linkDirectionalArrowLength={(l: FGLink) => (l.type === "cites" ? 2.4 : 0)}
|
||||
linkDirectionalArrowRelPos={1}
|
||||
onNodeClick={(n: FGNode) => onNodeClick(n)}
|
||||
onNodeHover={(n: FGNode | null) => setHoverId(n?.id ?? null)}
|
||||
onEngineStop={handleEngineStop}
|
||||
cooldownTicks={120}
|
||||
warmupTicks={20}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
178
web-ui/src/components/graph/graph-filter-panel.tsx
Normal file
178
web-ui/src/components/graph/graph-filter-panel.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Filter sidebar for the corpus graph. Controlled — all state lives in
|
||||
* GraphView. node_types toggles let the chair thin the graph (precedent is
|
||||
* always on; halacha is Phase 2 and shown disabled to telegraph the roadmap).
|
||||
*/
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
export type GraphControls = {
|
||||
practiceArea: string;
|
||||
source: string;
|
||||
minCitations: number;
|
||||
q: string;
|
||||
showTopics: boolean;
|
||||
showPracticeAreas: boolean;
|
||||
showHalachot: boolean;
|
||||
};
|
||||
|
||||
const ALL = "__all__";
|
||||
|
||||
const PRACTICE_AREAS: { value: string; label: string }[] = [
|
||||
{ value: "rishuy_uvniya", label: "רישוי ובנייה" },
|
||||
{ value: "betterment_levy", label: "היטל השבחה" },
|
||||
{ value: "compensation_197", label: "פיצויים (ס׳ 197)" },
|
||||
];
|
||||
|
||||
const SOURCES: { value: string; label: string }[] = [
|
||||
{ value: "external_upload", label: "פסיקה חיצונית" },
|
||||
{ value: "internal_committee", label: "החלטות ועדה" },
|
||||
{ value: "cited_only", label: "מוזכר בלבד" },
|
||||
];
|
||||
|
||||
const MIN_CITATIONS = [0, 1, 2, 3, 5];
|
||||
|
||||
export function GraphFilterPanel({
|
||||
controls,
|
||||
onChange,
|
||||
}: {
|
||||
controls: GraphControls;
|
||||
onChange: (patch: Partial<GraphControls>) => void;
|
||||
}) {
|
||||
return (
|
||||
<Card className="bg-surface border-rule shadow-sm w-72 shrink-0 overflow-y-auto">
|
||||
<CardContent className="space-y-5 p-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="graph-search" className="text-xs text-ink-muted">
|
||||
חיפוש פסיקה
|
||||
</Label>
|
||||
<Input
|
||||
id="graph-search"
|
||||
value={controls.q}
|
||||
placeholder="מספר תיק או שם…"
|
||||
onChange={(e) => onChange({ q: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-ink-muted">תחום</Label>
|
||||
<Select
|
||||
value={controls.practiceArea || ALL}
|
||||
onValueChange={(v) => onChange({ practiceArea: v === ALL ? "" : v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>כל התחומים</SelectItem>
|
||||
{PRACTICE_AREAS.map((p) => (
|
||||
<SelectItem key={p.value} value={p.value}>
|
||||
{p.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-ink-muted">מקור</Label>
|
||||
<Select
|
||||
value={controls.source || ALL}
|
||||
onValueChange={(v) => onChange({ source: v === ALL ? "" : v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>כל המקורות</SelectItem>
|
||||
{SOURCES.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-ink-muted">מינימום ציטוטים נכנסים</Label>
|
||||
<Select
|
||||
value={String(controls.minCitations)}
|
||||
onValueChange={(v) => onChange({ minCitations: Number(v) })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MIN_CITATIONS.map((n) => (
|
||||
<SelectItem key={n} value={String(n)}>
|
||||
{n === 0 ? "הצג הכל" : `${n}+`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs text-ink-muted">סוגי נקודות</Label>
|
||||
<ToggleRow
|
||||
label="נקודות-נושא"
|
||||
checked={controls.showTopics}
|
||||
onCheckedChange={(v) => onChange({ showTopics: v })}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="נקודות-תחום"
|
||||
checked={controls.showPracticeAreas}
|
||||
onCheckedChange={(v) => onChange({ showPracticeAreas: v })}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="הלכות (שלב ב׳)"
|
||||
checked={controls.showHalachot}
|
||||
onCheckedChange={(v) => onChange({ showHalachot: v })}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
label,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onCheckedChange: (v: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`text-sm ${disabled ? "text-ink-muted/50" : "text-ink"}`}>
|
||||
{label}
|
||||
</span>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
web-ui/src/components/graph/graph-node-panel.tsx
Normal file
105
web-ui/src/components/graph/graph-node-panel.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Side panel shown when a node is selected. For precedent/halacha nodes it
|
||||
* deep-links into the existing precedent library (/precedents/[id]) so the
|
||||
* graph is a navigation surface, not a dead-end visualization.
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
import { ExternalLink, X } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import type { GraphNode } from "@/lib/api/graph";
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
precedent: "פסיקה",
|
||||
halacha: "הלכה",
|
||||
topic: "נושא",
|
||||
practice_area: "תחום",
|
||||
};
|
||||
|
||||
const PA_LABELS: Record<string, string> = {
|
||||
rishuy_uvniya: "רישוי ובנייה",
|
||||
betterment_levy: "היטל השבחה",
|
||||
compensation_197: "פיצויים (ס׳ 197)",
|
||||
appeals_committee: "ועדת ערר",
|
||||
};
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
external_upload: "פסיקה חיצונית",
|
||||
internal_committee: "החלטת ועדה",
|
||||
cited_only: "מוזכר בלבד",
|
||||
nevo_seed: "נבו",
|
||||
};
|
||||
|
||||
export function GraphNodePanel({
|
||||
node,
|
||||
onClose,
|
||||
}: {
|
||||
node: GraphNode;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const isPrecedentLike = node.type === "precedent" || node.type === "halacha";
|
||||
return (
|
||||
<Card className="bg-surface border-rule shadow-sm w-80 shrink-0 overflow-y-auto">
|
||||
<CardContent className="space-y-4 p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="space-y-1">
|
||||
<Badge variant="outline" className="text-[0.65rem]">
|
||||
{TYPE_LABELS[node.type] ?? node.type}
|
||||
</Badge>
|
||||
<h2 className="text-navy text-base leading-snug m-0">{node.label}</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
aria-label="סגור"
|
||||
className="shrink-0"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<dl className="space-y-2 text-sm">
|
||||
{isPrecedentLike && (
|
||||
<Row label="ציטוטים נכנסים" value={String(node.size)} />
|
||||
)}
|
||||
{node.practice_area && (
|
||||
<Row label="תחום" value={PA_LABELS[node.practice_area] ?? node.practice_area} />
|
||||
)}
|
||||
{node.source_kind && (
|
||||
<Row label="מקור" value={SOURCE_LABELS[node.source_kind] ?? node.source_kind} />
|
||||
)}
|
||||
{node.precedent_level && <Row label="דרגה" value={node.precedent_level} />}
|
||||
{!isPrecedentLike && (
|
||||
<p className="text-ink-muted text-xs leading-relaxed m-0">
|
||||
לחיצה על נקודה זו מתמקדת בשכניה — כל הפסיקות המשויכות אליה.
|
||||
</p>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
{isPrecedentLike && node.case_law_id && (
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<Link href={`/precedents/${node.case_law_id}`}>
|
||||
<ExternalLink className="size-4 me-2" />
|
||||
פתח בספריית הפסיקה
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<dt className="text-ink-muted text-xs shrink-0">{label}</dt>
|
||||
<dd className="text-ink text-end m-0">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
179
web-ui/src/components/graph/graph-view.tsx
Normal file
179
web-ui/src/components/graph/graph-view.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Corpus graph orchestrator. Owns filter + selection state, decides whether to
|
||||
* render the full graph or a focused node neighborhood (the Obsidian "local
|
||||
* graph"), and wires the filter sidebar, canvas, and node detail panel.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
type CorpusGraph,
|
||||
type GraphNode,
|
||||
useCorpusGraph,
|
||||
useNodeNeighborhood,
|
||||
} from "@/lib/api/graph";
|
||||
import {
|
||||
type GraphControls,
|
||||
GraphFilterPanel,
|
||||
} from "@/components/graph/graph-filter-panel";
|
||||
import { GraphCanvas } from "@/components/graph/graph-canvas";
|
||||
import { GraphNodePanel } from "@/components/graph/graph-node-panel";
|
||||
|
||||
const NODE_LIMIT = 400;
|
||||
|
||||
function useDebouncedValue<T>(value: T, ms: number): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setDebounced(value), ms);
|
||||
return () => clearTimeout(id);
|
||||
}, [value, ms]);
|
||||
return debounced;
|
||||
}
|
||||
|
||||
export function GraphView() {
|
||||
const [controls, setControls] = useState<GraphControls>({
|
||||
practiceArea: "",
|
||||
source: "",
|
||||
minCitations: 0,
|
||||
q: "",
|
||||
showTopics: true,
|
||||
showPracticeAreas: true,
|
||||
showHalachot: false,
|
||||
});
|
||||
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
|
||||
const [focusNodeId, setFocusNodeId] = useState<string | null>(null);
|
||||
|
||||
const onChange = (patch: Partial<GraphControls>) =>
|
||||
setControls((c) => ({ ...c, ...patch }));
|
||||
|
||||
const nodeTypes = useMemo(() => {
|
||||
const t = ["precedent"];
|
||||
if (controls.showTopics) t.push("topic");
|
||||
if (controls.showPracticeAreas) t.push("practice_area");
|
||||
if (controls.showHalachot) t.push("halacha");
|
||||
return t.join(",");
|
||||
}, [controls.showTopics, controls.showPracticeAreas, controls.showHalachot]);
|
||||
|
||||
const debouncedQ = useDebouncedValue(controls.q, 350);
|
||||
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
practice_area: controls.practiceArea,
|
||||
source: controls.source,
|
||||
min_citations: controls.minCitations,
|
||||
node_types: nodeTypes,
|
||||
limit: NODE_LIMIT,
|
||||
q: debouncedQ,
|
||||
}),
|
||||
[controls.practiceArea, controls.source, controls.minCitations, nodeTypes, debouncedQ],
|
||||
);
|
||||
|
||||
const isFocused = !!focusNodeId;
|
||||
const full = useCorpusGraph(filters, !isFocused);
|
||||
const neighborhood = useNodeNeighborhood(focusNodeId, 1, nodeTypes);
|
||||
|
||||
const active = isFocused ? neighborhood : full;
|
||||
const data: CorpusGraph | undefined = active.data;
|
||||
const error = active.error as Error | undefined;
|
||||
|
||||
const handleNodeClick = (node: GraphNode) => {
|
||||
setSelectedNode(node);
|
||||
setFocusNodeId(node.id);
|
||||
};
|
||||
|
||||
const backToFull = () => {
|
||||
setFocusNodeId(null);
|
||||
setSelectedNode(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3 text-xs text-ink-muted">
|
||||
<span>
|
||||
{data
|
||||
? `${data.nodes.length} נקודות · ${data.edges.length} קשרים`
|
||||
: "—"}
|
||||
</span>
|
||||
{!isFocused && full.data?.truncated && (
|
||||
<span className="text-gold-deep">
|
||||
מוצגות {full.data.nodes.length} הנקודות המצוטטות ביותר מתוך{" "}
|
||||
{full.data.total_available} — צמצמו את הסינון כדי לראות פחות
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 h-[calc(100vh-320px)] min-h-[560px]">
|
||||
<GraphFilterPanel controls={controls} onChange={onChange} />
|
||||
|
||||
<div className="relative flex-1 rounded-lg border border-rule bg-surface overflow-hidden">
|
||||
{error ? (
|
||||
<div className="grid h-full place-items-center p-6 text-center">
|
||||
<div className="space-y-2">
|
||||
<p className="text-red-700 font-medium m-0">שגיאה בטעינת הגרף</p>
|
||||
<p className="text-ink-muted text-sm m-0">{error.message}</p>
|
||||
<Button variant="outline" size="sm" onClick={() => active.refetch()}>
|
||||
נסה שוב
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : active.isLoading && !data ? (
|
||||
<div className="grid h-full place-items-center text-sm text-ink-muted">
|
||||
טוען גרף…
|
||||
</div>
|
||||
) : data && data.nodes.length === 0 ? (
|
||||
<div className="grid h-full place-items-center text-sm text-ink-muted">
|
||||
אין נקודות התואמות לסינון.
|
||||
</div>
|
||||
) : (
|
||||
<GraphCanvas
|
||||
data={data}
|
||||
selectedId={selectedNode?.id ?? null}
|
||||
onNodeClick={handleNodeClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isFocused && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={backToFull}
|
||||
className="absolute top-3 start-3 bg-surface/90 backdrop-blur"
|
||||
>
|
||||
← חזרה לגרף המלא
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Legend />
|
||||
</div>
|
||||
|
||||
{selectedNode && (
|
||||
<GraphNodePanel node={selectedNode} onClose={() => setSelectedNode(null)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Legend() {
|
||||
const items = [
|
||||
{ color: "#1e3a5f", label: "פסיקה" },
|
||||
{ color: "#a97d3a", label: "נושא" },
|
||||
{ color: "#475569", label: "תחום" },
|
||||
];
|
||||
return (
|
||||
<div className="absolute bottom-3 end-3 flex flex-col gap-1 rounded-md bg-surface/85 backdrop-blur px-3 py-2 text-xs text-ink-muted">
|
||||
{items.map((i) => (
|
||||
<div key={i.label} className="flex items-center gap-2">
|
||||
<span
|
||||
className="inline-block size-2.5 rounded-full"
|
||||
style={{ backgroundColor: i.color }}
|
||||
/>
|
||||
{i.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
web-ui/src/lib/api/graph.ts
Normal file
111
web-ui/src/lib/api/graph.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Corpus graph hooks — feed the /graph page (the in-app, Obsidian-graph-view-
|
||||
* like network of the precedent corpus).
|
||||
*
|
||||
* The types below mirror web/graph_api.py (CorpusGraph / GraphNode / GraphEdge).
|
||||
* They are hand-declared for now because `npm run api:types` reads the PROD
|
||||
* OpenAPI schema, which won't expose /api/graph/* until this PR is deployed.
|
||||
* After deploy, run `npm run api:types` and these can be swapped for the
|
||||
* generated types (UI1) — same chicken-and-egg pattern documented in cases.ts.
|
||||
*
|
||||
* Read-only projection of the live corpus (G2): no parallel store, no drift.
|
||||
*/
|
||||
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { apiRequest } from "./client";
|
||||
|
||||
export type GraphNodeType = "precedent" | "halacha" | "topic" | "practice_area";
|
||||
|
||||
export type GraphEdgeType =
|
||||
| "cites"
|
||||
| "same_chain"
|
||||
| "tagged"
|
||||
| "in_area"
|
||||
| "corroborates"
|
||||
| "equivalent";
|
||||
|
||||
export type GraphNode = {
|
||||
id: string;
|
||||
type: GraphNodeType;
|
||||
label: string;
|
||||
size: number;
|
||||
practice_area: string | null;
|
||||
source_kind: string | null;
|
||||
precedent_level: string | null;
|
||||
case_law_id: string | null;
|
||||
};
|
||||
|
||||
export type GraphEdge = {
|
||||
source: string;
|
||||
target: string;
|
||||
type: GraphEdgeType;
|
||||
treatment: string | null;
|
||||
weight: number | null;
|
||||
};
|
||||
|
||||
export type CorpusGraph = {
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
truncated: boolean;
|
||||
total_available: number;
|
||||
};
|
||||
|
||||
export type GraphFilters = {
|
||||
practice_area?: string;
|
||||
source?: string;
|
||||
node_types?: string;
|
||||
min_citations?: number;
|
||||
limit?: number;
|
||||
q?: string;
|
||||
};
|
||||
|
||||
export const graphKeys = {
|
||||
all: ["graph"] as const,
|
||||
corpus: (f: GraphFilters) => [...graphKeys.all, "corpus", f] as const,
|
||||
neighborhood: (id: string, depth: number, nodeTypes: string) =>
|
||||
[...graphKeys.all, "neighborhood", id, depth, nodeTypes] as const,
|
||||
};
|
||||
|
||||
function buildParams(f: GraphFilters): string {
|
||||
const p = new URLSearchParams();
|
||||
if (f.practice_area) p.set("practice_area", f.practice_area);
|
||||
if (f.source) p.set("source", f.source);
|
||||
if (f.node_types) p.set("node_types", f.node_types);
|
||||
if (f.min_citations != null) p.set("min_citations", String(f.min_citations));
|
||||
if (f.limit != null) p.set("limit", String(f.limit));
|
||||
if (f.q) p.set("q", f.q.trim());
|
||||
return p.toString();
|
||||
}
|
||||
|
||||
/** Full corpus graph under the given filters. Disabled while a node is focused. */
|
||||
export function useCorpusGraph(filters: GraphFilters, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: graphKeys.corpus(filters),
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<CorpusGraph>(`/api/graph/corpus?${buildParams(filters)}`, { signal }),
|
||||
enabled,
|
||||
staleTime: 30_000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
|
||||
/** Local-graph: the focused node + its neighbors out to `depth` (1-2). */
|
||||
export function useNodeNeighborhood(
|
||||
nodeId: string | null,
|
||||
depth = 1,
|
||||
nodeTypes = "",
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: graphKeys.neighborhood(nodeId ?? "", depth, nodeTypes),
|
||||
queryFn: ({ signal }) => {
|
||||
const p = new URLSearchParams({ depth: String(depth) });
|
||||
if (nodeTypes) p.set("node_types", nodeTypes);
|
||||
return apiRequest<CorpusGraph>(
|
||||
`/api/graph/node/${encodeURIComponent(nodeId as string)}/neighborhood?${p.toString()}`,
|
||||
{ signal },
|
||||
);
|
||||
},
|
||||
enabled: !!nodeId,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
42
web/app.py
42
web/app.py
@@ -5757,6 +5757,48 @@ async def precedent_remove_relation(case_law_id: str, related_id: str):
|
||||
return {"unlinked": True, "case_law_id": case_law_id, "related_id": related_id}
|
||||
|
||||
|
||||
# ── Corpus graph (the /graph page) ────────────────────────────────────
|
||||
# Read-only topology projection of the precedent corpus — nodes + edges
|
||||
# assembled live from the canonical tables (G2: no parallel store, no drift).
|
||||
# NOT a retrieval path (03-retrieval): returns graph structure, not ranked
|
||||
# search results. Explicit Pydantic response_model (graph_api.CorpusGraph) so
|
||||
# the OpenAPI schema emits real types for the UI (UI2).
|
||||
from web import graph_api # noqa: E402 (FastAPI-only, web-ui-facing read projection)
|
||||
|
||||
|
||||
@app.get("/api/graph/corpus", response_model=graph_api.CorpusGraph)
|
||||
async def graph_corpus(
|
||||
practice_area: str = "",
|
||||
source: str = "",
|
||||
node_types: str = "",
|
||||
min_citations: int = 0,
|
||||
limit: int = graph_api.NODE_CAP_DEFAULT,
|
||||
q: str = "",
|
||||
):
|
||||
"""Full corpus graph under the given filters (most-cited nodes survive the cap)."""
|
||||
if practice_area and practice_area not in _PRACTICE_AREAS:
|
||||
raise HTTPException(400, "practice_area לא תקין")
|
||||
pool = await db.get_pool()
|
||||
return await graph_api.build_corpus_graph(
|
||||
pool,
|
||||
practice_area=practice_area,
|
||||
source=source,
|
||||
node_types=node_types,
|
||||
min_citations=min_citations,
|
||||
limit=limit,
|
||||
q=q,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/graph/node/{node_id}/neighborhood", response_model=graph_api.CorpusGraph)
|
||||
async def graph_node_neighborhood(node_id: str, depth: int = 1, node_types: str = ""):
|
||||
"""Local-graph focus: the node + its neighbors out to ``depth`` (1-2)."""
|
||||
pool = await db.get_pool()
|
||||
return await graph_api.build_node_neighborhood(
|
||||
pool, node_id, depth=depth, node_types=node_types
|
||||
)
|
||||
|
||||
|
||||
# Halacha and metadata extraction are LLM-driven and rely on the local
|
||||
# `claude` CLI via mcp-server/services/claude_session.py — they CANNOT run
|
||||
# from this container (no CLI, no claude.ai session). The endpoints below
|
||||
|
||||
385
web/graph_api.py
Normal file
385
web/graph_api.py
Normal file
@@ -0,0 +1,385 @@
|
||||
"""Corpus graph projection — read-only topology of the precedent corpus.
|
||||
|
||||
Powers the ``/graph`` page (the in-app, Obsidian-graph-view-like network of the
|
||||
legal corpus). This module is a **pure projection** of the live corpus, not a
|
||||
parallel store: every node and edge is assembled on the fly from the canonical
|
||||
tables via the shared ``db.get_pool()`` connection. It writes nothing
|
||||
(``SELECT`` only), so it cannot drift from the source of truth — preserving
|
||||
**G2** (single source of truth, no parallel paths). It is also **not a retrieval
|
||||
path** (03-retrieval): it returns graph topology (nodes + edges + in-degree),
|
||||
never ranked search results, so it cannot become a second, drifting way to
|
||||
"find" precedents.
|
||||
|
||||
Phase 1 node types:
|
||||
- ``precedent`` — a row in ``case_law`` (external rulings + committee decisions)
|
||||
- ``topic`` — a synthesized hub per ``subject_tag``
|
||||
- ``practice_area`` — a synthesized hub per ``case_law.practice_area``
|
||||
|
||||
Phase 1 edge types:
|
||||
- ``cites`` — ``precedent_internal_citations`` (source → cited)
|
||||
- ``same_chain`` — ``case_law_relations`` (undirected, same-case chain)
|
||||
- ``tagged`` — synthesized precedent → topic-hub membership
|
||||
- ``in_area`` — synthesized precedent → practice-area-hub membership
|
||||
|
||||
Node **size = importance = incoming-citation count**, computed in SQL via the
|
||||
``idx_pic_target`` index (a single index-backed ``GROUP BY``, never N+1).
|
||||
|
||||
Halacha nodes + corroboration/equivalence edges are Phase 2 (gated behind the
|
||||
``node_types`` param), so the frontend can already send/hide ``halacha`` without
|
||||
a contract change.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
import asyncpg
|
||||
from pydantic import BaseModel
|
||||
|
||||
# ── Node-type vocabulary ─────────────────────────────────────────────
|
||||
VALID_NODE_TYPES = {"precedent", "halacha", "topic", "practice_area"}
|
||||
DEFAULT_NODE_TYPES = ("precedent", "topic", "practice_area")
|
||||
NODE_CAP_DEFAULT = 400
|
||||
NODE_CAP_MAX = 1500
|
||||
|
||||
# Hebrew labels for the closed practice-area enum (G5). Unknown values fall
|
||||
# back to the raw token so a new area still renders rather than vanishing.
|
||||
_PA_LABELS = {
|
||||
"rishuy_uvniya": "רישוי ובנייה",
|
||||
"betterment_levy": "היטל השבחה",
|
||||
"compensation_197": "פיצויים (ס׳ 197)",
|
||||
"appeals_committee": "ועדת ערר",
|
||||
}
|
||||
|
||||
|
||||
# ── Response models (UI2: explicit Pydantic → real generated types) ───
|
||||
class GraphNode(BaseModel):
|
||||
id: str # "cl:<uuid>" | "hal:<uuid>" | "tag:<text>" | "pa:<token>"
|
||||
type: str # precedent | halacha | topic | practice_area
|
||||
label: str
|
||||
size: int = 0 # incoming-citation count; 0 for hubs in Phase 1
|
||||
practice_area: str | None = None
|
||||
source_kind: str | None = None # precedents only
|
||||
precedent_level: str | None = None # precedents only
|
||||
case_law_id: str | None = None # canonical id for deep-link (precedents)
|
||||
|
||||
|
||||
class GraphEdge(BaseModel):
|
||||
source: str
|
||||
target: str
|
||||
type: str # cites | same_chain | tagged | in_area
|
||||
treatment: str | None = None
|
||||
weight: float | None = None
|
||||
|
||||
|
||||
class CorpusGraph(BaseModel):
|
||||
nodes: list[GraphNode]
|
||||
edges: list[GraphEdge]
|
||||
truncated: bool = False # true when the node cap clipped the result
|
||||
total_available: int = 0 # precedents matching the filters before the cap
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────
|
||||
def normalize_node_types(node_types: str) -> set[str]:
|
||||
"""Parse the ``node_types`` CSV param into a validated set.
|
||||
|
||||
Empty / all-invalid input falls back to the Phase-1 default so a missing
|
||||
param never yields an empty graph.
|
||||
"""
|
||||
toks = {t.strip() for t in (node_types or "").split(",") if t.strip()}
|
||||
valid = {t for t in toks if t in VALID_NODE_TYPES}
|
||||
return valid or set(DEFAULT_NODE_TYPES)
|
||||
|
||||
|
||||
_PREC_INDEG_CTE = """
|
||||
WITH prec_indeg AS (
|
||||
SELECT cited_case_law_id AS id, COUNT(*) AS n
|
||||
FROM precedent_internal_citations
|
||||
WHERE cited_case_law_id IS NOT NULL
|
||||
GROUP BY cited_case_law_id
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def _precedent_node(row: asyncpg.Record) -> GraphNode:
|
||||
label = (row["case_number"] or "").strip() or (row["case_name"] or "").strip() or "—"
|
||||
return GraphNode(
|
||||
id=f"cl:{row['id']}",
|
||||
type="precedent",
|
||||
label=label,
|
||||
size=int(row["size"] or 0),
|
||||
practice_area=(row["practice_area"] or None),
|
||||
source_kind=(row["source_kind"] or None),
|
||||
precedent_level=(row["precedent_level"] or None),
|
||||
case_law_id=str(row["id"]),
|
||||
)
|
||||
|
||||
|
||||
async def _edges_and_hubs(
|
||||
conn: asyncpg.Connection,
|
||||
prec_rows: list[asyncpg.Record],
|
||||
types: set[str],
|
||||
) -> tuple[list[GraphNode], list[GraphEdge]]:
|
||||
"""Build intra-set edges + synthesized topic/practice-area hub nodes.
|
||||
|
||||
Only edges whose BOTH endpoints are in ``prec_rows`` are emitted — an edge
|
||||
to a precedent that was clipped by the node cap is dropped so the client
|
||||
never receives a dangling reference.
|
||||
"""
|
||||
hub_nodes: list[GraphNode] = []
|
||||
edges: list[GraphEdge] = []
|
||||
prec_ids = [r["id"] for r in prec_rows]
|
||||
if not prec_ids:
|
||||
return hub_nodes, edges
|
||||
|
||||
# cites — directional precedent → precedent
|
||||
cite_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT source_case_law_id AS s, cited_case_law_id AS t, treatment, confidence
|
||||
FROM precedent_internal_citations
|
||||
WHERE cited_case_law_id IS NOT NULL
|
||||
AND source_case_law_id = ANY($1::uuid[])
|
||||
AND cited_case_law_id = ANY($1::uuid[])
|
||||
""",
|
||||
prec_ids,
|
||||
)
|
||||
for r in cite_rows:
|
||||
edges.append(
|
||||
GraphEdge(
|
||||
source=f"cl:{r['s']}",
|
||||
target=f"cl:{r['t']}",
|
||||
type="cites",
|
||||
treatment=(r["treatment"] or None),
|
||||
weight=float(r["confidence"]) if r["confidence"] is not None else None,
|
||||
)
|
||||
)
|
||||
|
||||
# same_chain — undirected; stored possibly in both directions → dedup
|
||||
rel_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT case_law_id AS s, related_id AS t
|
||||
FROM case_law_relations
|
||||
WHERE case_law_id = ANY($1::uuid[]) AND related_id = ANY($1::uuid[])
|
||||
""",
|
||||
prec_ids,
|
||||
)
|
||||
seen_chain: set[tuple[str, str]] = set()
|
||||
for r in rel_rows:
|
||||
key = tuple(sorted((str(r["s"]), str(r["t"]))))
|
||||
if key in seen_chain:
|
||||
continue
|
||||
seen_chain.add(key)
|
||||
edges.append(
|
||||
GraphEdge(source=f"cl:{r['s']}", target=f"cl:{r['t']}", type="same_chain")
|
||||
)
|
||||
|
||||
# topic hubs — case_law.subject_tags is JSONB → expand in SQL
|
||||
if "topic" in types:
|
||||
tag_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT c.id, btrim(t.tag) AS tag
|
||||
FROM case_law c, jsonb_array_elements_text(c.subject_tags) AS t(tag)
|
||||
WHERE c.id = ANY($1::uuid[]) AND btrim(t.tag) <> ''
|
||||
""",
|
||||
prec_ids,
|
||||
)
|
||||
tag_seen: set[str] = set()
|
||||
for r in tag_rows:
|
||||
tag = r["tag"]
|
||||
tid = f"tag:{tag}"
|
||||
if tag not in tag_seen:
|
||||
tag_seen.add(tag)
|
||||
hub_nodes.append(GraphNode(id=tid, type="topic", label=tag))
|
||||
edges.append(GraphEdge(source=f"cl:{r['id']}", target=tid, type="tagged"))
|
||||
|
||||
# practice-area hubs — scalar column on each precedent row
|
||||
if "practice_area" in types:
|
||||
pa_seen: set[str] = set()
|
||||
for r in prec_rows:
|
||||
pa = (r["practice_area"] or "").strip()
|
||||
if not pa:
|
||||
continue
|
||||
pid = f"pa:{pa}"
|
||||
if pa not in pa_seen:
|
||||
pa_seen.add(pa)
|
||||
hub_nodes.append(
|
||||
GraphNode(
|
||||
id=pid,
|
||||
type="practice_area",
|
||||
label=_PA_LABELS.get(pa, pa),
|
||||
practice_area=pa,
|
||||
)
|
||||
)
|
||||
edges.append(GraphEdge(source=f"cl:{r['id']}", target=pid, type="in_area"))
|
||||
|
||||
return hub_nodes, edges
|
||||
|
||||
|
||||
# ── Endpoints' core logic ────────────────────────────────────────────
|
||||
async def build_corpus_graph(
|
||||
pool: asyncpg.Pool,
|
||||
*,
|
||||
practice_area: str = "",
|
||||
source: str = "",
|
||||
node_types: str = "",
|
||||
min_citations: int = 0,
|
||||
limit: int = NODE_CAP_DEFAULT,
|
||||
q: str = "",
|
||||
) -> CorpusGraph:
|
||||
"""Assemble the full corpus graph under the given filters.
|
||||
|
||||
The most-cited precedents always survive the cap (``ORDER BY size DESC``),
|
||||
so clipping never hides the structurally important nodes. ``truncated`` +
|
||||
``total_available`` let the UI prompt the user to narrow filters.
|
||||
"""
|
||||
types = normalize_node_types(node_types)
|
||||
cap = max(1, min(int(limit), NODE_CAP_MAX))
|
||||
min_cit = max(0, int(min_citations))
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
prec_rows = await conn.fetch(
|
||||
_PREC_INDEG_CTE
|
||||
+ """
|
||||
SELECT c.id, c.case_number, c.case_name,
|
||||
c.practice_area, c.source_kind, c.precedent_level,
|
||||
COALESCE(p.n, 0) AS size,
|
||||
COUNT(*) OVER () AS total_available
|
||||
FROM case_law c
|
||||
LEFT JOIN prec_indeg p ON p.id = c.id
|
||||
WHERE ($1 = '' OR c.practice_area = $1)
|
||||
AND ($2 = '' OR c.source_kind = $2)
|
||||
AND COALESCE(p.n, 0) >= $3
|
||||
AND ($4 = '' OR c.case_number ILIKE '%' || $4 || '%'
|
||||
OR c.case_name ILIKE '%' || $4 || '%')
|
||||
ORDER BY COALESCE(p.n, 0) DESC, c.case_number
|
||||
LIMIT $5
|
||||
""",
|
||||
practice_area,
|
||||
source,
|
||||
min_cit,
|
||||
q.strip(),
|
||||
cap,
|
||||
)
|
||||
|
||||
total_available = int(prec_rows[0]["total_available"]) if prec_rows else 0
|
||||
nodes = [_precedent_node(r) for r in prec_rows]
|
||||
hub_nodes, edges = await _edges_and_hubs(conn, prec_rows, types)
|
||||
nodes.extend(hub_nodes)
|
||||
|
||||
return CorpusGraph(
|
||||
nodes=nodes,
|
||||
edges=edges,
|
||||
truncated=total_available > len(prec_rows),
|
||||
total_available=total_available,
|
||||
)
|
||||
|
||||
|
||||
async def build_node_neighborhood(
|
||||
pool: asyncpg.Pool,
|
||||
node_id: str,
|
||||
*,
|
||||
depth: int = 1,
|
||||
node_types: str = "",
|
||||
) -> CorpusGraph:
|
||||
"""Local-graph focus: the seed node + its neighbors out to ``depth`` (1-2).
|
||||
|
||||
Naturally bounded (one seed, BFS depth ≤ 2), so it is the recommended way to
|
||||
"see everything around a node" when the full graph is clipped. Seeds:
|
||||
- ``cl:<uuid>`` — a precedent; BFS expands ``depth`` levels.
|
||||
- ``tag:<text>`` — a topic hub; its members are level 1, BFS ``depth-1`` more.
|
||||
- ``pa:<token>`` — a practice-area hub; same as topic.
|
||||
"""
|
||||
types = normalize_node_types(node_types)
|
||||
depth = max(1, min(int(depth), 2))
|
||||
prefix, _, rest = node_id.partition(":")
|
||||
rest = rest.strip()
|
||||
if prefix not in {"cl", "tag", "pa"} or not rest:
|
||||
return CorpusGraph(nodes=[], edges=[])
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
# Seed the precedent id set + remaining BFS levels.
|
||||
if prefix == "cl":
|
||||
try:
|
||||
seed_uuid = UUID(rest)
|
||||
except ValueError:
|
||||
return CorpusGraph(nodes=[], edges=[])
|
||||
current: set = {seed_uuid}
|
||||
levels_left = depth
|
||||
# The seed hub types are whatever the caller asked for.
|
||||
forced_types = types
|
||||
elif prefix == "tag":
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT c.id
|
||||
FROM case_law c, jsonb_array_elements_text(c.subject_tags) AS t(tag)
|
||||
WHERE btrim(t.tag) = $1
|
||||
LIMIT $2
|
||||
""",
|
||||
rest,
|
||||
NODE_CAP_MAX,
|
||||
)
|
||||
current = {r["id"] for r in rows}
|
||||
levels_left = depth - 1
|
||||
forced_types = types | {"topic"} # ensure the focused hub renders
|
||||
else: # pa
|
||||
rows = await conn.fetch(
|
||||
"SELECT id FROM case_law WHERE practice_area = $1 LIMIT $2",
|
||||
rest,
|
||||
NODE_CAP_MAX,
|
||||
)
|
||||
current = {r["id"] for r in rows}
|
||||
levels_left = depth - 1
|
||||
forced_types = types | {"practice_area"}
|
||||
|
||||
if not current:
|
||||
return CorpusGraph(nodes=[], edges=[])
|
||||
|
||||
# BFS over citation + same-chain edges (undirected for traversal).
|
||||
all_ids = set(current)
|
||||
frontier = set(current)
|
||||
truncated = False
|
||||
while levels_left > 0 and frontier:
|
||||
if len(all_ids) >= NODE_CAP_MAX:
|
||||
truncated = True
|
||||
break
|
||||
nb_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT cited_case_law_id AS nb FROM precedent_internal_citations
|
||||
WHERE cited_case_law_id IS NOT NULL AND source_case_law_id = ANY($1::uuid[])
|
||||
UNION
|
||||
SELECT source_case_law_id AS nb FROM precedent_internal_citations
|
||||
WHERE cited_case_law_id = ANY($1::uuid[])
|
||||
UNION
|
||||
SELECT related_id AS nb FROM case_law_relations WHERE case_law_id = ANY($1::uuid[])
|
||||
UNION
|
||||
SELECT case_law_id AS nb FROM case_law_relations WHERE related_id = ANY($1::uuid[])
|
||||
""",
|
||||
list(frontier),
|
||||
)
|
||||
nbs = {r["nb"] for r in nb_rows} - all_ids
|
||||
all_ids |= nbs
|
||||
frontier = nbs
|
||||
levels_left -= 1
|
||||
|
||||
ids = list(all_ids)[:NODE_CAP_MAX]
|
||||
prec_rows = await conn.fetch(
|
||||
_PREC_INDEG_CTE
|
||||
+ """
|
||||
SELECT c.id, c.case_number, c.case_name,
|
||||
c.practice_area, c.source_kind, c.precedent_level,
|
||||
COALESCE(p.n, 0) AS size
|
||||
FROM case_law c
|
||||
LEFT JOIN prec_indeg p ON p.id = c.id
|
||||
WHERE c.id = ANY($1::uuid[])
|
||||
""",
|
||||
ids,
|
||||
)
|
||||
nodes = [_precedent_node(r) for r in prec_rows]
|
||||
hub_nodes, edges = await _edges_and_hubs(conn, prec_rows, forced_types)
|
||||
nodes.extend(hub_nodes)
|
||||
|
||||
return CorpusGraph(
|
||||
nodes=nodes,
|
||||
edges=edges,
|
||||
truncated=truncated,
|
||||
total_available=len(nodes),
|
||||
)
|
||||
Reference in New Issue
Block a user