Compare commits
11 Commits
bf639063f7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 73e37df129 | |||
| 6a93c606b9 | |||
| 73078946f1 | |||
| 52c3e600e7 | |||
| 98c87a5d70 | |||
| 09acb021eb | |||
| 06679bb061 | |||
| 9633617e26 | |||
| f5a4cd1c62 | |||
| 23fda8da8c | |||
| 6b856dbe85 |
22
plugin.json
22
plugin.json
@@ -23,7 +23,8 @@
|
||||
"jobs.schedule",
|
||||
"activity.log.write",
|
||||
"companies.read",
|
||||
"projects.read"
|
||||
"projects.read",
|
||||
"webhooks.receive"
|
||||
],
|
||||
"entrypoints": {
|
||||
"worker": "dist/worker.js"
|
||||
@@ -62,6 +63,25 @@
|
||||
"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 המקושר"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export default {
|
||||
"activity.log.write",
|
||||
"companies.read",
|
||||
"projects.read",
|
||||
"webhooks.receive",
|
||||
] as const,
|
||||
entrypoints: {
|
||||
worker: "dist/worker.js",
|
||||
@@ -157,5 +158,26 @@ export default {
|
||||
"Polls legal-ai for case status changes and updates Paperclip issues",
|
||||
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 המקושר",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
325
src/worker.ts
325
src/worker.ts
@@ -1,8 +1,24 @@
|
||||
import type {
|
||||
PluginContext,
|
||||
PluginWebhookInput,
|
||||
} from "@paperclipai/plugin-sdk";
|
||||
import { definePlugin, runWorker } 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;
|
||||
@@ -517,8 +533,7 @@ const plugin = definePlugin({
|
||||
}
|
||||
});
|
||||
|
||||
// Route user comments through CEO agent
|
||||
const CEO_AGENT_ID = "752cebdd-6748-4a04-aacd-c7ab0294ef33";
|
||||
// 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)
|
||||
@@ -535,7 +550,7 @@ const plugin = definePlugin({
|
||||
body?: string;
|
||||
} | null;
|
||||
|
||||
let issueId = payload?.issueId;
|
||||
const issueId = payload?.issueId;
|
||||
let commentBody = payload?.body;
|
||||
|
||||
// If issueId is not in payload, try to find it from the comment entity
|
||||
@@ -570,23 +585,27 @@ const plugin = definePlugin({
|
||||
}
|
||||
}
|
||||
|
||||
// Wake the CEO agent with the comment context
|
||||
// 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(
|
||||
CEO_AGENT_ID,
|
||||
event.companyId,
|
||||
{
|
||||
prompt: [
|
||||
`תגובה חדשה מחיים על issue "${issue.title}" (${issue.identifier || issueId}):`,
|
||||
"",
|
||||
commentBody,
|
||||
"",
|
||||
`קרא את ה-comments האחרונים על ה-issue, הבן מה חיים מבקש, והחלט מה לעשות.`,
|
||||
`אם ההוראה ברורה — נתב לסוכן המתאים. אם לא ברור — שאל את חיים.`,
|
||||
].join("\n"),
|
||||
reason: `user_commented_on_${issue.identifier || issueId}`,
|
||||
},
|
||||
);
|
||||
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,
|
||||
@@ -688,12 +707,280 @@ const plugin = definePlugin({
|
||||
}
|
||||
});
|
||||
|
||||
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";
|
||||
|
||||
let resp: Awaited<ReturnType<typeof ctx.http.fetch>>;
|
||||
try {
|
||||
resp = await ctx.http.fetch(`${apiBase}/api/cases/stale?days=3`);
|
||||
} catch (err) {
|
||||
ctx.logger.error("stale-case-reminder: fetch failed", {
|
||||
error: String(err),
|
||||
});
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
// Pick the first company with a known CEO mapping — decision-lessons.md is
|
||||
// shared between companies, so a single CEO invocation is sufficient.
|
||||
const companies = await ctx.companies.list();
|
||||
const mapped = companies
|
||||
.map((c) => ({ company: c, ceoId: CEO_AGENT_IDS[c.id] }))
|
||||
.filter((x): x is { company: (typeof companies)[0]; ceoId: string } =>
|
||||
Boolean(x.ceoId),
|
||||
);
|
||||
|
||||
if (mapped.length === 0) {
|
||||
ctx.logger.warn(
|
||||
"weekly-feedback-analysis: no company has a mapped CEO agent — skipping",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { company, ceoId } = mapped[0];
|
||||
|
||||
try {
|
||||
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-job",
|
||||
});
|
||||
ctx.logger.info(
|
||||
`weekly-feedback-analysis: invoked CEO ${ceoId} (company ${company.id}) with ${data.entry_count} feedback entries`,
|
||||
);
|
||||
} catch (err) {
|
||||
ctx.logger.error("weekly-feedback-analysis: failed to invoke CEO", {
|
||||
error: String(err),
|
||||
ceoId,
|
||||
companyId: company.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
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
|
||||
|
||||
// Idempotency guard: skip duplicate deliveries within 5 minutes
|
||||
if (input.requestId) {
|
||||
const idempKey = `webhook-idem-${input.requestId}`;
|
||||
const seenAt = await pluginCtx.state.get({
|
||||
scopeKind: "instance",
|
||||
stateKey: idempKey,
|
||||
});
|
||||
if (seenAt && typeof seenAt === "string") {
|
||||
const ageMs = Date.now() - new Date(seenAt).getTime();
|
||||
if (ageMs < 5 * 60 * 1000) {
|
||||
pluginCtx.logger.info(
|
||||
`onWebhook: skipping duplicate requestId ${input.requestId} (age ${Math.round(ageMs / 1000)}s)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await pluginCtx.state.set(
|
||||
{ scopeKind: "instance", stateKey: idempKey },
|
||||
new Date().toISOString(),
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user