diff --git a/src/legal-api.ts b/src/legal-api.ts index 161df03..24d6e38 100644 --- a/src/legal-api.ts +++ b/src/legal-api.ts @@ -36,6 +36,10 @@ export class LegalApi { return this.request("/api/cases"); } + async getStatusModel(): Promise { + return this.request("/api/status-model"); + } + async getCase(caseNumber: string): Promise { return this.request(`/api/cases/${encodeURIComponent(caseNumber)}/details`); } @@ -184,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; diff --git a/src/sync-target.test.ts b/src/sync-target.test.ts index ed3f2bf..4d4fd44 100644 --- a/src/sync-target.test.ts +++ b/src/sync-target.test.ts @@ -5,7 +5,13 @@ 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"; +import type { StatusModelEntry } from "./legal-api.ts"; +import { + labelFor, + pickSyncTargetIssue, + resolveIssueStatus, + type SyncCandidate, +} from "./sync-target.ts"; /** * הכלל הישן (worker.ts:894 לפני #446): כל issue מקושר שסטטוסו שונה מהיעד — @@ -25,17 +31,27 @@ test("רגרסיית 8125-09-24 / CMPA-140: 11 ה-issues הממשיים של ה // רשומת 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" }, + { 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). @@ -53,7 +69,7 @@ test("רגרסיית 8125-09-24 / CMPA-140: 11 ה-issues הממשיים של ה test("sub-task יחיד ב-done ומקושר: sub-task לעולם לא נבחר", () => { const candidates: SyncCandidate[] = [ - { id: "sub-1", status: "done", parentId: "some-root" }, + { id: "sub-1", status: "done", parentId: "some-root", companyId: "c1" }, ]; const result = pickSyncTargetIssue(candidates); @@ -64,8 +80,8 @@ test("sub-task יחיד ב-done ומקושר: sub-task לעולם לא נבחר" 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" }, + { id: "root-1", status: "cancelled", parentId: null, companyId: "c1" }, + { id: "sub-1", status: "in_progress", parentId: "root-1", companyId: "c1" }, ]; const result = pickSyncTargetIssue(candidates); @@ -76,7 +92,7 @@ test("sub-task יחיד פתוח (in_progress) + שורש cancelled: פתוח א test("שורש יחיד ב-todo: נבחר", () => { const candidates: SyncCandidate[] = [ - { id: "root-1", status: "todo", parentId: null }, + { id: "root-1", status: "todo", parentId: null, companyId: "c1" }, ]; const result = pickSyncTargetIssue(candidates); @@ -88,7 +104,7 @@ test("שורש יחיד ב-todo: נבחר", () => { test("שורש יחיד ב-in_review: לא נכתב (הגנת auto-block)", () => { const candidates: SyncCandidate[] = [ - { id: "root-1", status: "in_review", parentId: null }, + { id: "root-1", status: "in_review", parentId: null, companyId: "c1" }, ]; const result = pickSyncTargetIssue(candidates); @@ -99,7 +115,7 @@ test("שורש יחיד ב-in_review: לא נכתב (הגנת auto-block)", () = test("שורש יחיד ב-blocked: לא נכתב", () => { const candidates: SyncCandidate[] = [ - { id: "root-1", status: "blocked", parentId: null }, + { id: "root-1", status: "blocked", parentId: null, companyId: "c1" }, ]; const result = pickSyncTargetIssue(candidates); @@ -110,8 +126,8 @@ test("שורש יחיד ב-blocked: לא נכתב", () => { test("שני שורשים ברי-כתיבה: ambiguous, לא נכתב", () => { const candidates: SyncCandidate[] = [ - { id: "root-1", status: "todo", parentId: null }, - { id: "root-2", status: "in_progress", parentId: null }, + { id: "root-1", status: "todo", parentId: null, companyId: "c1" }, + { id: "root-2", status: "in_progress", parentId: null, companyId: "c1" }, ]; const result = pickSyncTargetIssue(candidates); @@ -123,8 +139,13 @@ test("שני שורשים ברי-כתיבה: ambiguous, לא נכתב", () => { test("שורש todo + ילד in_progress: השורש נבחר", () => { const candidates: SyncCandidate[] = [ - { id: "root-1", status: "todo", parentId: null }, - { id: "child-1", status: "in_progress", parentId: "root-1" }, + { id: "root-1", status: "todo", parentId: null, companyId: "c1" }, + { + id: "child-1", + status: "in_progress", + parentId: "root-1", + companyId: "c1", + }, ]; const result = pickSyncTargetIssue(candidates); @@ -132,3 +153,174 @@ test("שורש todo + ילד in_progress: השורש נבחר", () => { 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"); +}); diff --git a/src/sync-target.ts b/src/sync-target.ts index ccea907..b54343c 100644 --- a/src/sync-target.ts +++ b/src/sync-target.ts @@ -7,9 +7,12 @@ * * המודול הזה טהור בכוונה — בלי import מה-SDK ובלי side effects — כדי שאפשר * יהיה לייבא אותו ישירות בטסט בלי להריץ את `runWorker(plugin, import.meta.url)` - * שקורה בזמן import של worker.ts. + * שקורה בזמן 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 = new Set([ "done", @@ -33,6 +36,7 @@ export interface SyncCandidate { id: string; status: string; parentId: string | null; + companyId: string; } export type SyncTargetReason = @@ -77,3 +81,32 @@ export function pickSyncTargetIssue( 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; +} diff --git a/src/worker.ts b/src/worker.ts index f52f7d3..da2553e 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -4,7 +4,12 @@ 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"; +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; @@ -19,43 +24,6 @@ const CEO_AGENT_IDS: Record = { 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 = { - 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. * @@ -879,32 +847,48 @@ 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 }); - - // מעבר אחד על ה-issues: state.get אחד ל-issue (לא לכל צירוף תיק×issue), - // וקיבוץ למפה case_number → מועמדים. הקיבוץ נדרש כדי לבחור "השורש היחיד". + // מעבר אחד על ה-issues של כל חברה: state.get אחד ל-issue (לא לכל + // צירוף תיק×issue), וקיבוץ למפה case_number → מועמדים. הקיבוץ נדרש + // כדי לבחור "השורש היחיד". סורק את **כל** החברות (לא רק הראשונה) — + // legal-ai issue #604. const byCase = new Map(); - 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); + 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 (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 = CASE_STATUS_TO_ISSUE_STATUS[legalCase.status]; - if (!targetStatus) continue; + 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 } = @@ -924,14 +908,19 @@ const plugin = definePlugin({ if (target.status === targetStatus) continue; const label = - CASE_STATUS_LABELS[legalCase.status] || legalCase.status; + labelFor(statusModel.statuses, legalCase.status) ?? + legalCase.status; await ctx.issues.update( target.id, { status: targetStatus }, - companyId, + target.companyId, + ); + await ctx.issues.createComment( + target.id, + `📋 ${label}`, + target.companyId, ); - await ctx.issues.createComment(target.id, `📋 ${label}`, companyId); ctx.logger.info("Synced issue status", { issueId: target.id, caseNumber: legalCase.case_number,