diff --git a/web-ui/package-lock.json b/web-ui/package-lock.json index 1e038cc..a405a3e 100644 --- a/web-ui/package-lock.json +++ b/web-ui/package-lock.json @@ -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", diff --git a/web-ui/package.json b/web-ui/package.json index 8b8c79e..26cfc29 100644 --- a/web-ui/package.json +++ b/web-ui/package.json @@ -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", diff --git a/web-ui/src/app/graph/page.tsx b/web-ui/src/app/graph/page.tsx new file mode 100644 index 0000000..c214e02 --- /dev/null +++ b/web-ui/src/app/graph/page.tsx @@ -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 ( + +
+
+ +

מפת הקורפוס

+

+ רשת הציטוטים של ספריית הפסיקה — כל נקודה היא פסיקה או נושא, וקו מציין ציטוט או שיוך. + גודל הנקודה משקף כמה פעמים הפסיקה צוטטה. לחצו על נקודה כדי להתמקד בשכניה. +

+
+
+ +
+
+ ); +} diff --git a/web-ui/src/components/app-shell.tsx b/web-ui/src/components/app-shell.tsx index 4eeb385..fd98e9d 100644 --- a/web-ui/src/components/app-shell.tsx +++ b/web-ui/src/components/app-shell.tsx @@ -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: "מדגם-זהב" }, diff --git a/web-ui/src/components/graph/graph-canvas.tsx b/web-ui/src/components/graph/graph-canvas.tsx new file mode 100644 index 0000000..7c3374e --- /dev/null +++ b/web-ui/src/components/graph/graph-canvas.tsx @@ -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: () => ( +
+ טוען גרף… +
+ ), +}); + +// 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 = { + precedent: "#1e3a5f", // navy + halacha: "#b45309", // amber + topic: "#a97d3a", // gold — hubs stand out + practice_area: "#475569", // slate +}; + +const TREATMENT_COLORS: Record = { + 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() { + const ref = useRef(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(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const fgRef = useRef(null); + const [hoverId, setHoverId] = useState(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>(); + 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
; + } + + return ( +
+ {size.width > 0 && ( + 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} + /> + )} +
+ ); +} diff --git a/web-ui/src/components/graph/graph-filter-panel.tsx b/web-ui/src/components/graph/graph-filter-panel.tsx new file mode 100644 index 0000000..50cc115 --- /dev/null +++ b/web-ui/src/components/graph/graph-filter-panel.tsx @@ -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) => void; +}) { + return ( + + +
+ + onChange({ q: e.target.value })} + /> +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + +
+ + onChange({ showTopics: v })} + /> + onChange({ showPracticeAreas: v })} + /> + onChange({ showHalachot: v })} + disabled + /> +
+
+
+ ); +} + +function ToggleRow({ + label, + checked, + onCheckedChange, + disabled, +}: { + label: string; + checked: boolean; + onCheckedChange: (v: boolean) => void; + disabled?: boolean; +}) { + return ( +
+ + {label} + + +
+ ); +} diff --git a/web-ui/src/components/graph/graph-node-panel.tsx b/web-ui/src/components/graph/graph-node-panel.tsx new file mode 100644 index 0000000..c0ebfa1 --- /dev/null +++ b/web-ui/src/components/graph/graph-node-panel.tsx @@ -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 = { + precedent: "פסיקה", + halacha: "הלכה", + topic: "נושא", + practice_area: "תחום", +}; + +const PA_LABELS: Record = { + rishuy_uvniya: "רישוי ובנייה", + betterment_levy: "היטל השבחה", + compensation_197: "פיצויים (ס׳ 197)", + appeals_committee: "ועדת ערר", +}; + +const SOURCE_LABELS: Record = { + 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 ( + + +
+
+ + {TYPE_LABELS[node.type] ?? node.type} + +

{node.label}

+
+ +
+ +
+ {isPrecedentLike && ( + + )} + {node.practice_area && ( + + )} + {node.source_kind && ( + + )} + {node.precedent_level && } + {!isPrecedentLike && ( +

+ לחיצה על נקודה זו מתמקדת בשכניה — כל הפסיקות המשויכות אליה. +

+ )} +
+ + {isPrecedentLike && node.case_law_id && ( + + )} +
+
+ ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/web-ui/src/components/graph/graph-view.tsx b/web-ui/src/components/graph/graph-view.tsx new file mode 100644 index 0000000..dd41104 --- /dev/null +++ b/web-ui/src/components/graph/graph-view.tsx @@ -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(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({ + practiceArea: "", + source: "", + minCitations: 0, + q: "", + showTopics: true, + showPracticeAreas: true, + showHalachot: false, + }); + const [selectedNode, setSelectedNode] = useState(null); + const [focusNodeId, setFocusNodeId] = useState(null); + + const onChange = (patch: Partial) => + 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 ( +
+
+ + {data + ? `${data.nodes.length} נקודות · ${data.edges.length} קשרים` + : "—"} + + {!isFocused && full.data?.truncated && ( + + מוצגות {full.data.nodes.length} הנקודות המצוטטות ביותר מתוך{" "} + {full.data.total_available} — צמצמו את הסינון כדי לראות פחות + + )} +
+ +
+ + +
+ {error ? ( +
+
+

שגיאה בטעינת הגרף

+

{error.message}

+ +
+
+ ) : active.isLoading && !data ? ( +
+ טוען גרף… +
+ ) : data && data.nodes.length === 0 ? ( +
+ אין נקודות התואמות לסינון. +
+ ) : ( + + )} + + {isFocused && ( + + )} + + +
+ + {selectedNode && ( + setSelectedNode(null)} /> + )} +
+
+ ); +} + +function Legend() { + const items = [ + { color: "#1e3a5f", label: "פסיקה" }, + { color: "#a97d3a", label: "נושא" }, + { color: "#475569", label: "תחום" }, + ]; + return ( +
+ {items.map((i) => ( +
+ + {i.label} +
+ ))} +
+ ); +} diff --git a/web-ui/src/lib/api/graph.ts b/web-ui/src/lib/api/graph.ts new file mode 100644 index 0000000..bacbb7f --- /dev/null +++ b/web-ui/src/lib/api/graph.ts @@ -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(`/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( + `/api/graph/node/${encodeURIComponent(nodeId as string)}/neighborhood?${p.toString()}`, + { signal }, + ); + }, + enabled: !!nodeId, + staleTime: 30_000, + }); +} diff --git a/web/app.py b/web/app.py index ced4781..5266c0f 100644 --- a/web/app.py +++ b/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 diff --git a/web/graph_api.py b/web/graph_api.py new file mode 100644 index 0000000..11ee542 --- /dev/null +++ b/web/graph_api.py @@ -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:" | "hal:" | "tag:" | "pa:" + 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:`` — a precedent; BFS expands ``depth`` levels. + - ``tag:`` — a topic hub; its members are level 1, BFS ``depth-1`` more. + - ``pa:`` — 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), + )