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

@@ -12,7 +12,8 @@ import os
import uuid
import asyncpg
import httpx
from web.paperclip_api import pc_request
logger = logging.getLogger(__name__)
@@ -21,7 +22,8 @@ PAPERCLIP_DB_URL = os.environ.get(
)
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", "")
# 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
if PAPERCLIP_BOARD_API_KEY:
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(
f"{PAPERCLIP_API_URL}/api/board/issues/{issue_id}/comments",
json={"body": body},
headers={"Authorization": f"Bearer {PAPERCLIP_BOARD_API_KEY}"},
)
if resp.status_code < 400:
result = resp.json()
logger.info("Posted comment via Board API on issue %s", issue_id)
return {"comment_id": result.get("id", ""), "issue_id": issue_id, "method": "api"}
resp = await pc_request(
"POST",
f"/api/board/issues/{issue_id}/comments",
json={"body": body},
)
if resp.status_code < 400:
result = resp.json()
logger.info("Posted comment via Board API on issue %s", issue_id)
return {"comment_id": result.get("id", ""), "issue_id": issue_id, "method": "api"}
except Exception:
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
ceo_id = CEO_AGENTS.get(company_id, CEO_AGENT_ID)
try:
url = f"{PAPERCLIP_API_URL}/api/agents/{ceo_id}/wakeup"
payload = {
"source": "on_demand",
"triggerDetail": "manual",
"reason": f"user_comment_{issue_id}",
"payload": {"issueId": issue_id, "mutation": "comment"},
}
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(
url, json=payload,
headers={"Authorization": f"Bearer {PAPERCLIP_BOARD_API_KEY}"},
)
resp.raise_for_status()
await pc_request(
"POST",
f"/api/agents/{ceo_id}/wakeup",
json={
"source": "on_demand",
"triggerDetail": "manual",
"reason": f"user_comment_{issue_id}",
"payload": {"issueId": issue_id, "mutation": "comment"},
},
raise_on_error=True,
)
except Exception:
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"}
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
# uploaded precedent — assigned to the CEO who runs the local-MCP extractor.
_LIBRARY_PROJECT_NAME = "ספריית פסיקה — תור חילוץ"
@@ -747,7 +841,6 @@ async def wake_for_precedent_extraction(
return {"ok": False, "error": f"db: {e}"}
# Wake the CEO. Per Paperclip rules: must use API + carry issueId in payload.
url = f"{PAPERCLIP_API_URL}/api/agents/{ceo_id}/wakeup"
payload = {
"source": "automation",
"triggerDetail": "precedent_library_upload",
@@ -759,14 +852,13 @@ async def wake_for_precedent_extraction(
},
}
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(
url,
json=payload,
headers={"Authorization": f"Bearer {PAPERCLIP_BOARD_API_KEY}"},
)
resp.raise_for_status()
result = resp.json()
resp = await pc_request(
"POST",
f"/api/agents/{ceo_id}/wakeup",
json=payload,
raise_on_error=True,
)
result = resp.json()
logger.info(
"Precedent-extraction wakeup queued: issue=%s case_law_id=%s",
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")
ceo_id = CEO_AGENTS.get(company_id, CEO_AGENT_ID)
url = f"{PAPERCLIP_API_URL}/api/agents/{ceo_id}/wakeup"
payload = {
"source": "on_demand",
"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 client.post(
url,
json=payload,
headers={"Authorization": f"Bearer {PAPERCLIP_BOARD_API_KEY}"},
)
resp.raise_for_status()
result = resp.json()
logger.info("CEO agent wakeup for case %s: %s", case_number, result)
return result
resp = await pc_request(
"POST",
f"/api/agents/{ceo_id}/wakeup",
json=payload,
raise_on_error=True,
)
result = resp.json()
logger.info("CEO agent wakeup for case %s: %s", case_number, result)
return result