"""Paperclip integration for Timmy. This module provides a client for the Paperclip API, and a poller for running research tasks. """ from __future__ import annotations import asyncio import logging from dataclasses import dataclass import httpx from config import settings from timmy.research_triage import triage_research_report from timmy.research_tools import google_web_search, get_llm_client logger = logging.getLogger(__name__) @dataclass class PaperclipTask: """A task from the Paperclip API.""" id: str kind: str context: dict class PaperclipClient: """A client for the Paperclip API.""" def __init__(self) -> None: self.base_url = settings.paperclip_url self.api_key = settings.paperclip_api_key self.agent_id = settings.paperclip_agent_id self.company_id = settings.paperclip_company_id self.timeout = settings.paperclip_timeout async def get_tasks(self) -> list[PaperclipTask]: """Get a list of tasks from the Paperclip API.""" async with httpx.AsyncClient(timeout=self.timeout) as client: resp = await client.get( f"{self.base_url}/api/tasks", headers={"Authorization": f"Bearer {self.api_key}"}, params={ "agent_id": self.agent_id, "company_id": self.company_id, "status": "queued", }, ) resp.raise_for_status() tasks = resp.json() return [ PaperclipTask(id=t["id"], kind=t["kind"], context=t["context"]) for t in tasks ] async def update_task_status( self, task_id: str, status: str, result: str | None = None ) -> None: """Update the status of a task.""" async with httpx.AsyncClient(timeout=self.timeout) as client: await client.patch( f"{self.base_url}/api/tasks/{task_id}", headers={"Authorization": f"Bearer {self.api_key}"}, json={"status": status, "result": result}, ) class ResearchOrchestrator: """Orchestrates research tasks.""" async def get_gitea_issue(self, issue_number: int) -> dict: """Get a Gitea issue by its number.""" owner, repo = settings.gitea_repo.split("/", 1) api_url = f"{settings.gitea_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}" async with httpx.AsyncClient(timeout=15) as client: resp = await client.get( api_url, headers={"Authorization": f"token {settings.gitea_token}"}, ) resp.raise_for_status() return resp.json() async def post_gitea_comment(self, issue_number: int, comment: str) -> None: """Post a comment to a Gitea issue.""" owner, repo = settings.gitea_repo.split("/", 1) api_url = f"{settings.gitea_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}/comments" async with httpx.AsyncClient(timeout=15) as client: await client.post( api_url, headers={"Authorization": f"token {settings.gitea_token}"}, json={"body": comment}, ) async def run_research_pipeline(self, issue_title: str) -> str: """Run the research pipeline.""" search_results = await google_web_search(issue_title) llm_client = get_llm_client() response = await llm_client.completion( f"Summarize the following search results and generate a research report:\\n\\n{search_results}", max_tokens=2048, ) return response.text async def run(self, context: dict) -> str: """Run a research task.""" issue_number = context.get("issue_number") if not issue_number: return "Missing issue_number in task context" issue = await self.get_gitea_issue(issue_number) report = await self.run_research_pipeline(issue["title"]) triage_results = await triage_research_report(report, source_issue=issue_number) comment = f"Research complete for issue #{issue_number}.\\n\\n" if triage_results: comment += "Created the following issues:\\n" for result in triage_results: if result["gitea_issue"]: comment += f"- #{result['gitea_issue']['number']}: {result['action_item'].title}\\n" else: comment += "No new issues were created.\\n" await self.post_gitea_comment(issue_number, comment) return f"Research complete for issue #{issue_number}" class PaperclipPoller: """Polls the Paperclip API for new tasks.""" def __init__(self) -> None: self.client = PaperclipClient() self.orchestrator = ResearchOrchestrator() self.poll_interval = settings.paperclip_poll_interval async def poll(self) -> None: """Poll the Paperclip API for new tasks.""" if self.poll_interval == 0: return while True: try: tasks = await self.client.get_tasks() for task in tasks: if task.kind == "research": await self.run_research_task(task) except httpx.HTTPError as exc: logger.warning("Error polling Paperclip: %s", exc) await asyncio.sleep(self.poll_interval) async def run_research_task(self, task: PaperclipTask) -> None: """Run a research task.""" await self.client.update_task_status(task.id, "running") try: result = await self.orchestrator.run(task.context) await self.client.update_task_status(task.id, "completed", result) except Exception as exc: logger.error("Error running research task: %s", exc, exc_info=True) await self.client.update_task_status(task.id, "failed", str(exc)) async def start_paperclip_poller() -> None: """Start the Paperclip poller.""" if settings.paperclip_enabled: poller = PaperclipPoller() asyncio.create_task(poller.poll())