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:
59
web/app.py
59
web/app.py
@@ -22,7 +22,7 @@ import zipfile
|
||||
|
||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
from pydantic import BaseModel
|
||||
|
||||
import asyncpg
|
||||
@@ -45,6 +45,7 @@ from web.mcp_env_catalog import (
|
||||
)
|
||||
from web.progress_store import ProgressStore
|
||||
from web.paperclip_client import (
|
||||
accept_interaction as pc_accept_interaction,
|
||||
archive_project as pc_archive_project,
|
||||
create_project as pc_create_project,
|
||||
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_case_issues as pc_get_case_issues,
|
||||
get_issue_comments as pc_get_issue_comments,
|
||||
get_issue_interactions as pc_get_issue_interactions,
|
||||
get_project_url,
|
||||
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,
|
||||
wake_ceo_agent as pc_wake_ceo,
|
||||
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")
|
||||
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)
|
||||
if not issues:
|
||||
return {"issues": [], "comments": [], "agents": []}
|
||||
return {"issues": [], "comments": [], "agents": [], "interactions": []}
|
||||
|
||||
issue_ids = [i["id"] for i in issues]
|
||||
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):
|
||||
@@ -2551,6 +2564,42 @@ async def api_post_agent_comment(case_number: str, req: AgentCommentRequest):
|
||||
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 ────────────────────────────
|
||||
#
|
||||
# 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 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
|
||||
|
||||
Reference in New Issue
Block a user