Compare commits
5 Commits
09acb021eb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 73e37df129 | |||
| 6a93c606b9 | |||
| 73078946f1 | |||
| 52c3e600e7 | |||
| 98c87a5d70 |
@@ -167,7 +167,8 @@ export default {
|
|||||||
{
|
{
|
||||||
jobKey: "weekly-feedback-analysis",
|
jobKey: "weekly-feedback-analysis",
|
||||||
displayName: "ניתוח פידבק שבועי",
|
displayName: "ניתוח פידבק שבועי",
|
||||||
description: 'מסכם פידבק יו"ר מהשבוע האחרון ומעדכן את decision-lessons.md',
|
description:
|
||||||
|
'מסכם פידבק יו"ר מהשבוע האחרון ומעדכן את decision-lessons.md',
|
||||||
schedule: "0 19 * * 0",
|
schedule: "0 19 * * 0",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
166
src/worker.ts
166
src/worker.ts
@@ -1,5 +1,8 @@
|
|||||||
|
import type {
|
||||||
|
PluginContext,
|
||||||
|
PluginWebhookInput,
|
||||||
|
} from "@paperclipai/plugin-sdk";
|
||||||
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
||||||
import type { PluginContext, PluginWebhookInput } from "@paperclipai/plugin-sdk";
|
|
||||||
import { LegalApi } from "./legal-api.js";
|
import { LegalApi } from "./legal-api.js";
|
||||||
|
|
||||||
// Hoisted so onWebhook can access the context after setup() completes.
|
// Hoisted so onWebhook can access the context after setup() completes.
|
||||||
@@ -7,8 +10,10 @@ let pluginCtx: PluginContext | null = null;
|
|||||||
|
|
||||||
// Per-company CEO agent IDs (shared between setup and onWebhook).
|
// Per-company CEO agent IDs (shared between setup and onWebhook).
|
||||||
const CEO_AGENT_IDS: Record<string, string> = {
|
const CEO_AGENT_IDS: Record<string, string> = {
|
||||||
"42a7acd0-30c5-4cbd-ac97-7424f65df294": "752cebdd-6748-4a04-aacd-c7ab0294ef33", // CMP (רישוי ובניה)
|
"42a7acd0-30c5-4cbd-ac97-7424f65df294":
|
||||||
"8639e837-4c9d-47fa-a76b-95788d651896": "cdbfa8bc-3d61-41a4-a2e7-677ec7d34562", // CMPA (היטלי השבחה)
|
"752cebdd-6748-4a04-aacd-c7ab0294ef33", // CMP (רישוי ובניה)
|
||||||
|
"8639e837-4c9d-47fa-a76b-95788d651896":
|
||||||
|
"cdbfa8bc-3d61-41a4-a2e7-677ec7d34562", // CMPA (היטלי השבחה)
|
||||||
};
|
};
|
||||||
|
|
||||||
const plugin = definePlugin({
|
const plugin = definePlugin({
|
||||||
@@ -545,7 +550,7 @@ const plugin = definePlugin({
|
|||||||
body?: string;
|
body?: string;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
let issueId = payload?.issueId;
|
const issueId = payload?.issueId;
|
||||||
let commentBody = payload?.body;
|
let commentBody = payload?.body;
|
||||||
|
|
||||||
// If issueId is not in payload, try to find it from the comment entity
|
// If issueId is not in payload, try to find it from the comment entity
|
||||||
@@ -590,10 +595,7 @@ const plugin = definePlugin({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { runId } = await ctx.agents.invoke(
|
const { runId } = await ctx.agents.invoke(ceoAgentId, event.companyId, {
|
||||||
ceoAgentId,
|
|
||||||
event.companyId,
|
|
||||||
{
|
|
||||||
prompt: [
|
prompt: [
|
||||||
`תגובה חדשה מחיים על issue "${issue.title}" (${issue.identifier || issueId}):`,
|
`תגובה חדשה מחיים על issue "${issue.title}" (${issue.identifier || issueId}):`,
|
||||||
"",
|
"",
|
||||||
@@ -603,8 +605,7 @@ const plugin = definePlugin({
|
|||||||
`אם ההוראה ברורה — נתב לסוכן המתאים. אם לא ברור — שאל את חיים.`,
|
`אם ההוראה ברורה — נתב לסוכן המתאים. אם לא ברור — שאל את חיים.`,
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
reason: `user_commented_on_${issue.identifier || issueId}`,
|
reason: `user_commented_on_${issue.identifier || issueId}`,
|
||||||
},
|
});
|
||||||
);
|
|
||||||
ctx.logger.info("Routed user comment to CEO agent", {
|
ctx.logger.info("Routed user comment to CEO agent", {
|
||||||
issueId,
|
issueId,
|
||||||
commentId: entityId,
|
commentId: entityId,
|
||||||
@@ -709,86 +710,136 @@ const plugin = definePlugin({
|
|||||||
ctx.jobs.register("stale-case-reminder", async (_job) => {
|
ctx.jobs.register("stale-case-reminder", async (_job) => {
|
||||||
ctx.logger.info("stale-case-reminder: starting");
|
ctx.logger.info("stale-case-reminder: starting");
|
||||||
const config = await ctx.config.get();
|
const config = await ctx.config.get();
|
||||||
const apiBase = (config.legalApiBaseUrl as string) ?? "http://localhost:8085";
|
const apiBase =
|
||||||
|
(config.legalApiBaseUrl as string) ?? "http://localhost:8085";
|
||||||
|
|
||||||
const resp = await ctx.http.fetch(`${apiBase}/api/cases/stale?days=3`);
|
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) {
|
if (!resp.ok) {
|
||||||
ctx.logger.error(`stale-case-reminder: API error ${resp.status}`);
|
ctx.logger.error(`stale-case-reminder: API error ${resp.status}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await resp.json()) as {
|
const data = (await resp.json()) as {
|
||||||
cases: Array<{ case_number: string; title: string; status: string; days_stale: number }>;
|
cases: Array<{
|
||||||
|
case_number: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
days_stale: number;
|
||||||
|
}>;
|
||||||
total: number;
|
total: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
let reminded = 0;
|
// Build case→issue map once (O(companies × issues)) to avoid N×M RPCs per stale case
|
||||||
const companies = await ctx.companies.list();
|
const companies = await ctx.companies.list();
|
||||||
for (const staleCase of data.cases) {
|
const caseIssueMap = new Map<
|
||||||
|
string,
|
||||||
|
{ issueId: string; companyId: string }
|
||||||
|
>();
|
||||||
for (const company of companies) {
|
for (const company of companies) {
|
||||||
const issues = await ctx.issues.list({ companyId: company.id });
|
const issues = await ctx.issues.list({ companyId: company.id });
|
||||||
let linkedIssueId: string | null = null;
|
|
||||||
for (const issue of issues) {
|
for (const issue of issues) {
|
||||||
const linkedCase = await ctx.state.get({
|
const linkedCase = await ctx.state.get({
|
||||||
scopeKind: "issue",
|
scopeKind: "issue",
|
||||||
scopeId: issue.id,
|
scopeId: issue.id,
|
||||||
stateKey: "legal-case-number",
|
stateKey: "legal-case-number",
|
||||||
});
|
});
|
||||||
if (linkedCase === staleCase.case_number) {
|
if (linkedCase && typeof linkedCase === "string") {
|
||||||
linkedIssueId = issue.id;
|
caseIssueMap.set(linkedCase, {
|
||||||
break;
|
issueId: issue.id,
|
||||||
|
companyId: company.id,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!linkedIssueId) continue;
|
}
|
||||||
|
|
||||||
|
let reminded = 0;
|
||||||
|
for (const staleCase of data.cases) {
|
||||||
|
const linked = caseIssueMap.get(staleCase.case_number);
|
||||||
|
if (!linked) continue;
|
||||||
|
|
||||||
await ctx.issues.createComment(
|
await ctx.issues.createComment(
|
||||||
linkedIssueId,
|
linked.issueId,
|
||||||
`⚠️ **תיק תקוע ${staleCase.case_number}** — ${staleCase.days_stale} ימים ללא עדכון (סטטוס: ${staleCase.status}). האם נדרשת פעולה?`,
|
`⚠️ **תיק תקוע ${staleCase.case_number}** — ${staleCase.days_stale} ימים ללא עדכון (סטטוס: ${staleCase.status}). האם נדרשת פעולה?`,
|
||||||
company.id,
|
linked.companyId,
|
||||||
);
|
);
|
||||||
reminded++;
|
reminded++;
|
||||||
ctx.logger.info(`stale-case-reminder: reminded case ${staleCase.case_number} (${staleCase.days_stale}d)`);
|
ctx.logger.info(
|
||||||
break; // Found the company, move to next case
|
`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.logger.info(
|
||||||
|
`stale-case-reminder: done. ${reminded}/${data.total} cases reminded`,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.jobs.register("weekly-feedback-analysis", async (_job) => {
|
ctx.jobs.register("weekly-feedback-analysis", async (_job) => {
|
||||||
ctx.logger.info("weekly-feedback-analysis: starting");
|
ctx.logger.info("weekly-feedback-analysis: starting");
|
||||||
const config = await ctx.config.get();
|
const config = await ctx.config.get();
|
||||||
const apiBase = (config.legalApiBaseUrl as string) ?? "http://localhost:8085";
|
const apiBase =
|
||||||
|
(config.legalApiBaseUrl as string) ?? "http://localhost:8085";
|
||||||
|
|
||||||
const resp = await ctx.http.fetch(`${apiBase}/api/chair-feedback/weekly-summary`);
|
const resp = await ctx.http.fetch(
|
||||||
|
`${apiBase}/api/chair-feedback/weekly-summary`,
|
||||||
|
);
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
ctx.logger.error(`weekly-feedback-analysis: API error ${resp.status}`);
|
ctx.logger.error(`weekly-feedback-analysis: API error ${resp.status}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await resp.json()) as { summary: string; entry_count: number };
|
const data = (await resp.json()) as {
|
||||||
|
summary: string;
|
||||||
|
entry_count: number;
|
||||||
|
};
|
||||||
|
|
||||||
if (data.entry_count === 0) {
|
if (data.entry_count === 0) {
|
||||||
ctx.logger.info("weekly-feedback-analysis: no feedback this week, skipping");
|
ctx.logger.info(
|
||||||
|
"weekly-feedback-analysis: no feedback this week, skipping",
|
||||||
|
);
|
||||||
return;
|
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 companies = await ctx.companies.list();
|
||||||
const company = companies[0];
|
const mapped = companies
|
||||||
if (!company) return;
|
.map((c) => ({ company: c, ceoId: CEO_AGENT_IDS[c.id] }))
|
||||||
|
.filter((x): x is { company: (typeof companies)[0]; ceoId: string } =>
|
||||||
|
Boolean(x.ceoId),
|
||||||
|
);
|
||||||
|
|
||||||
const ceoId = CEO_AGENT_IDS[company.id];
|
if (mapped.length === 0) {
|
||||||
if (!ceoId) {
|
ctx.logger.warn(
|
||||||
ctx.logger.warn(`weekly-feedback-analysis: no CEO agent for company ${company.id}`);
|
"weekly-feedback-analysis: no company has a mapped CEO agent — skipping",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { company, ceoId } = mapped[0];
|
||||||
|
|
||||||
|
try {
|
||||||
await ctx.agents.invoke(ceoId, company.id, {
|
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 עם הלקחים החדשים שעולים מהפידבק. הוסף רק לקחים חדשים שלא קיימים כבר. קבץ לפי נושא.`,
|
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",
|
reason: "weekly-feedback-job",
|
||||||
});
|
});
|
||||||
|
ctx.logger.info(
|
||||||
ctx.logger.info(`weekly-feedback-analysis: invoked CEO ${ceoId} with ${data.entry_count} feedback entries`);
|
`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");
|
ctx.logger.info("Legal AI plugin ready");
|
||||||
@@ -801,6 +852,28 @@ const plugin = definePlugin({
|
|||||||
async onWebhook(input: PluginWebhookInput): Promise<void> {
|
async onWebhook(input: PluginWebhookInput): Promise<void> {
|
||||||
if (!pluginCtx) return; // not yet initialized
|
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;
|
const { endpointKey, parsedBody } = input;
|
||||||
|
|
||||||
if (endpointKey !== "case-status") return;
|
if (endpointKey !== "case-status") return;
|
||||||
@@ -816,13 +889,20 @@ const plugin = definePlugin({
|
|||||||
const { caseNumber, oldStatus, newStatus, companyId } = payload;
|
const { caseNumber, oldStatus, newStatus, companyId } = payload;
|
||||||
|
|
||||||
if (!caseNumber || !newStatus || !companyId) {
|
if (!caseNumber || !newStatus || !companyId) {
|
||||||
pluginCtx.logger.warn("onWebhook: malformed payload", { caseNumber, newStatus, companyId });
|
pluginCtx.logger.warn("onWebhook: malformed payload", {
|
||||||
|
caseNumber,
|
||||||
|
newStatus,
|
||||||
|
companyId,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
pluginCtx.logger.info(`Webhook: case ${caseNumber} ${oldStatus} → ${newStatus}`, {
|
pluginCtx.logger.info(
|
||||||
|
`Webhook: case ${caseNumber} ${oldStatus} → ${newStatus}`,
|
||||||
|
{
|
||||||
companyId,
|
companyId,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Find the Paperclip issue linked to this case number by scanning plugin state.
|
// 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)
|
// State stores: issue.id → case_number (scopeKind=issue, stateKey=legal-case-number)
|
||||||
@@ -841,7 +921,9 @@ const plugin = definePlugin({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!linkedIssueId) {
|
if (!linkedIssueId) {
|
||||||
pluginCtx.logger.warn(`onWebhook: no Paperclip issue linked to case ${caseNumber}`);
|
pluginCtx.logger.warn(
|
||||||
|
`onWebhook: no Paperclip issue linked to case ${caseNumber}`,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user