New router intelligence modules (26 files): alert_ingest/store, audit_store, architecture_pressure, backlog_generator/store, cost_analyzer, data_governance, dependency_scanner, drift_analyzer, incident_* (5 files), llm_enrichment, platform_priority_digest, provider_budget, release_check_runner, risk_* (6 files), signature_state_store, sofiia_auto_router, tool_governance New services: - sofiia-console: Dockerfile, adapters/, monitor/nodes/ops/voice modules, launchd, react static - memory-service: integration_endpoints, integrations, voice_endpoints, static UI - aurora-service: full app suite (analysis, job_store, orchestrator, reporting, schemas, subagents) - sofiia-supervisor: new supervisor service - aistalk-bridge-lite: Telegram bridge lite - calendar-service: CalDAV calendar service with reminders - mlx-stt-service / mlx-tts-service: Apple Silicon speech services - binance-bot-monitor: market monitor service - node-worker: STT/TTS memory providers New tools (9): agent_email, browser_tool, contract_tool, observability_tool, oncall_tool, pr_reviewer_tool, repo_tool, safe_code_executor, secure_vault New crews: agromatrix_crew (10 modules: depth_classifier, doc_facts, doc_focus, farm_state, light_reply, llm_factory, memory_manager, proactivity, reflection_engine, session_context, style_adapter, telemetry) Tests: 85+ test files for all new modules Made-with: Cursor
263 lines
9.3 KiB
Python
263 lines
9.3 KiB
Python
"""
|
|
AISTALK Adapter — HTTP bridge integration.
|
|
|
|
Enables forwarding BFF events/messages to an external AISTALK bridge service.
|
|
The adapter is best-effort and non-blocking for callers.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import threading
|
|
import time
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
import httpx
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _split_paths(raw: str, default: str) -> List[str]:
|
|
src = (raw or "").strip()
|
|
if not src:
|
|
src = default
|
|
parts = [p.strip() for p in src.split(",") if p.strip()]
|
|
normalized: List[str] = []
|
|
for p in parts:
|
|
if not p.startswith("/"):
|
|
p = "/" + p
|
|
normalized.append(p)
|
|
return normalized
|
|
|
|
|
|
class AISTALKAdapter:
|
|
"""
|
|
AISTALK relay adapter.
|
|
|
|
Env overrides (optional):
|
|
AISTALK_HEALTH_PATHS=/healthz,/health,/api/health
|
|
AISTALK_EVENT_PATHS=/api/events,/events,/v1/events
|
|
AISTALK_TEXT_PATHS=/api/text,/text,/v1/text
|
|
AISTALK_AUDIO_PATHS=/api/audio,/audio,/v1/audio
|
|
"""
|
|
|
|
def __init__(self, base_url: str, api_key: Optional[str] = None) -> None:
|
|
self.base_url = base_url.rstrip("/") if base_url else ""
|
|
self.api_key = api_key or ""
|
|
self._enabled = bool(self.base_url)
|
|
|
|
self._health_paths = _split_paths(
|
|
os.getenv("AISTALK_HEALTH_PATHS", ""),
|
|
"/healthz,/health,/api/health",
|
|
)
|
|
self._event_paths = _split_paths(
|
|
os.getenv("AISTALK_EVENT_PATHS", ""),
|
|
"/api/events,/events,/v1/events",
|
|
)
|
|
self._text_paths = _split_paths(
|
|
os.getenv("AISTALK_TEXT_PATHS", ""),
|
|
"/api/text,/text,/v1/text",
|
|
)
|
|
self._audio_paths = _split_paths(
|
|
os.getenv("AISTALK_AUDIO_PATHS", ""),
|
|
"/api/audio,/audio,/v1/audio",
|
|
)
|
|
|
|
self._lock = threading.Lock()
|
|
self._last_ok_at: Optional[float] = None
|
|
self._last_error: str = ""
|
|
self._last_endpoint: str = ""
|
|
self._last_probe_ok: Optional[bool] = None
|
|
self._last_probe_at: Optional[float] = None
|
|
|
|
# Fire-and-forget outbound queue to avoid adding latency to BFF handlers.
|
|
self._pool = ThreadPoolExecutor(max_workers=2, thread_name_prefix="aistalk-relay")
|
|
|
|
if self._enabled:
|
|
logger.info("AISTALKAdapter init: url=%s (HTTP relay mode)", self.base_url)
|
|
else:
|
|
logger.info("AISTALKAdapter init: no base_url, adapter disabled")
|
|
|
|
@property
|
|
def enabled(self) -> bool:
|
|
return self._enabled
|
|
|
|
def _headers(self) -> Dict[str, str]:
|
|
headers = {"Content-Type": "application/json"}
|
|
if self.api_key:
|
|
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
headers["X-API-Key"] = self.api_key
|
|
return headers
|
|
|
|
def _mark_ok(self, endpoint: str) -> None:
|
|
with self._lock:
|
|
self._last_ok_at = time.time()
|
|
self._last_error = ""
|
|
self._last_endpoint = endpoint
|
|
|
|
def _mark_err(self, err: str) -> None:
|
|
with self._lock:
|
|
self._last_error = (err or "")[:300]
|
|
|
|
def _post_json(self, payload: Dict[str, Any], paths: List[str], kind: str) -> bool:
|
|
if not self._enabled:
|
|
return False
|
|
last_err = "unreachable"
|
|
timeout = httpx.Timeout(connect=0.6, read=1.8, write=1.8, pool=0.6)
|
|
for path in paths:
|
|
endpoint = f"{self.base_url}{path}"
|
|
try:
|
|
with httpx.Client(timeout=timeout) as client:
|
|
r = client.post(endpoint, headers=self._headers(), json=payload)
|
|
if 200 <= r.status_code < 300:
|
|
self._mark_ok(endpoint)
|
|
return True
|
|
last_err = f"HTTP {r.status_code} @ {path}"
|
|
except Exception as e:
|
|
last_err = f"{e.__class__.__name__}: {str(e)[:180]} @ {path}"
|
|
continue
|
|
self._mark_err(last_err)
|
|
logger.debug("AISTALK %s relay failed: %s", kind, last_err)
|
|
return False
|
|
|
|
def _post_audio(self, payload: Dict[str, Any], audio_bytes: bytes, mime: str) -> bool:
|
|
if not self._enabled:
|
|
return False
|
|
last_err = "unreachable"
|
|
timeout = httpx.Timeout(connect=0.8, read=2.5, write=2.5, pool=0.8)
|
|
for path in self._audio_paths:
|
|
endpoint = f"{self.base_url}{path}"
|
|
files = {"audio": ("chunk", audio_bytes, mime or "audio/wav")}
|
|
data = {"meta": str(payload)}
|
|
try:
|
|
with httpx.Client(timeout=timeout) as client:
|
|
headers = {}
|
|
if self.api_key:
|
|
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
headers["X-API-Key"] = self.api_key
|
|
r = client.post(endpoint, headers=headers, data=data, files=files)
|
|
if 200 <= r.status_code < 300:
|
|
self._mark_ok(endpoint)
|
|
return True
|
|
last_err = f"HTTP {r.status_code} @ {path}"
|
|
except Exception as e:
|
|
last_err = f"{e.__class__.__name__}: {str(e)[:180]} @ {path}"
|
|
continue
|
|
self._mark_err(last_err)
|
|
logger.debug("AISTALK audio relay failed: %s", last_err)
|
|
return False
|
|
|
|
def _dispatch(self, fn, *args: Any) -> None:
|
|
if not self._enabled:
|
|
return
|
|
try:
|
|
self._pool.submit(fn, *args)
|
|
except Exception as e:
|
|
self._mark_err(str(e))
|
|
logger.debug("AISTALK dispatch failed: %s", e)
|
|
|
|
def send_text(
|
|
self,
|
|
project_id: str,
|
|
session_id: str,
|
|
text: str,
|
|
user_id: str = "console_user",
|
|
) -> None:
|
|
if not self._enabled:
|
|
return
|
|
payload = {
|
|
"v": 1,
|
|
"type": "chat.reply",
|
|
"project_id": project_id,
|
|
"session_id": session_id,
|
|
"user_id": user_id,
|
|
"data": {"text": text},
|
|
}
|
|
self._dispatch(self._post_json, payload, self._text_paths, "text")
|
|
|
|
def send_audio(
|
|
self,
|
|
project_id: str,
|
|
session_id: str,
|
|
audio_bytes: bytes,
|
|
mime: str = "audio/wav",
|
|
) -> None:
|
|
if not self._enabled:
|
|
return
|
|
payload = {
|
|
"v": 1,
|
|
"type": "voice.tts",
|
|
"project_id": project_id,
|
|
"session_id": session_id,
|
|
"user_id": "console_user",
|
|
"data": {"mime": mime, "bytes": len(audio_bytes)},
|
|
}
|
|
self._dispatch(self._post_audio, payload, audio_bytes, mime)
|
|
|
|
def handle_event(self, event: Dict[str, Any]) -> None:
|
|
if not self._enabled:
|
|
return
|
|
self._dispatch(self._post_json, event, self._event_paths, "event")
|
|
|
|
def on_event(self, event: Dict[str, Any]) -> None:
|
|
self.handle_event(event)
|
|
|
|
def probe_health(self) -> Dict[str, Any]:
|
|
if not self._enabled:
|
|
return {"enabled": False, "ok": False, "error": "disabled"}
|
|
timeout = httpx.Timeout(connect=0.5, read=1.2, write=1.2, pool=0.5)
|
|
last_err = "unreachable"
|
|
for path in self._health_paths:
|
|
endpoint = f"{self.base_url}{path}"
|
|
try:
|
|
with httpx.Client(timeout=timeout) as client:
|
|
headers = {}
|
|
if self.api_key:
|
|
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
headers["X-API-Key"] = self.api_key
|
|
r = client.get(endpoint, headers=headers)
|
|
if r.status_code < 500:
|
|
with self._lock:
|
|
self._last_probe_ok = r.status_code == 200
|
|
self._last_probe_at = time.time()
|
|
if r.status_code == 200:
|
|
self._mark_ok(endpoint)
|
|
return {"enabled": True, "ok": True, "url": endpoint, "status": r.status_code}
|
|
last_err = f"HTTP {r.status_code} @ {path}"
|
|
else:
|
|
last_err = f"HTTP {r.status_code} @ {path}"
|
|
except Exception as e:
|
|
last_err = f"{e.__class__.__name__}: {str(e)[:180]} @ {path}"
|
|
continue
|
|
with self._lock:
|
|
self._last_probe_ok = False
|
|
self._last_probe_at = time.time()
|
|
self._mark_err(last_err)
|
|
return {"enabled": True, "ok": False, "error": last_err}
|
|
|
|
def status(self) -> Dict[str, Any]:
|
|
with self._lock:
|
|
return {
|
|
"enabled": self._enabled,
|
|
"base_url": self.base_url,
|
|
"last_ok_at": self._last_ok_at,
|
|
"last_endpoint": self._last_endpoint,
|
|
"last_error": self._last_error,
|
|
"last_probe_ok": self._last_probe_ok,
|
|
"last_probe_at": self._last_probe_at,
|
|
"paths": {
|
|
"health": self._health_paths,
|
|
"events": self._event_paths,
|
|
"text": self._text_paths,
|
|
"audio": self._audio_paths,
|
|
},
|
|
}
|
|
|
|
def __repr__(self) -> str:
|
|
s = self.status()
|
|
return (
|
|
f"AISTALKAdapter(url={s['base_url']!r}, enabled={s['enabled']}, "
|
|
f"last_probe_ok={s['last_probe_ok']}, last_endpoint={s['last_endpoint']!r})"
|
|
)
|