Merge pull request 'fix(sync): onWebhook statusLabels נגזרת מ-/api/status-model (legal-ai #616)' (#13) from fix/616-onwebhook-status-labels into main
This commit was merged in pull request #13.
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
labelFor,
|
||||
pickSyncTargetIssue,
|
||||
resolveIssueStatus,
|
||||
resolveStatusLabel,
|
||||
type SyncCandidate,
|
||||
} from "./sync-target.ts";
|
||||
|
||||
@@ -336,3 +337,53 @@ test("רגרסיה: companyId של המועמד-הנבחר שורד את pickSyn
|
||||
assert.equal(result.target?.id, "issue-b");
|
||||
assert.equal(result.target?.companyId, "company-b");
|
||||
});
|
||||
|
||||
test("resolveStatusLabel: סטטוס מוכר → תווית מה-SSOT, בלי fallback", () => {
|
||||
assert.deepEqual(resolveStatusLabel(STATUS_MODEL, "new"), {
|
||||
label: "חדש",
|
||||
fallback: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("resolveStatusLabel: סטטוס שאינו במודל → warning-worthy fallback + ערך גולמי, בלי זריקה", () => {
|
||||
assert.doesNotThrow(() =>
|
||||
resolveStatusLabel(STATUS_MODEL, "totally_unknown_status"),
|
||||
);
|
||||
assert.deepEqual(resolveStatusLabel(STATUS_MODEL, "totally_unknown_status"), {
|
||||
label: "totally_unknown_status",
|
||||
fallback: "unknown_status",
|
||||
});
|
||||
});
|
||||
|
||||
test("resolveStatusLabel: מודל לא-זמין (null) → model_unavailable, נבדל מסטטוס-לא-מוכר", () => {
|
||||
assert.deepEqual(resolveStatusLabel(null, "processing"), {
|
||||
label: "processing",
|
||||
fallback: "model_unavailable",
|
||||
});
|
||||
});
|
||||
|
||||
test("רגרסיה #616: כל הסטטוסים שהיו במפה הקשיחה ועודם חיים מקבלים תווית מה-SSOT", () => {
|
||||
const survivingKeys = [
|
||||
"new",
|
||||
"processing",
|
||||
"documents_ready",
|
||||
"outcome_set",
|
||||
"direction_approved",
|
||||
"qa_review",
|
||||
"drafted",
|
||||
"exported",
|
||||
"reviewed",
|
||||
"final",
|
||||
].filter((key) => STATUS_MODEL.some((s) => s.key === key));
|
||||
|
||||
for (const key of survivingKeys) {
|
||||
const result = resolveStatusLabel(STATUS_MODEL, key);
|
||||
assert.equal(result.fallback, null, `${key}: expected no fallback`);
|
||||
assert.equal(
|
||||
typeof result.label,
|
||||
"string",
|
||||
`${key}: expected string label`,
|
||||
);
|
||||
assert.notEqual(result.label, "", `${key}: expected non-empty label`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -115,3 +115,36 @@ export function labelFor(
|
||||
): string | null {
|
||||
return statuses.find((s) => s.key === caseStatus)?.label ?? null;
|
||||
}
|
||||
|
||||
/** למה נפלנו לערך-הגולמי, או `null` כשהסטטוס מוכר. הקורא **חייב** לדווח על ערך לא-null. */
|
||||
export type StatusLabelFallback = "unknown_status" | "model_unavailable" | null;
|
||||
|
||||
export interface StatusLabelResolution {
|
||||
label: string;
|
||||
fallback: StatusLabelFallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* התווית להצגה עבור `caseStatus`, יחד עם **סיבת** נפילה-לגולמי אם הייתה.
|
||||
*
|
||||
* ההחלטה מופרדת כאן מהדיווח (`ctx.logger`) בכוונה: `worker.ts` אינו ניתן
|
||||
* ל-import בטסט (הוא מריץ `runWorker(...)` ברמת-המודול), ולכן ההיגיון שחייב
|
||||
* כיסוי-טסט חי במודול הטהור הזה — בדיוק כמו `pickSyncTargetIssue` (#446)
|
||||
* ו-`resolveIssueStatus` (#604). legal-ai issue #616.
|
||||
*
|
||||
* `statuses === null` = מודל-הסטטוסים לא נטען כלל (כשל-רשת) — נבדל מסטטוס
|
||||
* שנטען ולא נמצא, כי הן שתי תקלות שונות עם אבחון שונה.
|
||||
*/
|
||||
export function resolveStatusLabel(
|
||||
statuses: readonly StatusModelEntry[] | null,
|
||||
caseStatus: string,
|
||||
): StatusLabelResolution {
|
||||
if (statuses === null) {
|
||||
return { label: caseStatus, fallback: "model_unavailable" };
|
||||
}
|
||||
const label = labelFor(statuses, caseStatus);
|
||||
if (label === null) {
|
||||
return { label: caseStatus, fallback: "unknown_status" };
|
||||
}
|
||||
return { label, fallback: null };
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
resolveApiBaseFrom,
|
||||
runJobHandler,
|
||||
} from "./company-scope.js";
|
||||
import type { StatusModelEntry } from "./legal-api.js";
|
||||
import { LegalApi } from "./legal-api.js";
|
||||
import {
|
||||
formatSyncRunSummary,
|
||||
@@ -23,12 +24,18 @@ import {
|
||||
labelFor,
|
||||
pickSyncTargetIssue,
|
||||
resolveIssueStatus,
|
||||
resolveStatusLabel,
|
||||
type SyncCandidate,
|
||||
} from "./sync-target.js";
|
||||
|
||||
// Hoisted so onWebhook can access the context after setup() completes.
|
||||
let pluginCtx: PluginContext | null = null;
|
||||
|
||||
// Hoisted for the same reason as pluginCtx: onWebhook is a sibling of setup()
|
||||
// and has no closure over the LegalApi instance created there. Same instance,
|
||||
// not a second one — see legal-ai issue #616.
|
||||
let legalApi: LegalApi | null = null;
|
||||
|
||||
// Per-company CEO agent IDs (shared between setup and onWebhook).
|
||||
const CEO_AGENT_IDS: Record<string, string> = {
|
||||
"42a7acd0-30c5-4cbd-ac97-7424f65df294":
|
||||
@@ -103,6 +110,7 @@ const plugin = definePlugin({
|
||||
// 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));
|
||||
legalApi = api;
|
||||
|
||||
ctx.logger.info("Legal AI plugin starting");
|
||||
|
||||
@@ -113,7 +121,7 @@ const plugin = definePlugin({
|
||||
{
|
||||
displayName: "רשימת תיקי ערר",
|
||||
description:
|
||||
"List all appeal cases in the legal system. Returns case number, title, and status (new/in_progress/drafted/reviewed/final).",
|
||||
"List all appeal cases in the legal system. Returns case number, title, and status (canonical case-status keys — see GET /api/status-model).",
|
||||
parametersSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
@@ -254,10 +262,16 @@ const plugin = definePlugin({
|
||||
type: "string",
|
||||
description: "Case number (e.g. 123/24)",
|
||||
},
|
||||
// ללא enum בכוונה: הרישום קורה ב-setup(), שאין לו הקשר-חברה
|
||||
// ולכן אינו יכול לשלוף את /api/status-model (ראה resolveApiBase
|
||||
// למעלה) — והעתק סטטי היה נסחף שוב. המניפסט (manifest.ts) כבר
|
||||
// מגדיר את השדה כמחרוזת חופשית, והוולידציה האמיתית היא
|
||||
// server-side (case_status_model.py + השומר הקדימה-בלבד
|
||||
// ב-tools/cases.py). legal-ai issue #616.
|
||||
status: {
|
||||
type: "string",
|
||||
enum: ["new", "in_progress", "drafted", "reviewed", "final"],
|
||||
description: "New case status",
|
||||
description:
|
||||
"New case status — a canonical key from GET /api/status-model",
|
||||
},
|
||||
title: { type: "string" },
|
||||
subject: { type: "string" },
|
||||
@@ -1488,7 +1502,7 @@ const plugin = definePlugin({
|
||||
}
|
||||
|
||||
try {
|
||||
await handleCaseStatusWebhook(pluginCtx, input);
|
||||
await handleCaseStatusWebhook(pluginCtx, legalApi, input);
|
||||
} catch (err) {
|
||||
// מחיקת סמן-האידמפוטנטיות **לפני** שהשגיאה יוצאת — אחרת מסירה
|
||||
// חוזרת של אותו webhook תוך 5 דק' תדולג בשקט כ"כבר נשלח", למרות
|
||||
@@ -1520,6 +1534,7 @@ const plugin = definePlugin({
|
||||
*/
|
||||
async function handleCaseStatusWebhook(
|
||||
pluginCtx: PluginContext,
|
||||
legalApi: LegalApi | null,
|
||||
input: PluginWebhookInput,
|
||||
): Promise<void> {
|
||||
const { endpointKey, parsedBody } = input;
|
||||
@@ -1703,30 +1718,48 @@ async function handleCaseStatusWebhook(
|
||||
return;
|
||||
}
|
||||
|
||||
// Status label map (Hebrew)
|
||||
const statusLabels: Record<string, string> = {
|
||||
new: "📂 תיק חדש",
|
||||
uploading: "📤 העלאת מסמכים",
|
||||
processing: "⚙️ עיבוד מסמכים",
|
||||
documents_ready: "📁 מסמכים מוכנים",
|
||||
outcome_set: "🎯 תוצאה הוזנה",
|
||||
brainstorming: "💡 סיעור מוחות",
|
||||
direction_approved: "✅ כיוון אושר",
|
||||
drafting: "✍️ כתיבה בתהליך",
|
||||
qa_review: "🔍 בדיקת איכות",
|
||||
in_progress: "🔄 בעבודה",
|
||||
drafted: "✍️ טיוטה מוכנה",
|
||||
qa_failed: "❌ QA נכשל",
|
||||
exported: "📄 יוצא ל-DOCX",
|
||||
reviewed: "✅ נבדק",
|
||||
final: "🎯 סופי",
|
||||
};
|
||||
const label = statusLabels[newStatus] ?? newStatus;
|
||||
// התוויות נגזרות מ-`GET /api/status-model` — אותו SSOT שהג'וב
|
||||
// `sync-case-status` כבר צורך (#604). המפה הקשיחה שהייתה כאן כבר סטתה:
|
||||
// חסרו בה מפתחות חדשים מהמודל, והיו בה מפתחות מתים שכבר לא קיימים בו.
|
||||
// legal-ai issue #616.
|
||||
let statuses: StatusModelEntry[] | null = null;
|
||||
if (!legalApi) {
|
||||
// לא אמור לקרות (מושם ב-setup, כמו pluginCtx) — אבל אם קרה, זו נפילה
|
||||
// לערך-גולמי שחייבת עקבה, בדיוק כמו כשל-הרשת שמתחתיה.
|
||||
pluginCtx.logger.error(
|
||||
"onWebhook: LegalApi unavailable — labels fall back to raw keys",
|
||||
{ caseNumber, newStatus },
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
statuses = (await legalApi.getStatusModel()).statuses;
|
||||
} catch (err) {
|
||||
pluginCtx.logger.error(
|
||||
"onWebhook: status-model fetch failed — labels fall back to raw keys",
|
||||
{ caseNumber, newStatus, error: String(err) },
|
||||
);
|
||||
}
|
||||
}
|
||||
const resolved = resolveStatusLabel(statuses, newStatus);
|
||||
if (resolved.fallback === "unknown_status") {
|
||||
// לא בליעה שקטה: הסטטוס נטען ופשוט אינו במודל הקנוני.
|
||||
pluginCtx.logger.warn(
|
||||
"onWebhook: status not in canonical status-model — using raw key as label",
|
||||
{ caseNumber, oldStatus, newStatus },
|
||||
);
|
||||
}
|
||||
// `model_unavailable` כבר דווח כ-error למעלה — אין הכפלת-דיווח.
|
||||
const label = resolved.label;
|
||||
// `oldStatus` מתורגם אף הוא, אך **בלי** לוג: הוא ריק לגיטימית בתיק-חדש
|
||||
// ועשוי להיות מפתח היסטורי שכבר אינו במודל — warn עליו היה רעש כרוני.
|
||||
const oldLabel = oldStatus
|
||||
? resolveStatusLabel(statuses, oldStatus).label
|
||||
: oldStatus;
|
||||
|
||||
// Post a Hebrew status comment on the linked issue
|
||||
await pluginCtx.issues.createComment(
|
||||
linkedIssueId,
|
||||
`**עדכון סטטוס תיק ${caseNumber}:** ${label} (היה: ${oldStatus})`,
|
||||
`**עדכון סטטוס תיק ${caseNumber}:** ${label} (היה: ${oldLabel})`,
|
||||
companyId,
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user