- Hoist issues.list() loop outside stale-cases loop — reduces RPCs from O(cases × companies × issues) to O(companies × issues) - Add TODO comment for requestId-based idempotency guard in onWebhook Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
910 lines
27 KiB
TypeScript
910 lines
27 KiB
TypeScript
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
||
import type { PluginContext, PluginWebhookInput } from "@paperclipai/plugin-sdk";
|
||
import { LegalApi } from "./legal-api.js";
|
||
|
||
// Hoisted so onWebhook can access the context after setup() completes.
|
||
let pluginCtx: PluginContext | null = null;
|
||
|
||
// Per-company CEO agent IDs (shared between setup and onWebhook).
|
||
const CEO_AGENT_IDS: Record<string, string> = {
|
||
"42a7acd0-30c5-4cbd-ac97-7424f65df294": "752cebdd-6748-4a04-aacd-c7ab0294ef33", // CMP (רישוי ובניה)
|
||
"8639e837-4c9d-47fa-a76b-95788d651896": "cdbfa8bc-3d61-41a4-a2e7-677ec7d34562", // CMPA (היטלי השבחה)
|
||
};
|
||
|
||
const plugin = definePlugin({
|
||
async setup(ctx) {
|
||
pluginCtx = ctx; // save for onWebhook
|
||
const config = (await ctx.config.get()) as {
|
||
legalApiBaseUrl?: string;
|
||
} | null;
|
||
const baseUrl = config?.legalApiBaseUrl || "http://localhost:8085";
|
||
const api = new LegalApi(baseUrl);
|
||
|
||
ctx.logger.info("Legal AI plugin starting", { url: baseUrl });
|
||
|
||
// ── Tools ──────────────────────────────────────────────────────
|
||
|
||
ctx.tools.register(
|
||
"legal_case_list",
|
||
{
|
||
displayName: "רשימת תיקי ערר",
|
||
description:
|
||
"List all appeal cases in the legal system. Returns case number, title, and status (new/in_progress/drafted/reviewed/final).",
|
||
parametersSchema: {
|
||
type: "object",
|
||
properties: {},
|
||
},
|
||
},
|
||
async () => {
|
||
const cases = await api.listCases();
|
||
return {
|
||
content: JSON.stringify(cases, null, 2),
|
||
data: cases,
|
||
};
|
||
},
|
||
);
|
||
|
||
ctx.tools.register(
|
||
"legal_case_get",
|
||
{
|
||
displayName: "פרטי תיק ערר",
|
||
description:
|
||
"Get full details of a legal case including documents list. Provide the case number (e.g. 123/24).",
|
||
parametersSchema: {
|
||
type: "object",
|
||
properties: {
|
||
case_number: {
|
||
type: "string",
|
||
description: "Case number (e.g. 123/24)",
|
||
},
|
||
},
|
||
required: ["case_number"],
|
||
},
|
||
},
|
||
async (params) => {
|
||
const { case_number } = params as { case_number: string };
|
||
const result = await api.getCase(case_number);
|
||
return {
|
||
content: JSON.stringify(result, null, 2),
|
||
data: result,
|
||
};
|
||
},
|
||
);
|
||
|
||
ctx.tools.register(
|
||
"legal_case_create",
|
||
{
|
||
displayName: "יצירת תיק ערר",
|
||
description:
|
||
"Create a new appeal case. Case numbers: 1xxx=licensing, 8xxx=betterment levy, 9xxx=compensation. Also creates a linked Paperclip issue.",
|
||
parametersSchema: {
|
||
type: "object",
|
||
properties: {
|
||
case_number: {
|
||
type: "string",
|
||
description: "Case number (e.g. 1234/24)",
|
||
},
|
||
title: { type: "string", description: "Case title" },
|
||
appellants: {
|
||
type: "array",
|
||
items: { type: "string" },
|
||
description: "Appellant names",
|
||
},
|
||
respondents: {
|
||
type: "array",
|
||
items: { type: "string" },
|
||
description: "Respondent names",
|
||
},
|
||
subject: { type: "string", description: "Case subject" },
|
||
property_address: {
|
||
type: "string",
|
||
description: "Property address",
|
||
},
|
||
expected_outcome: {
|
||
type: "string",
|
||
enum: [
|
||
"rejection",
|
||
"partial_acceptance",
|
||
"full_acceptance",
|
||
"betterment_levy",
|
||
],
|
||
description: "Expected outcome type",
|
||
},
|
||
},
|
||
required: ["case_number", "title"],
|
||
},
|
||
},
|
||
async (params, runCtx) => {
|
||
const input = params as {
|
||
case_number: string;
|
||
title: string;
|
||
appellants?: string[];
|
||
respondents?: string[];
|
||
subject?: string;
|
||
property_address?: string;
|
||
expected_outcome?: string;
|
||
};
|
||
|
||
// Create case in legal-ai
|
||
const legalCase = await api.createCase(input);
|
||
|
||
// Create linked Paperclip issue
|
||
const issue = await ctx.issues.create({
|
||
companyId: runCtx.companyId,
|
||
title: `[ערר ${input.case_number}] ${input.title}`,
|
||
description: `תיק ערר חדש\nנושא: ${input.subject || ""}\nתוצאה צפויה: ${input.expected_outcome || "לא הוגדרה"}`,
|
||
});
|
||
|
||
// Store mapping in plugin state
|
||
await ctx.state.set(
|
||
{
|
||
scopeKind: "issue",
|
||
scopeId: issue.id,
|
||
stateKey: "legal-case-number",
|
||
},
|
||
input.case_number,
|
||
);
|
||
|
||
await ctx.activity.log({
|
||
companyId: runCtx.companyId,
|
||
message: `נוצר תיק ערר ${input.case_number} וקושר ל-issue ${issue.id}`,
|
||
});
|
||
|
||
return {
|
||
content: `Case ${input.case_number} created and linked to Paperclip issue.\n\n${JSON.stringify(legalCase, null, 2)}`,
|
||
data: { legalCase, issueId: issue.id },
|
||
};
|
||
},
|
||
);
|
||
|
||
ctx.tools.register(
|
||
"legal_case_update",
|
||
{
|
||
displayName: "עדכון תיק ערר",
|
||
description:
|
||
"Update a legal case's status, title, subject, or expected outcome.",
|
||
parametersSchema: {
|
||
type: "object",
|
||
properties: {
|
||
case_number: {
|
||
type: "string",
|
||
description: "Case number (e.g. 123/24)",
|
||
},
|
||
status: {
|
||
type: "string",
|
||
enum: ["new", "in_progress", "drafted", "reviewed", "final"],
|
||
description: "New case status",
|
||
},
|
||
title: { type: "string" },
|
||
subject: { type: "string" },
|
||
notes: { type: "string" },
|
||
expected_outcome: {
|
||
type: "string",
|
||
enum: [
|
||
"rejection",
|
||
"partial_acceptance",
|
||
"full_acceptance",
|
||
"betterment_levy",
|
||
],
|
||
},
|
||
},
|
||
required: ["case_number"],
|
||
},
|
||
},
|
||
async (params) => {
|
||
const { case_number, ...updates } = params as {
|
||
case_number: string;
|
||
status?: string;
|
||
title?: string;
|
||
subject?: string;
|
||
notes?: string;
|
||
expected_outcome?: string;
|
||
};
|
||
const result = await api.updateCase(case_number, updates);
|
||
return {
|
||
content: JSON.stringify(result, null, 2),
|
||
data: result,
|
||
};
|
||
},
|
||
);
|
||
|
||
ctx.tools.register(
|
||
"legal_case_status",
|
||
{
|
||
displayName: "סטטוס תהליך עבודה",
|
||
description:
|
||
"Get full workflow status for a case: documents processed, chunks created, draft progress, and suggested next steps.",
|
||
parametersSchema: {
|
||
type: "object",
|
||
properties: {
|
||
case_number: {
|
||
type: "string",
|
||
description: "Case number (e.g. 123/24)",
|
||
},
|
||
},
|
||
required: ["case_number"],
|
||
},
|
||
},
|
||
async (params) => {
|
||
const { case_number } = params as { case_number: string };
|
||
const result = await api.getCaseStatus(case_number);
|
||
return {
|
||
content: JSON.stringify(result, null, 2),
|
||
data: result,
|
||
};
|
||
},
|
||
);
|
||
|
||
ctx.tools.register(
|
||
"legal_search",
|
||
{
|
||
displayName: "חיפוש תקדימים משפטיים",
|
||
description:
|
||
"Semantic search (RAG) across previous decisions and documents. Query in Hebrew for best results.",
|
||
parametersSchema: {
|
||
type: "object",
|
||
properties: {
|
||
query: {
|
||
type: "string",
|
||
description: "Search query in Hebrew",
|
||
},
|
||
limit: { type: "number", description: "Max results (default 10)" },
|
||
section_type: {
|
||
type: "string",
|
||
description:
|
||
"Filter by section type: facts, legal_analysis, conclusion, ruling",
|
||
},
|
||
},
|
||
required: ["query"],
|
||
},
|
||
},
|
||
async (params) => {
|
||
const { query, limit, section_type } = params as {
|
||
query: string;
|
||
limit?: number;
|
||
section_type?: string;
|
||
};
|
||
const results = await api.search(
|
||
query,
|
||
limit || 10,
|
||
section_type || "",
|
||
);
|
||
return {
|
||
content: JSON.stringify(results, null, 2),
|
||
data: results,
|
||
};
|
||
},
|
||
);
|
||
|
||
ctx.tools.register(
|
||
"legal_case_template",
|
||
{
|
||
displayName: "תבנית החלטה",
|
||
description:
|
||
"Get an outcome-aware decision template for a case, with guidance for the 12-block structure.",
|
||
parametersSchema: {
|
||
type: "object",
|
||
properties: {
|
||
case_number: {
|
||
type: "string",
|
||
description: "Case number (e.g. 123/24)",
|
||
},
|
||
},
|
||
required: ["case_number"],
|
||
},
|
||
},
|
||
async (params) => {
|
||
const { case_number } = params as { case_number: string };
|
||
const result = await api.getTemplate(case_number);
|
||
return {
|
||
content: result.template,
|
||
data: result,
|
||
};
|
||
},
|
||
);
|
||
|
||
ctx.tools.register(
|
||
"legal_processing_status",
|
||
{
|
||
displayName: "סטטוס עיבוד כללי",
|
||
description:
|
||
"Get overall processing status: total cases, documents, pending processing, chunks, and style corpus entries.",
|
||
parametersSchema: {
|
||
type: "object",
|
||
properties: {},
|
||
},
|
||
},
|
||
async () => {
|
||
const result = await api.getProcessingStatus();
|
||
return {
|
||
content: JSON.stringify(result, null, 2),
|
||
data: result,
|
||
};
|
||
},
|
||
);
|
||
|
||
// ── New Tools (Phase 3) ─────────────────────────────────────────
|
||
|
||
ctx.tools.register(
|
||
"legal_document_list",
|
||
{
|
||
displayName: "רשימת מסמכים בתיק",
|
||
description:
|
||
"List all documents in a case with their extraction status.",
|
||
parametersSchema: {
|
||
type: "object",
|
||
properties: {
|
||
case_number: { type: "string", description: "Case number" },
|
||
},
|
||
required: ["case_number"],
|
||
},
|
||
},
|
||
async (params) => {
|
||
const { case_number } = params as { case_number: string };
|
||
const docs = await api.listDocuments(case_number);
|
||
return { content: JSON.stringify(docs, null, 2), data: docs };
|
||
},
|
||
);
|
||
|
||
ctx.tools.register(
|
||
"legal_set_outcome",
|
||
{
|
||
displayName: "הזנת תוצאת ערר",
|
||
description:
|
||
"Set the decision outcome (rejection/full_acceptance/partial_acceptance) and optional reasoning from Dafna.",
|
||
parametersSchema: {
|
||
type: "object",
|
||
properties: {
|
||
case_number: { type: "string", description: "Case number" },
|
||
outcome: {
|
||
type: "string",
|
||
enum: ["rejection", "full_acceptance", "partial_acceptance"],
|
||
description: "Decision outcome",
|
||
},
|
||
reasoning: {
|
||
type: "string",
|
||
description: "Optional reasoning from Dafna",
|
||
},
|
||
},
|
||
required: ["case_number", "outcome"],
|
||
},
|
||
},
|
||
async (params) => {
|
||
const { case_number, outcome, reasoning } = params as {
|
||
case_number: string;
|
||
outcome: string;
|
||
reasoning?: string;
|
||
};
|
||
const result = await api.setOutcome(case_number, outcome, reasoning);
|
||
return { content: JSON.stringify(result, null, 2), data: result };
|
||
},
|
||
);
|
||
|
||
ctx.tools.register(
|
||
"legal_get_claims",
|
||
{
|
||
displayName: "טענות מחולצות",
|
||
description: "Get extracted claims for a case, grouped by party role.",
|
||
parametersSchema: {
|
||
type: "object",
|
||
properties: {
|
||
case_number: { type: "string", description: "Case number" },
|
||
},
|
||
required: ["case_number"],
|
||
},
|
||
},
|
||
async (params) => {
|
||
const { case_number } = params as { case_number: string };
|
||
const result = await api.getClaims(case_number);
|
||
return { content: JSON.stringify(result, null, 2), data: result };
|
||
},
|
||
);
|
||
|
||
ctx.tools.register(
|
||
"legal_search_case",
|
||
{
|
||
displayName: "חיפוש בתוך תיק",
|
||
description: "Semantic search within a specific case's documents.",
|
||
parametersSchema: {
|
||
type: "object",
|
||
properties: {
|
||
case_number: { type: "string", description: "Case number" },
|
||
query: { type: "string", description: "Search query in Hebrew" },
|
||
},
|
||
required: ["case_number", "query"],
|
||
},
|
||
},
|
||
async (params) => {
|
||
const { case_number, query } = params as {
|
||
case_number: string;
|
||
query: string;
|
||
};
|
||
const results = await api.searchCase(case_number, query);
|
||
return { content: JSON.stringify(results, null, 2), data: results };
|
||
},
|
||
);
|
||
|
||
ctx.tools.register(
|
||
"legal_find_similar",
|
||
{
|
||
displayName: "תקדימים דומים",
|
||
description: "Find similar cases/precedents for a given case.",
|
||
parametersSchema: {
|
||
type: "object",
|
||
properties: {
|
||
case_number: { type: "string", description: "Case number" },
|
||
},
|
||
required: ["case_number"],
|
||
},
|
||
},
|
||
async (params) => {
|
||
const { case_number } = params as { case_number: string };
|
||
const results = await api.findSimilarCases(case_number);
|
||
return { content: JSON.stringify(results, null, 2), data: results };
|
||
},
|
||
);
|
||
|
||
ctx.tools.register(
|
||
"legal_run_qa",
|
||
{
|
||
displayName: "בדיקת איכות",
|
||
description:
|
||
"Run QA validation on a drafted decision. Checks: grounding, claims coverage, neutral background, weights.",
|
||
parametersSchema: {
|
||
type: "object",
|
||
properties: {
|
||
case_number: { type: "string", description: "Case number" },
|
||
},
|
||
required: ["case_number"],
|
||
},
|
||
},
|
||
async (params) => {
|
||
const { case_number } = params as { case_number: string };
|
||
const result = await api.runQA(case_number);
|
||
return { content: JSON.stringify(result, null, 2), data: result };
|
||
},
|
||
);
|
||
|
||
ctx.tools.register(
|
||
"legal_trigger_learning",
|
||
{
|
||
displayName: "הפעלת לולאת למידה",
|
||
description:
|
||
"Trigger the learning loop — compare draft to final signed version.",
|
||
parametersSchema: {
|
||
type: "object",
|
||
properties: {
|
||
case_number: { type: "string", description: "Case number" },
|
||
},
|
||
required: ["case_number"],
|
||
},
|
||
},
|
||
async (params) => {
|
||
const { case_number } = params as { case_number: string };
|
||
const result = await api.triggerLearning(case_number);
|
||
return { content: JSON.stringify(result, null, 2), data: result };
|
||
},
|
||
);
|
||
|
||
ctx.tools.register(
|
||
"legal_style_guide",
|
||
{
|
||
displayName: "מדריך סגנון",
|
||
description: "Get reference to Dafna's writing style guide.",
|
||
parametersSchema: {
|
||
type: "object",
|
||
properties: {},
|
||
},
|
||
},
|
||
async () => {
|
||
const guide = await api.getStyleGuide();
|
||
return { content: guide, data: { reference: guide } };
|
||
},
|
||
);
|
||
|
||
// ── Events ─────────────────────────────────────────────────────
|
||
|
||
ctx.events.on("issue.created", async (event) => {
|
||
// Auto-link issues with case number in title
|
||
if (!event.companyId || !event.entityId) return;
|
||
const issue = await ctx.issues.get(event.entityId, event.companyId);
|
||
if (!issue) return;
|
||
|
||
const match = issue.title.match(/ערר\s+(\d+\/\d+)/);
|
||
if (match) {
|
||
const caseNumber = match[1];
|
||
await ctx.state.set(
|
||
{
|
||
scopeKind: "issue",
|
||
scopeId: issue.id,
|
||
stateKey: "legal-case-number",
|
||
},
|
||
caseNumber,
|
||
);
|
||
ctx.logger.info("Auto-linked issue to legal case", {
|
||
issueId: issue.id,
|
||
caseNumber,
|
||
});
|
||
}
|
||
});
|
||
|
||
// Route user comments through CEO agent — per company
|
||
|
||
ctx.events.on("issue.comment.created", async (event) => {
|
||
// Only intercept human comments — not agent comments (prevents loops)
|
||
if (event.actorType !== "user") return;
|
||
if (!event.companyId) return;
|
||
|
||
// entityId is the comment ID — fetch the comment to get issueId + body
|
||
const entityId = event.entityId;
|
||
if (!entityId) return;
|
||
|
||
// The event payload may contain the issueId directly
|
||
const payload = event.payload as {
|
||
issueId?: string;
|
||
body?: string;
|
||
} | null;
|
||
|
||
let issueId = payload?.issueId;
|
||
let commentBody = payload?.body;
|
||
|
||
// If issueId is not in payload, try to find it from the comment entity
|
||
if (!issueId) {
|
||
ctx.logger.warn(
|
||
"issue.comment.created event missing issueId in payload, skipping",
|
||
{ entityId, payload },
|
||
);
|
||
return;
|
||
}
|
||
|
||
// Fetch issue details for context
|
||
const issue = await ctx.issues.get(issueId, event.companyId);
|
||
if (!issue) {
|
||
ctx.logger.warn("Could not fetch issue for comment routing", {
|
||
issueId,
|
||
});
|
||
return;
|
||
}
|
||
|
||
// If comment body not in payload, fetch from API
|
||
if (!commentBody) {
|
||
try {
|
||
const comments = await ctx.issues.listComments(
|
||
issueId,
|
||
event.companyId,
|
||
);
|
||
const latest = comments[comments.length - 1];
|
||
commentBody = latest?.body || "(לא ניתן לקרוא את התגובה)";
|
||
} catch {
|
||
commentBody = "(שגיאה בקריאת התגובה)";
|
||
}
|
||
}
|
||
|
||
// Wake the CEO agent for this company
|
||
const ceoAgentId = CEO_AGENT_IDS[event.companyId];
|
||
if (!ceoAgentId) {
|
||
ctx.logger.warn("No CEO agent mapped for company", {
|
||
companyId: event.companyId,
|
||
});
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const { runId } = await ctx.agents.invoke(
|
||
ceoAgentId,
|
||
event.companyId,
|
||
{
|
||
prompt: [
|
||
`תגובה חדשה מחיים על issue "${issue.title}" (${issue.identifier || issueId}):`,
|
||
"",
|
||
commentBody,
|
||
"",
|
||
`קרא את ה-comments האחרונים על ה-issue, הבן מה חיים מבקש, והחלט מה לעשות.`,
|
||
`אם ההוראה ברורה — נתב לסוכן המתאים. אם לא ברור — שאל את חיים.`,
|
||
].join("\n"),
|
||
reason: `user_commented_on_${issue.identifier || issueId}`,
|
||
},
|
||
);
|
||
ctx.logger.info("Routed user comment to CEO agent", {
|
||
issueId,
|
||
commentId: entityId,
|
||
runId,
|
||
});
|
||
} catch (err) {
|
||
ctx.logger.error("Failed to invoke CEO agent for comment routing", {
|
||
issueId,
|
||
error: String(err),
|
||
});
|
||
}
|
||
});
|
||
|
||
// ── Jobs ───────────────────────────────────────────────────────
|
||
|
||
ctx.jobs.register("sync-case-status", async (job) => {
|
||
ctx.logger.info("Starting case status sync", { runId: job.runId });
|
||
|
||
try {
|
||
const cases = await api.listCases();
|
||
const companies = await ctx.companies.list();
|
||
if (!companies.length) return;
|
||
|
||
const companyId = companies[0].id;
|
||
const issues = await ctx.issues.list({ companyId });
|
||
|
||
for (const legalCase of cases) {
|
||
for (const issue of issues) {
|
||
const linkedCase = await ctx.state.get({
|
||
scopeKind: "issue",
|
||
scopeId: issue.id,
|
||
stateKey: "legal-case-number",
|
||
});
|
||
|
||
if (linkedCase === legalCase.case_number) {
|
||
// Map 13 legal-ai statuses to Paperclip issue status
|
||
const statusMap: Record<string, "todo" | "in_progress" | "done"> =
|
||
{
|
||
new: "todo",
|
||
uploading: "todo",
|
||
processing: "in_progress",
|
||
documents_ready: "in_progress",
|
||
outcome_set: "in_progress",
|
||
brainstorming: "in_progress",
|
||
direction_approved: "in_progress",
|
||
drafting: "in_progress",
|
||
qa_review: "in_progress",
|
||
drafted: "in_progress",
|
||
exported: "in_progress",
|
||
reviewed: "in_progress",
|
||
final: "done",
|
||
};
|
||
|
||
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,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
ctx.logger.info("Case status sync completed", {
|
||
casesChecked: cases.length,
|
||
});
|
||
} catch (err) {
|
||
ctx.logger.error("Case status sync failed", { error: String(err) });
|
||
}
|
||
});
|
||
|
||
ctx.jobs.register("stale-case-reminder", async (_job) => {
|
||
ctx.logger.info("stale-case-reminder: starting");
|
||
const config = await ctx.config.get();
|
||
const apiBase = (config.legalApiBaseUrl as string) ?? "http://localhost:8085";
|
||
|
||
const resp = await ctx.http.fetch(`${apiBase}/api/cases/stale?days=3`);
|
||
if (!resp.ok) {
|
||
ctx.logger.error(`stale-case-reminder: API error ${resp.status}`);
|
||
return;
|
||
}
|
||
|
||
const data = (await resp.json()) as {
|
||
cases: Array<{ case_number: string; title: string; status: string; days_stale: number }>;
|
||
total: number;
|
||
};
|
||
|
||
// Build case→issue map once (O(companies × issues)) to avoid N×M RPCs per stale case
|
||
const companies = await ctx.companies.list();
|
||
const caseIssueMap = new Map<string, { issueId: string; companyId: string }>();
|
||
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 (linkedCase && typeof linkedCase === "string") {
|
||
caseIssueMap.set(linkedCase, { issueId: issue.id, companyId: company.id });
|
||
}
|
||
}
|
||
}
|
||
|
||
let reminded = 0;
|
||
for (const staleCase of data.cases) {
|
||
const linked = caseIssueMap.get(staleCase.case_number);
|
||
if (!linked) continue;
|
||
|
||
await ctx.issues.createComment(
|
||
linked.issueId,
|
||
`⚠️ **תיק תקוע ${staleCase.case_number}** — ${staleCase.days_stale} ימים ללא עדכון (סטטוס: ${staleCase.status}). האם נדרשת פעולה?`,
|
||
linked.companyId,
|
||
);
|
||
reminded++;
|
||
ctx.logger.info(`stale-case-reminder: reminded case ${staleCase.case_number} (${staleCase.days_stale}d)`);
|
||
}
|
||
|
||
ctx.logger.info(`stale-case-reminder: done. ${reminded}/${data.total} cases reminded`);
|
||
});
|
||
|
||
ctx.jobs.register("weekly-feedback-analysis", async (_job) => {
|
||
ctx.logger.info("weekly-feedback-analysis: starting");
|
||
const config = await ctx.config.get();
|
||
const apiBase = (config.legalApiBaseUrl as string) ?? "http://localhost:8085";
|
||
|
||
const resp = await ctx.http.fetch(`${apiBase}/api/chair-feedback/weekly-summary`);
|
||
if (!resp.ok) {
|
||
ctx.logger.error(`weekly-feedback-analysis: API error ${resp.status}`);
|
||
return;
|
||
}
|
||
|
||
const data = (await resp.json()) as { summary: string; entry_count: number };
|
||
|
||
if (data.entry_count === 0) {
|
||
ctx.logger.info("weekly-feedback-analysis: no feedback this week, skipping");
|
||
return;
|
||
}
|
||
|
||
const companies = await ctx.companies.list();
|
||
const company = companies[0];
|
||
if (!company) return;
|
||
|
||
const ceoId = CEO_AGENT_IDS[company.id];
|
||
if (!ceoId) {
|
||
ctx.logger.warn(`weekly-feedback-analysis: no CEO agent for company ${company.id}`);
|
||
return;
|
||
}
|
||
|
||
await ctx.agents.invoke(ceoId, company.id, {
|
||
prompt: `ניתוח פידבק שבועי יו"ר (${data.entry_count} פריטים):\n\n${data.summary}\n\nהמשימה: עדכן את /home/chaim/legal-ai/docs/legal-decision-lessons.md עם הלקחים החדשים שעולים מהפידבק. הוסף רק לקחים חדשים שלא קיימים כבר. קבץ לפי נושא.`,
|
||
reason: "weekly-feedback-analysis scheduled job",
|
||
});
|
||
|
||
ctx.logger.info(`weekly-feedback-analysis: invoked CEO ${ceoId} with ${data.entry_count} feedback entries`);
|
||
});
|
||
|
||
ctx.logger.info("Legal AI plugin ready");
|
||
},
|
||
|
||
async onHealth() {
|
||
return { status: "ok" as const };
|
||
},
|
||
|
||
async onWebhook(input: PluginWebhookInput): Promise<void> {
|
||
if (!pluginCtx) return; // not yet initialized
|
||
|
||
// TODO: add idempotency guard using input.requestId to prevent duplicate
|
||
// comments on rapid retries (store requestId in plugin state with ~60s TTL)
|
||
|
||
const { endpointKey, parsedBody } = input;
|
||
|
||
if (endpointKey !== "case-status") return;
|
||
|
||
const payload = parsedBody as {
|
||
caseNumber: string;
|
||
oldStatus: string;
|
||
newStatus: string;
|
||
companyId: string | null;
|
||
timestamp: string;
|
||
};
|
||
|
||
const { caseNumber, oldStatus, newStatus, companyId } = payload;
|
||
|
||
if (!caseNumber || !newStatus || !companyId) {
|
||
pluginCtx.logger.warn("onWebhook: malformed payload", { caseNumber, newStatus, companyId });
|
||
return;
|
||
}
|
||
|
||
pluginCtx.logger.info(`Webhook: case ${caseNumber} ${oldStatus} → ${newStatus}`, {
|
||
companyId,
|
||
});
|
||
|
||
// Find the Paperclip issue linked to this case number by scanning plugin state.
|
||
// State stores: issue.id → case_number (scopeKind=issue, stateKey=legal-case-number)
|
||
const issues = await pluginCtx.issues.list({ companyId });
|
||
let linkedIssueId: string | null = null;
|
||
for (const issue of issues) {
|
||
const linkedCase = await pluginCtx.state.get({
|
||
scopeKind: "issue",
|
||
scopeId: issue.id,
|
||
stateKey: "legal-case-number",
|
||
});
|
||
if (linkedCase === caseNumber) {
|
||
linkedIssueId = issue.id;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!linkedIssueId) {
|
||
pluginCtx.logger.warn(`onWebhook: no Paperclip issue linked to case ${caseNumber}`);
|
||
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;
|
||
|
||
// Post a Hebrew status comment on the linked issue
|
||
await pluginCtx.issues.createComment(
|
||
linkedIssueId,
|
||
`**עדכון סטטוס תיק ${caseNumber}:** ${label} (היה: ${oldStatus})`,
|
||
companyId,
|
||
);
|
||
|
||
// Wake the CEO agent if QA failed
|
||
if (newStatus === "qa_failed") {
|
||
const ceoId = CEO_AGENT_IDS[companyId];
|
||
if (ceoId) {
|
||
try {
|
||
const { runId } = await pluginCtx.agents.invoke(ceoId, companyId, {
|
||
prompt: `תיק ${caseNumber} נכשל בבדיקת QA. עיין בתוצאות QA ותקן את הבעיות לפני שתמשיך.`,
|
||
reason: "qa_failed webhook",
|
||
});
|
||
pluginCtx.logger.info(`Invoked CEO agent for qa_failed`, {
|
||
caseNumber,
|
||
ceoId,
|
||
runId,
|
||
});
|
||
} catch (err) {
|
||
pluginCtx.logger.error("Failed to invoke CEO agent for qa_failed", {
|
||
caseNumber,
|
||
error: String(err),
|
||
});
|
||
}
|
||
} else {
|
||
pluginCtx.logger.warn("onWebhook: no CEO agent mapped for company", {
|
||
companyId,
|
||
});
|
||
}
|
||
}
|
||
},
|
||
});
|
||
|
||
export default plugin;
|
||
runWorker(plugin, import.meta.url);
|