feat(agents): mirror Paperclip interactions in case page
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:
2026-05-04 16:40:45 +00:00
parent 82b29510f2
commit d0994704cf
5 changed files with 870 additions and 59 deletions

View File

@@ -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>

View File

@@ -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) });
}
},
});
}

View File

@@ -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
View 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

View File

@@ -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