3 Commits

Author SHA1 Message Date
952c70b2c0 docs: add changelog for hooks/jobs/sync improvements (2026-05-17) 2026-05-17 10:24:28 +00:00
52c3e600e7 style: biome auto-fix (const, import order, formatter)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 10:18:33 +00:00
98c87a5d70 perf: build case-issue map once in stale-case-reminder; add idempotency TODO
- 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>
2026-05-17 10:15:51 +00:00
3 changed files with 127 additions and 58 deletions

26
CHANGELOG.md Normal file
View File

@@ -0,0 +1,26 @@
# Changelog — Plugin Legal AI
## [2026-05-17] שיפורי תשתית: Hooks, Scheduled Jobs, Per-Agent Validation
### שינויים ב-plugin-legal-ai
#### Webhook Hook — עדכוני סטטוס תיק
- נוסף `onWebhook()` handler לאירוע `case-status` מ-legal-ai
- כל שינוי סטטוס תיק (מכל endpoint) → תגובה אוטומטית בעברית על ה-issue המקושר ב-Paperclip
- בשינוי סטטוס `qa_failed` — CEO מתעורר אוטומטית עם הנחיות לתיקון
- נוספה הגנת payload + לוג אזהרה על שדות חסרים
#### Scheduled Jobs — עבודות רקע
- **`stale-case-reminder`** (כל יום 08:00): מזהה תיקים שלא עודכנו 3+ ימים → מוסיף תגובה אזהרה על ה-issue
- **`weekly-feedback-analysis`** (כל ראשון 19:00): מסכם פידבק יו"ר מהשבוע → CEO מעדכן `decision-lessons.md`
- ביצועים: בנייה חד-פעמית של `Map<caseNumber, {issueId, companyId}>` — O(companies × issues) במקום O(cases × companies × issues)
#### manifest / plugin.json
- נוספה capability: `webhooks.receive`
- נוספה הצהרת webhook: `{ endpointKey: "case-status" }`
- נוספו הגדרות ל-2 jobs חדשים
#### תיקוני Quality
- החלפת `let` ב-`const` (biome)
- תיקון סדר imports (biome)
- תיקון פורמט manifest.ts (biome)

View File

@@ -167,7 +167,8 @@ export default {
{
jobKey: "weekly-feedback-analysis",
displayName: "ניתוח פידבק שבועי",
description: 'מסכם פידבק יו"ר מהשבוע האחרון ומעדכן את decision-lessons.md',
description:
'מסכם פידבק יו"ר מהשבוע האחרון ומעדכן את decision-lessons.md',
schedule: "0 19 * * 0",
},
],

View File

@@ -1,5 +1,8 @@
import type {
PluginContext,
PluginWebhookInput,
} 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";
// 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).
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 (היטלי השבחה)
"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({
@@ -545,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
@@ -590,21 +595,17 @@ const plugin = definePlugin({
}
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}`,
},
);
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,
@@ -709,7 +710,8 @@ 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";
const apiBase =
(config.legalApiBaseUrl as string) ?? "http://localhost:8085";
const resp = await ctx.http.fetch(`${apiBase}/api/cases/stale?days=3`);
if (!resp.ok) {
@@ -718,58 +720,82 @@ const plugin = definePlugin({
}
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;
};
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();
for (const staleCase of data.cases) {
for (const company of companies) {
const issues = await ctx.issues.list({ companyId: company.id });
let linkedIssueId: string | null = null;
for (const issue of issues) {
const linkedCase = await ctx.state.get({
scopeKind: "issue",
scopeId: issue.id,
stateKey: "legal-case-number",
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,
});
if (linkedCase === staleCase.case_number) {
linkedIssueId = issue.id;
break;
}
}
if (!linkedIssueId) continue;
await ctx.issues.createComment(
linkedIssueId,
`⚠️ **תיק תקוע ${staleCase.case_number}** — ${staleCase.days_stale} ימים ללא עדכון (סטטוס: ${staleCase.status}). האם נדרשת פעולה?`,
company.id,
);
reminded++;
ctx.logger.info(`stale-case-reminder: reminded case ${staleCase.case_number} (${staleCase.days_stale}d)`);
break; // Found the company, move to next case
}
}
ctx.logger.info(`stale-case-reminder: done. ${reminded}/${data.total} cases reminded`);
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 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) {
ctx.logger.error(`weekly-feedback-analysis: API error ${resp.status}`);
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) {
ctx.logger.info("weekly-feedback-analysis: no feedback this week, skipping");
ctx.logger.info(
"weekly-feedback-analysis: no feedback this week, skipping",
);
return;
}
@@ -779,7 +805,9 @@ const plugin = definePlugin({
const ceoId = CEO_AGENT_IDS[company.id];
if (!ceoId) {
ctx.logger.warn(`weekly-feedback-analysis: no CEO agent for company ${company.id}`);
ctx.logger.warn(
`weekly-feedback-analysis: no CEO agent for company ${company.id}`,
);
return;
}
@@ -788,7 +816,9 @@ const plugin = definePlugin({
reason: "weekly-feedback-analysis scheduled job",
});
ctx.logger.info(`weekly-feedback-analysis: invoked CEO ${ceoId} with ${data.entry_count} feedback entries`);
ctx.logger.info(
`weekly-feedback-analysis: invoked CEO ${ceoId} with ${data.entry_count} feedback entries`,
);
});
ctx.logger.info("Legal AI plugin ready");
@@ -801,6 +831,9 @@ const plugin = definePlugin({
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;
@@ -816,13 +849,20 @@ const plugin = definePlugin({
const { caseNumber, oldStatus, newStatus, companyId } = payload;
if (!caseNumber || !newStatus || !companyId) {
pluginCtx.logger.warn("onWebhook: malformed payload", { caseNumber, newStatus, companyId });
pluginCtx.logger.warn("onWebhook: malformed payload", {
caseNumber,
newStatus,
companyId,
});
return;
}
pluginCtx.logger.info(`Webhook: case ${caseNumber} ${oldStatus}${newStatus}`, {
companyId,
});
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)
@@ -841,7 +881,9 @@ const plugin = definePlugin({
}
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;
}