feat(agents): mirror Paperclip interactions in case page
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 47s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 47s
Surface issue_thread_interactions (ask_user_questions / request_confirmation /
suggest_tasks) directly inside legal-ai's case detail feed so the user can
answer agent prompts without switching to Paperclip's UI.
Backend (FastAPI):
- paperclip_client.py: 4 new helpers — get_issue_interactions (DB),
respond_to_interaction / accept_interaction / reject_interaction (REST).
- app.py: extends GET /api/cases/{case_number}/agents to include
`interactions`, and adds POST /api/cases/{case_number}/agents/interaction-response
routing to /respond, /accept, /reject in Paperclip.
- paperclip_client.py: also pulls existing httpx calls onto the centralized
pc_request helper (paperclip_api.py) for consistent auth + run-id headers.
Frontend (web-ui, Next.js 16 + TanStack Query):
- agents.ts: Interaction / InteractionPayload / InteractionStatus types,
useSubmitInteraction mutation hook (invalidates the activity query).
- agent-activity-feed.tsx: InteractionCard renders radio (single) /
checkbox (multi) for ask_user_questions, accept/reject + reason for
request_confirmation, task selection for suggest_tasks. Resolved
interactions show a read-only summary. Cards are interleaved with
comments by created_at, so the feed reads chronologically.
Paperclip auto-wakes the issue assignee on a successful response
(queueResolvedInteractionContinuationWakeup) — no explicit wakeup needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,8 +5,18 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Markdown } from "@/components/ui/markdown";
|
import { Markdown } from "@/components/ui/markdown";
|
||||||
import { useAgentActivity, useSendComment } from "@/lib/api/agents";
|
import {
|
||||||
import type { PaperclipComment } from "@/lib/api/agents";
|
useAgentActivity,
|
||||||
|
useSendComment,
|
||||||
|
useSubmitInteraction,
|
||||||
|
} from "@/lib/api/agents";
|
||||||
|
import type {
|
||||||
|
Interaction,
|
||||||
|
InteractionPayload,
|
||||||
|
InteractionQuestion,
|
||||||
|
InteractionTask,
|
||||||
|
PaperclipComment,
|
||||||
|
} from "@/lib/api/agents";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
Bot,
|
Bot,
|
||||||
@@ -15,6 +25,9 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Clock,
|
Clock,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
HelpCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
/* ── Role → color mapping ────────────────────────────────────── */
|
/* ── Role → color mapping ────────────────────────────────────── */
|
||||||
@@ -153,6 +166,463 @@ function CommentCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Interaction card ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const RESOLVED_LABELS: Record<string, { text: string; tone: string; Icon: typeof CheckCircle2 }> = {
|
||||||
|
answered: { text: "נענה", tone: "text-emerald-700 bg-emerald-50 border-emerald-200", Icon: CheckCircle2 },
|
||||||
|
accepted: { text: "התקבל", tone: "text-emerald-700 bg-emerald-50 border-emerald-200", Icon: CheckCircle2 },
|
||||||
|
rejected: { text: "נדחה", tone: "text-rose-700 bg-rose-50 border-rose-200", Icon: XCircle },
|
||||||
|
expired: { text: "פג תוקף", tone: "text-ink-faint bg-gray-50 border-gray-200", Icon: XCircle },
|
||||||
|
failed: { text: "כשל", tone: "text-rose-700 bg-rose-50 border-rose-200", Icon: XCircle },
|
||||||
|
};
|
||||||
|
|
||||||
|
function ResolvedBadge({ status }: { status: string }) {
|
||||||
|
const meta = RESOLVED_LABELS[status];
|
||||||
|
if (!meta) return null;
|
||||||
|
const { text, tone, Icon } = meta;
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full border ${tone}`}>
|
||||||
|
<Icon className="w-3 h-3" />
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function summaryAnswer(interaction: Interaction): string | null {
|
||||||
|
const result = interaction.result;
|
||||||
|
if (!result) return null;
|
||||||
|
if (typeof result.summaryMarkdown === "string" && result.summaryMarkdown.trim()) {
|
||||||
|
return result.summaryMarkdown;
|
||||||
|
}
|
||||||
|
if (interaction.kind === "ask_user_questions" && Array.isArray(result.answers)) {
|
||||||
|
const optionLabel = (qid: string, oid: string): string => {
|
||||||
|
const q = interaction.payload.questions?.find((qq) => qq.id === qid);
|
||||||
|
return q?.options.find((o) => o.id === oid)?.label ?? oid;
|
||||||
|
};
|
||||||
|
return (result.answers as Array<{ questionId: string; optionIds: string[] }>)
|
||||||
|
.map((a) =>
|
||||||
|
`**${interaction.payload.questions?.find((q) => q.id === a.questionId)?.prompt ?? a.questionId}** — ${a.optionIds
|
||||||
|
.map((oid) => optionLabel(a.questionId, oid))
|
||||||
|
.join(", ")}`,
|
||||||
|
)
|
||||||
|
.join("\n\n");
|
||||||
|
}
|
||||||
|
if (interaction.kind === "request_confirmation" && typeof result.reason === "string" && result.reason) {
|
||||||
|
return `נימוק: ${result.reason}`;
|
||||||
|
}
|
||||||
|
if (interaction.kind === "suggest_tasks") {
|
||||||
|
const created = Array.isArray(result.createdTasks) ? result.createdTasks.length : 0;
|
||||||
|
const skipped = Array.isArray(result.skippedClientKeys) ? result.skippedClientKeys.length : 0;
|
||||||
|
if (created || skipped) {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (created) parts.push(`נוצרו ${created} משימות`);
|
||||||
|
if (skipped) parts.push(`דילוג על ${skipped}`);
|
||||||
|
return parts.join(" · ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AskUserQuestionsForm({
|
||||||
|
interaction,
|
||||||
|
onSubmit,
|
||||||
|
pending,
|
||||||
|
}: {
|
||||||
|
interaction: Interaction;
|
||||||
|
onSubmit: (answers: Array<{ questionId: string; optionIds: string[] }>) => void;
|
||||||
|
pending: boolean;
|
||||||
|
}) {
|
||||||
|
const questions: InteractionQuestion[] = interaction.payload.questions ?? [];
|
||||||
|
const [selections, setSelections] = useState<Record<string, string[]>>({});
|
||||||
|
|
||||||
|
const setSingle = (qid: string, oid: string) =>
|
||||||
|
setSelections((prev) => ({ ...prev, [qid]: [oid] }));
|
||||||
|
|
||||||
|
const toggleMulti = (qid: string, oid: string) =>
|
||||||
|
setSelections((prev) => {
|
||||||
|
const cur = prev[qid] ?? [];
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[qid]: cur.includes(oid) ? cur.filter((x) => x !== oid) : [...cur, oid],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const missingRequired = questions.some(
|
||||||
|
(q) => (q.required ?? true) && !(selections[q.id]?.length),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSend = () => {
|
||||||
|
const answers = questions
|
||||||
|
.map((q) => ({ questionId: q.id, optionIds: selections[q.id] ?? [] }))
|
||||||
|
.filter((a) => a.optionIds.length > 0);
|
||||||
|
onSubmit(answers);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{questions.map((q) => {
|
||||||
|
const isSingle = (q.selectionMode ?? "single") === "single";
|
||||||
|
const chosen = selections[q.id] ?? [];
|
||||||
|
return (
|
||||||
|
<fieldset key={q.id} className="space-y-2">
|
||||||
|
<legend className="text-sm font-semibold text-navy mb-1">
|
||||||
|
{q.prompt}
|
||||||
|
{(q.required ?? true) && <span className="text-rose-600 mr-1">*</span>}
|
||||||
|
</legend>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{q.options.map((opt) => {
|
||||||
|
const checked = chosen.includes(opt.id);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={opt.id}
|
||||||
|
className={`flex items-start gap-2 cursor-pointer rounded-md border p-2 transition-colors ${
|
||||||
|
checked
|
||||||
|
? "border-navy bg-navy/5"
|
||||||
|
: "border-rule hover:bg-sand-soft/60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type={isSingle ? "radio" : "checkbox"}
|
||||||
|
name={q.id}
|
||||||
|
value={opt.id}
|
||||||
|
checked={checked}
|
||||||
|
onChange={() =>
|
||||||
|
isSingle ? setSingle(q.id, opt.id) : toggleMulti(q.id, opt.id)
|
||||||
|
}
|
||||||
|
className="mt-1 accent-navy"
|
||||||
|
disabled={pending}
|
||||||
|
/>
|
||||||
|
<span className="flex-1 text-sm">
|
||||||
|
<span className="font-medium text-navy">{opt.label}</span>
|
||||||
|
{opt.description && (
|
||||||
|
<span className="block text-xs text-ink-faint mt-0.5">
|
||||||
|
{opt.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={pending || missingRequired}
|
||||||
|
>
|
||||||
|
{pending ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="w-4 h-4 ml-1" />
|
||||||
|
)}
|
||||||
|
{interaction.payload.submitLabel || "שלח תשובה"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RequestConfirmationForm({
|
||||||
|
interaction,
|
||||||
|
onAccept,
|
||||||
|
onReject,
|
||||||
|
pending,
|
||||||
|
}: {
|
||||||
|
interaction: Interaction;
|
||||||
|
onAccept: () => void;
|
||||||
|
onReject: (reason: string) => void;
|
||||||
|
pending: boolean;
|
||||||
|
}) {
|
||||||
|
const payload = interaction.payload;
|
||||||
|
const allowReason = payload.allowDeclineReason !== false;
|
||||||
|
const requireReason = payload.rejectRequiresReason === true;
|
||||||
|
const [showReason, setShowReason] = useState(requireReason);
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
|
||||||
|
const acceptLabel = (payload.acceptLabel as string) || "אישור";
|
||||||
|
const rejectLabel = (payload.rejectLabel as string) || "דחייה";
|
||||||
|
const reasonLabel =
|
||||||
|
(payload.rejectReasonLabel as string) || "נימוק (לא חובה)";
|
||||||
|
const reasonPlaceholder =
|
||||||
|
(payload.declineReasonPlaceholder as string) || "סיבת הדחייה...";
|
||||||
|
|
||||||
|
const handleReject = () => {
|
||||||
|
if (requireReason && !reason.trim()) {
|
||||||
|
setShowReason(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onReject(reason.trim());
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{typeof payload.prompt === "string" && (
|
||||||
|
<div className="text-sm text-navy whitespace-pre-line">{payload.prompt}</div>
|
||||||
|
)}
|
||||||
|
{typeof payload.detailsMarkdown === "string" && payload.detailsMarkdown && (
|
||||||
|
<div className="text-sm bg-sand-soft/40 rounded-md p-2">
|
||||||
|
<Markdown content={payload.detailsMarkdown} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showReason && allowReason && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium text-ink-faint">{reasonLabel}</label>
|
||||||
|
<Textarea
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
placeholder={reasonPlaceholder}
|
||||||
|
className="min-h-[60px] text-sm"
|
||||||
|
dir="rtl"
|
||||||
|
disabled={pending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-2 justify-end">
|
||||||
|
{allowReason && !showReason && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowReason(true)}
|
||||||
|
disabled={pending}
|
||||||
|
>
|
||||||
|
הוסף נימוק
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleReject}
|
||||||
|
disabled={pending || (requireReason && !reason.trim())}
|
||||||
|
>
|
||||||
|
<XCircle className="w-4 h-4 ml-1" />
|
||||||
|
{rejectLabel}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={onAccept} disabled={pending}>
|
||||||
|
{pending ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className="w-4 h-4 ml-1" />
|
||||||
|
)}
|
||||||
|
{acceptLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SuggestTasksForm({
|
||||||
|
interaction,
|
||||||
|
onAccept,
|
||||||
|
onReject,
|
||||||
|
pending,
|
||||||
|
}: {
|
||||||
|
interaction: Interaction;
|
||||||
|
onAccept: (selectedClientKeys: string[]) => void;
|
||||||
|
onReject: (reason: string) => void;
|
||||||
|
pending: boolean;
|
||||||
|
}) {
|
||||||
|
const tasks: InteractionTask[] = (interaction.payload.tasks as InteractionTask[]) ?? [];
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(
|
||||||
|
() => new Set(tasks.map((t) => t.clientKey)),
|
||||||
|
);
|
||||||
|
const [showReason, setShowReason] = useState(false);
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
|
||||||
|
const toggle = (key: string) =>
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) next.delete(key);
|
||||||
|
else next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1.5 max-h-[260px] overflow-y-auto">
|
||||||
|
{tasks.map((t) => {
|
||||||
|
const checked = selected.has(t.clientKey);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={t.clientKey}
|
||||||
|
className={`flex items-start gap-2 cursor-pointer rounded-md border p-2 ${
|
||||||
|
checked
|
||||||
|
? "border-navy bg-navy/5"
|
||||||
|
: "border-rule hover:bg-sand-soft/60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => toggle(t.clientKey)}
|
||||||
|
className="mt-1 accent-navy"
|
||||||
|
disabled={pending}
|
||||||
|
/>
|
||||||
|
<span className="flex-1 text-sm">
|
||||||
|
<span className="font-medium text-navy">{t.title}</span>
|
||||||
|
{t.description && (
|
||||||
|
<span className="block text-xs text-ink-faint mt-0.5 whitespace-pre-line">
|
||||||
|
{t.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{showReason && (
|
||||||
|
<Textarea
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
placeholder="סיבת הדחייה (לא חובה)..."
|
||||||
|
className="min-h-[60px] text-sm"
|
||||||
|
dir="rtl"
|
||||||
|
disabled={pending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-2 justify-end">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => (showReason ? onReject(reason.trim()) : setShowReason(true))}
|
||||||
|
disabled={pending}
|
||||||
|
>
|
||||||
|
<XCircle className="w-4 h-4 ml-1" />
|
||||||
|
{showReason ? "אישור דחייה" : "דחייה"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onAccept(Array.from(selected))}
|
||||||
|
disabled={pending || selected.size === 0}
|
||||||
|
>
|
||||||
|
{pending ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className="w-4 h-4 ml-1" />
|
||||||
|
)}
|
||||||
|
אישור משימות נבחרות ({selected.size})
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InteractionCard({
|
||||||
|
interaction,
|
||||||
|
caseNumber,
|
||||||
|
issueMap,
|
||||||
|
}: {
|
||||||
|
interaction: Interaction;
|
||||||
|
caseNumber: string;
|
||||||
|
issueMap: Map<string, string>;
|
||||||
|
}) {
|
||||||
|
const submit = useSubmitInteraction(caseNumber);
|
||||||
|
const identifier = issueMap.get(interaction.issue_id) ?? "";
|
||||||
|
const isPending = interaction.status === "pending";
|
||||||
|
const summary = summaryAnswer(interaction);
|
||||||
|
|
||||||
|
const send = (action: "respond" | "accept" | "reject", payload: InteractionPayload | Record<string, unknown>) => {
|
||||||
|
submit.mutate(
|
||||||
|
{
|
||||||
|
issue_id: interaction.issue_id,
|
||||||
|
interaction_id: interaction.id,
|
||||||
|
action,
|
||||||
|
payload: payload as Record<string, unknown>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => toast.success("התשובה נשלחה"),
|
||||||
|
onError: () => toast.error("שגיאה בשליחת התשובה"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`group relative flex gap-3 py-3 px-2 rounded-lg border transition-colors ${
|
||||||
|
isPending
|
||||||
|
? "border-amber-300 bg-amber-50/40"
|
||||||
|
: "border-rule bg-sand-soft/30"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0 pt-0.5">
|
||||||
|
<div
|
||||||
|
className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
||||||
|
isPending
|
||||||
|
? "bg-amber-100 text-amber-800 border border-amber-300"
|
||||||
|
: "bg-emerald-100 text-emerald-800 border border-emerald-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<HelpCircle className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
|
<span className="text-sm font-semibold text-navy">
|
||||||
|
{interaction.title || "שאלה לסוכן"}
|
||||||
|
</span>
|
||||||
|
{isPending ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full border border-amber-300 bg-amber-100 text-amber-800">
|
||||||
|
ממתין לתשובה
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<ResolvedBadge status={interaction.status} />
|
||||||
|
)}
|
||||||
|
{identifier && (
|
||||||
|
<Badge variant="outline" className="text-[10px] font-mono">
|
||||||
|
{identifier}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<span className="text-[11px] text-ink-faint mr-auto flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{timeAgo(interaction.resolved_at ?? interaction.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{interaction.summary && (
|
||||||
|
<div className="text-xs text-ink-faint mb-2">{interaction.summary}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isPending ? (
|
||||||
|
interaction.kind === "ask_user_questions" ? (
|
||||||
|
<AskUserQuestionsForm
|
||||||
|
interaction={interaction}
|
||||||
|
onSubmit={(answers) => send("respond", { answers })}
|
||||||
|
pending={submit.isPending}
|
||||||
|
/>
|
||||||
|
) : interaction.kind === "request_confirmation" ? (
|
||||||
|
<RequestConfirmationForm
|
||||||
|
interaction={interaction}
|
||||||
|
onAccept={() => send("accept", {})}
|
||||||
|
onReject={(reason) =>
|
||||||
|
send("reject", reason ? { reason } : {})
|
||||||
|
}
|
||||||
|
pending={submit.isPending}
|
||||||
|
/>
|
||||||
|
) : interaction.kind === "suggest_tasks" ? (
|
||||||
|
<SuggestTasksForm
|
||||||
|
interaction={interaction}
|
||||||
|
onAccept={(keys) =>
|
||||||
|
send("accept", keys.length ? { selectedClientKeys: keys } : {})
|
||||||
|
}
|
||||||
|
onReject={(reason) =>
|
||||||
|
send("reject", reason ? { reason } : {})
|
||||||
|
}
|
||||||
|
pending={submit.isPending}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
) : summary ? (
|
||||||
|
<div className="text-sm">
|
||||||
|
<Markdown content={summary} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Main Feed ───────────────────────────────────────────────── */
|
/* ── Main Feed ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
export function AgentActivityFeed({
|
export function AgentActivityFeed({
|
||||||
@@ -173,11 +643,12 @@ export function AgentActivityFeed({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-scroll on new comments
|
// Auto-scroll on new comments or interactions
|
||||||
const commentCount = data?.comments?.length ?? 0;
|
const commentCount = data?.comments?.length ?? 0;
|
||||||
|
const interactionCount = data?.interactions?.length ?? 0;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
endRef.current?.scrollIntoView({ behavior: "smooth" });
|
endRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
}, [commentCount]);
|
}, [commentCount, interactionCount]);
|
||||||
|
|
||||||
const handleSend = () => {
|
const handleSend = () => {
|
||||||
if (!body.trim()) return;
|
if (!body.trim()) return;
|
||||||
@@ -224,6 +695,25 @@ export function AgentActivityFeed({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const comments = data.comments ?? [];
|
const comments = data.comments ?? [];
|
||||||
|
const interactions = data.interactions ?? [];
|
||||||
|
|
||||||
|
// Unified, time-sorted feed: comments + interactions interleaved.
|
||||||
|
type FeedItem =
|
||||||
|
| { kind: "comment"; at: number; comment: PaperclipComment }
|
||||||
|
| { kind: "interaction"; at: number; interaction: Interaction };
|
||||||
|
|
||||||
|
const feed: FeedItem[] = [
|
||||||
|
...comments.map<FeedItem>((c) => ({
|
||||||
|
kind: "comment",
|
||||||
|
at: c.created_at ? new Date(c.created_at).getTime() : 0,
|
||||||
|
comment: c,
|
||||||
|
})),
|
||||||
|
...interactions.map<FeedItem>((i) => ({
|
||||||
|
kind: "interaction",
|
||||||
|
at: i.created_at ? new Date(i.created_at).getTime() : 0,
|
||||||
|
interaction: i,
|
||||||
|
})),
|
||||||
|
].sort((a, b) => a.at - b.at);
|
||||||
|
|
||||||
// An issue is "active" if it's not done/cancelled. When everything is closed
|
// An issue is "active" if it's not done/cancelled. When everything is closed
|
||||||
// we should NOT show the "agents are working, waiting for report" spinner.
|
// we should NOT show the "agents are working, waiting for report" spinner.
|
||||||
@@ -246,9 +736,9 @@ export function AgentActivityFeed({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Comments stream */}
|
{/* Comments + interactions stream */}
|
||||||
<div className="flex-1 overflow-y-auto max-h-[500px] space-y-1 px-1">
|
<div className="flex-1 overflow-y-auto max-h-[500px] space-y-1 px-1">
|
||||||
{comments.length === 0 ? (
|
{feed.length === 0 ? (
|
||||||
hasActiveIssue ? (
|
hasActiveIssue ? (
|
||||||
<div className="text-center py-8 text-ink-faint text-sm">
|
<div className="text-center py-8 text-ink-faint text-sm">
|
||||||
<Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />
|
<Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />
|
||||||
@@ -261,9 +751,22 @@ export function AgentActivityFeed({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
comments.map((c) => (
|
feed.map((item) =>
|
||||||
<CommentCard key={c.id} comment={c} issueMap={issueMap} />
|
item.kind === "comment" ? (
|
||||||
))
|
<CommentCard
|
||||||
|
key={`c-${item.comment.id}`}
|
||||||
|
comment={item.comment}
|
||||||
|
issueMap={issueMap}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<InteractionCard
|
||||||
|
key={`i-${item.interaction.id}`}
|
||||||
|
interaction={item.interaction}
|
||||||
|
caseNumber={caseNumber}
|
||||||
|
issueMap={issueMap}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
<div ref={endRef} />
|
<div ref={endRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,10 +42,80 @@ export type PaperclipAgent = {
|
|||||||
last_heartbeat_at: string | null;
|
last_heartbeat_at: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type InteractionKind =
|
||||||
|
| "ask_user_questions"
|
||||||
|
| "request_confirmation"
|
||||||
|
| "suggest_tasks";
|
||||||
|
|
||||||
|
export type InteractionStatus =
|
||||||
|
| "pending"
|
||||||
|
| "answered"
|
||||||
|
| "accepted"
|
||||||
|
| "rejected"
|
||||||
|
| "expired"
|
||||||
|
| "failed";
|
||||||
|
|
||||||
|
export type InteractionOption = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InteractionQuestion = {
|
||||||
|
id: string;
|
||||||
|
prompt: string;
|
||||||
|
selectionMode?: "single" | "multi";
|
||||||
|
required?: boolean;
|
||||||
|
options: InteractionOption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InteractionTask = {
|
||||||
|
clientKey: string;
|
||||||
|
parentClientKey?: string | null;
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Free-form payload — shape depends on `kind`. Common fields surfaced for the
|
||||||
|
* UI; everything else is preserved on the wire. */
|
||||||
|
export type InteractionPayload = {
|
||||||
|
version?: number;
|
||||||
|
submitLabel?: string;
|
||||||
|
acceptLabel?: string;
|
||||||
|
rejectLabel?: string;
|
||||||
|
questions?: InteractionQuestion[];
|
||||||
|
tasks?: InteractionTask[];
|
||||||
|
body?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Interaction = {
|
||||||
|
id: string;
|
||||||
|
issue_id: string;
|
||||||
|
kind: InteractionKind;
|
||||||
|
status: InteractionStatus;
|
||||||
|
title: string | null;
|
||||||
|
summary: string | null;
|
||||||
|
payload: InteractionPayload;
|
||||||
|
result: Record<string, unknown> | null;
|
||||||
|
created_at: string | null;
|
||||||
|
resolved_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type AgentActivityResponse = {
|
export type AgentActivityResponse = {
|
||||||
issues: PaperclipIssue[];
|
issues: PaperclipIssue[];
|
||||||
comments: PaperclipComment[];
|
comments: PaperclipComment[];
|
||||||
agents: PaperclipAgent[];
|
agents: PaperclipAgent[];
|
||||||
|
interactions: Interaction[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InteractionAction = "respond" | "accept" | "reject";
|
||||||
|
|
||||||
|
export type InteractionSubmitVars = {
|
||||||
|
issue_id: string;
|
||||||
|
interaction_id: string;
|
||||||
|
action: InteractionAction;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Query Keys ───────────────────────────────────────────────────
|
// ── Query Keys ───────────────────────────────────────────────────
|
||||||
@@ -85,3 +155,19 @@ export function useSendComment(caseNumber: string | undefined) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useSubmitInteraction(caseNumber: string | undefined) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (vars: InteractionSubmitVars) =>
|
||||||
|
apiRequest<Interaction>(
|
||||||
|
`/api/cases/${caseNumber}/agents/interaction-response`,
|
||||||
|
{ method: "POST", body: vars },
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
if (caseNumber) {
|
||||||
|
qc.invalidateQueries({ queryKey: agentKeys.activity(caseNumber) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
59
web/app.py
59
web/app.py
@@ -22,7 +22,7 @@ import zipfile
|
|||||||
|
|
||||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||||
from fastapi.responses import FileResponse, StreamingResponse
|
from fastapi.responses import FileResponse, StreamingResponse
|
||||||
from typing import Any
|
from typing import Any, Literal
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
@@ -45,6 +45,7 @@ from web.mcp_env_catalog import (
|
|||||||
)
|
)
|
||||||
from web.progress_store import ProgressStore
|
from web.progress_store import ProgressStore
|
||||||
from web.paperclip_client import (
|
from web.paperclip_client import (
|
||||||
|
accept_interaction as pc_accept_interaction,
|
||||||
archive_project as pc_archive_project,
|
archive_project as pc_archive_project,
|
||||||
create_project as pc_create_project,
|
create_project as pc_create_project,
|
||||||
create_workflow_issue as pc_create_workflow_issue,
|
create_workflow_issue as pc_create_workflow_issue,
|
||||||
@@ -52,8 +53,11 @@ from web.paperclip_client import (
|
|||||||
get_agents_for_company as pc_get_agents,
|
get_agents_for_company as pc_get_agents,
|
||||||
get_case_issues as pc_get_case_issues,
|
get_case_issues as pc_get_case_issues,
|
||||||
get_issue_comments as pc_get_issue_comments,
|
get_issue_comments as pc_get_issue_comments,
|
||||||
|
get_issue_interactions as pc_get_issue_interactions,
|
||||||
get_project_url,
|
get_project_url,
|
||||||
post_comment as pc_post_comment,
|
post_comment as pc_post_comment,
|
||||||
|
reject_interaction as pc_reject_interaction,
|
||||||
|
respond_to_interaction as pc_respond_to_interaction,
|
||||||
restore_project as pc_restore_project,
|
restore_project as pc_restore_project,
|
||||||
wake_ceo_agent as pc_wake_ceo,
|
wake_ceo_agent as pc_wake_ceo,
|
||||||
wake_for_precedent_extraction as pc_wake_for_precedent_extraction,
|
wake_for_precedent_extraction as pc_wake_for_precedent_extraction,
|
||||||
@@ -2507,17 +2511,26 @@ async def api_start_workflow(case_number: str):
|
|||||||
|
|
||||||
@app.get("/api/cases/{case_number}/agents")
|
@app.get("/api/cases/{case_number}/agents")
|
||||||
async def api_case_agents(case_number: str):
|
async def api_case_agents(case_number: str):
|
||||||
"""Get all Paperclip agent activity for a case: issues, comments, agent status."""
|
"""Get all Paperclip agent activity for a case: issues, comments, interactions, agent status."""
|
||||||
issues = await pc_get_case_issues(case_number)
|
issues = await pc_get_case_issues(case_number)
|
||||||
if not issues:
|
if not issues:
|
||||||
return {"issues": [], "comments": [], "agents": []}
|
return {"issues": [], "comments": [], "agents": [], "interactions": []}
|
||||||
|
|
||||||
issue_ids = [i["id"] for i in issues]
|
issue_ids = [i["id"] for i in issues]
|
||||||
company_id = issues[0]["company_id"]
|
company_id = issues[0]["company_id"]
|
||||||
|
|
||||||
comments, agents = await pc_get_issue_comments(issue_ids), await pc_get_agents_for_case(company_id, issue_ids)
|
comments, agents, interactions = await asyncio.gather(
|
||||||
|
pc_get_issue_comments(issue_ids),
|
||||||
|
pc_get_agents_for_case(company_id, issue_ids),
|
||||||
|
pc_get_issue_interactions(issue_ids),
|
||||||
|
)
|
||||||
|
|
||||||
return {"issues": issues, "comments": comments, "agents": agents}
|
return {
|
||||||
|
"issues": issues,
|
||||||
|
"comments": comments,
|
||||||
|
"agents": agents,
|
||||||
|
"interactions": interactions,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class AgentCommentRequest(BaseModel):
|
class AgentCommentRequest(BaseModel):
|
||||||
@@ -2551,6 +2564,42 @@ async def api_post_agent_comment(case_number: str, req: AgentCommentRequest):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class InteractionResponseRequest(BaseModel):
|
||||||
|
issue_id: str
|
||||||
|
interaction_id: str
|
||||||
|
action: Literal["respond", "accept", "reject"]
|
||||||
|
payload: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/cases/{case_number}/agents/interaction-response")
|
||||||
|
async def api_post_interaction_response(
|
||||||
|
case_number: str, req: InteractionResponseRequest,
|
||||||
|
):
|
||||||
|
"""Submit a user's answer to a Paperclip issue-thread interaction.
|
||||||
|
|
||||||
|
Routes to /respond | /accept | /reject based on `action`. Paperclip
|
||||||
|
auto-wakes the issue assignee after a successful submission.
|
||||||
|
"""
|
||||||
|
issues = await pc_get_case_issues(case_number)
|
||||||
|
if not any(i["id"] == req.issue_id for i in issues):
|
||||||
|
raise HTTPException(404, f"Issue {req.issue_id} לא שייך לתיק {case_number}")
|
||||||
|
|
||||||
|
handlers = {
|
||||||
|
"respond": pc_respond_to_interaction,
|
||||||
|
"accept": pc_accept_interaction,
|
||||||
|
"reject": pc_reject_interaction,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
return await handlers[req.action](
|
||||||
|
req.issue_id, req.interaction_id, req.payload,
|
||||||
|
)
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
body = e.response.text or ""
|
||||||
|
raise HTTPException(e.response.status_code, body[:500])
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(502, f"שגיאת Paperclip: {e}")
|
||||||
|
|
||||||
|
|
||||||
# ── Settings: MCP Server Configuration ────────────────────────────
|
# ── Settings: MCP Server Configuration ────────────────────────────
|
||||||
#
|
#
|
||||||
# Source of truth for legal-ai env vars is Coolify (see memory:
|
# Source of truth for legal-ai env vars is Coolify (see memory:
|
||||||
|
|||||||
83
web/paperclip_api.py
Normal file
83
web/paperclip_api.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"""Paperclip REST API helper.
|
||||||
|
|
||||||
|
All HTTP calls from legal-ai backend (FastAPI) to Paperclip should go through
|
||||||
|
``pc_request`` so that auth + audit headers are applied consistently.
|
||||||
|
|
||||||
|
The bash counterpart for agents lives at ``scripts/pc.sh``.
|
||||||
|
|
||||||
|
Notes
|
||||||
|
-----
|
||||||
|
* Uses ``PAPERCLIP_BOARD_API_KEY`` (long-lived) — these are *board* actions
|
||||||
|
(wakeups, comments-as-user) initiated from outside a heartbeat, not agent
|
||||||
|
actions. Board API keys are not JWTs, so they do **not** carry a ``run_id``
|
||||||
|
claim; pass ``run_id=`` explicitly when you have one (rare for board flows).
|
||||||
|
* For agent actions inside a heartbeat run, agents use the bash helper with
|
||||||
|
the auto-injected ``PAPERCLIP_API_KEY`` JWT — those carry ``run_id`` in
|
||||||
|
claims, so the header is informational/future-proofing.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PAPERCLIP_API_URL = os.environ.get("PAPERCLIP_API_URL", "http://localhost:3100")
|
||||||
|
PAPERCLIP_BOARD_API_KEY = os.environ.get("PAPERCLIP_BOARD_API_KEY", "")
|
||||||
|
|
||||||
|
DEFAULT_TIMEOUT = 15.0
|
||||||
|
|
||||||
|
|
||||||
|
def _build_headers(run_id: str | None, has_body: bool) -> dict[str, str]:
|
||||||
|
if not PAPERCLIP_BOARD_API_KEY:
|
||||||
|
raise RuntimeError("PAPERCLIP_BOARD_API_KEY not set — cannot call Paperclip API")
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {PAPERCLIP_BOARD_API_KEY}",
|
||||||
|
"X-Paperclip-Run-Id": run_id or "",
|
||||||
|
}
|
||||||
|
if has_body:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
async def pc_request(
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
json: dict[str, Any] | None = None,
|
||||||
|
run_id: str | None = None,
|
||||||
|
timeout: float = DEFAULT_TIMEOUT,
|
||||||
|
raise_on_error: bool = False,
|
||||||
|
) -> httpx.Response:
|
||||||
|
"""Make a Paperclip REST request.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
method : str
|
||||||
|
HTTP method (GET, POST, PATCH, DELETE).
|
||||||
|
path : str
|
||||||
|
Path relative to PAPERCLIP_API_URL (must start with ``/``).
|
||||||
|
json : dict, optional
|
||||||
|
Request body — sent as JSON.
|
||||||
|
run_id : str, optional
|
||||||
|
Heartbeat run ID for audit trail (X-Paperclip-Run-Id header).
|
||||||
|
Rare for board actions; provide when initiating from inside a run.
|
||||||
|
timeout : float
|
||||||
|
httpx timeout (default 15s).
|
||||||
|
raise_on_error : bool
|
||||||
|
If True, calls ``response.raise_for_status()`` on 4xx/5xx.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
httpx.Response
|
||||||
|
"""
|
||||||
|
headers = _build_headers(run_id, has_body=json is not None)
|
||||||
|
url = f"{PAPERCLIP_API_URL}{path}"
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
resp = await client.request(method, url, headers=headers, json=json)
|
||||||
|
if raise_on_error:
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp
|
||||||
@@ -12,7 +12,8 @@ import os
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
import httpx
|
|
||||||
|
from web.paperclip_api import pc_request
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -21,7 +22,8 @@ PAPERCLIP_DB_URL = os.environ.get(
|
|||||||
)
|
)
|
||||||
|
|
||||||
PLUGIN_ID = "53461b5a-7f58-411a-9952-72f9c8d4a328" # marcusgroup.legal-ai
|
PLUGIN_ID = "53461b5a-7f58-411a-9952-72f9c8d4a328" # marcusgroup.legal-ai
|
||||||
PAPERCLIP_API_URL = os.environ.get("PAPERCLIP_API_URL", "http://localhost:3100")
|
# PAPERCLIP_API_URL — moved to web.paperclip_api (used only by pc_request now).
|
||||||
|
# Direct DB calls below use PAPERCLIP_DB_URL instead.
|
||||||
PAPERCLIP_BOARD_API_KEY = os.environ.get("PAPERCLIP_BOARD_API_KEY", "")
|
PAPERCLIP_BOARD_API_KEY = os.environ.get("PAPERCLIP_BOARD_API_KEY", "")
|
||||||
|
|
||||||
# Default workspace attached to every new Paperclip project — points agents at
|
# Default workspace attached to every new Paperclip project — points agents at
|
||||||
@@ -584,16 +586,15 @@ async def post_comment(issue_id: str, company_id: str, body: str) -> dict:
|
|||||||
# Try Board API first — this triggers the event bus
|
# Try Board API first — this triggers the event bus
|
||||||
if PAPERCLIP_BOARD_API_KEY:
|
if PAPERCLIP_BOARD_API_KEY:
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=15) as client:
|
resp = await pc_request(
|
||||||
resp = await client.post(
|
"POST",
|
||||||
f"{PAPERCLIP_API_URL}/api/board/issues/{issue_id}/comments",
|
f"/api/board/issues/{issue_id}/comments",
|
||||||
json={"body": body},
|
json={"body": body},
|
||||||
headers={"Authorization": f"Bearer {PAPERCLIP_BOARD_API_KEY}"},
|
)
|
||||||
)
|
if resp.status_code < 400:
|
||||||
if resp.status_code < 400:
|
result = resp.json()
|
||||||
result = resp.json()
|
logger.info("Posted comment via Board API on issue %s", issue_id)
|
||||||
logger.info("Posted comment via Board API on issue %s", issue_id)
|
return {"comment_id": result.get("id", ""), "issue_id": issue_id, "method": "api"}
|
||||||
return {"comment_id": result.get("id", ""), "issue_id": issue_id, "method": "api"}
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("Board API comment failed for issue %s, falling back to DB", issue_id)
|
logger.debug("Board API comment failed for issue %s, falling back to DB", issue_id)
|
||||||
|
|
||||||
@@ -613,25 +614,118 @@ async def post_comment(issue_id: str, company_id: str, body: str) -> dict:
|
|||||||
# Wake the correct CEO for this company
|
# Wake the correct CEO for this company
|
||||||
ceo_id = CEO_AGENTS.get(company_id, CEO_AGENT_ID)
|
ceo_id = CEO_AGENTS.get(company_id, CEO_AGENT_ID)
|
||||||
try:
|
try:
|
||||||
url = f"{PAPERCLIP_API_URL}/api/agents/{ceo_id}/wakeup"
|
await pc_request(
|
||||||
payload = {
|
"POST",
|
||||||
"source": "on_demand",
|
f"/api/agents/{ceo_id}/wakeup",
|
||||||
"triggerDetail": "manual",
|
json={
|
||||||
"reason": f"user_comment_{issue_id}",
|
"source": "on_demand",
|
||||||
"payload": {"issueId": issue_id, "mutation": "comment"},
|
"triggerDetail": "manual",
|
||||||
}
|
"reason": f"user_comment_{issue_id}",
|
||||||
async with httpx.AsyncClient(timeout=15) as client:
|
"payload": {"issueId": issue_id, "mutation": "comment"},
|
||||||
resp = await client.post(
|
},
|
||||||
url, json=payload,
|
raise_on_error=True,
|
||||||
headers={"Authorization": f"Bearer {PAPERCLIP_BOARD_API_KEY}"},
|
)
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Failed to wake CEO after DB comment on issue %s", issue_id)
|
logger.warning("Failed to wake CEO after DB comment on issue %s", issue_id)
|
||||||
|
|
||||||
return {"comment_id": comment_id, "issue_id": issue_id, "method": "db_fallback"}
|
return {"comment_id": comment_id, "issue_id": issue_id, "method": "db_fallback"}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_issue_interactions(issue_ids: list[str]) -> list[dict]:
|
||||||
|
"""Fetch issue-thread interactions (agent → user button prompts).
|
||||||
|
|
||||||
|
Returns all `pending` interactions plus any resolved within the last 24h
|
||||||
|
so the user sees a brief tail of recent answers without flooding the feed.
|
||||||
|
Ordered by ``created_at`` so callers can interleave with comments.
|
||||||
|
"""
|
||||||
|
if not issue_ids:
|
||||||
|
return []
|
||||||
|
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||||
|
try:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""SELECT id, issue_id, kind, status, title, summary,
|
||||||
|
payload, result, created_at, resolved_at
|
||||||
|
FROM issue_thread_interactions
|
||||||
|
WHERE issue_id = ANY($1::uuid[])
|
||||||
|
AND (status = 'pending'
|
||||||
|
OR resolved_at > now() - interval '24 hours')
|
||||||
|
ORDER BY created_at""",
|
||||||
|
issue_ids,
|
||||||
|
)
|
||||||
|
out: list[dict] = []
|
||||||
|
for r in rows:
|
||||||
|
payload = r["payload"]
|
||||||
|
result = r["result"]
|
||||||
|
if isinstance(payload, str):
|
||||||
|
try:
|
||||||
|
payload = json.loads(payload)
|
||||||
|
except Exception:
|
||||||
|
payload = {}
|
||||||
|
if isinstance(result, str):
|
||||||
|
try:
|
||||||
|
result = json.loads(result)
|
||||||
|
except Exception:
|
||||||
|
result = None
|
||||||
|
out.append({
|
||||||
|
"id": str(r["id"]),
|
||||||
|
"issue_id": str(r["issue_id"]),
|
||||||
|
"kind": r["kind"],
|
||||||
|
"status": r["status"],
|
||||||
|
"title": r["title"],
|
||||||
|
"summary": r["summary"],
|
||||||
|
"payload": payload or {},
|
||||||
|
"result": result,
|
||||||
|
"created_at": r["created_at"].isoformat() if r["created_at"] else None,
|
||||||
|
"resolved_at": r["resolved_at"].isoformat() if r["resolved_at"] else None,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def respond_to_interaction(
|
||||||
|
issue_id: str, interaction_id: str, payload: dict,
|
||||||
|
) -> dict:
|
||||||
|
"""Submit a user response to an `ask_user_questions` interaction.
|
||||||
|
|
||||||
|
Paperclip auto-wakes the issue assignee on success
|
||||||
|
(`queueResolvedInteractionContinuationWakeup`).
|
||||||
|
"""
|
||||||
|
resp = await pc_request(
|
||||||
|
"POST",
|
||||||
|
f"/api/issues/{issue_id}/interactions/{interaction_id}/respond",
|
||||||
|
json=payload,
|
||||||
|
raise_on_error=True,
|
||||||
|
)
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def accept_interaction(
|
||||||
|
issue_id: str, interaction_id: str, payload: dict,
|
||||||
|
) -> dict:
|
||||||
|
"""Accept a `request_confirmation` or `suggest_tasks` interaction."""
|
||||||
|
resp = await pc_request(
|
||||||
|
"POST",
|
||||||
|
f"/api/issues/{issue_id}/interactions/{interaction_id}/accept",
|
||||||
|
json=payload,
|
||||||
|
raise_on_error=True,
|
||||||
|
)
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def reject_interaction(
|
||||||
|
issue_id: str, interaction_id: str, payload: dict,
|
||||||
|
) -> dict:
|
||||||
|
"""Reject a `request_confirmation` or `suggest_tasks` interaction."""
|
||||||
|
resp = await pc_request(
|
||||||
|
"POST",
|
||||||
|
f"/api/issues/{issue_id}/interactions/{interaction_id}/reject",
|
||||||
|
json=payload,
|
||||||
|
raise_on_error=True,
|
||||||
|
)
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
# Singleton project for the precedent-library extraction queue. One issue per
|
# Singleton project for the precedent-library extraction queue. One issue per
|
||||||
# uploaded precedent — assigned to the CEO who runs the local-MCP extractor.
|
# uploaded precedent — assigned to the CEO who runs the local-MCP extractor.
|
||||||
_LIBRARY_PROJECT_NAME = "ספריית פסיקה — תור חילוץ"
|
_LIBRARY_PROJECT_NAME = "ספריית פסיקה — תור חילוץ"
|
||||||
@@ -747,7 +841,6 @@ async def wake_for_precedent_extraction(
|
|||||||
return {"ok": False, "error": f"db: {e}"}
|
return {"ok": False, "error": f"db: {e}"}
|
||||||
|
|
||||||
# Wake the CEO. Per Paperclip rules: must use API + carry issueId in payload.
|
# Wake the CEO. Per Paperclip rules: must use API + carry issueId in payload.
|
||||||
url = f"{PAPERCLIP_API_URL}/api/agents/{ceo_id}/wakeup"
|
|
||||||
payload = {
|
payload = {
|
||||||
"source": "automation",
|
"source": "automation",
|
||||||
"triggerDetail": "precedent_library_upload",
|
"triggerDetail": "precedent_library_upload",
|
||||||
@@ -759,14 +852,13 @@ async def wake_for_precedent_extraction(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=15) as client:
|
resp = await pc_request(
|
||||||
resp = await client.post(
|
"POST",
|
||||||
url,
|
f"/api/agents/{ceo_id}/wakeup",
|
||||||
json=payload,
|
json=payload,
|
||||||
headers={"Authorization": f"Bearer {PAPERCLIP_BOARD_API_KEY}"},
|
raise_on_error=True,
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
result = resp.json()
|
||||||
result = resp.json()
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Precedent-extraction wakeup queued: issue=%s case_law_id=%s",
|
"Precedent-extraction wakeup queued: issue=%s case_law_id=%s",
|
||||||
identifier, case_law_id,
|
identifier, case_law_id,
|
||||||
@@ -787,7 +879,6 @@ async def wake_ceo_agent(issue_id: str, case_number: str, company_id: str = "")
|
|||||||
raise RuntimeError("PAPERCLIP_BOARD_API_KEY not set — cannot wake CEO agent")
|
raise RuntimeError("PAPERCLIP_BOARD_API_KEY not set — cannot wake CEO agent")
|
||||||
|
|
||||||
ceo_id = CEO_AGENTS.get(company_id, CEO_AGENT_ID)
|
ceo_id = CEO_AGENTS.get(company_id, CEO_AGENT_ID)
|
||||||
url = f"{PAPERCLIP_API_URL}/api/agents/{ceo_id}/wakeup"
|
|
||||||
payload = {
|
payload = {
|
||||||
"source": "on_demand",
|
"source": "on_demand",
|
||||||
"triggerDetail": "manual",
|
"triggerDetail": "manual",
|
||||||
@@ -798,13 +889,12 @@ async def wake_ceo_agent(issue_id: str, case_number: str, company_id: str = "")
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=15) as client:
|
resp = await pc_request(
|
||||||
resp = await client.post(
|
"POST",
|
||||||
url,
|
f"/api/agents/{ceo_id}/wakeup",
|
||||||
json=payload,
|
json=payload,
|
||||||
headers={"Authorization": f"Bearer {PAPERCLIP_BOARD_API_KEY}"},
|
raise_on_error=True,
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
result = resp.json()
|
||||||
result = resp.json()
|
logger.info("CEO agent wakeup for case %s: %s", case_number, result)
|
||||||
logger.info("CEO agent wakeup for case %s: %s", case_number, result)
|
return result
|
||||||
return result
|
|
||||||
|
|||||||
Reference in New Issue
Block a user