Compare commits
4 Commits
d0669a0878
...
fix/446-sy
| Author | SHA1 | Date | |
|---|---|---|---|
| 378e5657b5 | |||
| 6721c0a4ed | |||
| 3da37e1519 | |||
| 6294d94bed |
16
package-lock.json
generated
16
package-lock.json
generated
@@ -8,7 +8,7 @@
|
||||
"name": "@marcusgroup/plugin-legal-ai",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@paperclipai/plugin-sdk": "^2026.525.0",
|
||||
"@paperclipai/plugin-sdk": "^2026.722.0",
|
||||
"react": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -625,12 +625,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@paperclipai/plugin-sdk": {
|
||||
"version": "2026.525.0",
|
||||
"resolved": "https://registry.npmjs.org/@paperclipai/plugin-sdk/-/plugin-sdk-2026.525.0.tgz",
|
||||
"integrity": "sha512-7ivXbFSwH7cS+/2WNbPwQve5vVtLnqgxX5lmqTKn+i3fOY03d5KHaYF2bbI5zeTkAAwwEJ0Nz3rfBWNPguRRUw==",
|
||||
"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.525.0",
|
||||
"@paperclipai/shared": "2026.722.0",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"bin": {
|
||||
@@ -646,9 +646,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@paperclipai/shared": {
|
||||
"version": "2026.525.0",
|
||||
"resolved": "https://registry.npmjs.org/@paperclipai/shared/-/shared-2026.525.0.tgz",
|
||||
"integrity": "sha512-fbVrEx96oxkxXbRBFDLAgW5Z0lRC7Ii+BeCNhKqKRCg9m2IwtP97CGoOeAROdRrvRpP8Neb3vgaaDbDjXT74lQ==",
|
||||
"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"
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"biome:fix": "biome check --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paperclipai/plugin-sdk": "^2026.525.0",
|
||||
"@paperclipai/plugin-sdk": "^2026.722.0",
|
||||
"react": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
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",
|
||||
|
||||
134
src/sync-target.test.ts
Normal file
134
src/sync-target.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/// <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 { pickSyncTargetIssue, 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 },
|
||||
{ id: "CMPA-84", status: "cancelled", parentId: null },
|
||||
{ id: "CMPA-86", status: "cancelled", parentId: null },
|
||||
{ id: "CMPA-140", status: "done", parentId: "CMPA-139" }, // זה שנדרס בפועל
|
||||
{ id: "CMPA-141", status: "cancelled", parentId: "CMPA-139" },
|
||||
{ id: "CMPA-85", status: "done", parentId: "CMPA-84" },
|
||||
{ id: "CMPA-146", status: "done", parentId: "CMPA-84" },
|
||||
{ id: "CMPA-147", status: "done", parentId: "CMPA-84" },
|
||||
{ id: "CMPA-87", status: "done", parentId: "CMPA-86" },
|
||||
{ id: "CMPA-88", status: "done", parentId: "CMPA-86" },
|
||||
{ id: "CMPA-89", status: "cancelled", parentId: "CMPA-86" },
|
||||
];
|
||||
|
||||
// סטטוס-התיק היה 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" },
|
||||
];
|
||||
|
||||
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 },
|
||||
{ id: "sub-1", status: "in_progress", parentId: "root-1" },
|
||||
];
|
||||
|
||||
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 },
|
||||
];
|
||||
|
||||
const result = pickSyncTargetIssue(candidates);
|
||||
|
||||
assert.equal(result.target?.id, "root-1");
|
||||
assert.equal(result.reason, "ok");
|
||||
assert.equal(result.writableRoots, 1);
|
||||
});
|
||||
|
||||
test("שורש יחיד ב-in_review: לא נכתב (הגנת auto-block)", () => {
|
||||
const candidates: SyncCandidate[] = [
|
||||
{ id: "root-1", status: "in_review", parentId: null },
|
||||
];
|
||||
|
||||
const result = pickSyncTargetIssue(candidates);
|
||||
|
||||
assert.equal(result.target, null);
|
||||
assert.equal(result.reason, "no_writable_root");
|
||||
});
|
||||
|
||||
test("שורש יחיד ב-blocked: לא נכתב", () => {
|
||||
const candidates: SyncCandidate[] = [
|
||||
{ id: "root-1", status: "blocked", parentId: null },
|
||||
];
|
||||
|
||||
const result = pickSyncTargetIssue(candidates);
|
||||
|
||||
assert.equal(result.target, null);
|
||||
assert.equal(result.reason, "no_writable_root");
|
||||
});
|
||||
|
||||
test("שני שורשים ברי-כתיבה: ambiguous, לא נכתב", () => {
|
||||
const candidates: SyncCandidate[] = [
|
||||
{ id: "root-1", status: "todo", parentId: null },
|
||||
{ id: "root-2", status: "in_progress", parentId: null },
|
||||
];
|
||||
|
||||
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 },
|
||||
{ id: "child-1", status: "in_progress", parentId: "root-1" },
|
||||
];
|
||||
|
||||
const result = pickSyncTargetIssue(candidates);
|
||||
|
||||
assert.equal(result.target?.id, "root-1");
|
||||
assert.equal(result.reason, "ok");
|
||||
});
|
||||
79
src/sync-target.ts
Normal file
79
src/sync-target.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* בחירת יעד-הסנכרון של הג'וב `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.
|
||||
*/
|
||||
|
||||
/** "סגור" — ההגדרה היחידה. מראה של web/paperclip_client.py:561 ב-legal-ai. */
|
||||
export const CLOSED_ISSUE_STATUSES: ReadonlySet<string> = new Set([
|
||||
"done",
|
||||
"cancelled",
|
||||
]);
|
||||
|
||||
/**
|
||||
* מצב בבעלות Paperclip / בהמתנה-לאדם — **לא** הגדרה שנייה של "סגור".
|
||||
* `in_review` הוא מצב-ההמתנה-ליו"ר המכוון: CEO שמשאיר issue ב-`in_progress`
|
||||
* מקבל auto-block מ-Paperclip תוך דקה, ולכן הוא מעביר ל-`in_review`
|
||||
* (legal-ai/docs/paperclip-quirks.md §3). כתיבת-סטטוס אוטומטית עליו גונבת את
|
||||
* ה-issue מתור-הביקורת של היו"ר ומזמינה קרב-סטטוסים.
|
||||
* `blocked` — כתיבה עליו מסתירה חוסם קיים.
|
||||
*/
|
||||
export const NON_WRITABLE_STATUSES: ReadonlySet<string> = new Set([
|
||||
"in_review",
|
||||
"blocked",
|
||||
]);
|
||||
|
||||
export interface SyncCandidate {
|
||||
id: string;
|
||||
status: string;
|
||||
parentId: string | null;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
218
src/worker.ts
218
src/worker.ts
@@ -4,6 +4,7 @@ import type {
|
||||
} from "@paperclipai/plugin-sdk";
|
||||
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
||||
import { LegalApi } from "./legal-api.js";
|
||||
import { pickSyncTargetIssue, type SyncCandidate } from "./sync-target.js";
|
||||
|
||||
// Hoisted so onWebhook can access the context after setup() completes.
|
||||
let pluginCtx: PluginContext | null = null;
|
||||
@@ -16,16 +17,116 @@ const CEO_AGENT_IDS: Record<string, string> = {
|
||||
"cdbfa8bc-3d61-41a4-a2e7-677ec7d34562", // CMPA (היטלי השבחה)
|
||||
};
|
||||
|
||||
const DEFAULT_LEGAL_API_BASE = "http://localhost:8085";
|
||||
|
||||
// Map 13 legal-ai case statuses to Paperclip issue status (used by the
|
||||
// `sync-case-status` job).
|
||||
const CASE_STATUS_TO_ISSUE_STATUS: 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 CASE_STATUS_LABELS: Record<string, string> = {
|
||||
new: "תיק חדש",
|
||||
uploading: "העלאת מסמכים",
|
||||
processing: "עיבוד מסמכים",
|
||||
documents_ready: "מסמכים מוכנים — הזן תוצאה",
|
||||
outcome_set: "תוצאה הוזנה — נדרש סיעור מוחות",
|
||||
brainstorming: "גיבוש כיוון בתהליך",
|
||||
direction_approved: "כיוון אושר — מוכן לכתיבה",
|
||||
drafting: "כתיבת החלטה בתהליך",
|
||||
qa_review: "בדיקת איכות",
|
||||
drafted: "טיוטה מוכנה — בדוק ושלח לדפנה",
|
||||
exported: "DOCX נוצר — ממתין לדפנה",
|
||||
reviewed: "דפנה הגיהה — העלה גרסה סופית",
|
||||
final: "גרסה סופית — לולאת למידה",
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -782,72 +883,61 @@ const plugin = definePlugin({
|
||||
const companyId = companies[0].id;
|
||||
const issues = await ctx.issues.list({ companyId });
|
||||
|
||||
for (const legalCase of cases) {
|
||||
// מעבר אחד על ה-issues: state.get אחד ל-issue (לא לכל צירוף תיק×issue),
|
||||
// וקיבוץ למפה case_number → מועמדים. הקיבוץ נדרש כדי לבחור "השורש היחיד".
|
||||
const byCase = new Map<string, SyncCandidate[]>();
|
||||
for (const issue of issues) {
|
||||
const linkedCase = await ctx.state.get({
|
||||
scopeKind: "issue",
|
||||
scopeId: issue.id,
|
||||
stateKey: "legal-case-number",
|
||||
});
|
||||
if (typeof linkedCase !== "string" || !linkedCase) continue;
|
||||
const list = byCase.get(linkedCase) ?? [];
|
||||
list.push({
|
||||
id: issue.id,
|
||||
status: issue.status,
|
||||
parentId: issue.parentId,
|
||||
});
|
||||
byCase.set(linkedCase, list);
|
||||
}
|
||||
|
||||
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",
|
||||
};
|
||||
for (const legalCase of cases) {
|
||||
const targetStatus = CASE_STATUS_TO_ISSUE_STATUS[legalCase.status];
|
||||
if (!targetStatus) continue;
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
new: "תיק חדש",
|
||||
uploading: "העלאת מסמכים",
|
||||
processing: "עיבוד מסמכים",
|
||||
documents_ready: "מסמכים מוכנים — הזן תוצאה",
|
||||
outcome_set: "תוצאה הוזנה — נדרש סיעור מוחות",
|
||||
brainstorming: "גיבוש כיוון בתהליך",
|
||||
direction_approved: "כיוון אושר — מוכן לכתיבה",
|
||||
drafting: "כתיבת החלטה בתהליך",
|
||||
qa_review: "בדיקת איכות",
|
||||
drafted: "טיוטה מוכנה — בדוק ושלח לדפנה",
|
||||
exported: "DOCX נוצר — ממתין לדפנה",
|
||||
reviewed: "דפנה הגיהה — העלה גרסה סופית",
|
||||
final: "גרסה סופית — לולאת למידה",
|
||||
};
|
||||
const candidates = byCase.get(legalCase.case_number) ?? [];
|
||||
const { target, reason, writableRoots } =
|
||||
pickSyncTargetIssue(candidates);
|
||||
|
||||
const targetStatus = statusMap[legalCase.status];
|
||||
const label = statusLabels[legalCase.status] || legalCase.status;
|
||||
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 =
|
||||
CASE_STATUS_LABELS[legalCase.status] || legalCase.status;
|
||||
|
||||
if (targetStatus && issue.status !== targetStatus) {
|
||||
await ctx.issues.update(
|
||||
issue.id,
|
||||
target.id,
|
||||
{ status: targetStatus },
|
||||
companyId,
|
||||
);
|
||||
await ctx.issues.createComment(
|
||||
issue.id,
|
||||
`📋 ${label}`,
|
||||
companyId,
|
||||
);
|
||||
await ctx.issues.createComment(target.id, `📋 ${label}`, companyId);
|
||||
ctx.logger.info("Synced issue status", {
|
||||
issueId: issue.id,
|
||||
issueId: target.id,
|
||||
caseNumber: legalCase.case_number,
|
||||
newStatus: targetStatus,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.logger.info("Case status sync completed", {
|
||||
casesChecked: cases.length,
|
||||
@@ -859,9 +949,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 {
|
||||
@@ -933,9 +1022,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`,
|
||||
@@ -1129,9 +1217,7 @@ const plugin = definePlugin({
|
||||
if (!issueId) return null;
|
||||
const caseNumber = await resolveCaseNumber(issueId);
|
||||
if (!caseNumber) return null;
|
||||
const config2 = await ctx.config.get();
|
||||
const apiBase =
|
||||
(config2.legalApiBaseUrl as string) ?? "http://localhost:8085";
|
||||
const apiBase = await resolveApiBase(ctx, companyIdOf(params));
|
||||
try {
|
||||
const resp = await ctx.http.fetch(
|
||||
`${apiBase}/api/cases/${encodeURIComponent(caseNumber)}/legal-arguments`,
|
||||
@@ -1168,9 +1254,7 @@ const plugin = definePlugin({
|
||||
if (!issueId) return [];
|
||||
const caseNumber = await resolveCaseNumber(issueId);
|
||||
if (!caseNumber) return [];
|
||||
const config2 = await ctx.config.get();
|
||||
const apiBase =
|
||||
(config2.legalApiBaseUrl as string) ?? "http://localhost:8085";
|
||||
const apiBase = await resolveApiBase(ctx, companyIdOf(params));
|
||||
try {
|
||||
const resp = await ctx.http.fetch(
|
||||
`${apiBase}/api/cases/${encodeURIComponent(caseNumber)}/precedents`,
|
||||
@@ -1206,9 +1290,7 @@ const plugin = definePlugin({
|
||||
if (!issueId) return [];
|
||||
const caseNumber = await resolveCaseNumber(issueId);
|
||||
if (!caseNumber) return [];
|
||||
const config2 = await ctx.config.get();
|
||||
const apiBase =
|
||||
(config2.legalApiBaseUrl as string) ?? "http://localhost:8085";
|
||||
const apiBase = await resolveApiBase(ctx, companyIdOf(params));
|
||||
try {
|
||||
const url = new URL(`${apiBase}/api/missing-precedents`);
|
||||
url.searchParams.set("case_number", caseNumber);
|
||||
|
||||
@@ -12,5 +12,6 @@
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"]
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user