forked from Rockachopa/Timmy-time-dashboard
Compare commits
2 Commits
test/chat-
...
gemini/iss
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c45f78171b | ||
|
|
a445df758b |
2087
poetry.lock
generated
2087
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -37,6 +37,7 @@ typer = ">=0.12.0"
|
||||
rich = ">=13.0.0"
|
||||
pydantic-settings = ">=2.0.0,<3.0"
|
||||
mcp = ">=1.0.0"
|
||||
serpapi = ">=2.0.0"
|
||||
# Optional extras
|
||||
redis = { version = ">=5.0.0", optional = true }
|
||||
celery = { version = ">=5.3.0", extras = ["redis"], optional = true }
|
||||
@@ -68,7 +69,7 @@ voice = ["pyttsx3", "openai-whisper", "piper-tts", "sounddevice"]
|
||||
celery = ["celery"]
|
||||
embeddings = ["sentence-transformers", "numpy"]
|
||||
git = ["GitPython"]
|
||||
research = ["requests", "trafilatura"]
|
||||
research = ["requests", "trafilatura", "google-search-results", "serpapi"]
|
||||
dev = ["pytest", "pytest-asyncio", "pytest-cov", "pytest-timeout", "pytest-randomly", "pytest-xdist", "selenium"]
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
@@ -81,6 +82,7 @@ pytest-randomly = "^4.0.1"
|
||||
pytest-xdist = "^3.8.0"
|
||||
ruff = ">=0.8.0"
|
||||
mypy = ">=1.0.0"
|
||||
pytest-httpx = "^0.36.0"
|
||||
|
||||
[tool.poetry.scripts]
|
||||
timmy = "timmy.cli:main"
|
||||
|
||||
@@ -375,13 +375,21 @@ def _startup_init() -> None:
|
||||
|
||||
def _startup_background_tasks() -> list[asyncio.Task]:
|
||||
"""Spawn all recurring background tasks (non-blocking)."""
|
||||
return [
|
||||
bg_tasks = [
|
||||
asyncio.create_task(_briefing_scheduler()),
|
||||
asyncio.create_task(_thinking_scheduler()),
|
||||
asyncio.create_task(_loop_qa_scheduler()),
|
||||
asyncio.create_task(_presence_watcher()),
|
||||
asyncio.create_task(_start_chat_integrations_background()),
|
||||
]
|
||||
try:
|
||||
from timmy.paperclip import start_paperclip_poller
|
||||
bg_tasks.append(asyncio.create_task(start_paperclip_poller()))
|
||||
logger.info("Paperclip poller started")
|
||||
except ImportError:
|
||||
logger.debug("Paperclip module not found, skipping poller")
|
||||
|
||||
return bg_tasks
|
||||
|
||||
|
||||
def _try_prune(label: str, prune_fn, days: int) -> None:
|
||||
|
||||
175
src/timmy/paperclip.py
Normal file
175
src/timmy/paperclip.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""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())
|
||||
|
||||
42
src/timmy/research_tools.py
Normal file
42
src/timmy/research_tools.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Tools for the research pipeline."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from config import settings
|
||||
from serpapi import GoogleSearch
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def google_web_search(query: str) -> str:
|
||||
"""Perform a Google search and return the results."""
|
||||
if "SERPAPI_API_KEY" not in os.environ:
|
||||
logger.warning("SERPAPI_API_KEY not set, skipping web search")
|
||||
return ""
|
||||
params = {
|
||||
"q": query,
|
||||
"api_key": os.environ["SERPAPI_API_KEY"],
|
||||
}
|
||||
search = GoogleSearch(params)
|
||||
results = search.get_dict()
|
||||
return str(results)
|
||||
|
||||
|
||||
def get_llm_client() -> Any:
|
||||
"""Get an LLM client."""
|
||||
# This is a placeholder. In a real application, this would return
|
||||
# a client for an LLM service like OpenAI, Anthropic, or a local
|
||||
# model.
|
||||
class MockLLMClient:
|
||||
async def completion(self, prompt: str, max_tokens: int) -> Any:
|
||||
class MockCompletion:
|
||||
def __init__(self, text: str) -> None:
|
||||
self.text = text
|
||||
|
||||
return MockCompletion(f"This is a summary of the search results for '{prompt}'.")
|
||||
|
||||
return MockLLMClient()
|
||||
106
tests/timmy/test_paperclip.py
Normal file
106
tests/timmy/test_paperclip.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Tests for the Paperclip integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from timmy.paperclip import (
|
||||
PaperclipClient,
|
||||
PaperclipPoller,
|
||||
PaperclipTask,
|
||||
ResearchOrchestrator,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_settings(monkeypatch):
|
||||
"""Mock the settings for the Paperclip integration."""
|
||||
monkeypatch.setattr("timmy.paperclip.settings.paperclip_enabled", True)
|
||||
monkeypatch.setattr("timmy.paperclip.settings.paperclip_url", "http://localhost:3100")
|
||||
monkeypatch.setattr("timmy.paperclip.settings.paperclip_api_key", "test-key")
|
||||
monkeypatch.setattr("timmy.paperclip.settings.paperclip_agent_id", "test-agent")
|
||||
monkeypatch.setattr("timmy.paperclip.settings.paperclip_company_id", "test-company")
|
||||
monkeypatch.setattr("timmy.paperclip.settings.paperclip_poll_interval", 1)
|
||||
monkeypatch.setattr("timmy.paperclip.settings.gitea_url", "http://localhost:3000")
|
||||
monkeypatch.setattr("timmy.paperclip.settings.gitea_token", "test-token")
|
||||
monkeypatch.setattr("timmy.paperclip.settings.gitea_repo", "test/repo")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_paperclip_client_get_tasks(mock_settings, httpx_mock):
|
||||
"""Test that the Paperclip client can get tasks."""
|
||||
httpx_mock.add_response(
|
||||
url="http://localhost:3100/api/tasks?agent_id=test-agent&company_id=test-company&status=queued",
|
||||
json=[
|
||||
{
|
||||
"id": "1",
|
||||
"kind": "research",
|
||||
"context": {"issue_number": 123},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
client = PaperclipClient()
|
||||
tasks = await client.get_tasks()
|
||||
|
||||
assert len(tasks) == 1
|
||||
assert tasks[0].id == "1"
|
||||
assert tasks[0].kind == "research"
|
||||
assert tasks[0].context == {"issue_number": 123}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_paperclip_client_update_task_status(mock_settings, httpx_mock):
|
||||
"""Test that the Paperclip client can update a task's status."""
|
||||
httpx_mock.add_response(
|
||||
url="http://localhost:3100/api/tasks/1",
|
||||
method="PATCH",
|
||||
)
|
||||
|
||||
client = PaperclipClient()
|
||||
await client.update_task_status("1", "running")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_research_orchestrator_run(mock_settings, httpx_mock):
|
||||
"""Test that the ResearchOrchestrator can run a research task."""
|
||||
httpx_mock.add_response(
|
||||
url="http://localhost:3000/api/v1/repos/test/repo/issues/123",
|
||||
json={"title": "Test Issue", "body": "This is a test issue."},
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
url="http://localhost:3000/api/v1/repos/test/repo/issues/123/comments",
|
||||
method="POST",
|
||||
)
|
||||
|
||||
with patch("timmy.paperclip.triage_research_report", new_callable=AsyncMock) as mock_triage:
|
||||
mock_triage.return_value = []
|
||||
orchestrator = ResearchOrchestrator()
|
||||
result = await orchestrator.run({"issue_number": 123})
|
||||
|
||||
assert result == "Research complete for issue #123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_paperclip_poller_poll(mock_settings):
|
||||
"""Test that the Paperclip poller can poll for tasks."""
|
||||
with patch("timmy.paperclip.PaperclipClient.get_tasks", new_callable=AsyncMock) as mock_get_tasks:
|
||||
mock_get_tasks.return_value = [
|
||||
PaperclipTask(
|
||||
id="1",
|
||||
kind="research",
|
||||
context={"issue_number": 123},
|
||||
)
|
||||
]
|
||||
with patch("timmy.paperclip.PaperclipPoller.run_research_task", new_callable=AsyncMock):
|
||||
poller = PaperclipPoller()
|
||||
poller.poll_interval = 0.1
|
||||
task = asyncio.create_task(poller.poll())
|
||||
await asyncio.sleep(0.2)
|
||||
task.cancel()
|
||||
|
||||
mock_get_tasks.assert_called()
|
||||
|
||||
Reference in New Issue
Block a user