Files
legal-ai/web/paperclip_api.py
Chaim b01722b1b4
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 9s
feat: emit missing_precedent + export_complete webhooks to plugin
Adds two webhook emitters in paperclip_api.py that the plugin's
onWebhook handler now routes by ``eventType``:

* ``emit_missing_precedent_webhook(...)`` — fires from
  POST /api/missing-precedents on first insert (non-duplicate).
  The plugin surfaces an askUserQuestions interaction on the
  linked issue so Daphna can choose upload / irrelevant / defer
  without needing to open the legal-ai UI.

* ``emit_export_complete_webhook(...)`` — fires from
  POST /api/cases/{n}/export-docx after a successful export. The
  plugin attaches a "final-decision" markdown document with a
  download link to the linked Paperclip issue.

Both are fire-and-forget BackgroundTasks — failures are logged
but never block the originating request. Company resolution
follows the same 1xxx→licensing / 8-9xxx→betterment rule used
by emit_case_status_webhook.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 13:29:04 +00:00

205 lines
6.5 KiB
Python

"""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 datetime import datetime, timezone
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
async def emit_case_status_webhook(
case_number: str,
old_status: str,
new_status: str,
company_id: str | None = None,
run_id: str | None = None,
) -> None:
"""Notify the Paperclip plugin that a case status changed.
Fire-and-forget: logs errors but never raises, so callers aren't blocked.
"""
try:
await pc_request(
"POST",
"/api/plugins/marcusgroup.legal-ai/webhooks/case-status",
json={
"eventType": "status_change",
"caseNumber": case_number,
"oldStatus": old_status,
"newStatus": new_status,
"companyId": company_id,
"timestamp": datetime.now(timezone.utc).isoformat(),
},
run_id=run_id,
timeout=5.0,
)
except Exception as exc:
logger.warning(
"emit_case_status_webhook failed for case %s (%s%s): %s",
case_number, old_status, new_status, exc,
)
async def emit_missing_precedent_webhook(
*,
case_number: str,
missing_precedent_id: str,
citation: str,
cited_by_party: str | None = None,
cited_by_party_name: str | None = None,
legal_topic: str | None = None,
legal_issue: str | None = None,
company_id: str | None = None,
run_id: str | None = None,
) -> None:
"""Tell the plugin that a missing precedent was logged for a case.
The plugin uses this to surface an ``askUserQuestions`` interaction
on the linked Paperclip issue so the chair can decide whether to
upload the cited precedent or mark it irrelevant.
Fire-and-forget.
"""
try:
await pc_request(
"POST",
"/api/plugins/marcusgroup.legal-ai/webhooks/case-status",
json={
"eventType": "missing_precedent_created",
"caseNumber": case_number,
"companyId": company_id,
"timestamp": datetime.now(timezone.utc).isoformat(),
"missingPrecedent": {
"id": missing_precedent_id,
"citation": citation,
"citedByParty": cited_by_party,
"citedByPartyName": cited_by_party_name,
"legalTopic": legal_topic,
"legalIssue": legal_issue,
},
},
run_id=run_id,
timeout=5.0,
)
except Exception as exc:
logger.warning(
"emit_missing_precedent_webhook failed for case %s (%s): %s",
case_number, citation, exc,
)
async def emit_export_complete_webhook(
*,
case_number: str,
docx_filename: str,
docx_title: str | None = None,
company_id: str | None = None,
run_id: str | None = None,
) -> None:
"""Tell the plugin that a final DOCX was exported for a case.
The plugin uses this to attach a "final decision" document to the
linked Paperclip issue (markdown body with a download link to the
DOCX). Binary attachment is intentionally avoided — the SDK's
``documents.upsert`` accepts text only.
Fire-and-forget.
"""
try:
await pc_request(
"POST",
"/api/plugins/marcusgroup.legal-ai/webhooks/case-status",
json={
"eventType": "export_complete",
"caseNumber": case_number,
"companyId": company_id,
"timestamp": datetime.now(timezone.utc).isoformat(),
"docxFilename": docx_filename,
"docxTitle": docx_title or f"החלטה סופית — {case_number}",
},
run_id=run_id,
timeout=5.0,
)
except Exception as exc:
logger.warning(
"emit_export_complete_webhook failed for case %s (%s): %s",
case_number, docx_filename, exc,
)