Compare commits
15 Commits
b6bf726570
...
fix/604-pa
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a134d0134 | |||
| 602b42ca5e | |||
| 378e5657b5 | |||
| 6721c0a4ed | |||
| 3da37e1519 | |||
| 6294d94bed | |||
| d0669a0878 | |||
| b9906f6047 | |||
| 8d933386d3 | |||
| 0b6117cd7e | |||
| fc5d8725f1 | |||
| b9a5bb6e80 | |||
| 0bf2e46212 | |||
| aeffbe65ee | |||
| 0a8790b088 |
16
package-lock.json
generated
16
package-lock.json
generated
@@ -8,7 +8,7 @@
|
|||||||
"name": "@marcusgroup/plugin-legal-ai",
|
"name": "@marcusgroup/plugin-legal-ai",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@paperclipai/plugin-sdk": "^2026.525.0",
|
"@paperclipai/plugin-sdk": "^2026.722.0",
|
||||||
"react": "^19.0.0"
|
"react": "^19.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -625,12 +625,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@paperclipai/plugin-sdk": {
|
"node_modules/@paperclipai/plugin-sdk": {
|
||||||
"version": "2026.525.0",
|
"version": "2026.722.0",
|
||||||
"resolved": "https://registry.npmjs.org/@paperclipai/plugin-sdk/-/plugin-sdk-2026.525.0.tgz",
|
"resolved": "https://registry.npmjs.org/@paperclipai/plugin-sdk/-/plugin-sdk-2026.722.0.tgz",
|
||||||
"integrity": "sha512-7ivXbFSwH7cS+/2WNbPwQve5vVtLnqgxX5lmqTKn+i3fOY03d5KHaYF2bbI5zeTkAAwwEJ0Nz3rfBWNPguRRUw==",
|
"integrity": "sha512-2rMJCBo8ZAouuMsapdaY4/l+oHmrXKHW/k/HfPOLCxFng7+g04c5JhoFq+/ptGfNcW3kXzs34y7brviDCml06Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@paperclipai/shared": "2026.525.0",
|
"@paperclipai/shared": "2026.722.0",
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -646,9 +646,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@paperclipai/shared": {
|
"node_modules/@paperclipai/shared": {
|
||||||
"version": "2026.525.0",
|
"version": "2026.722.0",
|
||||||
"resolved": "https://registry.npmjs.org/@paperclipai/shared/-/shared-2026.525.0.tgz",
|
"resolved": "https://registry.npmjs.org/@paperclipai/shared/-/shared-2026.722.0.tgz",
|
||||||
"integrity": "sha512-fbVrEx96oxkxXbRBFDLAgW5Z0lRC7Ii+BeCNhKqKRCg9m2IwtP97CGoOeAROdRrvRpP8Neb3vgaaDbDjXT74lQ==",
|
"integrity": "sha512-JciI6EbtNwHSeoyN5ZkshwqpyfIluyO46JHp8I7pPCvpJ7Uv7MSZEBs3lKXdXLadFEN6qzfwNmf39xjIFC8isA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"biome:fix": "biome check --write src/"
|
"biome:fix": "biome check --write src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@paperclipai/plugin-sdk": "^2026.525.0",
|
"@paperclipai/plugin-sdk": "^2026.722.0",
|
||||||
"react": "^19.0.0"
|
"react": "^19.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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.
|
* 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 {
|
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> {
|
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,
|
...init,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -24,6 +36,10 @@ export class LegalApi {
|
|||||||
return this.request("/api/cases");
|
return this.request("/api/cases");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getStatusModel(): Promise<StatusModel> {
|
||||||
|
return this.request("/api/status-model");
|
||||||
|
}
|
||||||
|
|
||||||
async getCase(caseNumber: string): Promise<CaseDetails> {
|
async getCase(caseNumber: string): Promise<CaseDetails> {
|
||||||
return this.request(`/api/cases/${encodeURIComponent(caseNumber)}/details`);
|
return this.request(`/api/cases/${encodeURIComponent(caseNumber)}/details`);
|
||||||
}
|
}
|
||||||
@@ -148,6 +164,20 @@ export class LegalApi {
|
|||||||
// Return static style guide reference
|
// Return static style guide reference
|
||||||
return "ראה skill-legal-decision/SKILL.md — מדריך סגנון מלא של דפנה תמיר";
|
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
|
// Types
|
||||||
@@ -158,6 +188,21 @@ export interface CaseSummary {
|
|||||||
status: string;
|
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 {
|
export interface CaseDetails {
|
||||||
id: string;
|
id: string;
|
||||||
case_number: string;
|
case_number: string;
|
||||||
@@ -260,3 +305,21 @@ export interface QAResponse {
|
|||||||
}>;
|
}>;
|
||||||
status: string;
|
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[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -156,6 +156,23 @@ export default {
|
|||||||
description: "Get overall system processing status",
|
description: "Get overall system processing status",
|
||||||
parametersSchema: { type: "object" as const, properties: {} },
|
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: [
|
jobs: [
|
||||||
{
|
{
|
||||||
@@ -178,6 +195,13 @@ export default {
|
|||||||
'מסכם פידבק יו"ר מהשבוע האחרון ומעדכן את decision-lessons.md',
|
'מסכם פידבק יו"ר מהשבוע האחרון ומעדכן את decision-lessons.md',
|
||||||
schedule: "0 19 * * 0",
|
schedule: "0 19 * * 0",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
jobKey: "route-pending-comments",
|
||||||
|
displayName: "ערובת מסירת-הערות (sweep)",
|
||||||
|
description:
|
||||||
|
"sweep פיוס: מנתב כל הערת-משתמש שטרם נותבה ל-CEO (at-least-once), כדי שהערה לא תיפול גם אם ה-wakeup התבטל/התלכד",
|
||||||
|
schedule: "*/2 * * * *",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
webhooks: [
|
webhooks: [
|
||||||
{
|
{
|
||||||
|
|||||||
326
src/sync-target.test.ts
Normal file
326
src/sync-target.test.ts
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
/// <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 {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("שורש יחיד ב-in_review: לא נכתב (הגנת auto-block)", () => {
|
||||||
|
const candidates: SyncCandidate[] = [
|
||||||
|
{ id: "root-1", status: "in_review", parentId: null, companyId: "c1" },
|
||||||
|
];
|
||||||
|
|
||||||
|
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, companyId: "c1" },
|
||||||
|
];
|
||||||
|
|
||||||
|
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, 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");
|
||||||
|
});
|
||||||
112
src/sync-target.ts
Normal file
112
src/sync-target.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* בחירת יעד-הסנכרון של הג'וב `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 / בהמתנה-לאדם — **לא** הגדרה שנייה של "סגור".
|
||||||
|
* `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;
|
||||||
|
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;
|
||||||
|
}
|
||||||
504
src/worker.ts
504
src/worker.ts
@@ -4,6 +4,12 @@ import type {
|
|||||||
} from "@paperclipai/plugin-sdk";
|
} from "@paperclipai/plugin-sdk";
|
||||||
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
||||||
import { LegalApi } from "./legal-api.js";
|
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.
|
// Hoisted so onWebhook can access the context after setup() completes.
|
||||||
let pluginCtx: PluginContext | null = null;
|
let pluginCtx: PluginContext | null = null;
|
||||||
@@ -16,16 +22,79 @@ const CEO_AGENT_IDS: Record<string, string> = {
|
|||||||
"cdbfa8bc-3d61-41a4-a2e7-677ec7d34562", // CMPA (היטלי השבחה)
|
"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({
|
const plugin = definePlugin({
|
||||||
async setup(ctx) {
|
async setup(ctx) {
|
||||||
pluginCtx = ctx; // save for onWebhook
|
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 ──────────────────────────────────────────────────────
|
// ── 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 ─────────────────────────────────────────────────────
|
// ── Events ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
ctx.events.on("issue.created", async (event) => {
|
ctx.events.on("issue.created", async (event) => {
|
||||||
@@ -535,6 +638,83 @@ const plugin = definePlugin({
|
|||||||
|
|
||||||
// Route user comments through CEO agent — per company
|
// 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) => {
|
ctx.events.on("issue.comment.created", async (event) => {
|
||||||
// Only intercept human comments — not agent comments (prevents loops)
|
// Only intercept human comments — not agent comments (prevents loops)
|
||||||
if (event.actorType !== "user") return;
|
if (event.actorType !== "user") return;
|
||||||
@@ -601,6 +781,9 @@ const plugin = definePlugin({
|
|||||||
"Comment reopened CEO-owned issue; native wake handles it, skipping plugin route",
|
"Comment reopened CEO-owned issue; native wake handles it, skipping plugin route",
|
||||||
{ issueId, ceoAgentId },
|
{ 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -617,40 +800,42 @@ const plugin = definePlugin({
|
|||||||
const matched = commentId
|
const matched = commentId
|
||||||
? comments.find((c) => c.id === commentId)
|
? comments.find((c) => c.id === commentId)
|
||||||
: undefined;
|
: undefined;
|
||||||
const latest = comments[comments.length - 1];
|
// 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 =
|
commentBody =
|
||||||
matched?.body ||
|
matched?.body ||
|
||||||
latest?.body ||
|
latest?.body ||
|
||||||
payload?.bodySnippet ||
|
payload?.bodySnippet ||
|
||||||
"(לא ניתן לקרוא את התגובה)";
|
"(לא ניתן לקרוא את התגובה)";
|
||||||
} catch {
|
} catch (err) {
|
||||||
commentBody = payload?.bodySnippet || "(שגיאה בקריאת התגובה)";
|
ctx.logger.warn("listComments failed, falling back to bodySnippet", {
|
||||||
|
issueId,
|
||||||
|
commentId,
|
||||||
|
error: String(err),
|
||||||
|
});
|
||||||
|
commentBody = payload?.bodySnippet ?? "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Route through the shared helper (also used by the sweep). It stamps
|
||||||
const { runId } = await ctx.agents.invoke(ceoAgentId, event.companyId, {
|
// the `last-routed-comment-id` marker on success so the reconciliation
|
||||||
prompt: [
|
// sweep does not re-deliver this comment.
|
||||||
`תגובה חדשה מחיים על issue "${issue.title}" (${issue.identifier || issueId}):`,
|
await routeCommentToCeo({
|
||||||
"",
|
issue,
|
||||||
commentBody,
|
companyId: event.companyId,
|
||||||
"",
|
ceoAgentId,
|
||||||
`קרא את ה-comments האחרונים על ה-issue, הבן מה חיים מבקש, והחלט מה לעשות.`,
|
commentBody: commentBody ?? "",
|
||||||
`אם ההוראה ברורה — נתב לסוכן המתאים. אם לא ברור — שאל את חיים.`,
|
commentId,
|
||||||
].join("\n"),
|
source: "event",
|
||||||
reason: `user_commented_on_${issue.identifier || issueId}`,
|
});
|
||||||
});
|
|
||||||
ctx.logger.info("Routed user comment to CEO agent", {
|
|
||||||
issueId,
|
|
||||||
commentId: commentId || null,
|
|
||||||
runId,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
ctx.logger.error("Failed to invoke CEO agent for comment routing", {
|
|
||||||
issueId,
|
|
||||||
error: String(err),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Jobs ───────────────────────────────────────────────────────
|
// ── Jobs ───────────────────────────────────────────────────────
|
||||||
@@ -662,77 +847,87 @@ const plugin = definePlugin({
|
|||||||
const cases = await api.listCases();
|
const cases = await api.listCases();
|
||||||
const companies = await ctx.companies.list();
|
const companies = await ctx.companies.list();
|
||||||
if (!companies.length) return;
|
if (!companies.length) return;
|
||||||
|
const statusModel = await api.getStatusModel();
|
||||||
|
|
||||||
const companyId = companies[0].id;
|
// מעבר אחד על ה-issues של כל חברה: state.get אחד ל-issue (לא לכל
|
||||||
const issues = await ctx.issues.list({ companyId });
|
// צירוף תיק×issue), וקיבוץ למפה case_number → מועמדים. הקיבוץ נדרש
|
||||||
|
// כדי לבחור "השורש היחיד". סורק את **כל** החברות (לא רק הראשונה) —
|
||||||
for (const legalCase of cases) {
|
// 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) {
|
for (const issue of issues) {
|
||||||
const linkedCase = await ctx.state.get({
|
const linkedCase = await ctx.state.get({
|
||||||
scopeKind: "issue",
|
scopeKind: "issue",
|
||||||
scopeId: issue.id,
|
scopeId: issue.id,
|
||||||
stateKey: "legal-case-number",
|
stateKey: "legal-case-number",
|
||||||
});
|
});
|
||||||
|
if (typeof linkedCase !== "string" || !linkedCase) continue;
|
||||||
if (linkedCase === legalCase.case_number) {
|
const list = byCase.get(linkedCase) ?? [];
|
||||||
// Map 13 legal-ai statuses to Paperclip issue status
|
list.push({
|
||||||
const statusMap: Record<string, "todo" | "in_progress" | "done"> =
|
id: issue.id,
|
||||||
{
|
status: issue.status,
|
||||||
new: "todo",
|
parentId: issue.parentId,
|
||||||
uploading: "todo",
|
companyId: company.id,
|
||||||
processing: "in_progress",
|
});
|
||||||
documents_ready: "in_progress",
|
byCase.set(linkedCase, list);
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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", {
|
ctx.logger.info("Case status sync completed", {
|
||||||
casesChecked: cases.length,
|
casesChecked: cases.length,
|
||||||
});
|
});
|
||||||
@@ -743,9 +938,8 @@ const plugin = definePlugin({
|
|||||||
|
|
||||||
ctx.jobs.register("stale-case-reminder", async (_job) => {
|
ctx.jobs.register("stale-case-reminder", async (_job) => {
|
||||||
ctx.logger.info("stale-case-reminder: starting");
|
ctx.logger.info("stale-case-reminder: starting");
|
||||||
const config = await ctx.config.get();
|
// Scheduled job — no company context; resolveApiBase probes known companies.
|
||||||
const apiBase =
|
const apiBase = await resolveApiBase(ctx);
|
||||||
(config.legalApiBaseUrl as string) ?? "http://localhost:8085";
|
|
||||||
|
|
||||||
let resp: Awaited<ReturnType<typeof ctx.http.fetch>>;
|
let resp: Awaited<ReturnType<typeof ctx.http.fetch>>;
|
||||||
try {
|
try {
|
||||||
@@ -817,9 +1011,8 @@ const plugin = definePlugin({
|
|||||||
|
|
||||||
ctx.jobs.register("weekly-feedback-analysis", async (_job) => {
|
ctx.jobs.register("weekly-feedback-analysis", async (_job) => {
|
||||||
ctx.logger.info("weekly-feedback-analysis: starting");
|
ctx.logger.info("weekly-feedback-analysis: starting");
|
||||||
const config = await ctx.config.get();
|
// Scheduled job — no company context; resolveApiBase probes known companies.
|
||||||
const apiBase =
|
const apiBase = await resolveApiBase(ctx);
|
||||||
(config.legalApiBaseUrl as string) ?? "http://localhost:8085";
|
|
||||||
|
|
||||||
const resp = await ctx.http.fetch(
|
const resp = await ctx.http.fetch(
|
||||||
`${apiBase}/api/chair-feedback/weekly-summary`,
|
`${apiBase}/api/chair-feedback/weekly-summary`,
|
||||||
@@ -876,6 +1069,107 @@ 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) ──────────────────────────────────
|
// ── Data handlers (UI bridge) ──────────────────────────────────
|
||||||
// These back `usePluginData(key, params)` calls from the React UI bundle
|
// These back `usePluginData(key, params)` calls from the React UI bundle
|
||||||
// in `dist/ui/index.js`. Errors are caught and rethrown with a clear
|
// in `dist/ui/index.js`. Errors are caught and rethrown with a clear
|
||||||
@@ -912,9 +1206,7 @@ const plugin = definePlugin({
|
|||||||
if (!issueId) return null;
|
if (!issueId) return null;
|
||||||
const caseNumber = await resolveCaseNumber(issueId);
|
const caseNumber = await resolveCaseNumber(issueId);
|
||||||
if (!caseNumber) return null;
|
if (!caseNumber) return null;
|
||||||
const config2 = await ctx.config.get();
|
const apiBase = await resolveApiBase(ctx, companyIdOf(params));
|
||||||
const apiBase =
|
|
||||||
(config2.legalApiBaseUrl as string) ?? "http://localhost:8085";
|
|
||||||
try {
|
try {
|
||||||
const resp = await ctx.http.fetch(
|
const resp = await ctx.http.fetch(
|
||||||
`${apiBase}/api/cases/${encodeURIComponent(caseNumber)}/legal-arguments`,
|
`${apiBase}/api/cases/${encodeURIComponent(caseNumber)}/legal-arguments`,
|
||||||
@@ -951,9 +1243,7 @@ const plugin = definePlugin({
|
|||||||
if (!issueId) return [];
|
if (!issueId) return [];
|
||||||
const caseNumber = await resolveCaseNumber(issueId);
|
const caseNumber = await resolveCaseNumber(issueId);
|
||||||
if (!caseNumber) return [];
|
if (!caseNumber) return [];
|
||||||
const config2 = await ctx.config.get();
|
const apiBase = await resolveApiBase(ctx, companyIdOf(params));
|
||||||
const apiBase =
|
|
||||||
(config2.legalApiBaseUrl as string) ?? "http://localhost:8085";
|
|
||||||
try {
|
try {
|
||||||
const resp = await ctx.http.fetch(
|
const resp = await ctx.http.fetch(
|
||||||
`${apiBase}/api/cases/${encodeURIComponent(caseNumber)}/precedents`,
|
`${apiBase}/api/cases/${encodeURIComponent(caseNumber)}/precedents`,
|
||||||
@@ -989,9 +1279,7 @@ const plugin = definePlugin({
|
|||||||
if (!issueId) return [];
|
if (!issueId) return [];
|
||||||
const caseNumber = await resolveCaseNumber(issueId);
|
const caseNumber = await resolveCaseNumber(issueId);
|
||||||
if (!caseNumber) return [];
|
if (!caseNumber) return [];
|
||||||
const config2 = await ctx.config.get();
|
const apiBase = await resolveApiBase(ctx, companyIdOf(params));
|
||||||
const apiBase =
|
|
||||||
(config2.legalApiBaseUrl as string) ?? "http://localhost:8085";
|
|
||||||
try {
|
try {
|
||||||
const url = new URL(`${apiBase}/api/missing-precedents`);
|
const url = new URL(`${apiBase}/api/missing-precedents`);
|
||||||
url.searchParams.set("case_number", caseNumber);
|
url.searchParams.set("case_number", caseNumber);
|
||||||
|
|||||||
@@ -12,5 +12,6 @@
|
|||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"]
|
"lib": ["ES2022", "DOM", "DOM.Iterable"]
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user