feat(ui): Phase 2 — issue detail tab + dashboard widget
Plugin now mounts two React components inside Paperclip via the
SDK's UI slot mechanism. Both are read-only views over data the
plugin worker fetches from legal-ai on demand.
## Slots
* **LegalCaseTab** (type: detailTab, entityTypes: ["issue"])
Mounted as a "ערר" tab on every issue page. Shows case summary
(status / practice_area / appeal_subtype), legal_arguments
grouped by party (עוררים/ועדה/משיבה/מבקשי היתר), attached
precedents, and open missing_precedents.
* **LegalCasesWidget** (type: dashboardWidget)
Dashboard tile with case counts by status + 7-day activity.
## Worker handlers (ctx.data.register)
Five handlers added at the end of setup() — all read-only over the
existing legal-ai HTTP API, all wrapped in try/catch so a transient
failure shows a placeholder instead of crashing the host:
- legal-case-summary → /api/cases/{n}/details
- legal-case-arguments → /api/cases/{n}/legal-arguments
- legal-case-precedents → /api/cases/{n}/precedents
- legal-case-missing-precedents → /api/missing-precedents?case_number=&status=open
- legal-dashboard-stats → in-memory aggregation over /api/cases
case_number is resolved from plugin state (scopeKind=issue,
stateKey=legal-case-number) — populated by legal_case_create.
## Build pipeline
- esbuild.ui.config.mjs uses createPluginBundlerPresets from the SDK
to build src/ui/index.tsx → dist/ui/index.js (13.5kb, react +
@paperclipai/plugin-sdk/ui externalized)
- package.json: build = "build:worker" (tsc) + "build:ui" (esbuild)
- tsconfig.json: jsx=react-jsx, lib += DOM
- New deps: react@19, @types/react, esbuild
## Manifest
- capabilities += ui.detailTab.register, ui.dashboardWidget.register
- entrypoints.ui = "dist/ui"
- ui.slots declared with entityTypes (not "entities" — fixed against
PluginUiSlotDeclaration validator)
## Verified
- tsc + esbuild + biome clean
- Plugin re-installs (20 capabilities) and activates with worker
+ 8 tools + 3 jobs + 1 webhook + 2 event subs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -26,9 +26,12 @@ export default {
|
||||
"companies.read",
|
||||
"projects.read",
|
||||
"webhooks.receive",
|
||||
"ui.detailTab.register",
|
||||
"ui.dashboardWidget.register",
|
||||
] as const,
|
||||
entrypoints: {
|
||||
worker: "dist/worker.js",
|
||||
ui: "dist/ui",
|
||||
},
|
||||
instanceConfigSchema: {
|
||||
type: "object" as const,
|
||||
@@ -184,4 +187,21 @@ export default {
|
||||
"מקבל עדכוני סטטוס מ-legal-ai ומפרסם תגובה על ה-issue המקושר",
|
||||
},
|
||||
],
|
||||
ui: {
|
||||
slots: [
|
||||
{
|
||||
type: "detailTab" as const,
|
||||
id: "legal-case-tab",
|
||||
displayName: "ערר",
|
||||
exportName: "LegalCaseTab",
|
||||
entityTypes: ["issue" as const],
|
||||
},
|
||||
{
|
||||
type: "dashboardWidget" as const,
|
||||
id: "legal-cases-widget",
|
||||
displayName: "תיקי ערר",
|
||||
exportName: "LegalCasesWidget",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
248
src/ui/LegalCaseTab.tsx
Normal file
248
src/ui/LegalCaseTab.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import type { PluginDetailTabProps } from "@paperclipai/plugin-sdk/ui";
|
||||
import { usePluginData } from "@paperclipai/plugin-sdk/ui/hooks";
|
||||
import type {
|
||||
LegalArgument,
|
||||
LegalArgumentsResponse,
|
||||
LegalCaseDetails,
|
||||
MissingPrecedentRow,
|
||||
PrecedentRow,
|
||||
} from "./types.js";
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
padding: 16,
|
||||
fontFamily:
|
||||
"system-ui, -apple-system, 'Segoe UI', 'Noto Sans Hebrew', sans-serif",
|
||||
color: "inherit",
|
||||
};
|
||||
|
||||
const sectionStyle: React.CSSProperties = {
|
||||
marginBottom: 24,
|
||||
};
|
||||
|
||||
const headingStyle: React.CSSProperties = {
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
margin: "0 0 8px 0",
|
||||
borderBottom: "1px solid rgba(127,127,127,0.25)",
|
||||
paddingBottom: 4,
|
||||
};
|
||||
|
||||
const metaListStyle: React.CSSProperties = {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "max-content 1fr",
|
||||
columnGap: 12,
|
||||
rowGap: 4,
|
||||
fontSize: 14,
|
||||
margin: 0,
|
||||
};
|
||||
|
||||
const detailsBlockStyle: React.CSSProperties = {
|
||||
margin: "6px 0",
|
||||
padding: "6px 10px",
|
||||
background: "rgba(127,127,127,0.08)",
|
||||
borderRadius: 6,
|
||||
};
|
||||
|
||||
const ulStyle: React.CSSProperties = {
|
||||
listStyle: "none",
|
||||
paddingInlineStart: 0,
|
||||
margin: 0,
|
||||
};
|
||||
|
||||
const liStyle: React.CSSProperties = {
|
||||
padding: "4px 0",
|
||||
fontSize: 14,
|
||||
borderBottom: "1px dotted rgba(127,127,127,0.2)",
|
||||
};
|
||||
|
||||
const mutedStyle: React.CSSProperties = {
|
||||
color: "rgba(127,127,127,0.8)",
|
||||
fontSize: 13,
|
||||
};
|
||||
|
||||
function PartySection({
|
||||
party,
|
||||
args,
|
||||
}: {
|
||||
party: string;
|
||||
args: LegalArgument[];
|
||||
}) {
|
||||
if (!args || args.length === 0) return null;
|
||||
return (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<strong style={{ fontSize: 14 }}>{party}</strong>
|
||||
{args.map((a, idx) => {
|
||||
const key = a.id ?? `${party}-${a.claim_index ?? idx}`;
|
||||
const title =
|
||||
a.argument_title ||
|
||||
(a.argument_body || "").slice(0, 80) ||
|
||||
"(ללא כותרת)";
|
||||
const body = a.argument_body || "";
|
||||
return (
|
||||
<details key={key} style={detailsBlockStyle}>
|
||||
<summary style={{ cursor: "pointer", fontSize: 14 }}>
|
||||
{title}
|
||||
</summary>
|
||||
{body && (
|
||||
<p style={{ margin: "6px 0 0 0", whiteSpace: "pre-wrap" }}>
|
||||
{body}
|
||||
</p>
|
||||
)}
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LegalCaseTab({ context }: PluginDetailTabProps) {
|
||||
const issueId = context.entityId;
|
||||
|
||||
const summary = usePluginData<LegalCaseDetails | null>("legal-case-summary", {
|
||||
issueId,
|
||||
});
|
||||
const args = usePluginData<LegalArgumentsResponse | null>(
|
||||
"legal-case-arguments",
|
||||
{ issueId },
|
||||
);
|
||||
const precedents = usePluginData<PrecedentRow[] | null>(
|
||||
"legal-case-precedents",
|
||||
{ issueId },
|
||||
);
|
||||
const missing = usePluginData<MissingPrecedentRow[] | null>(
|
||||
"legal-case-missing-precedents",
|
||||
{ issueId },
|
||||
);
|
||||
|
||||
if (summary.loading) {
|
||||
return (
|
||||
<div dir="rtl" style={containerStyle}>
|
||||
<p>טוען נתוני תיק…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (summary.error) {
|
||||
return (
|
||||
<div dir="rtl" style={containerStyle}>
|
||||
<p style={{ color: "crimson" }}>
|
||||
שגיאה בטעינת התיק: {summary.error.message}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!summary.data) {
|
||||
return (
|
||||
<div dir="rtl" style={containerStyle}>
|
||||
<p style={mutedStyle}>
|
||||
אין תיק ערר מקושר ל-issue זה. ניתן ליצור תיק חדש דרך CEO או דרך כלי
|
||||
המערכת.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const caseDetails = summary.data;
|
||||
const byParty = args.data?.by_party ?? {};
|
||||
const argsTotal = args.data?.total ?? 0;
|
||||
const precedentsList = precedents.data ?? [];
|
||||
const missingList = missing.data ?? [];
|
||||
|
||||
return (
|
||||
<div dir="rtl" style={containerStyle}>
|
||||
<section style={sectionStyle}>
|
||||
<h2 style={{ margin: "0 0 8px 0", fontSize: 20 }}>
|
||||
תיק {caseDetails.case_number}
|
||||
</h2>
|
||||
<div style={metaListStyle}>
|
||||
<span style={mutedStyle}>כותרת:</span>
|
||||
<span>{caseDetails.title}</span>
|
||||
<span style={mutedStyle}>סטטוס:</span>
|
||||
<span>
|
||||
<code>{caseDetails.status}</code>
|
||||
</span>
|
||||
{caseDetails.practice_area && (
|
||||
<>
|
||||
<span style={mutedStyle}>תחום:</span>
|
||||
<span>{caseDetails.practice_area}</span>
|
||||
</>
|
||||
)}
|
||||
{caseDetails.appeal_subtype && (
|
||||
<>
|
||||
<span style={mutedStyle}>סוג ערר:</span>
|
||||
<span>{caseDetails.appeal_subtype}</span>
|
||||
</>
|
||||
)}
|
||||
{caseDetails.expected_outcome && (
|
||||
<>
|
||||
<span style={mutedStyle}>תוצאה צפויה:</span>
|
||||
<span>{caseDetails.expected_outcome}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={sectionStyle}>
|
||||
<h3 style={headingStyle}>טיעונים משפטיים ({argsTotal})</h3>
|
||||
{args.loading && <p style={mutedStyle}>טוען טיעונים…</p>}
|
||||
{args.error && (
|
||||
<p style={{ color: "crimson" }}>שגיאה: {args.error.message}</p>
|
||||
)}
|
||||
{!args.loading && argsTotal === 0 && (
|
||||
<p style={mutedStyle}>לא חולצו עדיין טיעונים מהמסמכים.</p>
|
||||
)}
|
||||
{Object.entries(byParty).map(([party, list]) => (
|
||||
<PartySection key={party} party={party} args={list} />
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section style={sectionStyle}>
|
||||
<h3 style={headingStyle}>פסיקה מצורפת ({precedentsList.length})</h3>
|
||||
{precedents.loading && <p style={mutedStyle}>טוען פסיקה…</p>}
|
||||
{precedents.error && (
|
||||
<p style={{ color: "crimson" }}>שגיאה: {precedents.error.message}</p>
|
||||
)}
|
||||
{!precedents.loading && precedentsList.length === 0 && (
|
||||
<p style={mutedStyle}>לא צורפה עדיין פסיקה לתיק.</p>
|
||||
)}
|
||||
{precedentsList.length > 0 && (
|
||||
<ul style={ulStyle}>
|
||||
{precedentsList.map((p) => (
|
||||
<li key={p.id} style={liStyle}>
|
||||
<strong>{p.citation}</strong>
|
||||
{p.practice_area && (
|
||||
<span style={mutedStyle}> — {p.practice_area}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section style={sectionStyle}>
|
||||
<h3 style={headingStyle}>פסיקה חסרה ({missingList.length})</h3>
|
||||
{missing.loading && <p style={mutedStyle}>טוען רשימת חסרים…</p>}
|
||||
{missing.error && (
|
||||
<p style={{ color: "crimson" }}>שגיאה: {missing.error.message}</p>
|
||||
)}
|
||||
{!missing.loading && missingList.length === 0 && (
|
||||
<p style={mutedStyle}>אין פסיקה חסרה פתוחה בתיק זה.</p>
|
||||
)}
|
||||
{missingList.length > 0 && (
|
||||
<ul style={ulStyle}>
|
||||
{missingList.map((m) => (
|
||||
<li key={m.id} style={liStyle}>
|
||||
<strong>{m.citation}</strong>
|
||||
<span style={mutedStyle}> — {m.status}</span>
|
||||
{m.legal_topic && (
|
||||
<span style={mutedStyle}> ({m.legal_topic})</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
132
src/ui/LegalCasesWidget.tsx
Normal file
132
src/ui/LegalCasesWidget.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import type { PluginWidgetProps } from "@paperclipai/plugin-sdk/ui";
|
||||
import { usePluginData } from "@paperclipai/plugin-sdk/ui/hooks";
|
||||
import type { DashboardStats } from "./types.js";
|
||||
|
||||
const widgetStyle: React.CSSProperties = {
|
||||
padding: 12,
|
||||
fontFamily:
|
||||
"system-ui, -apple-system, 'Segoe UI', 'Noto Sans Hebrew', sans-serif",
|
||||
color: "inherit",
|
||||
};
|
||||
|
||||
const headingStyle: React.CSSProperties = {
|
||||
margin: "0 0 8px 0",
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
const totalStyle: React.CSSProperties = {
|
||||
fontSize: 22,
|
||||
fontWeight: 700,
|
||||
margin: "4px 0",
|
||||
};
|
||||
|
||||
const ulStyle: React.CSSProperties = {
|
||||
listStyle: "none",
|
||||
paddingInlineStart: 0,
|
||||
margin: 0,
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr max-content",
|
||||
rowGap: 2,
|
||||
fontSize: 13,
|
||||
};
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
color: "rgba(127,127,127,0.85)",
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
new: "תיק חדש",
|
||||
uploading: "העלאת מסמכים",
|
||||
processing: "עיבוד מסמכים",
|
||||
documents_ready: "מסמכים מוכנים",
|
||||
outcome_set: "תוצאה הוזנה",
|
||||
brainstorming: "סיעור מוחות",
|
||||
direction_approved: "כיוון אושר",
|
||||
drafting: "כתיבה",
|
||||
qa_review: "בדיקת איכות",
|
||||
drafted: "טיוטה מוכנה",
|
||||
exported: "DOCX נוצר",
|
||||
reviewed: "נבדק",
|
||||
final: "סופי",
|
||||
};
|
||||
|
||||
export function LegalCasesWidget(_props: PluginWidgetProps) {
|
||||
const { data, loading, error } = usePluginData<DashboardStats | null>(
|
||||
"legal-dashboard-stats",
|
||||
{},
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div dir="rtl" style={widgetStyle}>
|
||||
<h3 style={headingStyle}>תיקי ערר</h3>
|
||||
<p style={labelStyle}>טוען…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div dir="rtl" style={widgetStyle}>
|
||||
<h3 style={headingStyle}>תיקי ערר</h3>
|
||||
<p style={{ color: "crimson", fontSize: 13 }}>{error.message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div dir="rtl" style={widgetStyle}>
|
||||
<h3 style={headingStyle}>תיקי ערר</h3>
|
||||
<p style={labelStyle}>אין נתונים זמינים.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const entries = Object.entries(data.byStatus).sort(([, a], [, b]) => b - a);
|
||||
|
||||
return (
|
||||
<div dir="rtl" style={widgetStyle}>
|
||||
<h3 style={headingStyle}>תיקי ערר</h3>
|
||||
<div style={totalStyle}>{data.totalCases}</div>
|
||||
<div style={{ fontSize: 12, color: "rgba(127,127,127,0.85)" }}>
|
||||
סה"כ תיקים פעילים
|
||||
</div>
|
||||
<hr
|
||||
style={{
|
||||
margin: "8px 0",
|
||||
border: 0,
|
||||
borderTop: "1px solid rgba(127,127,127,0.25)",
|
||||
}}
|
||||
/>
|
||||
{entries.length > 0 ? (
|
||||
<ul style={ulStyle}>
|
||||
{entries.map(([status, count]) => (
|
||||
<li
|
||||
key={status}
|
||||
style={{ display: "contents" }}
|
||||
aria-label={`${STATUS_LABELS[status] ?? status}: ${count}`}
|
||||
>
|
||||
<span style={labelStyle}>{STATUS_LABELS[status] ?? status}</span>
|
||||
<span>{count}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p style={labelStyle}>אין תיקים פעילים.</p>
|
||||
)}
|
||||
<hr
|
||||
style={{
|
||||
margin: "8px 0",
|
||||
border: 0,
|
||||
borderTop: "1px solid rgba(127,127,127,0.25)",
|
||||
}}
|
||||
/>
|
||||
<p style={{ margin: 0, fontSize: 13 }}>
|
||||
<span style={labelStyle}>פעילות שבועית: </span>
|
||||
<strong>{data.weekActivity}</strong>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
src/ui/index.tsx
Normal file
10
src/ui/index.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* UI bundle entrypoint for the legal-ai Paperclip plugin.
|
||||
*
|
||||
* Each named export here corresponds to a `slot.exportName` in the manifest's
|
||||
* `ui.slots` declaration. The host loads this module as an ES module and
|
||||
* mounts the matching component into its slot.
|
||||
*/
|
||||
|
||||
export { LegalCasesWidget } from "./LegalCasesWidget.js";
|
||||
export { LegalCaseTab } from "./LegalCaseTab.js";
|
||||
72
src/ui/types.ts
Normal file
72
src/ui/types.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Shared types for the legal-ai plugin UI bundle.
|
||||
*
|
||||
* Mirrors a minimal subset of the legal-ai backend response shapes so the UI
|
||||
* components can type their props/data without pulling the full LegalApi
|
||||
* client (which is a worker-side dependency).
|
||||
*/
|
||||
|
||||
export interface LegalCaseSummary {
|
||||
case_number: string;
|
||||
title: string;
|
||||
status: string;
|
||||
practice_area?: string | null;
|
||||
appeal_subtype?: string | null;
|
||||
proceeding_type?: string | null;
|
||||
updated_at?: string | null;
|
||||
archived_at?: string | null;
|
||||
}
|
||||
|
||||
export interface LegalCaseDetails {
|
||||
id: string;
|
||||
case_number: string;
|
||||
title: string;
|
||||
status: string;
|
||||
practice_area?: string | null;
|
||||
appeal_subtype?: string | null;
|
||||
appellants?: string[];
|
||||
respondents?: string[];
|
||||
subject?: string;
|
||||
property_address?: string;
|
||||
expected_outcome?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface LegalArgument {
|
||||
id?: string;
|
||||
party: string;
|
||||
argument_title?: string;
|
||||
argument_body?: string;
|
||||
claim_index?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface LegalArgumentsResponse {
|
||||
case_number: string;
|
||||
total: number;
|
||||
by_party: Record<string, LegalArgument[]>;
|
||||
arguments: LegalArgument[];
|
||||
}
|
||||
|
||||
export interface PrecedentRow {
|
||||
id: string;
|
||||
citation: string;
|
||||
section_id?: string | null;
|
||||
practice_area?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface MissingPrecedentRow {
|
||||
id: string;
|
||||
citation: string;
|
||||
status: string;
|
||||
legal_topic?: string | null;
|
||||
cited_by_party?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
byStatus: Record<string, number>;
|
||||
weekActivity: number;
|
||||
totalCases: number;
|
||||
}
|
||||
166
src/worker.ts
166
src/worker.ts
@@ -842,6 +842,172 @@ const plugin = definePlugin({
|
||||
}
|
||||
});
|
||||
|
||||
// ── Data handlers (UI bridge) ──────────────────────────────────
|
||||
// These back `usePluginData(key, params)` calls from the React UI bundle
|
||||
// in `dist/ui/index.js`. Errors are caught and rethrown with a clear
|
||||
// message so the UI shows them through PluginBridgeError.
|
||||
|
||||
// Resolve the legal-ai case number linked to an issue via plugin state.
|
||||
async function resolveCaseNumber(issueId: string): Promise<string | null> {
|
||||
const value = await ctx.state.get({
|
||||
scopeKind: "issue",
|
||||
scopeId: issueId,
|
||||
stateKey: "legal-case-number",
|
||||
});
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
ctx.data.register("legal-case-summary", async (params) => {
|
||||
const issueId = String((params as { issueId?: string }).issueId ?? "");
|
||||
if (!issueId) return null;
|
||||
const caseNumber = await resolveCaseNumber(issueId);
|
||||
if (!caseNumber) return null;
|
||||
try {
|
||||
return await api.getCase(caseNumber);
|
||||
} catch (err) {
|
||||
ctx.logger.warn("legal-case-summary: getCase failed", {
|
||||
caseNumber,
|
||||
error: String(err),
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
ctx.data.register("legal-case-arguments", async (params) => {
|
||||
const issueId = String((params as { issueId?: string }).issueId ?? "");
|
||||
if (!issueId) return null;
|
||||
const caseNumber = await resolveCaseNumber(issueId);
|
||||
if (!caseNumber) return null;
|
||||
const config2 = await ctx.config.get();
|
||||
const apiBase =
|
||||
(config2.legalApiBaseUrl as string) ?? "http://localhost:8085";
|
||||
try {
|
||||
const resp = await ctx.http.fetch(
|
||||
`${apiBase}/api/cases/${encodeURIComponent(caseNumber)}/legal-arguments`,
|
||||
);
|
||||
if (!resp.ok) {
|
||||
ctx.logger.warn("legal-case-arguments: API error", {
|
||||
caseNumber,
|
||||
status: resp.status,
|
||||
});
|
||||
return {
|
||||
case_number: caseNumber,
|
||||
total: 0,
|
||||
by_party: {},
|
||||
arguments: [],
|
||||
};
|
||||
}
|
||||
return await resp.json();
|
||||
} catch (err) {
|
||||
ctx.logger.warn("legal-case-arguments: fetch failed", {
|
||||
caseNumber,
|
||||
error: String(err),
|
||||
});
|
||||
return {
|
||||
case_number: caseNumber,
|
||||
total: 0,
|
||||
by_party: {},
|
||||
arguments: [],
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
ctx.data.register("legal-case-precedents", async (params) => {
|
||||
const issueId = String((params as { issueId?: string }).issueId ?? "");
|
||||
if (!issueId) return [];
|
||||
const caseNumber = await resolveCaseNumber(issueId);
|
||||
if (!caseNumber) return [];
|
||||
const config2 = await ctx.config.get();
|
||||
const apiBase =
|
||||
(config2.legalApiBaseUrl as string) ?? "http://localhost:8085";
|
||||
try {
|
||||
const resp = await ctx.http.fetch(
|
||||
`${apiBase}/api/cases/${encodeURIComponent(caseNumber)}/precedents`,
|
||||
);
|
||||
if (!resp.ok) {
|
||||
ctx.logger.warn("legal-case-precedents: API error", {
|
||||
caseNumber,
|
||||
status: resp.status,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
const data = await resp.json();
|
||||
// Endpoint may return a flat array or `{ precedents: [...] }`.
|
||||
if (Array.isArray(data)) return data;
|
||||
if (
|
||||
data &&
|
||||
Array.isArray((data as { precedents?: unknown[] }).precedents)
|
||||
) {
|
||||
return (data as { precedents: unknown[] }).precedents;
|
||||
}
|
||||
return [];
|
||||
} catch (err) {
|
||||
ctx.logger.warn("legal-case-precedents: fetch failed", {
|
||||
caseNumber,
|
||||
error: String(err),
|
||||
});
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
ctx.data.register("legal-case-missing-precedents", async (params) => {
|
||||
const issueId = String((params as { issueId?: string }).issueId ?? "");
|
||||
if (!issueId) return [];
|
||||
const caseNumber = await resolveCaseNumber(issueId);
|
||||
if (!caseNumber) return [];
|
||||
const config2 = await ctx.config.get();
|
||||
const apiBase =
|
||||
(config2.legalApiBaseUrl as string) ?? "http://localhost:8085";
|
||||
try {
|
||||
const url = new URL(`${apiBase}/api/missing-precedents`);
|
||||
url.searchParams.set("case_number", caseNumber);
|
||||
url.searchParams.set("status", "open");
|
||||
const resp = await ctx.http.fetch(url.toString());
|
||||
if (!resp.ok) {
|
||||
ctx.logger.warn("legal-case-missing-precedents: API error", {
|
||||
caseNumber,
|
||||
status: resp.status,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
const data = (await resp.json()) as { items?: unknown[] };
|
||||
return Array.isArray(data.items) ? data.items : [];
|
||||
} catch (err) {
|
||||
ctx.logger.warn("legal-case-missing-precedents: fetch failed", {
|
||||
caseNumber,
|
||||
error: String(err),
|
||||
});
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
ctx.data.register("legal-dashboard-stats", async () => {
|
||||
try {
|
||||
const cases = await api.listCases();
|
||||
const byStatus: Record<string, number> = {};
|
||||
let weekActivity = 0;
|
||||
const weekAgoMs = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
||||
for (const c of cases) {
|
||||
byStatus[c.status] = (byStatus[c.status] ?? 0) + 1;
|
||||
const updated = (c as { updated_at?: string | null }).updated_at;
|
||||
if (updated) {
|
||||
const t = Date.parse(updated);
|
||||
if (!Number.isNaN(t) && t >= weekAgoMs) weekActivity += 1;
|
||||
}
|
||||
}
|
||||
return {
|
||||
byStatus,
|
||||
weekActivity,
|
||||
totalCases: cases.length,
|
||||
};
|
||||
} catch (err) {
|
||||
ctx.logger.warn("legal-dashboard-stats: listCases failed", {
|
||||
error: String(err),
|
||||
});
|
||||
return { byStatus: {}, weekActivity: 0, totalCases: 0 };
|
||||
}
|
||||
});
|
||||
|
||||
ctx.logger.info("Legal AI plugin ready");
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user