forked from Rockachopa/Timmy-time-dashboard
feat: dockerize OpenFang as vendored tool runtime sidecar (#96)
This commit is contained in:
committed by
GitHub
parent
d7d7a5a80a
commit
b7c89d1101
216
src/infrastructure/openfang/client.py
Normal file
216
src/infrastructure/openfang/client.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""OpenFang HTTP client — bridge between Timmy coordinator and OpenFang runtime.
|
||||
|
||||
Follows project conventions:
|
||||
- Graceful degradation (log error, return fallback, never crash)
|
||||
- Config via ``from config import settings``
|
||||
- Singleton pattern for module-level import
|
||||
|
||||
The client wraps OpenFang's REST API and exposes its Hands
|
||||
(Browser, Collector, Predictor, Lead, Twitter, Researcher, Clip)
|
||||
as callable tool endpoints.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
from config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Hand names that OpenFang ships out of the box
|
||||
OPENFANG_HANDS = (
|
||||
"browser",
|
||||
"collector",
|
||||
"predictor",
|
||||
"lead",
|
||||
"twitter",
|
||||
"researcher",
|
||||
"clip",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class HandResult:
|
||||
"""Result from an OpenFang Hand execution."""
|
||||
|
||||
hand: str
|
||||
success: bool
|
||||
output: str = ""
|
||||
error: str = ""
|
||||
latency_ms: float = 0.0
|
||||
metadata: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
class OpenFangClient:
|
||||
"""HTTP client for the OpenFang sidecar.
|
||||
|
||||
All methods degrade gracefully — if OpenFang is down the client
|
||||
returns a ``HandResult(success=False)`` rather than raising.
|
||||
"""
|
||||
|
||||
def __init__(self, base_url: Optional[str] = None, timeout: int = 60) -> None:
|
||||
self._base_url = (base_url or settings.openfang_url).rstrip("/")
|
||||
self._timeout = timeout
|
||||
self._healthy = False
|
||||
self._last_health_check: float = 0.0
|
||||
self._health_cache_ttl = 30.0 # seconds
|
||||
logger.info("OpenFangClient initialised → %s", self._base_url)
|
||||
|
||||
# ── Health ───────────────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def healthy(self) -> bool:
|
||||
"""Cached health check — hits /health at most once per TTL."""
|
||||
now = time.time()
|
||||
if now - self._last_health_check > self._health_cache_ttl:
|
||||
self._healthy = self._check_health()
|
||||
self._last_health_check = now
|
||||
return self._healthy
|
||||
|
||||
def _check_health(self) -> bool:
|
||||
try:
|
||||
import urllib.request
|
||||
|
||||
req = urllib.request.Request(
|
||||
f"{self._base_url}/health",
|
||||
method="GET",
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
return resp.status == 200
|
||||
except Exception as exc:
|
||||
logger.debug("OpenFang health check failed: %s", exc)
|
||||
return False
|
||||
|
||||
# ── Hand execution ───────────────────────────────────────────────────────
|
||||
|
||||
async def execute_hand(
|
||||
self,
|
||||
hand: str,
|
||||
params: dict[str, Any],
|
||||
timeout: Optional[int] = None,
|
||||
) -> HandResult:
|
||||
"""Execute an OpenFang Hand and return the result.
|
||||
|
||||
Args:
|
||||
hand: Hand name (browser, collector, predictor, etc.)
|
||||
params: Parameters for the hand (task-specific)
|
||||
timeout: Override default timeout for long-running hands
|
||||
|
||||
Returns:
|
||||
HandResult with output or error details.
|
||||
"""
|
||||
if hand not in OPENFANG_HANDS:
|
||||
return HandResult(
|
||||
hand=hand,
|
||||
success=False,
|
||||
error=f"Unknown hand: {hand}. Available: {', '.join(OPENFANG_HANDS)}",
|
||||
)
|
||||
|
||||
start = time.time()
|
||||
try:
|
||||
import json
|
||||
import urllib.request
|
||||
|
||||
payload = json.dumps({"hand": hand, "params": params}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{self._base_url}/api/v1/hands/{hand}/execute",
|
||||
data=payload,
|
||||
method="POST",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
effective_timeout = timeout or self._timeout
|
||||
with urllib.request.urlopen(req, timeout=effective_timeout) as resp:
|
||||
body = json.loads(resp.read().decode())
|
||||
latency = (time.time() - start) * 1000
|
||||
|
||||
return HandResult(
|
||||
hand=hand,
|
||||
success=body.get("success", True),
|
||||
output=body.get("output", body.get("result", "")),
|
||||
latency_ms=latency,
|
||||
metadata=body.get("metadata", {}),
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
latency = (time.time() - start) * 1000
|
||||
logger.warning(
|
||||
"OpenFang hand '%s' failed (%.0fms): %s",
|
||||
hand,
|
||||
latency,
|
||||
exc,
|
||||
)
|
||||
return HandResult(
|
||||
hand=hand,
|
||||
success=False,
|
||||
error=str(exc),
|
||||
latency_ms=latency,
|
||||
)
|
||||
|
||||
# ── Convenience wrappers for common hands ────────────────────────────────
|
||||
|
||||
async def browse(self, url: str, instruction: str = "") -> HandResult:
|
||||
"""Web automation via OpenFang's Browser hand."""
|
||||
return await self.execute_hand(
|
||||
"browser", {"url": url, "instruction": instruction}
|
||||
)
|
||||
|
||||
async def collect(self, target: str, depth: str = "shallow") -> HandResult:
|
||||
"""OSINT collection via OpenFang's Collector hand."""
|
||||
return await self.execute_hand(
|
||||
"collector", {"target": target, "depth": depth}
|
||||
)
|
||||
|
||||
async def predict(self, question: str, horizon: str = "1w") -> HandResult:
|
||||
"""Superforecasting via OpenFang's Predictor hand."""
|
||||
return await self.execute_hand(
|
||||
"predictor", {"question": question, "horizon": horizon}
|
||||
)
|
||||
|
||||
async def find_leads(self, icp: str, max_results: int = 10) -> HandResult:
|
||||
"""Prospect discovery via OpenFang's Lead hand."""
|
||||
return await self.execute_hand(
|
||||
"lead", {"icp": icp, "max_results": max_results}
|
||||
)
|
||||
|
||||
async def research(self, topic: str, depth: str = "standard") -> HandResult:
|
||||
"""Deep research via OpenFang's Researcher hand."""
|
||||
return await self.execute_hand(
|
||||
"researcher", {"topic": topic, "depth": depth}
|
||||
)
|
||||
|
||||
# ── Inventory ────────────────────────────────────────────────────────────
|
||||
|
||||
async def list_hands(self) -> list[dict]:
|
||||
"""Query OpenFang for its available hands and their status."""
|
||||
try:
|
||||
import json
|
||||
import urllib.request
|
||||
|
||||
req = urllib.request.Request(
|
||||
f"{self._base_url}/api/v1/hands",
|
||||
method="GET",
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to list OpenFang hands: %s", exc)
|
||||
return []
|
||||
|
||||
def status(self) -> dict:
|
||||
"""Return a status summary for the dashboard."""
|
||||
return {
|
||||
"url": self._base_url,
|
||||
"healthy": self.healthy,
|
||||
"available_hands": list(OPENFANG_HANDS),
|
||||
}
|
||||
|
||||
|
||||
# ── Module-level singleton ──────────────────────────────────────────────────
|
||||
openfang_client = OpenFangClient()
|
||||
Reference in New Issue
Block a user