fix(sync): מיפוי-הסטטוסים נגזר מ-/api/status-model, וסריקה בכל החברות (legal-ai #604)

שלושה כשלים בג'וב sync-case-status, שהשביתו אותו בשקט מאז 22.7:

1. המפה הקשיחה CASE_STATUS_TO_ISSUE_STATUS נותקה מ-case_status_model.py
   (מקור-האמת בצד legal-ai): חסרו analyst_verified ו-research_complete
   שנוספו ב-851a4fb, ונשארו בה 3 מפתחות מתים (uploading/brainstorming/drafting).
   סטטוס חסר נפל ל-`continue` — בלי לוג ובלי שגיאה. כעת המיפוי נגזר מ-
   GET /api/status-model דרך resolveIssueStatus/labelFor: terminal→done,
   הסטטוס הראשון בסדר→todo, השאר→in_progress. סטטוס חדש בצד legal-ai לא
   דורש עוד עריכה כאן (G2 — מקור-אמת אחד).
2. companies[0] בלבד — מתוך שתי החברות, אחת מעולם לא נסרקה (נמדד: 135 מול
   67 issues מקושרי-תיק). כעת נסרקות כל החברות, וה-companyId של המועמד
   שנבחר הוא זה שנכתב אליו — לא החברה הראשונה גורפת.
3. דילוג שקט על סטטוס לא-מוכר → ctx.logger.warn עם שם-הסטטוס ומספר-התיק.

אימות מול השרת החי: 0 סטיות מול המפה הישנה על כל 10 הסטטוסים שכיסתה,
+2 שכוסו לראשונה. סימולציה על הנתונים החיים: דילוג-שקט 3→0, שורות-לוג 3→6,
כתיבות בפועל 0→0 — כלומר אין נסיגה ל-flip-flop של #446.

לוגיקת בחירת-היעד (#446) לא שונתה: pickSyncTargetIssue, isWritableStatus,
CLOSED_ISSUE_STATUSES ו-NON_WRITABLE_STATUSES זהות; ל-SyncCandidate נוסף שדה
companyId בלבד (passthrough). 8 הטסטים הקיימים עוברים ללא שינוי באסרשנים,
+7 חדשים.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 01:32:24 +03:00
parent 602b42ca5e
commit 0a134d0134
4 changed files with 318 additions and 85 deletions

View File

@@ -36,6 +36,10 @@ export class LegalApi {
return this.request("/api/cases");
}
async getStatusModel(): Promise<StatusModel> {
return this.request("/api/status-model");
}
async getCase(caseNumber: string): Promise<CaseDetails> {
return this.request(`/api/cases/${encodeURIComponent(caseNumber)}/details`);
}
@@ -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;

View File

@@ -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");
});

View File

@@ -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<string> = 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;
}

View File

@@ -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<string, string> = {
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.
*
@@ -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<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);
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,