Files
microdao-daarion/gateway-bot/memory_client.py
Apple 0c8bef82f4 feat: Add Alateya, Clan, Eonarch agents + fix gateway-router connection
## Agents Added
- Alateya: R&D, biotech, innovations
- Clan (Spirit): Community spirit agent
- Eonarch: Consciousness evolution agent

## Changes
- docker-compose.node1.yml: Added tokens for all 3 new agents
- gateway-bot/http_api.py: Added configs and webhook endpoints
- gateway-bot/clan_prompt.txt: New prompt file
- gateway-bot/eonarch_prompt.txt: New prompt file

## Fixes
- Fixed ROUTER_URL from :9102 to :8000 (internal container port)
- All 9 Telegram agents now working

## Documentation
- Created PROJECT-MASTER-INDEX.md - single entry point
- Added various status documents and scripts

Tokens configured:
- Helion, NUTRA, Agromatrix (existing)
- Alateya, Clan, Eonarch (new)
- Druid, GreenFood, DAARWIZZ (configured)
2026-01-28 06:40:34 -08:00

343 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import asyncio
import os
import logging
import time
from typing import Optional, Dict, Any, List, Tuple
from datetime import datetime
from collections import deque
import httpx
logger = logging.getLogger(__name__)
MEMORY_SERVICE_URL = os.getenv("MEMORY_SERVICE_URL", "http://memory-service:8000")
CONTEXT_CACHE_TTL = float(os.getenv("MEMORY_CONTEXT_CACHE_TTL", "5"))
LOCAL_CONTEXT_MAX_MESSAGES = int(os.getenv("LOCAL_CONTEXT_MAX_MESSAGES", "50"))
# =====================================
# LOCAL CONTEXT STORE (fallback when Memory Service unavailable)
# =====================================
class LocalContextStore:
"""Локальне сховище контексту (in-memory) для випадків, коли Memory Service недоступний"""
def __init__(self, max_messages: int = LOCAL_CONTEXT_MAX_MESSAGES):
self.max_messages = max_messages
# {chat_id: deque([(role, text, timestamp), ...])}
self._store: Dict[str, deque] = {}
def add_message(self, chat_id: str, role: str, text: str):
"""Додати повідомлення до контексту"""
if chat_id not in self._store:
self._store[chat_id] = deque(maxlen=self.max_messages)
self._store[chat_id].append({
"role": role,
"text": text,
"timestamp": datetime.now().isoformat()
})
def get_context(self, chat_id: str, limit: int = 30) -> List[Dict[str, Any]]:
"""Отримати останні повідомлення для контексту"""
if chat_id not in self._store:
return []
messages = list(self._store[chat_id])
return messages[-limit:] if limit else messages
def clear_chat(self, chat_id: str):
"""Очистити контекст чату"""
if chat_id in self._store:
del self._store[chat_id]
def format_for_prompt(self, chat_id: str, limit: int = 30) -> str:
"""Форматувати контекст для system prompt"""
messages = self.get_context(chat_id, limit)
if not messages:
return ""
lines = []
for msg in messages:
role = "User" if msg["role"] == "user" else "Assistant"
lines.append(f"{role}: {msg['text']}")
return "\n".join(lines)
# Global local context store
local_context = LocalContextStore()
class MemoryClient:
"""Клієнт для роботи з Memory Service"""
def __init__(self, base_url: str = MEMORY_SERVICE_URL):
self.base_url = base_url.rstrip("/")
self.timeout = 10.0
self._context_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
def _cache_key(
self,
user_id: str,
agent_id: str,
team_id: str,
channel_id: Optional[str],
limit: int
) -> str:
return f"{user_id}:{agent_id}:{team_id}:{channel_id}:{limit}"
async def get_context(
self,
user_id: str,
agent_id: str,
team_id: str,
channel_id: Optional[str] = None,
limit: int = 10
) -> Dict[str, Any]:
"""
Отримати контекст пам'яті для діалогу.
Використовує локальний кеш як fallback, якщо Memory Service недоступний.
"""
cache_key = self._cache_key(user_id, agent_id, team_id, channel_id, limit)
cached = self._context_cache.get(cache_key)
now = time.monotonic()
if cached and now - cached[0] < CONTEXT_CACHE_TTL:
return cached[1]
# Спроба отримати контекст із Memory Service
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
params = {
"user_id": user_id,
"channel_id": channel_id,
"limit": limit,
}
resp = await client.get(
f"{self.base_url}/agents/{agent_id}/memory",
params=params,
headers={"Authorization": f"Bearer {user_id}"},
)
if resp.status_code == 200:
data = resp.json()
events = data.get("events", [])
# Сортуємо за timestamp, якщо є
events = sorted(
events,
key=lambda e: e.get("timestamp", ""),
)
recent_events = [
{
"body_text": e.get("content", ""),
"kind": e.get("kind", "message"),
"type": "user" if e.get("role") == "user" else "agent",
}
for e in events
if e.get("content")
]
# Формуємо контекст для prompt
lines = []
for e in events:
content = e.get("content", "")
if not content:
continue
role = "User" if e.get("role") == "user" else "Assistant"
lines.append(f"{role}: {content}")
result = {
"facts": [],
"recent_events": recent_events,
"dialog_summaries": [],
"local_context_text": "\n".join(lines[-limit:]),
}
self._context_cache[cache_key] = (now, result)
return result
except Exception as e:
logger.debug(f"Memory Service context fetch failed, using local: {e}")
# FALLBACK: локальний контекст (in-memory)
local_messages = local_context.get_context(str(channel_id or user_id), limit)
local_events = [
{"body_text": msg["text"], "kind": "message", "type": "user" if msg["role"] == "user" else "agent"}
for msg in local_messages
]
result = {
"facts": [],
"recent_events": local_events,
"dialog_summaries": [],
"local_context_text": local_context.format_for_prompt(str(channel_id or user_id), limit),
}
self._context_cache[cache_key] = (now, result)
return result
async def save_chat_turn(
self,
agent_id: str,
team_id: str,
user_id: str,
message: str,
response: str,
channel_id: Optional[str] = None,
scope: str = "short_term",
save_agent_response: bool = True,
agent_metadata: Optional[Dict[str, Any]] = None
) -> bool:
"""
Зберегти один turn діалогу (повідомлення + відповідь).
Завжди зберігає в локальний контекст + намагається зберегти в Memory Service.
"""
chat_key = str(channel_id or user_id)
# ЗАВЖДИ зберігаємо в локальний контекст
local_context.add_message(chat_key, "user", message)
if save_agent_response and response:
local_context.add_message(chat_key, "assistant", response)
logger.info(f"💾 Saved to local context: chat={chat_key}, messages={len(local_context.get_context(chat_key))}")
# Спроба зберегти в Memory Service (може бути недоступний)
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
user_event = {
"agent_id": agent_id,
"team_id": team_id,
"channel_id": channel_id,
"user_id": user_id,
"scope": scope,
"kind": "message",
"body_text": message,
"body_json": {"type": "user_message", "source": "telegram"}
}
await client.post(
f"{self.base_url}/agents/{agent_id}/memory",
json=user_event,
headers={"Authorization": f"Bearer {user_id}"}
)
if save_agent_response and response:
agent_event = {
"agent_id": agent_id,
"team_id": team_id,
"channel_id": channel_id,
"user_id": user_id,
"scope": scope,
"kind": "message",
"body_text": response,
"body_json": {
"type": "agent_response",
"source": "telegram",
**(agent_metadata or {})
}
}
await client.post(
f"{self.base_url}/agents/{agent_id}/memory",
json=agent_event,
headers={"Authorization": f"Bearer {user_id}"}
)
return True
except Exception as e:
# Memory Service недоступний - але локальний контекст вже збережено
logger.debug(f"Memory Service unavailable (using local context): {e}")
return True # Return True because local context was saved
async def create_dialog_summary(
self,
team_id: str,
channel_id: Optional[str],
agent_id: str,
user_id: Optional[str],
period_start: datetime,
period_end: datetime,
summary_text: str,
message_count: int = 0,
participant_count: int = 0,
topics: Optional[List[str]] = None,
summary_json: Optional[Dict[str, Any]] = None
) -> bool:
"""
Створити підсумок діалогу для масштабування без переповнення контексту
"""
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/summaries",
json={
"team_id": team_id,
"channel_id": channel_id,
"agent_id": agent_id,
"user_id": user_id,
"period_start": period_start.isoformat(),
"period_end": period_end.isoformat(),
"summary_text": summary_text,
"summary_json": summary_json,
"message_count": message_count,
"participant_count": participant_count,
"topics": topics or [],
"meta": {}
},
headers={"Authorization": f"Bearer {user_id or 'system'}"}
)
return response.status_code in [200, 201]
except Exception as e:
logger.warning(f"Failed to create dialog summary: {e}")
return False
async def upsert_fact(
self,
user_id: str,
fact_key: str,
fact_value: Optional[str] = None,
fact_value_json: Optional[Dict[str, Any]] = None,
team_id: Optional[str] = None
) -> bool:
"""
Створити або оновити факт користувача
"""
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/facts/upsert",
json={
"user_id": user_id,
"fact_key": fact_key,
"fact_value": fact_value,
"fact_value_json": fact_value_json,
"team_id": team_id
},
headers={"Authorization": f"Bearer {user_id}"}
)
return response.status_code in [200, 201]
except Exception as e:
logger.warning(f"Failed to upsert fact: {e}")
return False
async def get_fact(
self,
user_id: str,
fact_key: str,
team_id: Optional[str] = None
) -> Optional[Dict[str, Any]]:
"""
Отримати факт користувача
Returns:
Fact dict with fact_value and fact_value_json, or None if not found
"""
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/facts/{fact_key}",
params={
"user_id": user_id,
"team_id": team_id
},
headers={"Authorization": f"Bearer {user_id}"}
)
if response.status_code == 200:
return response.json()
return None
except Exception as e:
logger.warning(f"Failed to get fact: {e}")
return None
# Глобальний екземпляр клієнта
memory_client = MemoryClient()