Compare commits
23 Commits
pre-sdk-up
...
41305fbb36
| Author | SHA1 | Date | |
|---|---|---|---|
| 41305fbb36 | |||
| 511d5c6e34 | |||
| 10d6216bec | |||
| 0a134d0134 | |||
| 602b42ca5e | |||
| 378e5657b5 | |||
| 6721c0a4ed | |||
| 3da37e1519 | |||
| 6294d94bed | |||
| d0669a0878 | |||
| b9906f6047 | |||
| 8d933386d3 | |||
| 0b6117cd7e | |||
| fc5d8725f1 | |||
| b9a5bb6e80 | |||
| 0bf2e46212 | |||
| aeffbe65ee | |||
| 0a8790b088 | |||
| b6bf726570 | |||
| 2d6dd8a072 | |||
| f70023fccc | |||
| 0ca7831c53 | |||
| 6388062cdc |
27
esbuild.ui.config.mjs
Normal file
27
esbuild.ui.config.mjs
Normal file
@@ -0,0 +1,27 @@
|
||||
import { build } from "esbuild";
|
||||
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const presets = createPluginBundlerPresets({
|
||||
pluginRoot: __dirname,
|
||||
uiEntry: "src/ui/index.tsx",
|
||||
outdir: "dist",
|
||||
sourcemap: true,
|
||||
minify: false,
|
||||
});
|
||||
|
||||
if (!presets.esbuild.ui) {
|
||||
throw new Error("UI preset missing — check createPluginBundlerPresets input");
|
||||
}
|
||||
|
||||
await build({
|
||||
...presets.esbuild.ui,
|
||||
// Ensure JSX runtime is bundled-resolved at host runtime through React peer.
|
||||
jsx: "automatic",
|
||||
logLevel: "info",
|
||||
});
|
||||
|
||||
console.log("[esbuild] UI bundle written to dist/ui/index.js");
|
||||
529
package-lock.json
generated
529
package-lock.json
generated
@@ -8,11 +8,14 @@
|
||||
"name": "@marcusgroup/plugin-legal-ai",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@paperclipai/plugin-sdk": "^2026.325.0"
|
||||
"@paperclipai/plugin-sdk": "^2026.722.0",
|
||||
"react": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.11",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"esbuild": "^0.28.0",
|
||||
"typescript": "^6.0.2"
|
||||
}
|
||||
},
|
||||
@@ -179,13 +182,455 @@
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
|
||||
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
|
||||
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
|
||||
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
|
||||
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@paperclipai/plugin-sdk": {
|
||||
"version": "2026.325.0",
|
||||
"resolved": "https://registry.npmjs.org/@paperclipai/plugin-sdk/-/plugin-sdk-2026.325.0.tgz",
|
||||
"integrity": "sha512-axAIK90QVrRihlCXcGoePDc0qaf0weqOwUagWsp88v6T2NXAWXALCzFtY4IipPnaVli6xZe5ZsuC+gTlPXhMKQ==",
|
||||
"version": "2026.722.0",
|
||||
"resolved": "https://registry.npmjs.org/@paperclipai/plugin-sdk/-/plugin-sdk-2026.722.0.tgz",
|
||||
"integrity": "sha512-2rMJCBo8ZAouuMsapdaY4/l+oHmrXKHW/k/HfPOLCxFng7+g04c5JhoFq+/ptGfNcW3kXzs34y7brviDCml06Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@paperclipai/shared": "2026.325.0",
|
||||
"@paperclipai/shared": "2026.722.0",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"bin": {
|
||||
@@ -201,9 +646,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@paperclipai/shared": {
|
||||
"version": "2026.325.0",
|
||||
"resolved": "https://registry.npmjs.org/@paperclipai/shared/-/shared-2026.325.0.tgz",
|
||||
"integrity": "sha512-l0ZeaQhswxjAyJ6G/TdgyrfbEgFKqdkly4zn4QgOPCu+x8j8Gma51Xf45M2Br3ILJlIx93HhT3hAzWKChdxgMg==",
|
||||
"version": "2026.722.0",
|
||||
"resolved": "https://registry.npmjs.org/@paperclipai/shared/-/shared-2026.722.0.tgz",
|
||||
"integrity": "sha512-JciI6EbtNwHSeoyN5ZkshwqpyfIluyO46JHp8I7pPCvpJ7Uv7MSZEBs3lKXdXLadFEN6qzfwNmf39xjIFC8isA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"zod": "^3.24.2"
|
||||
@@ -219,6 +664,74 @@
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz",
|
||||
"integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
|
||||
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.0",
|
||||
"@esbuild/android-arm": "0.28.0",
|
||||
"@esbuild/android-arm64": "0.28.0",
|
||||
"@esbuild/android-x64": "0.28.0",
|
||||
"@esbuild/darwin-arm64": "0.28.0",
|
||||
"@esbuild/darwin-x64": "0.28.0",
|
||||
"@esbuild/freebsd-arm64": "0.28.0",
|
||||
"@esbuild/freebsd-x64": "0.28.0",
|
||||
"@esbuild/linux-arm": "0.28.0",
|
||||
"@esbuild/linux-arm64": "0.28.0",
|
||||
"@esbuild/linux-ia32": "0.28.0",
|
||||
"@esbuild/linux-loong64": "0.28.0",
|
||||
"@esbuild/linux-mips64el": "0.28.0",
|
||||
"@esbuild/linux-ppc64": "0.28.0",
|
||||
"@esbuild/linux-riscv64": "0.28.0",
|
||||
"@esbuild/linux-s390x": "0.28.0",
|
||||
"@esbuild/linux-x64": "0.28.0",
|
||||
"@esbuild/netbsd-arm64": "0.28.0",
|
||||
"@esbuild/netbsd-x64": "0.28.0",
|
||||
"@esbuild/openbsd-arm64": "0.28.0",
|
||||
"@esbuild/openbsd-x64": "0.28.0",
|
||||
"@esbuild/openharmony-arm64": "0.28.0",
|
||||
"@esbuild/sunos-x64": "0.28.0",
|
||||
"@esbuild/win32-arm64": "0.28.0",
|
||||
"@esbuild/win32-ia32": "0.28.0",
|
||||
"@esbuild/win32-x64": "0.28.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.6",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
|
||||
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
"manifest": "dist/manifest.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"build": "tsc && node esbuild.ui.config.mjs",
|
||||
"build:worker": "tsc",
|
||||
"build:ui": "node esbuild.ui.config.mjs",
|
||||
"dev": "paperclip-plugin-dev-server",
|
||||
"format": "biome format --write src/",
|
||||
"format:check": "biome format src/",
|
||||
@@ -15,11 +17,14 @@
|
||||
"biome:fix": "biome check --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paperclipai/plugin-sdk": "^2026.325.0"
|
||||
"@paperclipai/plugin-sdk": "^2026.722.0",
|
||||
"react": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.11",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"esbuild": "^0.28.0",
|
||||
"typescript": "^6.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
87
plugin.json
87
plugin.json
@@ -1,87 +0,0 @@
|
||||
{
|
||||
"id": "marcusgroup.legal-ai",
|
||||
"apiVersion": 1,
|
||||
"version": "0.2.0",
|
||||
"displayName": "Ezer Mishpati - Legal AI",
|
||||
"description": "Integration with legal decision drafting system — case management, semantic search, workflow tracking, QA validation, and learning loop",
|
||||
"author": "Marcus Group",
|
||||
"categories": ["integration"],
|
||||
"minimumHostVersion": "2026.325.0",
|
||||
"capabilities": [
|
||||
"events.subscribe",
|
||||
"issues.read",
|
||||
"issues.create",
|
||||
"issues.update",
|
||||
"issue.comments.create",
|
||||
"issue.comments.read",
|
||||
"agents.read",
|
||||
"agents.invoke",
|
||||
"agent.tools.register",
|
||||
"http.outbound",
|
||||
"plugin.state.read",
|
||||
"plugin.state.write",
|
||||
"jobs.schedule",
|
||||
"activity.log.write",
|
||||
"companies.read",
|
||||
"projects.read",
|
||||
"webhooks.receive"
|
||||
],
|
||||
"entrypoints": {
|
||||
"worker": "dist/worker.js"
|
||||
},
|
||||
"instanceConfigSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"legalApiBaseUrl": {
|
||||
"type": "string",
|
||||
"default": "http://localhost:8085",
|
||||
"description": "Base URL for the Ezer Mishpati API"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tools": [
|
||||
{ "toolKey": "legal_case_list", "displayName": "רשימת תיקי ערר" },
|
||||
{ "toolKey": "legal_case_get", "displayName": "פרטי תיק ערר" },
|
||||
{ "toolKey": "legal_case_create", "displayName": "יצירת תיק ערר" },
|
||||
{ "toolKey": "legal_case_update", "displayName": "עדכון תיק ערר" },
|
||||
{ "toolKey": "legal_case_status", "displayName": "סטטוס תהליך עבודה" },
|
||||
{ "toolKey": "legal_search", "displayName": "חיפוש תקדימים" },
|
||||
{ "toolKey": "legal_case_template", "displayName": "תבנית החלטה" },
|
||||
{ "toolKey": "legal_processing_status", "displayName": "סטטוס עיבוד כללי" },
|
||||
{ "toolKey": "legal_document_list", "displayName": "רשימת מסמכים בתיק" },
|
||||
{ "toolKey": "legal_set_outcome", "displayName": "הזנת תוצאת ערר" },
|
||||
{ "toolKey": "legal_get_claims", "displayName": "טענות מחולצות" },
|
||||
{ "toolKey": "legal_search_case", "displayName": "חיפוש בתוך תיק" },
|
||||
{ "toolKey": "legal_find_similar", "displayName": "תקדימים דומים" },
|
||||
{ "toolKey": "legal_run_qa", "displayName": "בדיקת איכות" },
|
||||
{ "toolKey": "legal_trigger_learning", "displayName": "לולאת למידה" },
|
||||
{ "toolKey": "legal_style_guide", "displayName": "מדריך סגנון" }
|
||||
],
|
||||
"jobs": [
|
||||
{
|
||||
"jobKey": "sync-case-status",
|
||||
"displayName": "סנכרון סטטוס תיקים",
|
||||
"description": "סנכרון סטטוס בין legal-ai ל-Paperclip כל 15 דקות",
|
||||
"schedule": "*/15 * * * *"
|
||||
},
|
||||
{
|
||||
"jobKey": "stale-case-reminder",
|
||||
"displayName": "תזכורת תיקים תקועים",
|
||||
"description": "מזהה תיקים שלא עודכנו 3+ ימים ומוסיף תגובה ל-issue",
|
||||
"schedule": "0 8 * * *"
|
||||
},
|
||||
{
|
||||
"jobKey": "weekly-feedback-analysis",
|
||||
"displayName": "ניתוח פידבק שבועי",
|
||||
"description": "מסכם פידבק יו\"ר מהשבוע האחרון ומעדכן את decision-lessons.md",
|
||||
"schedule": "0 19 * * 0"
|
||||
}
|
||||
],
|
||||
"webhooks": [
|
||||
{
|
||||
"endpointKey": "case-status",
|
||||
"displayName": "עדכון סטטוס תיק",
|
||||
"description": "מקבל עדכוני סטטוס מ-legal-ai ומפרסם תגובה על ה-issue המקושר"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,11 +2,23 @@
|
||||
* HTTP client for Ezer Mishpati legal-ai REST API.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Where the client gets its base URL. A plain string still works; a resolver is
|
||||
* used when the URL comes from company-scoped plugin config, which cannot be
|
||||
* read at worker startup (see worker.ts — `ctx.config.get` needs a company).
|
||||
*/
|
||||
export type BaseUrlSource = string | (() => string | Promise<string>);
|
||||
|
||||
export class LegalApi {
|
||||
constructor(private baseUrl: string) {}
|
||||
constructor(private baseUrl: BaseUrlSource) {}
|
||||
|
||||
private async resolveBaseUrl(): Promise<string> {
|
||||
return typeof this.baseUrl === "string" ? this.baseUrl : this.baseUrl();
|
||||
}
|
||||
|
||||
private async request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
const base = await this.resolveBaseUrl();
|
||||
const res = await fetch(`${base}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -24,6 +36,10 @@ export class LegalApi {
|
||||
return this.request("/api/cases");
|
||||
}
|
||||
|
||||
async getStatusModel(): Promise<StatusModel> {
|
||||
return this.request("/api/status-model");
|
||||
}
|
||||
|
||||
async getCase(caseNumber: string): Promise<CaseDetails> {
|
||||
return this.request(`/api/cases/${encodeURIComponent(caseNumber)}/details`);
|
||||
}
|
||||
@@ -148,6 +164,20 @@ export class LegalApi {
|
||||
// Return static style guide reference
|
||||
return "ראה skill-legal-decision/SKILL.md — מדריך סגנון מלא של דפנה תמיר";
|
||||
}
|
||||
|
||||
/**
|
||||
* Recent conclusions of prior heartbeat runs across a case's issues (#220,
|
||||
* "seance"). Lets a resuming agent read what earlier sessions concluded
|
||||
* instead of re-deriving context from scratch.
|
||||
*/
|
||||
async getPredecessorForCase(
|
||||
caseNumber: string,
|
||||
limit = 5,
|
||||
): Promise<PredecessorResponse> {
|
||||
return this.request(
|
||||
`/api/operations/cases/${encodeURIComponent(caseNumber)}/predecessor?limit=${limit}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Types
|
||||
@@ -158,6 +188,21 @@ export interface CaseSummary {
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface StatusModelEntry {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
phase: string;
|
||||
selectable: boolean;
|
||||
terminal: boolean;
|
||||
on_enter: string | null;
|
||||
}
|
||||
|
||||
export interface StatusModel {
|
||||
statuses: StatusModelEntry[];
|
||||
phases: Array<{ key: string; label: string }>;
|
||||
}
|
||||
|
||||
export interface CaseDetails {
|
||||
id: string;
|
||||
case_number: string;
|
||||
@@ -260,3 +305,21 @@ export interface QAResponse {
|
||||
}>;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface PredecessorRun {
|
||||
run_id: string;
|
||||
status: string;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
summary: string | null;
|
||||
error_code: string | null;
|
||||
session_id: string | null;
|
||||
agent_name: string | null;
|
||||
identifier: string | null;
|
||||
}
|
||||
|
||||
export interface PredecessorResponse {
|
||||
ok: boolean;
|
||||
case_number: string;
|
||||
runs: PredecessorRun[];
|
||||
}
|
||||
|
||||
@@ -12,8 +12,12 @@ export default {
|
||||
"issues.read",
|
||||
"issues.create",
|
||||
"issues.update",
|
||||
"issue.comments.read",
|
||||
"issue.comments.create",
|
||||
"issue.interactions.create",
|
||||
"issue.documents.write",
|
||||
"agent.tools.register",
|
||||
"agents.invoke",
|
||||
"http.outbound",
|
||||
"plugin.state.read",
|
||||
"plugin.state.write",
|
||||
@@ -22,9 +26,12 @@ export default {
|
||||
"companies.read",
|
||||
"projects.read",
|
||||
"webhooks.receive",
|
||||
"ui.detailTab.register",
|
||||
"ui.dashboardWidget.register",
|
||||
] as const,
|
||||
entrypoints: {
|
||||
worker: "dist/worker.js",
|
||||
ui: "dist/ui",
|
||||
},
|
||||
instanceConfigSchema: {
|
||||
type: "object" as const,
|
||||
@@ -149,6 +156,23 @@ export default {
|
||||
description: "Get overall system processing status",
|
||||
parametersSchema: { type: "object" as const, properties: {} },
|
||||
},
|
||||
{
|
||||
toolKey: "legal_predecessor_context",
|
||||
name: "legal_predecessor_context",
|
||||
displayName: "הקשר מריצות קודמות",
|
||||
description:
|
||||
"Recent conclusions from prior heartbeat runs on this case (the 'summary' each run left). Call this when RESUMING work on a case to see what earlier sessions already did/decided — instead of re-deriving context from scratch.",
|
||||
parametersSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
case_number: {
|
||||
type: "string" as const,
|
||||
description: "Case number (e.g. 1043-02-26)",
|
||||
},
|
||||
},
|
||||
required: ["case_number"],
|
||||
},
|
||||
},
|
||||
],
|
||||
jobs: [
|
||||
{
|
||||
@@ -171,6 +195,13 @@ export default {
|
||||
'מסכם פידבק יו"ר מהשבוע האחרון ומעדכן את decision-lessons.md',
|
||||
schedule: "0 19 * * 0",
|
||||
},
|
||||
{
|
||||
jobKey: "route-pending-comments",
|
||||
displayName: "ערובת מסירת-הערות (sweep)",
|
||||
description:
|
||||
"sweep פיוס: מנתב כל הערת-משתמש שטרם נותבה ל-CEO (at-least-once), כדי שהערה לא תיפול גם אם ה-wakeup התבטל/התלכד",
|
||||
schedule: "*/2 * * * *",
|
||||
},
|
||||
],
|
||||
webhooks: [
|
||||
{
|
||||
@@ -180,4 +211,21 @@ export default {
|
||||
"מקבל עדכוני סטטוס מ-legal-ai ומפרסם תגובה על ה-issue המקושר",
|
||||
},
|
||||
],
|
||||
ui: {
|
||||
slots: [
|
||||
{
|
||||
type: "detailTab" as const,
|
||||
id: "legal-case-tab",
|
||||
displayName: "ערר",
|
||||
exportName: "LegalCaseTab",
|
||||
entityTypes: ["issue" as const],
|
||||
},
|
||||
{
|
||||
type: "dashboardWidget" as const,
|
||||
id: "legal-cases-widget",
|
||||
displayName: "תיקי ערר",
|
||||
exportName: "LegalCasesWidget",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
338
src/sync-target.test.ts
Normal file
338
src/sync-target.test.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
/// <reference types="node" />
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
// Excluded from tsc (tsconfig.json) — never bundled/emitted, run natively via
|
||||
// `node --test`, which requires the literal `.ts` extension (Node's ESM
|
||||
// resolver does not remap `.js` specifiers to `.ts` files at runtime).
|
||||
import type { StatusModelEntry } from "./legal-api.ts";
|
||||
import {
|
||||
isWritableStatus,
|
||||
labelFor,
|
||||
pickSyncTargetIssue,
|
||||
resolveIssueStatus,
|
||||
type SyncCandidate,
|
||||
} from "./sync-target.ts";
|
||||
|
||||
/**
|
||||
* הכלל הישן (worker.ts:894 לפני #446): כל issue מקושר שסטטוסו שונה מהיעד —
|
||||
* נכתב, בלי סינון parentId ובלי סינון-סטטוס.
|
||||
*/
|
||||
function legacyTargets(
|
||||
candidates: readonly SyncCandidate[],
|
||||
targetStatus: string,
|
||||
): string[] {
|
||||
return candidates.filter((c) => c.status !== targetStatus).map((c) => c.id);
|
||||
}
|
||||
|
||||
test("רגרסיית 8125-09-24 / CMPA-140: 11 ה-issues הממשיים של התיק, אף sub-task done לא נבחר", () => {
|
||||
// נמדד ב-DB החי של Paperclip (2026-08-24) — כל ה-issues המקושרים לתיק
|
||||
// 8125-09-24. CMPA-140 (זה שנדרס בפועל, done→in_progress) הוא **ילד** של
|
||||
// CMPA-139, לא שורש. CMPA-139 עצמו אינו ברשימה בכוונה: נמדד שאין לו
|
||||
// רשומת legal-case-number ב-plugin_state, ולכן הוא לא מגיע כמועמד כלל —
|
||||
// לילד יש הורה שאינו מקושר.
|
||||
const candidates: SyncCandidate[] = [
|
||||
{ id: "CMPA-116", status: "cancelled", parentId: null, companyId: "c1" },
|
||||
{ id: "CMPA-84", status: "cancelled", parentId: null, companyId: "c1" },
|
||||
{ id: "CMPA-86", status: "cancelled", parentId: null, companyId: "c1" },
|
||||
{ id: "CMPA-140", status: "done", parentId: "CMPA-139", companyId: "c1" }, // זה שנדרס בפועל
|
||||
{
|
||||
id: "CMPA-141",
|
||||
status: "cancelled",
|
||||
parentId: "CMPA-139",
|
||||
companyId: "c1",
|
||||
},
|
||||
{ id: "CMPA-85", status: "done", parentId: "CMPA-84", companyId: "c1" },
|
||||
{ id: "CMPA-146", status: "done", parentId: "CMPA-84", companyId: "c1" },
|
||||
{ id: "CMPA-147", status: "done", parentId: "CMPA-84", companyId: "c1" },
|
||||
{ id: "CMPA-87", status: "done", parentId: "CMPA-86", companyId: "c1" },
|
||||
{ id: "CMPA-88", status: "done", parentId: "CMPA-86", companyId: "c1" },
|
||||
{
|
||||
id: "CMPA-89",
|
||||
status: "cancelled",
|
||||
parentId: "CMPA-86",
|
||||
companyId: "c1",
|
||||
},
|
||||
];
|
||||
|
||||
// סטטוס-התיק היה drafted → in_progress (CASE_STATUS_TO_ISSUE_STATUS ב-worker.ts).
|
||||
const targetStatus = "in_progress";
|
||||
|
||||
// הכלל הישן היה כותב על CMPA-140 — זה הבאג שתועד.
|
||||
assert.ok(legacyTargets(candidates, targetStatus).includes("CMPA-140"));
|
||||
|
||||
const result = pickSyncTargetIssue(candidates);
|
||||
|
||||
// הכלל החדש אינו כותב על דבר.
|
||||
assert.equal(result.target, null);
|
||||
assert.equal(result.reason, "no_writable_root");
|
||||
});
|
||||
|
||||
test("sub-task יחיד ב-done ומקושר: sub-task לעולם לא נבחר", () => {
|
||||
const candidates: SyncCandidate[] = [
|
||||
{ id: "sub-1", status: "done", parentId: "some-root", companyId: "c1" },
|
||||
];
|
||||
|
||||
const result = pickSyncTargetIssue(candidates);
|
||||
|
||||
assert.equal(result.target, null);
|
||||
assert.equal(result.reason, "no_writable_root");
|
||||
});
|
||||
|
||||
test("sub-task יחיד פתוח (in_progress) + שורש cancelled: פתוח אינו מספיק, חייב שורש", () => {
|
||||
const candidates: SyncCandidate[] = [
|
||||
{ id: "root-1", status: "cancelled", parentId: null, companyId: "c1" },
|
||||
{ id: "sub-1", status: "in_progress", parentId: "root-1", companyId: "c1" },
|
||||
];
|
||||
|
||||
const result = pickSyncTargetIssue(candidates);
|
||||
|
||||
assert.equal(result.target, null);
|
||||
assert.equal(result.reason, "no_writable_root");
|
||||
});
|
||||
|
||||
test("שורש יחיד ב-todo: נבחר", () => {
|
||||
const candidates: SyncCandidate[] = [
|
||||
{ id: "root-1", status: "todo", parentId: null, companyId: "c1" },
|
||||
];
|
||||
|
||||
const result = pickSyncTargetIssue(candidates);
|
||||
|
||||
assert.equal(result.target?.id, "root-1");
|
||||
assert.equal(result.reason, "ok");
|
||||
assert.equal(result.writableRoots, 1);
|
||||
});
|
||||
|
||||
// הכרעת #626 (2026-08-26): in_review כבר לא ב-NON_WRITABLE_STATUSES —
|
||||
// שורש יחיד ב-in_review עכשיו נבחר כיעד-כתיבה, במקום להיחסם.
|
||||
test("שורש יחיד ב-in_review: נבחר (הכרעת #626 — 2026-08-26)", () => {
|
||||
const candidates: SyncCandidate[] = [
|
||||
{ id: "root-1", status: "in_review", parentId: null, companyId: "c1" },
|
||||
];
|
||||
|
||||
const result = pickSyncTargetIssue(candidates);
|
||||
|
||||
assert.equal(result.target?.id, "root-1");
|
||||
assert.equal(result.reason, "ok");
|
||||
assert.equal(result.writableRoots, 1);
|
||||
});
|
||||
|
||||
test("שורש יחיד ב-blocked: לא נכתב", () => {
|
||||
const candidates: SyncCandidate[] = [
|
||||
{ id: "root-1", status: "blocked", parentId: null, companyId: "c1" },
|
||||
];
|
||||
|
||||
const result = pickSyncTargetIssue(candidates);
|
||||
|
||||
assert.equal(result.target, null);
|
||||
assert.equal(result.reason, "no_writable_root");
|
||||
});
|
||||
|
||||
test("isWritableStatus: הגבול המלא בין בר-כתיבה ללא-בר-כתיבה", () => {
|
||||
assert.equal(isWritableStatus("in_review"), true);
|
||||
assert.equal(isWritableStatus("blocked"), false);
|
||||
assert.equal(isWritableStatus("done"), false);
|
||||
assert.equal(isWritableStatus("cancelled"), false);
|
||||
assert.equal(isWritableStatus("todo"), true);
|
||||
});
|
||||
|
||||
test("שני שורשים ברי-כתיבה: ambiguous, לא נכתב", () => {
|
||||
const candidates: SyncCandidate[] = [
|
||||
{ id: "root-1", status: "todo", parentId: null, companyId: "c1" },
|
||||
{ id: "root-2", status: "in_progress", parentId: null, companyId: "c1" },
|
||||
];
|
||||
|
||||
const result = pickSyncTargetIssue(candidates);
|
||||
|
||||
assert.equal(result.target, null);
|
||||
assert.equal(result.reason, "ambiguous_writable_roots");
|
||||
assert.equal(result.writableRoots, 2);
|
||||
});
|
||||
|
||||
test("שורש todo + ילד in_progress: השורש נבחר", () => {
|
||||
const candidates: SyncCandidate[] = [
|
||||
{ id: "root-1", status: "todo", parentId: null, companyId: "c1" },
|
||||
{
|
||||
id: "child-1",
|
||||
status: "in_progress",
|
||||
parentId: "root-1",
|
||||
companyId: "c1",
|
||||
},
|
||||
];
|
||||
|
||||
const result = pickSyncTargetIssue(candidates);
|
||||
|
||||
assert.equal(result.target?.id, "root-1");
|
||||
assert.equal(result.reason, "ok");
|
||||
});
|
||||
|
||||
/**
|
||||
* פיקסצ'ר ריאלי של `/api/status-model` (case_status_model.py) — הסדר קובע
|
||||
* מי הוא "todo" (האינדקס הראשון), ו-`terminal: true` קובע מי "done".
|
||||
*/
|
||||
const STATUS_MODEL: StatusModelEntry[] = [
|
||||
{
|
||||
key: "new",
|
||||
label: "חדש",
|
||||
description: "תיק נפתח, טרם הועלו מסמכים",
|
||||
phase: "intake",
|
||||
selectable: true,
|
||||
terminal: false,
|
||||
on_enter: null,
|
||||
},
|
||||
{
|
||||
key: "processing",
|
||||
label: "בעיבוד",
|
||||
description: "מסמכים בעיבוד",
|
||||
phase: "intake",
|
||||
selectable: false,
|
||||
terminal: false,
|
||||
on_enter: null,
|
||||
},
|
||||
{
|
||||
key: "documents_ready",
|
||||
label: "מסמכים מוכנים",
|
||||
description: "העיבוד הושלם",
|
||||
phase: "intake",
|
||||
selectable: true,
|
||||
terminal: false,
|
||||
on_enter: null,
|
||||
},
|
||||
{
|
||||
key: "analyst_verified",
|
||||
label: 'אומת ע"י אנליסט',
|
||||
description: "אנליסט אימת את התוכן",
|
||||
phase: "analysis",
|
||||
selectable: true,
|
||||
terminal: false,
|
||||
on_enter: null,
|
||||
},
|
||||
{
|
||||
key: "research_complete",
|
||||
label: "מחקר הושלם",
|
||||
description: "מחקר תקדימים הושלם",
|
||||
phase: "analysis",
|
||||
selectable: true,
|
||||
terminal: false,
|
||||
on_enter: null,
|
||||
},
|
||||
{
|
||||
key: "outcome_set",
|
||||
label: "תוצאה נקבעה",
|
||||
description: "תוצאה הוזנה",
|
||||
phase: "analysis",
|
||||
selectable: true,
|
||||
terminal: false,
|
||||
on_enter: null,
|
||||
},
|
||||
{
|
||||
key: "direction_approved",
|
||||
label: "כיוון אושר",
|
||||
description: "כיוון הכתיבה אושר",
|
||||
phase: "drafting",
|
||||
selectable: true,
|
||||
terminal: false,
|
||||
on_enter: null,
|
||||
},
|
||||
{
|
||||
key: "qa_review",
|
||||
label: "בדיקת איכות",
|
||||
description: "בבדיקת איכות",
|
||||
phase: "drafting",
|
||||
selectable: true,
|
||||
terminal: false,
|
||||
on_enter: null,
|
||||
},
|
||||
{
|
||||
key: "drafted",
|
||||
label: "טיוטה מוכנה",
|
||||
description: "טיוטה נכתבה",
|
||||
phase: "drafting",
|
||||
selectable: true,
|
||||
terminal: false,
|
||||
on_enter: null,
|
||||
},
|
||||
{
|
||||
key: "exported",
|
||||
label: "יוצא ל-DOCX",
|
||||
description: "יוצא כקובץ DOCX",
|
||||
phase: "review",
|
||||
selectable: true,
|
||||
terminal: false,
|
||||
on_enter: null,
|
||||
},
|
||||
{
|
||||
key: "reviewed",
|
||||
label: 'נבדק ע"י דפנה',
|
||||
description: "דפנה הגיהה",
|
||||
phase: "review",
|
||||
selectable: true,
|
||||
terminal: false,
|
||||
on_enter: null,
|
||||
},
|
||||
{
|
||||
key: "final",
|
||||
label: "סופי",
|
||||
description: "גרסה סופית",
|
||||
phase: "final",
|
||||
selectable: true,
|
||||
terminal: true,
|
||||
on_enter: null,
|
||||
},
|
||||
];
|
||||
|
||||
test("resolveIssueStatus: הסטטוס הראשון ברשימה (new) → todo", () => {
|
||||
assert.equal(resolveIssueStatus(STATUS_MODEL, "new"), "todo");
|
||||
});
|
||||
|
||||
test("resolveIssueStatus: סטטוס terminal (final) → done", () => {
|
||||
assert.equal(resolveIssueStatus(STATUS_MODEL, "final"), "done");
|
||||
});
|
||||
|
||||
test("resolveIssueStatus: analyst_verified (חסר במפה הישנה) → in_progress", () => {
|
||||
assert.equal(
|
||||
resolveIssueStatus(STATUS_MODEL, "analyst_verified"),
|
||||
"in_progress",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveIssueStatus: research_complete → in_progress", () => {
|
||||
assert.equal(
|
||||
resolveIssueStatus(STATUS_MODEL, "research_complete"),
|
||||
"in_progress",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveIssueStatus: סטטוס לא-מוכר → null (לא בליעה שקטה)", () => {
|
||||
assert.equal(
|
||||
resolveIssueStatus(STATUS_MODEL, "totally_unknown_status"),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("labelFor: מחזיר את התווית מהפיקסצ'ר, ו-null לסטטוס לא-מוכר", () => {
|
||||
assert.equal(labelFor(STATUS_MODEL, "new"), "חדש");
|
||||
assert.equal(labelFor(STATUS_MODEL, "totally_unknown_status"), null);
|
||||
});
|
||||
|
||||
test("רגרסיה: companyId של המועמד-הנבחר שורד את pickSyncTargetIssue (לא companyId חיצוני קבוע)", () => {
|
||||
const candidates: SyncCandidate[] = [
|
||||
{
|
||||
id: "issue-a",
|
||||
status: "cancelled",
|
||||
parentId: null,
|
||||
companyId: "company-a",
|
||||
},
|
||||
{
|
||||
id: "issue-b",
|
||||
status: "todo",
|
||||
parentId: null,
|
||||
companyId: "company-b",
|
||||
},
|
||||
];
|
||||
|
||||
const result = pickSyncTargetIssue(candidates);
|
||||
|
||||
assert.equal(result.target?.id, "issue-b");
|
||||
assert.equal(result.target?.companyId, "company-b");
|
||||
});
|
||||
117
src/sync-target.ts
Normal file
117
src/sync-target.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* בחירת יעד-הסנכרון של הג'וב `sync-case-status` (issue #446).
|
||||
*
|
||||
* המקור לבאג: הג'וב הישן כתב על **כל** issue מקושר-לתיק, כולל sub-issues
|
||||
* ו-issues בסטטוסים סגורים/בהמתנה-לאדם. זה החזיר CMPA-140 מ-`done` ל-`in_progress`
|
||||
* (8125-09-24) — כי sub-task שהושלם עדיין נמנה כ"מקושר".
|
||||
*
|
||||
* המודול הזה טהור בכוונה — בלי import מה-SDK ובלי side effects — כדי שאפשר
|
||||
* יהיה לייבא אותו ישירות בטסט בלי להריץ את `runWorker(plugin, import.meta.url)`
|
||||
* שקורה בזמן import של worker.ts. ה-import היחיד למטה הוא type-only (`import
|
||||
* type`), כך שהוא לא נדרס בזמן ריצה ולא שובר את הטוהר הזה.
|
||||
*/
|
||||
|
||||
import type { StatusModelEntry } from "./legal-api.ts";
|
||||
|
||||
/** "סגור" — ההגדרה היחידה. מראה של web/paperclip_client.py:561 ב-legal-ai. */
|
||||
export const CLOSED_ISSUE_STATUSES: ReadonlySet<string> = new Set([
|
||||
"done",
|
||||
"cancelled",
|
||||
]);
|
||||
|
||||
/**
|
||||
* מצב בבעלות Paperclip — **לא** הגדרה שנייה של "סגור". `blocked` — כתיבה
|
||||
* עליו מסתירה חוסם קיים.
|
||||
*
|
||||
* `in_review` **הוסר מהסט** בהכרעת חיים מ-2026-08-26 (legal-ai issue #626):
|
||||
* נשאל במפורש אם השחרור נקודתי או שיטתי, והשיב "התכוונתי לשיטתי".
|
||||
*
|
||||
* המחיר, בכנות: `in_review` הוא מצב-ההמתנה-ליו"ר המכוון — CEO שמשאיר issue
|
||||
* ב-`in_progress` מקבל auto-block מ-Paperclip תוך דקה, ולכן מעביר אותו
|
||||
* ל-`in_review` כדי לחמוק מזה (legal-ai/docs/paperclip-quirks.md §3). מעתה
|
||||
* issue שממתין לביקורת היו"ר עשוי להידרס בחזרה ל-`in_progress` בריצת-
|
||||
* הסנכרון הבאה (כל 15 דק') ולרדת מתור-הביקורת. זה מכוון ומתועד, לא תקלה.
|
||||
*
|
||||
* מסלול-חזרה: להחזיר `"in_review"` לסט למטה + build + התקנה +
|
||||
* `pm2 restart paperclip`.
|
||||
*/
|
||||
export const NON_WRITABLE_STATUSES: ReadonlySet<string> = new Set(["blocked"]);
|
||||
|
||||
export interface SyncCandidate {
|
||||
id: string;
|
||||
status: string;
|
||||
parentId: string | null;
|
||||
companyId: string;
|
||||
}
|
||||
|
||||
export type SyncTargetReason =
|
||||
| "ok"
|
||||
| "no_linked_issues"
|
||||
| "no_writable_root"
|
||||
| "ambiguous_writable_roots";
|
||||
|
||||
export interface SyncTargetResult {
|
||||
target: SyncCandidate | null;
|
||||
reason: SyncTargetReason;
|
||||
/** כמה שורשים ברי-כתיבה נמצאו — לצורך לוג. */
|
||||
writableRoots: number;
|
||||
}
|
||||
|
||||
export function isWritableStatus(status: string): boolean {
|
||||
return (
|
||||
!CLOSED_ISSUE_STATUSES.has(status) && !NON_WRITABLE_STATUSES.has(status)
|
||||
);
|
||||
}
|
||||
|
||||
export function pickSyncTargetIssue(
|
||||
candidates: readonly SyncCandidate[],
|
||||
): SyncTargetResult {
|
||||
if (candidates.length === 0) {
|
||||
return { target: null, reason: "no_linked_issues", writableRoots: 0 };
|
||||
}
|
||||
|
||||
const roots = candidates.filter(
|
||||
(c) => c.parentId === null && isWritableStatus(c.status),
|
||||
);
|
||||
|
||||
if (roots.length === 1) {
|
||||
return { target: roots[0], reason: "ok", writableRoots: 1 };
|
||||
}
|
||||
if (roots.length === 0) {
|
||||
return { target: null, reason: "no_writable_root", writableRoots: 0 };
|
||||
}
|
||||
return {
|
||||
target: null,
|
||||
reason: "ambiguous_writable_roots",
|
||||
writableRoots: roots.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the coarse Paperclip issue status for a legal-ai case status, using
|
||||
* only generic fields already returned by legal-ai's `/api/status-model`
|
||||
* (the canonical `case_status_model.py` registry) — never a hardcoded list of
|
||||
* case-status keys. This is what makes a new case status "just work" without
|
||||
* editing this plugin: `terminal: true` → done; the very first status in the
|
||||
* ordered list → todo (nothing has started yet); everything else → in_progress.
|
||||
* Returns `null` when `caseStatus` is not present in `statuses` at all (a
|
||||
* genuinely unknown status — the caller must log this loudly, never swallow
|
||||
* it silently; see legal-ai issue #604).
|
||||
*/
|
||||
export function resolveIssueStatus(
|
||||
statuses: readonly StatusModelEntry[],
|
||||
caseStatus: string,
|
||||
): "todo" | "in_progress" | "done" | null {
|
||||
const index = statuses.findIndex((s) => s.key === caseStatus);
|
||||
if (index === -1) return null;
|
||||
if (statuses[index].terminal) return "done";
|
||||
return index === 0 ? "todo" : "in_progress";
|
||||
}
|
||||
|
||||
/** Hebrew label for a case status from the canonical model, or null if unknown. */
|
||||
export function labelFor(
|
||||
statuses: readonly StatusModelEntry[],
|
||||
caseStatus: string,
|
||||
): string | null {
|
||||
return statuses.find((s) => s.key === caseStatus)?.label ?? null;
|
||||
}
|
||||
248
src/ui/LegalCaseTab.tsx
Normal file
248
src/ui/LegalCaseTab.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import type { PluginDetailTabProps } from "@paperclipai/plugin-sdk/ui";
|
||||
import { usePluginData } from "@paperclipai/plugin-sdk/ui/hooks";
|
||||
import type {
|
||||
LegalArgument,
|
||||
LegalArgumentsResponse,
|
||||
LegalCaseDetails,
|
||||
MissingPrecedentRow,
|
||||
PrecedentRow,
|
||||
} from "./types.js";
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
padding: 16,
|
||||
fontFamily:
|
||||
"system-ui, -apple-system, 'Segoe UI', 'Noto Sans Hebrew', sans-serif",
|
||||
color: "inherit",
|
||||
};
|
||||
|
||||
const sectionStyle: React.CSSProperties = {
|
||||
marginBottom: 24,
|
||||
};
|
||||
|
||||
const headingStyle: React.CSSProperties = {
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
margin: "0 0 8px 0",
|
||||
borderBottom: "1px solid rgba(127,127,127,0.25)",
|
||||
paddingBottom: 4,
|
||||
};
|
||||
|
||||
const metaListStyle: React.CSSProperties = {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "max-content 1fr",
|
||||
columnGap: 12,
|
||||
rowGap: 4,
|
||||
fontSize: 14,
|
||||
margin: 0,
|
||||
};
|
||||
|
||||
const detailsBlockStyle: React.CSSProperties = {
|
||||
margin: "6px 0",
|
||||
padding: "6px 10px",
|
||||
background: "rgba(127,127,127,0.08)",
|
||||
borderRadius: 6,
|
||||
};
|
||||
|
||||
const ulStyle: React.CSSProperties = {
|
||||
listStyle: "none",
|
||||
paddingInlineStart: 0,
|
||||
margin: 0,
|
||||
};
|
||||
|
||||
const liStyle: React.CSSProperties = {
|
||||
padding: "4px 0",
|
||||
fontSize: 14,
|
||||
borderBottom: "1px dotted rgba(127,127,127,0.2)",
|
||||
};
|
||||
|
||||
const mutedStyle: React.CSSProperties = {
|
||||
color: "rgba(127,127,127,0.8)",
|
||||
fontSize: 13,
|
||||
};
|
||||
|
||||
function PartySection({
|
||||
party,
|
||||
args,
|
||||
}: {
|
||||
party: string;
|
||||
args: LegalArgument[];
|
||||
}) {
|
||||
if (!args || args.length === 0) return null;
|
||||
return (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<strong style={{ fontSize: 14 }}>{party}</strong>
|
||||
{args.map((a, idx) => {
|
||||
const key = a.id ?? `${party}-${a.claim_index ?? idx}`;
|
||||
const title =
|
||||
a.argument_title ||
|
||||
(a.argument_body || "").slice(0, 80) ||
|
||||
"(ללא כותרת)";
|
||||
const body = a.argument_body || "";
|
||||
return (
|
||||
<details key={key} style={detailsBlockStyle}>
|
||||
<summary style={{ cursor: "pointer", fontSize: 14 }}>
|
||||
{title}
|
||||
</summary>
|
||||
{body && (
|
||||
<p style={{ margin: "6px 0 0 0", whiteSpace: "pre-wrap" }}>
|
||||
{body}
|
||||
</p>
|
||||
)}
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LegalCaseTab({ context }: PluginDetailTabProps) {
|
||||
const issueId = context.entityId;
|
||||
|
||||
const summary = usePluginData<LegalCaseDetails | null>("legal-case-summary", {
|
||||
issueId,
|
||||
});
|
||||
const args = usePluginData<LegalArgumentsResponse | null>(
|
||||
"legal-case-arguments",
|
||||
{ issueId },
|
||||
);
|
||||
const precedents = usePluginData<PrecedentRow[] | null>(
|
||||
"legal-case-precedents",
|
||||
{ issueId },
|
||||
);
|
||||
const missing = usePluginData<MissingPrecedentRow[] | null>(
|
||||
"legal-case-missing-precedents",
|
||||
{ issueId },
|
||||
);
|
||||
|
||||
if (summary.loading) {
|
||||
return (
|
||||
<div dir="rtl" style={containerStyle}>
|
||||
<p>טוען נתוני תיק…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (summary.error) {
|
||||
return (
|
||||
<div dir="rtl" style={containerStyle}>
|
||||
<p style={{ color: "crimson" }}>
|
||||
שגיאה בטעינת התיק: {summary.error.message}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!summary.data) {
|
||||
return (
|
||||
<div dir="rtl" style={containerStyle}>
|
||||
<p style={mutedStyle}>
|
||||
אין תיק ערר מקושר ל-issue זה. ניתן ליצור תיק חדש דרך CEO או דרך כלי
|
||||
המערכת.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const caseDetails = summary.data;
|
||||
const byParty = args.data?.by_party ?? {};
|
||||
const argsTotal = args.data?.total ?? 0;
|
||||
const precedentsList = precedents.data ?? [];
|
||||
const missingList = missing.data ?? [];
|
||||
|
||||
return (
|
||||
<div dir="rtl" style={containerStyle}>
|
||||
<section style={sectionStyle}>
|
||||
<h2 style={{ margin: "0 0 8px 0", fontSize: 20 }}>
|
||||
תיק {caseDetails.case_number}
|
||||
</h2>
|
||||
<div style={metaListStyle}>
|
||||
<span style={mutedStyle}>כותרת:</span>
|
||||
<span>{caseDetails.title}</span>
|
||||
<span style={mutedStyle}>סטטוס:</span>
|
||||
<span>
|
||||
<code>{caseDetails.status}</code>
|
||||
</span>
|
||||
{caseDetails.practice_area && (
|
||||
<>
|
||||
<span style={mutedStyle}>תחום:</span>
|
||||
<span>{caseDetails.practice_area}</span>
|
||||
</>
|
||||
)}
|
||||
{caseDetails.appeal_subtype && (
|
||||
<>
|
||||
<span style={mutedStyle}>סוג ערר:</span>
|
||||
<span>{caseDetails.appeal_subtype}</span>
|
||||
</>
|
||||
)}
|
||||
{caseDetails.expected_outcome && (
|
||||
<>
|
||||
<span style={mutedStyle}>תוצאה צפויה:</span>
|
||||
<span>{caseDetails.expected_outcome}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={sectionStyle}>
|
||||
<h3 style={headingStyle}>טיעונים משפטיים ({argsTotal})</h3>
|
||||
{args.loading && <p style={mutedStyle}>טוען טיעונים…</p>}
|
||||
{args.error && (
|
||||
<p style={{ color: "crimson" }}>שגיאה: {args.error.message}</p>
|
||||
)}
|
||||
{!args.loading && argsTotal === 0 && (
|
||||
<p style={mutedStyle}>לא חולצו עדיין טיעונים מהמסמכים.</p>
|
||||
)}
|
||||
{Object.entries(byParty).map(([party, list]) => (
|
||||
<PartySection key={party} party={party} args={list} />
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section style={sectionStyle}>
|
||||
<h3 style={headingStyle}>פסיקה מצורפת ({precedentsList.length})</h3>
|
||||
{precedents.loading && <p style={mutedStyle}>טוען פסיקה…</p>}
|
||||
{precedents.error && (
|
||||
<p style={{ color: "crimson" }}>שגיאה: {precedents.error.message}</p>
|
||||
)}
|
||||
{!precedents.loading && precedentsList.length === 0 && (
|
||||
<p style={mutedStyle}>לא צורפה עדיין פסיקה לתיק.</p>
|
||||
)}
|
||||
{precedentsList.length > 0 && (
|
||||
<ul style={ulStyle}>
|
||||
{precedentsList.map((p) => (
|
||||
<li key={p.id} style={liStyle}>
|
||||
<strong>{p.citation}</strong>
|
||||
{p.practice_area && (
|
||||
<span style={mutedStyle}> — {p.practice_area}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section style={sectionStyle}>
|
||||
<h3 style={headingStyle}>פסיקה חסרה ({missingList.length})</h3>
|
||||
{missing.loading && <p style={mutedStyle}>טוען רשימת חסרים…</p>}
|
||||
{missing.error && (
|
||||
<p style={{ color: "crimson" }}>שגיאה: {missing.error.message}</p>
|
||||
)}
|
||||
{!missing.loading && missingList.length === 0 && (
|
||||
<p style={mutedStyle}>אין פסיקה חסרה פתוחה בתיק זה.</p>
|
||||
)}
|
||||
{missingList.length > 0 && (
|
||||
<ul style={ulStyle}>
|
||||
{missingList.map((m) => (
|
||||
<li key={m.id} style={liStyle}>
|
||||
<strong>{m.citation}</strong>
|
||||
<span style={mutedStyle}> — {m.status}</span>
|
||||
{m.legal_topic && (
|
||||
<span style={mutedStyle}> ({m.legal_topic})</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
132
src/ui/LegalCasesWidget.tsx
Normal file
132
src/ui/LegalCasesWidget.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import type { PluginWidgetProps } from "@paperclipai/plugin-sdk/ui";
|
||||
import { usePluginData } from "@paperclipai/plugin-sdk/ui/hooks";
|
||||
import type { DashboardStats } from "./types.js";
|
||||
|
||||
const widgetStyle: React.CSSProperties = {
|
||||
padding: 12,
|
||||
fontFamily:
|
||||
"system-ui, -apple-system, 'Segoe UI', 'Noto Sans Hebrew', sans-serif",
|
||||
color: "inherit",
|
||||
};
|
||||
|
||||
const headingStyle: React.CSSProperties = {
|
||||
margin: "0 0 8px 0",
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
const totalStyle: React.CSSProperties = {
|
||||
fontSize: 22,
|
||||
fontWeight: 700,
|
||||
margin: "4px 0",
|
||||
};
|
||||
|
||||
const ulStyle: React.CSSProperties = {
|
||||
listStyle: "none",
|
||||
paddingInlineStart: 0,
|
||||
margin: 0,
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr max-content",
|
||||
rowGap: 2,
|
||||
fontSize: 13,
|
||||
};
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
color: "rgba(127,127,127,0.85)",
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
new: "תיק חדש",
|
||||
uploading: "העלאת מסמכים",
|
||||
processing: "עיבוד מסמכים",
|
||||
documents_ready: "מסמכים מוכנים",
|
||||
outcome_set: "תוצאה הוזנה",
|
||||
brainstorming: "סיעור מוחות",
|
||||
direction_approved: "כיוון אושר",
|
||||
drafting: "כתיבה",
|
||||
qa_review: "בדיקת איכות",
|
||||
drafted: "טיוטה מוכנה",
|
||||
exported: "DOCX נוצר",
|
||||
reviewed: "נבדק",
|
||||
final: "סופי",
|
||||
};
|
||||
|
||||
export function LegalCasesWidget(_props: PluginWidgetProps) {
|
||||
const { data, loading, error } = usePluginData<DashboardStats | null>(
|
||||
"legal-dashboard-stats",
|
||||
{},
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div dir="rtl" style={widgetStyle}>
|
||||
<h3 style={headingStyle}>תיקי ערר</h3>
|
||||
<p style={labelStyle}>טוען…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div dir="rtl" style={widgetStyle}>
|
||||
<h3 style={headingStyle}>תיקי ערר</h3>
|
||||
<p style={{ color: "crimson", fontSize: 13 }}>{error.message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div dir="rtl" style={widgetStyle}>
|
||||
<h3 style={headingStyle}>תיקי ערר</h3>
|
||||
<p style={labelStyle}>אין נתונים זמינים.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const entries = Object.entries(data.byStatus).sort(([, a], [, b]) => b - a);
|
||||
|
||||
return (
|
||||
<div dir="rtl" style={widgetStyle}>
|
||||
<h3 style={headingStyle}>תיקי ערר</h3>
|
||||
<div style={totalStyle}>{data.totalCases}</div>
|
||||
<div style={{ fontSize: 12, color: "rgba(127,127,127,0.85)" }}>
|
||||
סה"כ תיקים פעילים
|
||||
</div>
|
||||
<hr
|
||||
style={{
|
||||
margin: "8px 0",
|
||||
border: 0,
|
||||
borderTop: "1px solid rgba(127,127,127,0.25)",
|
||||
}}
|
||||
/>
|
||||
{entries.length > 0 ? (
|
||||
<ul style={ulStyle}>
|
||||
{entries.map(([status, count]) => (
|
||||
<li
|
||||
key={status}
|
||||
style={{ display: "contents" }}
|
||||
aria-label={`${STATUS_LABELS[status] ?? status}: ${count}`}
|
||||
>
|
||||
<span style={labelStyle}>{STATUS_LABELS[status] ?? status}</span>
|
||||
<span>{count}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p style={labelStyle}>אין תיקים פעילים.</p>
|
||||
)}
|
||||
<hr
|
||||
style={{
|
||||
margin: "8px 0",
|
||||
border: 0,
|
||||
borderTop: "1px solid rgba(127,127,127,0.25)",
|
||||
}}
|
||||
/>
|
||||
<p style={{ margin: 0, fontSize: 13 }}>
|
||||
<span style={labelStyle}>פעילות שבועית: </span>
|
||||
<strong>{data.weekActivity}</strong>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
src/ui/index.tsx
Normal file
10
src/ui/index.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* UI bundle entrypoint for the legal-ai Paperclip plugin.
|
||||
*
|
||||
* Each named export here corresponds to a `slot.exportName` in the manifest's
|
||||
* `ui.slots` declaration. The host loads this module as an ES module and
|
||||
* mounts the matching component into its slot.
|
||||
*/
|
||||
|
||||
export { LegalCasesWidget } from "./LegalCasesWidget.js";
|
||||
export { LegalCaseTab } from "./LegalCaseTab.js";
|
||||
72
src/ui/types.ts
Normal file
72
src/ui/types.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Shared types for the legal-ai plugin UI bundle.
|
||||
*
|
||||
* Mirrors a minimal subset of the legal-ai backend response shapes so the UI
|
||||
* components can type their props/data without pulling the full LegalApi
|
||||
* client (which is a worker-side dependency).
|
||||
*/
|
||||
|
||||
export interface LegalCaseSummary {
|
||||
case_number: string;
|
||||
title: string;
|
||||
status: string;
|
||||
practice_area?: string | null;
|
||||
appeal_subtype?: string | null;
|
||||
proceeding_type?: string | null;
|
||||
updated_at?: string | null;
|
||||
archived_at?: string | null;
|
||||
}
|
||||
|
||||
export interface LegalCaseDetails {
|
||||
id: string;
|
||||
case_number: string;
|
||||
title: string;
|
||||
status: string;
|
||||
practice_area?: string | null;
|
||||
appeal_subtype?: string | null;
|
||||
appellants?: string[];
|
||||
respondents?: string[];
|
||||
subject?: string;
|
||||
property_address?: string;
|
||||
expected_outcome?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface LegalArgument {
|
||||
id?: string;
|
||||
party: string;
|
||||
argument_title?: string;
|
||||
argument_body?: string;
|
||||
claim_index?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface LegalArgumentsResponse {
|
||||
case_number: string;
|
||||
total: number;
|
||||
by_party: Record<string, LegalArgument[]>;
|
||||
arguments: LegalArgument[];
|
||||
}
|
||||
|
||||
export interface PrecedentRow {
|
||||
id: string;
|
||||
citation: string;
|
||||
section_id?: string | null;
|
||||
practice_area?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface MissingPrecedentRow {
|
||||
id: string;
|
||||
citation: string;
|
||||
status: string;
|
||||
legal_topic?: string | null;
|
||||
cited_by_party?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
byStatus: Record<string, number>;
|
||||
weekActivity: number;
|
||||
totalCases: number;
|
||||
}
|
||||
879
src/worker.ts
879
src/worker.ts
@@ -4,6 +4,12 @@ import type {
|
||||
} from "@paperclipai/plugin-sdk";
|
||||
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
||||
import { LegalApi } from "./legal-api.js";
|
||||
import {
|
||||
labelFor,
|
||||
pickSyncTargetIssue,
|
||||
resolveIssueStatus,
|
||||
type SyncCandidate,
|
||||
} from "./sync-target.js";
|
||||
|
||||
// Hoisted so onWebhook can access the context after setup() completes.
|
||||
let pluginCtx: PluginContext | null = null;
|
||||
@@ -16,16 +22,79 @@ const CEO_AGENT_IDS: Record<string, string> = {
|
||||
"cdbfa8bc-3d61-41a4-a2e7-677ec7d34562", // CMPA (היטלי השבחה)
|
||||
};
|
||||
|
||||
const DEFAULT_LEGAL_API_BASE = "http://localhost:8085";
|
||||
|
||||
/**
|
||||
* Resolve `legalApiBaseUrl` from company-scoped plugin config.
|
||||
*
|
||||
* ⚠️ Plugin config became company-scoped in @paperclipai/server 2026.722.0
|
||||
* (migration `0164_plugin_config_company_scope`). A bare `ctx.config.get()` now
|
||||
* throws `company context is required` unless the host can derive the company
|
||||
* from the current invocation — which it CANNOT during `setup()`, because
|
||||
* worker initialisation belongs to no company. Reading config at startup is what
|
||||
* made the whole plugin fail to activate on the 722.0 upgrade.
|
||||
*
|
||||
* So: never call this from setup(); call it from a handler, passing the
|
||||
* companyId that handler was given. Tool handlers get `runCtx.companyId`; data
|
||||
* handlers get `params.companyId`. Scheduled jobs have no company at all, so
|
||||
* they fall back to probing the companies we know about.
|
||||
*/
|
||||
/** Pull the companyId the host passes to `ctx.data` handlers, if present. */
|
||||
function companyIdOf(params: unknown): string | undefined {
|
||||
const id = (params as { companyId?: unknown } | null)?.companyId;
|
||||
return typeof id === "string" && id.trim() ? id : undefined;
|
||||
}
|
||||
|
||||
async function resolveApiBase(
|
||||
ctx: PluginContext,
|
||||
companyId?: string,
|
||||
): Promise<string> {
|
||||
const read = async (id?: string): Promise<string | null> => {
|
||||
try {
|
||||
const cfg = (await ctx.config.get(id)) as {
|
||||
legalApiBaseUrl?: unknown;
|
||||
} | null;
|
||||
const url = cfg?.legalApiBaseUrl;
|
||||
return typeof url === "string" && url.trim() ? url.trim() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (companyId) {
|
||||
const scoped = await read(companyId);
|
||||
if (scoped) return scoped;
|
||||
}
|
||||
|
||||
// No companyId passed in — but the host derives one itself when the call
|
||||
// happens inside a host-scoped invocation (e.g. a tool handler). Ask before
|
||||
// falling back to guesswork, so tools honour their own company's config.
|
||||
const derived = await read(undefined);
|
||||
if (derived) return derived;
|
||||
|
||||
// Genuinely no company (scheduled jobs): try each company we know of. All of
|
||||
// them point at the same legal-ai instance in practice, so the first hit wins.
|
||||
for (const knownCompanyId of Object.keys(CEO_AGENT_IDS)) {
|
||||
const url = await read(knownCompanyId);
|
||||
if (url) return url;
|
||||
}
|
||||
|
||||
ctx.logger.warn("legalApiBaseUrl unresolved — using default", {
|
||||
companyId: companyId ?? null,
|
||||
fallback: DEFAULT_LEGAL_API_BASE,
|
||||
});
|
||||
return DEFAULT_LEGAL_API_BASE;
|
||||
}
|
||||
|
||||
const plugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
pluginCtx = ctx; // save for onWebhook
|
||||
const config = (await ctx.config.get()) as {
|
||||
legalApiBaseUrl?: string;
|
||||
} | null;
|
||||
const baseUrl = config?.legalApiBaseUrl || "http://localhost:8085";
|
||||
const api = new LegalApi(baseUrl);
|
||||
|
||||
ctx.logger.info("Legal AI plugin starting", { url: baseUrl });
|
||||
// Lazy: the URL is fetched on the first request, from inside a handler
|
||||
// that has company context — not here. See resolveApiBase() above.
|
||||
const api = new LegalApi(() => resolveApiBase(ctx));
|
||||
|
||||
ctx.logger.info("Legal AI plugin starting");
|
||||
|
||||
// ── Tools ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -507,6 +576,40 @@ const plugin = definePlugin({
|
||||
},
|
||||
);
|
||||
|
||||
ctx.tools.register(
|
||||
"legal_predecessor_context",
|
||||
{
|
||||
displayName: "הקשר מריצות קודמות",
|
||||
description:
|
||||
"Recent conclusions from prior heartbeat runs on this case (the 'summary' each run left). Call this when RESUMING work on a case to see what earlier sessions already did/decided — instead of re-deriving context from scratch. Provide the case number.",
|
||||
parametersSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
case_number: {
|
||||
type: "string",
|
||||
description: "Case number (e.g. 1043-02-26)",
|
||||
},
|
||||
},
|
||||
required: ["case_number"],
|
||||
},
|
||||
},
|
||||
async (params) => {
|
||||
const { case_number } = params as { case_number: string };
|
||||
const result = await api.getPredecessorForCase(case_number);
|
||||
// Readable digest for the agent — each prior run's conclusion,
|
||||
// newest-first, summaries trimmed to keep the wake context lean.
|
||||
const lines = result.runs.map((r) => {
|
||||
const summary = (r.summary ?? "").trim().slice(0, 600);
|
||||
const when = r.finished_at ?? r.started_at ?? "";
|
||||
return `▸ ${r.agent_name ?? "?"} · ${r.identifier ?? ""} · ${when} · ${r.status}\n${summary}`;
|
||||
});
|
||||
const content = lines.length
|
||||
? lines.join("\n\n")
|
||||
: "אין ריצות קודמות עם מסקנות לתיק זה.";
|
||||
return { content, data: result };
|
||||
},
|
||||
);
|
||||
|
||||
// ── Events ─────────────────────────────────────────────────────
|
||||
|
||||
ctx.events.on("issue.created", async (event) => {
|
||||
@@ -535,32 +638,118 @@ const plugin = definePlugin({
|
||||
|
||||
// Route user comments through CEO agent — per company
|
||||
|
||||
// Marker: the id of the last user comment already routed to the CEO for
|
||||
// an issue. BOTH the event-driven route below and the reconciliation
|
||||
// sweep (`route-pending-comments`) set it, so the sweep never re-routes a
|
||||
// comment the fast path already handled. This is what turns best-effort
|
||||
// event delivery into an at-least-once guarantee.
|
||||
const LAST_ROUTED_COMMENT_KEY = "last-routed-comment-id";
|
||||
|
||||
async function markCommentRouted(
|
||||
issueId: string,
|
||||
commentId: string | undefined,
|
||||
): Promise<void> {
|
||||
if (!commentId) return;
|
||||
try {
|
||||
await ctx.state.set(
|
||||
{
|
||||
scopeKind: "issue",
|
||||
scopeId: issueId,
|
||||
stateKey: LAST_ROUTED_COMMENT_KEY,
|
||||
},
|
||||
commentId,
|
||||
);
|
||||
} catch (err) {
|
||||
ctx.logger.warn("Failed to persist last-routed-comment marker", {
|
||||
issueId,
|
||||
commentId,
|
||||
error: String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Invoke the company CEO to handle a user comment on an issue. Shared by
|
||||
// the event-driven route and the reconciliation sweep. On success it
|
||||
// stamps the marker so the comment is not routed twice. Returns true when
|
||||
// the CEO was invoked.
|
||||
async function routeCommentToCeo(args: {
|
||||
issue: { id: string; title: string; identifier?: string | null };
|
||||
companyId: string;
|
||||
ceoAgentId: string;
|
||||
commentBody: string;
|
||||
commentId?: string;
|
||||
source: "event" | "sweep";
|
||||
}): Promise<boolean> {
|
||||
const { issue, companyId, ceoAgentId, commentBody, commentId, source } =
|
||||
args;
|
||||
const promptBody = commentBody
|
||||
? commentBody
|
||||
: "(לא ניתן לקרוא את תוכן התגובה — קרא את ה-comments האחרונים על ה-issue ישירות)";
|
||||
try {
|
||||
const { runId } = await ctx.agents.invoke(ceoAgentId, companyId, {
|
||||
prompt: [
|
||||
`תגובה חדשה מחיים על issue "${issue.title}" (${issue.identifier || issue.id}):`,
|
||||
"",
|
||||
promptBody,
|
||||
"",
|
||||
`קרא את ה-comments האחרונים על ה-issue, הבן מה חיים מבקש, והחלט מה לעשות.`,
|
||||
`אם ההוראה ברורה — נתב לסוכן המתאים. אם לא ברור — שאל את חיים.`,
|
||||
].join("\n"),
|
||||
reason: `user_commented_on_${issue.identifier || issue.id}`,
|
||||
});
|
||||
await markCommentRouted(issue.id, commentId);
|
||||
ctx.logger.info("Routed user comment to CEO agent", {
|
||||
issueId: issue.id,
|
||||
commentId: commentId || null,
|
||||
runId,
|
||||
source,
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
ctx.logger.error("Failed to invoke CEO agent for comment routing", {
|
||||
issueId: issue.id,
|
||||
source,
|
||||
error: String(err),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.events.on("issue.comment.created", async (event) => {
|
||||
// Only intercept human comments — not agent comments (prevents loops)
|
||||
if (event.actorType !== "user") return;
|
||||
if (!event.companyId) return;
|
||||
|
||||
// entityId is the comment ID — fetch the comment to get issueId + body
|
||||
const entityId = event.entityId;
|
||||
if (!entityId) return;
|
||||
|
||||
// The event payload may contain the issueId directly
|
||||
const payload = event.payload as {
|
||||
// Event/payload shape for `issue.comment.created` (Paperclip host):
|
||||
// event.entityId → the ISSUE id (the primary entity of the event)
|
||||
// event.payload.commentId → the comment id
|
||||
// event.payload.bodySnippet → TRUNCATED body (not the full text)
|
||||
// event.payload.reopened / reopenedFrom → set when the comment
|
||||
// reopened a `done` issue (host then natively wakes its assignee)
|
||||
// NOTE: the host does NOT send `payload.issueId` and does NOT send the
|
||||
// full `body`. The earlier code read `payload.issueId` (always absent)
|
||||
// and skipped every event — silently disabling comment→CEO routing.
|
||||
// See @paperclipai/plugin-sdk index.d.ts: `issueId: event.entityId`.
|
||||
const payload = (event.payload ?? null) as {
|
||||
issueId?: string;
|
||||
commentId?: string;
|
||||
body?: string;
|
||||
bodySnippet?: string;
|
||||
reopened?: boolean;
|
||||
reopenedFrom?: string;
|
||||
} | null;
|
||||
|
||||
const issueId = payload?.issueId;
|
||||
let commentBody = payload?.body;
|
||||
|
||||
// If issueId is not in payload, try to find it from the comment entity
|
||||
// issueId: prefer entityId (current host), fall back to payload for
|
||||
// forward/backward compatibility with other host versions.
|
||||
const issueId = event.entityId || payload?.issueId;
|
||||
if (!issueId) {
|
||||
ctx.logger.warn(
|
||||
"issue.comment.created event missing issueId in payload, skipping",
|
||||
{ entityId, payload },
|
||||
"issue.comment.created event missing issueId (entityId+payload empty), skipping",
|
||||
{ entityId: event.entityId, payload },
|
||||
);
|
||||
return;
|
||||
}
|
||||
const commentId = payload?.commentId;
|
||||
|
||||
// Fetch issue details for context
|
||||
const issue = await ctx.issues.get(issueId, event.companyId);
|
||||
@@ -571,20 +760,6 @@ const plugin = definePlugin({
|
||||
return;
|
||||
}
|
||||
|
||||
// If comment body not in payload, fetch from API
|
||||
if (!commentBody) {
|
||||
try {
|
||||
const comments = await ctx.issues.listComments(
|
||||
issueId,
|
||||
event.companyId,
|
||||
);
|
||||
const latest = comments[comments.length - 1];
|
||||
commentBody = latest?.body || "(לא ניתן לקרוא את התגובה)";
|
||||
} catch {
|
||||
commentBody = "(שגיאה בקריאת התגובה)";
|
||||
}
|
||||
}
|
||||
|
||||
// Wake the CEO agent for this company
|
||||
const ceoAgentId = CEO_AGENT_IDS[event.companyId];
|
||||
if (!ceoAgentId) {
|
||||
@@ -594,29 +769,73 @@ const plugin = definePlugin({
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { runId } = await ctx.agents.invoke(ceoAgentId, event.companyId, {
|
||||
prompt: [
|
||||
`תגובה חדשה מחיים על issue "${issue.title}" (${issue.identifier || issueId}):`,
|
||||
"",
|
||||
commentBody,
|
||||
"",
|
||||
`קרא את ה-comments האחרונים על ה-issue, הבן מה חיים מבקש, והחלט מה לעשות.`,
|
||||
`אם ההוראה ברורה — נתב לסוכן המתאים. אם לא ברור — שאל את חיים.`,
|
||||
].join("\n"),
|
||||
reason: `user_commented_on_${issue.identifier || issueId}`,
|
||||
});
|
||||
ctx.logger.info("Routed user comment to CEO agent", {
|
||||
issueId,
|
||||
commentId: entityId,
|
||||
runId,
|
||||
});
|
||||
} catch (err) {
|
||||
ctx.logger.error("Failed to invoke CEO agent for comment routing", {
|
||||
issueId,
|
||||
error: String(err),
|
||||
});
|
||||
// Dedup against the host's native reopen-on-comment wake: when a comment
|
||||
// reopens a `done` issue, the host already wakes that issue's assignee.
|
||||
// If the assignee IS this company's CEO, that native wake covers us —
|
||||
// invoking again would double-run the CEO. Skip ONLY in that exact case.
|
||||
// For issues owned by any other agent (e.g. an analyst sub-task), the
|
||||
// native wake targets the wrong agent (and the queued run is cancelled
|
||||
// with `issue_assignee_changed`), so we MUST route the comment to the CEO.
|
||||
if (issue.assigneeAgentId === ceoAgentId && payload?.reopened === true) {
|
||||
ctx.logger.info(
|
||||
"Comment reopened CEO-owned issue; native wake handles it, skipping plugin route",
|
||||
{ issueId, ceoAgentId },
|
||||
);
|
||||
// The comment IS being handled (natively) — stamp the marker so the
|
||||
// reconciliation sweep does not re-route it a second time.
|
||||
await markCommentRouted(issueId, commentId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve the full comment body. The payload only carries a truncated
|
||||
// snippet, so fetch the comment list and match by id (fall back to the
|
||||
// latest comment, then the snippet).
|
||||
let commentBody = payload?.body;
|
||||
if (!commentBody) {
|
||||
try {
|
||||
const comments = await ctx.issues.listComments(
|
||||
issueId,
|
||||
event.companyId,
|
||||
);
|
||||
const matched = commentId
|
||||
? comments.find((c) => c.id === commentId)
|
||||
: undefined;
|
||||
// Newest by createdAt — do NOT rely on listComments array order.
|
||||
const latest = comments.reduce<(typeof comments)[number] | null>(
|
||||
(acc, c) =>
|
||||
!acc ||
|
||||
new Date(c.createdAt).getTime() >
|
||||
new Date(acc.createdAt).getTime()
|
||||
? c
|
||||
: acc,
|
||||
null,
|
||||
);
|
||||
commentBody =
|
||||
matched?.body ||
|
||||
latest?.body ||
|
||||
payload?.bodySnippet ||
|
||||
"(לא ניתן לקרוא את התגובה)";
|
||||
} catch (err) {
|
||||
ctx.logger.warn("listComments failed, falling back to bodySnippet", {
|
||||
issueId,
|
||||
commentId,
|
||||
error: String(err),
|
||||
});
|
||||
commentBody = payload?.bodySnippet ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
// Route through the shared helper (also used by the sweep). It stamps
|
||||
// the `last-routed-comment-id` marker on success so the reconciliation
|
||||
// sweep does not re-deliver this comment.
|
||||
await routeCommentToCeo({
|
||||
issue,
|
||||
companyId: event.companyId,
|
||||
ceoAgentId,
|
||||
commentBody: commentBody ?? "",
|
||||
commentId,
|
||||
source: "event",
|
||||
});
|
||||
});
|
||||
|
||||
// ── Jobs ───────────────────────────────────────────────────────
|
||||
@@ -628,77 +847,87 @@ const plugin = definePlugin({
|
||||
const cases = await api.listCases();
|
||||
const companies = await ctx.companies.list();
|
||||
if (!companies.length) return;
|
||||
const statusModel = await api.getStatusModel();
|
||||
|
||||
const companyId = companies[0].id;
|
||||
const issues = await ctx.issues.list({ companyId });
|
||||
|
||||
for (const legalCase of cases) {
|
||||
// מעבר אחד על ה-issues של כל חברה: state.get אחד ל-issue (לא לכל
|
||||
// צירוף תיק×issue), וקיבוץ למפה case_number → מועמדים. הקיבוץ נדרש
|
||||
// כדי לבחור "השורש היחיד". סורק את **כל** החברות (לא רק הראשונה) —
|
||||
// legal-ai issue #604.
|
||||
const byCase = new Map<string, SyncCandidate[]>();
|
||||
for (const company of companies) {
|
||||
const issues = await ctx.issues.list({ companyId: company.id });
|
||||
for (const issue of issues) {
|
||||
const linkedCase = await ctx.state.get({
|
||||
scopeKind: "issue",
|
||||
scopeId: issue.id,
|
||||
stateKey: "legal-case-number",
|
||||
});
|
||||
|
||||
if (linkedCase === legalCase.case_number) {
|
||||
// Map 13 legal-ai statuses to Paperclip issue status
|
||||
const statusMap: Record<string, "todo" | "in_progress" | "done"> =
|
||||
{
|
||||
new: "todo",
|
||||
uploading: "todo",
|
||||
processing: "in_progress",
|
||||
documents_ready: "in_progress",
|
||||
outcome_set: "in_progress",
|
||||
brainstorming: "in_progress",
|
||||
direction_approved: "in_progress",
|
||||
drafting: "in_progress",
|
||||
qa_review: "in_progress",
|
||||
drafted: "in_progress",
|
||||
exported: "in_progress",
|
||||
reviewed: "in_progress",
|
||||
final: "done",
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
new: "תיק חדש",
|
||||
uploading: "העלאת מסמכים",
|
||||
processing: "עיבוד מסמכים",
|
||||
documents_ready: "מסמכים מוכנים — הזן תוצאה",
|
||||
outcome_set: "תוצאה הוזנה — נדרש סיעור מוחות",
|
||||
brainstorming: "גיבוש כיוון בתהליך",
|
||||
direction_approved: "כיוון אושר — מוכן לכתיבה",
|
||||
drafting: "כתיבת החלטה בתהליך",
|
||||
qa_review: "בדיקת איכות",
|
||||
drafted: "טיוטה מוכנה — בדוק ושלח לדפנה",
|
||||
exported: "DOCX נוצר — ממתין לדפנה",
|
||||
reviewed: "דפנה הגיהה — העלה גרסה סופית",
|
||||
final: "גרסה סופית — לולאת למידה",
|
||||
};
|
||||
|
||||
const targetStatus = statusMap[legalCase.status];
|
||||
const label = statusLabels[legalCase.status] || legalCase.status;
|
||||
|
||||
if (targetStatus && issue.status !== targetStatus) {
|
||||
await ctx.issues.update(
|
||||
issue.id,
|
||||
{ status: targetStatus },
|
||||
companyId,
|
||||
);
|
||||
await ctx.issues.createComment(
|
||||
issue.id,
|
||||
`📋 ${label}`,
|
||||
companyId,
|
||||
);
|
||||
ctx.logger.info("Synced issue status", {
|
||||
issueId: issue.id,
|
||||
caseNumber: legalCase.case_number,
|
||||
newStatus: targetStatus,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (typeof linkedCase !== "string" || !linkedCase) continue;
|
||||
const list = byCase.get(linkedCase) ?? [];
|
||||
list.push({
|
||||
id: issue.id,
|
||||
status: issue.status,
|
||||
parentId: issue.parentId,
|
||||
companyId: company.id,
|
||||
});
|
||||
byCase.set(linkedCase, list);
|
||||
}
|
||||
}
|
||||
|
||||
for (const legalCase of cases) {
|
||||
const targetStatus = resolveIssueStatus(
|
||||
statusModel.statuses,
|
||||
legalCase.status,
|
||||
);
|
||||
if (targetStatus === null) {
|
||||
ctx.logger.warn(
|
||||
"sync-case-status: unknown case status — not in /api/status-model (case_status_model.py)",
|
||||
{
|
||||
caseNumber: legalCase.case_number,
|
||||
status: legalCase.status,
|
||||
},
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidates = byCase.get(legalCase.case_number) ?? [];
|
||||
const { target, reason, writableRoots } =
|
||||
pickSyncTargetIssue(candidates);
|
||||
|
||||
if (!target) {
|
||||
// אין בליעה שקטה: מדווח למה לא נכתב כלום.
|
||||
ctx.logger.info("sync-case-status: no sync target", {
|
||||
caseNumber: legalCase.case_number,
|
||||
reason,
|
||||
linked: candidates.length,
|
||||
writableRoots,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (target.status === targetStatus) continue;
|
||||
|
||||
const label =
|
||||
labelFor(statusModel.statuses, legalCase.status) ??
|
||||
legalCase.status;
|
||||
|
||||
await ctx.issues.update(
|
||||
target.id,
|
||||
{ status: targetStatus },
|
||||
target.companyId,
|
||||
);
|
||||
await ctx.issues.createComment(
|
||||
target.id,
|
||||
`📋 ${label}`,
|
||||
target.companyId,
|
||||
);
|
||||
ctx.logger.info("Synced issue status", {
|
||||
issueId: target.id,
|
||||
caseNumber: legalCase.case_number,
|
||||
newStatus: targetStatus,
|
||||
});
|
||||
}
|
||||
|
||||
ctx.logger.info("Case status sync completed", {
|
||||
casesChecked: cases.length,
|
||||
});
|
||||
@@ -709,9 +938,8 @@ const plugin = definePlugin({
|
||||
|
||||
ctx.jobs.register("stale-case-reminder", async (_job) => {
|
||||
ctx.logger.info("stale-case-reminder: starting");
|
||||
const config = await ctx.config.get();
|
||||
const apiBase =
|
||||
(config.legalApiBaseUrl as string) ?? "http://localhost:8085";
|
||||
// Scheduled job — no company context; resolveApiBase probes known companies.
|
||||
const apiBase = await resolveApiBase(ctx);
|
||||
|
||||
let resp: Awaited<ReturnType<typeof ctx.http.fetch>>;
|
||||
try {
|
||||
@@ -783,9 +1011,8 @@ const plugin = definePlugin({
|
||||
|
||||
ctx.jobs.register("weekly-feedback-analysis", async (_job) => {
|
||||
ctx.logger.info("weekly-feedback-analysis: starting");
|
||||
const config = await ctx.config.get();
|
||||
const apiBase =
|
||||
(config.legalApiBaseUrl as string) ?? "http://localhost:8085";
|
||||
// Scheduled job — no company context; resolveApiBase probes known companies.
|
||||
const apiBase = await resolveApiBase(ctx);
|
||||
|
||||
const resp = await ctx.http.fetch(
|
||||
`${apiBase}/api/chair-feedback/weekly-summary`,
|
||||
@@ -842,6 +1069,267 @@ const plugin = definePlugin({
|
||||
}
|
||||
});
|
||||
|
||||
// Reconciliation sweep — the at-least-once guarantee for user comments.
|
||||
// The event-driven route above is best-effort: a failed/coalesced CEO
|
||||
// invoke, or a comment that lands while an agent is mid-run, can leave a
|
||||
// user comment unrouted with nothing to retry (this is exactly how a
|
||||
// chair comment was silently dropped — see legal-ai task #164). This sweep
|
||||
// re-routes any issue whose newest user comment id does not match the
|
||||
// `last-routed-comment-id` marker, so no comment falls through. Idempotent:
|
||||
// the fast path stamps the marker, so a healthy comment is never
|
||||
// double-routed. Runs every 2 minutes (manifest schedule).
|
||||
ctx.jobs.register("route-pending-comments", async (_job) => {
|
||||
let companies: Awaited<ReturnType<typeof ctx.companies.list>>;
|
||||
try {
|
||||
companies = await ctx.companies.list();
|
||||
} catch (err) {
|
||||
ctx.logger.warn("route-pending-comments: companies.list failed", {
|
||||
error: String(err),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
for (const company of companies) {
|
||||
const ceoAgentId = CEO_AGENT_IDS[company.id];
|
||||
if (!ceoAgentId) continue;
|
||||
|
||||
let issues: Awaited<ReturnType<typeof ctx.issues.list>>;
|
||||
try {
|
||||
issues = await ctx.issues.list({ companyId: company.id, limit: 200 });
|
||||
} catch (err) {
|
||||
ctx.logger.warn("route-pending-comments: issues.list failed", {
|
||||
companyId: company.id,
|
||||
error: String(err),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const issue of issues) {
|
||||
// Terminal issues need no routing. A fresh user comment on a
|
||||
// `done` issue reopens it natively → it becomes active and is
|
||||
// caught on the next sweep.
|
||||
if (issue.status === "done" || issue.status === "cancelled") continue;
|
||||
|
||||
try {
|
||||
const comments = await ctx.issues.listComments(
|
||||
issue.id,
|
||||
company.id,
|
||||
);
|
||||
if (comments.length === 0) continue;
|
||||
|
||||
// "Pending" = the chair spoke last among real participants: the
|
||||
// newest USER comment is newer than the newest AGENT comment (or
|
||||
// no agent has commented yet), so an agent never responded to it.
|
||||
// Order-independent (sort by createdAt, do NOT rely on the array
|
||||
// order of listComments) and system comments are ignored — an
|
||||
// automated "draft ready" line is not an agent response. This
|
||||
// targets the exact failure (chair commented, no agent reply) and
|
||||
// skips historically-answered comments.
|
||||
const ts = (c: { createdAt: string | Date }) =>
|
||||
new Date(c.createdAt).getTime();
|
||||
let newestUser: (typeof comments)[number] | null = null;
|
||||
let newestAgent: (typeof comments)[number] | null = null;
|
||||
for (const c of comments) {
|
||||
if (c.authorType === "user") {
|
||||
if (!newestUser || ts(c) > ts(newestUser)) newestUser = c;
|
||||
} else if (c.authorType === "agent") {
|
||||
if (!newestAgent || ts(c) > ts(newestAgent)) newestAgent = c;
|
||||
}
|
||||
}
|
||||
if (!newestUser) continue; // no chair comment
|
||||
// An agent has already responded after the chair — handled.
|
||||
if (newestAgent && ts(newestAgent) >= ts(newestUser)) continue;
|
||||
|
||||
const marker = await ctx.state.get({
|
||||
scopeKind: "issue",
|
||||
scopeId: issue.id,
|
||||
stateKey: LAST_ROUTED_COMMENT_KEY,
|
||||
});
|
||||
// Already routed (CEO may still be working) — don't re-fire.
|
||||
if (marker === newestUser.id) continue;
|
||||
|
||||
ctx.logger.info(
|
||||
"route-pending-comments: re-routing unrouted user comment",
|
||||
{ issueId: issue.id, commentId: newestUser.id },
|
||||
);
|
||||
await routeCommentToCeo({
|
||||
issue,
|
||||
companyId: company.id,
|
||||
ceoAgentId,
|
||||
commentBody: newestUser.body,
|
||||
commentId: newestUser.id,
|
||||
source: "sweep",
|
||||
});
|
||||
} catch (err) {
|
||||
ctx.logger.warn("route-pending-comments: failed for issue", {
|
||||
issueId: issue.id,
|
||||
error: String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Data handlers (UI bridge) ──────────────────────────────────
|
||||
// These back `usePluginData(key, params)` calls from the React UI bundle
|
||||
// in `dist/ui/index.js`. Errors are caught and rethrown with a clear
|
||||
// message so the UI shows them through PluginBridgeError.
|
||||
|
||||
// Resolve the legal-ai case number linked to an issue via plugin state.
|
||||
async function resolveCaseNumber(issueId: string): Promise<string | null> {
|
||||
const value = await ctx.state.get({
|
||||
scopeKind: "issue",
|
||||
scopeId: issueId,
|
||||
stateKey: "legal-case-number",
|
||||
});
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
ctx.data.register("legal-case-summary", async (params) => {
|
||||
const issueId = String((params as { issueId?: string }).issueId ?? "");
|
||||
if (!issueId) return null;
|
||||
const caseNumber = await resolveCaseNumber(issueId);
|
||||
if (!caseNumber) return null;
|
||||
try {
|
||||
return await api.getCase(caseNumber);
|
||||
} catch (err) {
|
||||
ctx.logger.warn("legal-case-summary: getCase failed", {
|
||||
caseNumber,
|
||||
error: String(err),
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
ctx.data.register("legal-case-arguments", async (params) => {
|
||||
const issueId = String((params as { issueId?: string }).issueId ?? "");
|
||||
if (!issueId) return null;
|
||||
const caseNumber = await resolveCaseNumber(issueId);
|
||||
if (!caseNumber) return null;
|
||||
const apiBase = await resolveApiBase(ctx, companyIdOf(params));
|
||||
try {
|
||||
const resp = await ctx.http.fetch(
|
||||
`${apiBase}/api/cases/${encodeURIComponent(caseNumber)}/legal-arguments`,
|
||||
);
|
||||
if (!resp.ok) {
|
||||
ctx.logger.warn("legal-case-arguments: API error", {
|
||||
caseNumber,
|
||||
status: resp.status,
|
||||
});
|
||||
return {
|
||||
case_number: caseNumber,
|
||||
total: 0,
|
||||
by_party: {},
|
||||
arguments: [],
|
||||
};
|
||||
}
|
||||
return await resp.json();
|
||||
} catch (err) {
|
||||
ctx.logger.warn("legal-case-arguments: fetch failed", {
|
||||
caseNumber,
|
||||
error: String(err),
|
||||
});
|
||||
return {
|
||||
case_number: caseNumber,
|
||||
total: 0,
|
||||
by_party: {},
|
||||
arguments: [],
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
ctx.data.register("legal-case-precedents", async (params) => {
|
||||
const issueId = String((params as { issueId?: string }).issueId ?? "");
|
||||
if (!issueId) return [];
|
||||
const caseNumber = await resolveCaseNumber(issueId);
|
||||
if (!caseNumber) return [];
|
||||
const apiBase = await resolveApiBase(ctx, companyIdOf(params));
|
||||
try {
|
||||
const resp = await ctx.http.fetch(
|
||||
`${apiBase}/api/cases/${encodeURIComponent(caseNumber)}/precedents`,
|
||||
);
|
||||
if (!resp.ok) {
|
||||
ctx.logger.warn("legal-case-precedents: API error", {
|
||||
caseNumber,
|
||||
status: resp.status,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
const data = await resp.json();
|
||||
// Endpoint may return a flat array or `{ precedents: [...] }`.
|
||||
if (Array.isArray(data)) return data;
|
||||
if (
|
||||
data &&
|
||||
Array.isArray((data as { precedents?: unknown[] }).precedents)
|
||||
) {
|
||||
return (data as { precedents: unknown[] }).precedents;
|
||||
}
|
||||
return [];
|
||||
} catch (err) {
|
||||
ctx.logger.warn("legal-case-precedents: fetch failed", {
|
||||
caseNumber,
|
||||
error: String(err),
|
||||
});
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
ctx.data.register("legal-case-missing-precedents", async (params) => {
|
||||
const issueId = String((params as { issueId?: string }).issueId ?? "");
|
||||
if (!issueId) return [];
|
||||
const caseNumber = await resolveCaseNumber(issueId);
|
||||
if (!caseNumber) return [];
|
||||
const apiBase = await resolveApiBase(ctx, companyIdOf(params));
|
||||
try {
|
||||
const url = new URL(`${apiBase}/api/missing-precedents`);
|
||||
url.searchParams.set("case_number", caseNumber);
|
||||
url.searchParams.set("status", "open");
|
||||
const resp = await ctx.http.fetch(url.toString());
|
||||
if (!resp.ok) {
|
||||
ctx.logger.warn("legal-case-missing-precedents: API error", {
|
||||
caseNumber,
|
||||
status: resp.status,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
const data = (await resp.json()) as { items?: unknown[] };
|
||||
return Array.isArray(data.items) ? data.items : [];
|
||||
} catch (err) {
|
||||
ctx.logger.warn("legal-case-missing-precedents: fetch failed", {
|
||||
caseNumber,
|
||||
error: String(err),
|
||||
});
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
ctx.data.register("legal-dashboard-stats", async () => {
|
||||
try {
|
||||
const cases = await api.listCases();
|
||||
const byStatus: Record<string, number> = {};
|
||||
let weekActivity = 0;
|
||||
const weekAgoMs = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
||||
for (const c of cases) {
|
||||
byStatus[c.status] = (byStatus[c.status] ?? 0) + 1;
|
||||
const updated = (c as { updated_at?: string | null }).updated_at;
|
||||
if (updated) {
|
||||
const t = Date.parse(updated);
|
||||
if (!Number.isNaN(t) && t >= weekAgoMs) weekActivity += 1;
|
||||
}
|
||||
}
|
||||
return {
|
||||
byStatus,
|
||||
weekActivity,
|
||||
totalCases: cases.length,
|
||||
};
|
||||
} catch (err) {
|
||||
ctx.logger.warn("legal-dashboard-stats: listCases failed", {
|
||||
error: String(err),
|
||||
});
|
||||
return { byStatus: {}, weekActivity: 0, totalCases: 0 };
|
||||
}
|
||||
});
|
||||
|
||||
ctx.logger.info("Legal AI plugin ready");
|
||||
},
|
||||
|
||||
@@ -878,27 +1366,47 @@ const plugin = definePlugin({
|
||||
|
||||
if (endpointKey !== "case-status") return;
|
||||
|
||||
// Webhook payload is a discriminated union by `eventType`:
|
||||
// • undefined / "status_change" — legacy case-status update (default)
|
||||
// • "missing_precedent_created" — ask Daphna to upload a missing citation
|
||||
// • "export_complete" — attach a generated DOCX to the linked issue
|
||||
const payload = parsedBody as {
|
||||
eventType?: string;
|
||||
caseNumber: string;
|
||||
oldStatus: string;
|
||||
newStatus: string;
|
||||
companyId: string | null;
|
||||
timestamp: string;
|
||||
// status_change fields
|
||||
oldStatus?: string;
|
||||
newStatus?: string;
|
||||
// missing_precedent_created fields
|
||||
missingPrecedent?: {
|
||||
id: string;
|
||||
citation: string;
|
||||
citedByParty?: string;
|
||||
citedByPartyName?: string;
|
||||
legalTopic?: string;
|
||||
legalIssue?: string;
|
||||
};
|
||||
// export_complete fields
|
||||
docxBase64?: string;
|
||||
docxFilename?: string;
|
||||
docxTitle?: string;
|
||||
};
|
||||
|
||||
const { caseNumber, oldStatus, newStatus, companyId } = payload;
|
||||
const { caseNumber, companyId } = payload;
|
||||
const eventType = payload.eventType ?? "status_change";
|
||||
|
||||
if (!caseNumber || !newStatus || !companyId) {
|
||||
if (!caseNumber || !companyId) {
|
||||
pluginCtx.logger.warn("onWebhook: malformed payload", {
|
||||
eventType,
|
||||
caseNumber,
|
||||
newStatus,
|
||||
companyId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
pluginCtx.logger.info(
|
||||
`Webhook: case ${caseNumber} ${oldStatus} → ${newStatus}`,
|
||||
`Webhook: case ${caseNumber} eventType=${eventType}`,
|
||||
{
|
||||
companyId,
|
||||
},
|
||||
@@ -927,6 +1435,123 @@ const plugin = definePlugin({
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Branch by eventType ────────────────────────────────────────
|
||||
|
||||
// EVENT: missing_precedent_created — ask Daphna to upload via SDK
|
||||
// interaction (ask_user_questions). Avoids plain-text comment for
|
||||
// a decision that has a clear set of choices.
|
||||
if (eventType === "missing_precedent_created" && payload.missingPrecedent) {
|
||||
const mp = payload.missingPrecedent;
|
||||
const partyLabel = mp.citedByPartyName || mp.citedByParty || "אחד הצדדים";
|
||||
try {
|
||||
await pluginCtx.issues.askUserQuestions(
|
||||
linkedIssueId,
|
||||
{
|
||||
continuationPolicy: "wake_assignee_on_accept",
|
||||
payload: {
|
||||
version: 1,
|
||||
title: `פסיקה חסרה בקורפוס: ${mp.citation}`,
|
||||
questions: [
|
||||
{
|
||||
id: `missing_precedent:${mp.id}`,
|
||||
prompt: `יש להעלות את ${mp.citation}`,
|
||||
helpText: [
|
||||
`הציטוט הוזכר על-ידי ${partyLabel} בתיק ${caseNumber}.`,
|
||||
mp.legalTopic ? `נושא: ${mp.legalTopic}` : "",
|
||||
mp.legalIssue ? `סוגיה: ${mp.legalIssue}` : "",
|
||||
"",
|
||||
"כדי שהמערכת תוכל לבחון את הטענה מול הפסיקה — יש להעלות את ה-PDF/DOCX לדף /missing-precedents.",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
selectionMode: "single",
|
||||
required: true,
|
||||
options: [
|
||||
{ id: "upload", label: "אני מעלה PDF/DOCX" },
|
||||
{ id: "irrelevant", label: "ההלכה לא רלוונטית" },
|
||||
{ id: "defer", label: "אכריע מאוחר יותר" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
companyId,
|
||||
);
|
||||
pluginCtx.logger.info(
|
||||
"askUserQuestions: missing_precedent prompt sent",
|
||||
{
|
||||
caseNumber,
|
||||
missingPrecedentId: mp.id,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
pluginCtx.logger.error(
|
||||
"askUserQuestions failed for missing_precedent",
|
||||
{
|
||||
caseNumber,
|
||||
missingPrecedentId: mp.id,
|
||||
error: String(err),
|
||||
},
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// EVENT: export_complete — attach a markdown "final decision"
|
||||
// document to the issue, with a link back to the DOCX in legal-ai.
|
||||
// The SDK's `documents.upsert` stores text (markdown), so we keep
|
||||
// the binary DOCX in legal-ai and reference it; the issue page
|
||||
// shows the document as a discoverable artifact.
|
||||
if (eventType === "export_complete" && payload.docxFilename) {
|
||||
const filename = payload.docxFilename;
|
||||
const title = payload.docxTitle || `החלטה סופית — ${caseNumber}`;
|
||||
const docxUrl = `https://legal-ai.nautilus.marcusgroup.org/api/cases/${encodeURIComponent(caseNumber)}/export/download?filename=${encodeURIComponent(filename)}`;
|
||||
const markdownBody = [
|
||||
`# ${title}`,
|
||||
"",
|
||||
`**תיק:** ${caseNumber}`,
|
||||
`**קובץ:** \`${filename}\``,
|
||||
`**הופק:** ${payload.timestamp}`,
|
||||
"",
|
||||
`[הורדה](${docxUrl})`,
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
'החלטה זו יוצאה אוטומטית ע"י legal-ai והוצמדה ל-issue זה.',
|
||||
].join("\n");
|
||||
try {
|
||||
await pluginCtx.issues.documents.upsert({
|
||||
issueId: linkedIssueId,
|
||||
companyId,
|
||||
key: `final-decision:${caseNumber}`,
|
||||
body: markdownBody,
|
||||
title,
|
||||
format: "markdown",
|
||||
changeSummary: `Auto-attached final DOCX (${filename})`,
|
||||
});
|
||||
pluginCtx.logger.info("documents.upsert: final-decision attached", {
|
||||
caseNumber,
|
||||
filename,
|
||||
});
|
||||
} catch (err) {
|
||||
pluginCtx.logger.error("documents.upsert failed for export_complete", {
|
||||
caseNumber,
|
||||
filename,
|
||||
error: String(err),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// EVENT: status_change (default) — legacy behavior.
|
||||
const { oldStatus = "", newStatus = "" } = payload;
|
||||
if (!newStatus) {
|
||||
pluginCtx.logger.warn("onWebhook status_change: missing newStatus", {
|
||||
caseNumber,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Status label map (Hebrew)
|
||||
const statusLabels: Record<string, string> = {
|
||||
new: "📂 תיק חדש",
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true
|
||||
"declaration": true,
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"]
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user