feat: Add presence heartbeat for Matrix online status
- matrix-gateway: POST /internal/matrix/presence/online endpoint - usePresenceHeartbeat hook with activity tracking - Auto away after 5 min inactivity - Offline on page close/visibility change - Integrated in MatrixChatRoom component
This commit is contained in:
@@ -5,7 +5,7 @@ Memory Service - FastAPI додаток
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional, List
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import FastAPI, Depends, HTTPException, Query, Header
|
||||
@@ -32,7 +32,7 @@ from app.crud import (
|
||||
|
||||
DATABASE_URL = os.getenv(
|
||||
"DATABASE_URL",
|
||||
"postgresql://user:password@localhost:5432/microdao"
|
||||
"sqlite:///./memory.db" # SQLite для розробки, PostgreSQL для продакшену
|
||||
)
|
||||
|
||||
# Створюємо engine та sessionmaker
|
||||
@@ -389,6 +389,35 @@ async def delete_memory_event(
|
||||
return {"success": True}
|
||||
|
||||
|
||||
# ========== Monitor Events Endpoints (Batch Processing) ==========
|
||||
|
||||
from app.monitor_events import MonitorEventBatch, MonitorEventResponse, save_monitor_events_batch, save_monitor_event_single
|
||||
|
||||
@app.post("/api/memory/monitor-events/batch", response_model=MonitorEventResponse)
|
||||
async def save_monitor_events_batch_endpoint(
|
||||
batch: MonitorEventBatch,
|
||||
db: Session = Depends(get_db),
|
||||
authorization: Optional[str] = Header(None)
|
||||
):
|
||||
"""
|
||||
Зберегти батч подій Monitor Agent
|
||||
Оптимізовано для збору метрик з багатьох нод
|
||||
"""
|
||||
return await save_monitor_events_batch(batch, db, authorization)
|
||||
|
||||
@app.post("/api/memory/monitor-events/{node_id}", response_model=AgentMemoryEventResponse)
|
||||
async def save_monitor_event_endpoint(
|
||||
node_id: str,
|
||||
event: Dict[str, Any],
|
||||
db: Session = Depends(get_db),
|
||||
authorization: Optional[str] = Header(None)
|
||||
):
|
||||
"""
|
||||
Зберегти одну подію Monitor Agent
|
||||
"""
|
||||
return await save_monitor_event_single(node_id, event, db, authorization)
|
||||
|
||||
|
||||
# ========== Token Gate Integration Endpoint ==========
|
||||
|
||||
@app.post("/token-gate/check", response_model=TokenGateCheckResponse)
|
||||
|
||||
@@ -7,10 +7,33 @@ from sqlalchemy import (
|
||||
Column, String, Text, JSON, TIMESTAMP,
|
||||
CheckConstraint, Index, Boolean, Integer
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.sql import func
|
||||
from pgvector.sqlalchemy import Vector
|
||||
import os
|
||||
|
||||
# Перевірка типу бази даних
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./memory.db")
|
||||
IS_SQLITE = "sqlite" in DATABASE_URL.lower()
|
||||
|
||||
if IS_SQLITE:
|
||||
# Для SQLite використовуємо стандартні типи
|
||||
from sqlalchemy import JSON as JSONB_TYPE
|
||||
UUID_TYPE = String # SQLite не має UUID, використовуємо String
|
||||
else:
|
||||
# Для PostgreSQL використовуємо специфічні типи
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
UUID_TYPE = UUID
|
||||
JSONB_TYPE = JSONB
|
||||
|
||||
try:
|
||||
from pgvector.sqlalchemy import Vector
|
||||
HAS_PGVECTOR = True
|
||||
except ImportError:
|
||||
HAS_PGVECTOR = False
|
||||
# Заглушка для SQLite
|
||||
class Vector:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
@@ -23,7 +46,7 @@ class UserFact(Base):
|
||||
"""
|
||||
__tablename__ = "user_facts"
|
||||
|
||||
id = Column(UUID(as_uuid=False), primary_key=True, server_default=func.gen_random_uuid())
|
||||
id = Column(UUID_TYPE(as_uuid=False) if not IS_SQLITE else String, primary_key=True, server_default=func.gen_random_uuid() if not IS_SQLITE else None)
|
||||
user_id = Column(String, nullable=False, index=True) # Без FK constraint для тестування
|
||||
team_id = Column(String, nullable=True, index=True) # Без FK constraint, оскільки teams може не існувати
|
||||
|
||||
@@ -32,18 +55,18 @@ class UserFact(Base):
|
||||
|
||||
# Значення факту (може бути текст, число, boolean, JSON)
|
||||
fact_value = Column(Text, nullable=True)
|
||||
fact_value_json = Column(JSONB, nullable=True)
|
||||
fact_value_json = Column(JSONB_TYPE, nullable=True)
|
||||
|
||||
# Метадані: джерело, впевненість, термін дії
|
||||
meta = Column(JSONB, nullable=False, server_default="{}")
|
||||
meta = Column(JSONB_TYPE, nullable=False, server_default="{}")
|
||||
|
||||
# Токен-гейт: чи залежить факт від токенів/активності
|
||||
token_gated = Column(Boolean, nullable=False, server_default="false")
|
||||
token_requirements = Column(JSONB, nullable=True) # {"token": "DAAR", "min_balance": 1}
|
||||
token_requirements = Column(JSONB_TYPE, nullable=True) # {"token": "DAAR", "min_balance": 1}
|
||||
|
||||
created_at = Column(TIMESTAMP(timezone=True), nullable=False, server_default=func.now())
|
||||
updated_at = Column(TIMESTAMP(timezone=True), nullable=True, onupdate=func.now())
|
||||
expires_at = Column(TIMESTAMP(timezone=True), nullable=True) # Для тимчасових фактів
|
||||
created_at = Column(TIMESTAMP(timezone=True) if not IS_SQLITE else TIMESTAMP, nullable=False, server_default=func.now())
|
||||
updated_at = Column(TIMESTAMP(timezone=True) if not IS_SQLITE else TIMESTAMP, nullable=True, onupdate=func.now())
|
||||
expires_at = Column(TIMESTAMP(timezone=True) if not IS_SQLITE else TIMESTAMP, nullable=True) # Для тимчасових фактів
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_user_facts_user_key", "user_id", "fact_key"),
|
||||
@@ -59,7 +82,7 @@ class DialogSummary(Base):
|
||||
"""
|
||||
__tablename__ = "dialog_summaries"
|
||||
|
||||
id = Column(UUID(as_uuid=False), primary_key=True, server_default=func.gen_random_uuid())
|
||||
id = Column(UUID_TYPE(as_uuid=False) if not IS_SQLITE else String, primary_key=True, server_default=func.gen_random_uuid() if not IS_SQLITE else None)
|
||||
|
||||
# Контекст діалогу (без FK constraints для тестування)
|
||||
team_id = Column(String, nullable=False, index=True)
|
||||
@@ -68,24 +91,24 @@ class DialogSummary(Base):
|
||||
user_id = Column(String, nullable=True, index=True)
|
||||
|
||||
# Період, який охоплює підсумок
|
||||
period_start = Column(TIMESTAMP(timezone=True), nullable=False)
|
||||
period_end = Column(TIMESTAMP(timezone=True), nullable=False)
|
||||
period_start = Column(TIMESTAMP(timezone=True) if not IS_SQLITE else TIMESTAMP, nullable=False)
|
||||
period_end = Column(TIMESTAMP(timezone=True) if not IS_SQLITE else TIMESTAMP, nullable=False)
|
||||
|
||||
# Підсумок
|
||||
summary_text = Column(Text, nullable=False)
|
||||
summary_json = Column(JSONB, nullable=True) # Структуровані дані
|
||||
summary_json = Column(JSONB_TYPE, nullable=True) # Структуровані дані
|
||||
|
||||
# Статистика
|
||||
message_count = Column(Integer, nullable=False, server_default="0")
|
||||
participant_count = Column(Integer, nullable=False, server_default="0")
|
||||
|
||||
# Ключові теми/теги
|
||||
topics = Column(JSONB, nullable=True) # ["project-planning", "bug-fix", ...]
|
||||
topics = Column(JSONB_TYPE, nullable=True) # ["project-planning", "bug-fix", ...]
|
||||
|
||||
# Метадані
|
||||
meta = Column(JSONB, nullable=False, server_default="{}")
|
||||
meta = Column(JSONB_TYPE, nullable=False, server_default="{}")
|
||||
|
||||
created_at = Column(TIMESTAMP(timezone=True), nullable=False, server_default=func.now())
|
||||
created_at = Column(TIMESTAMP(timezone=True) if not IS_SQLITE else TIMESTAMP, nullable=False, server_default=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_dialog_summaries_team_period", "team_id", "period_start", "period_end"),
|
||||
@@ -101,7 +124,7 @@ class AgentMemoryEvent(Base):
|
||||
"""
|
||||
__tablename__ = "agent_memory_events"
|
||||
|
||||
id = Column(UUID(as_uuid=False), primary_key=True, server_default=func.gen_random_uuid())
|
||||
id = Column(UUID_TYPE(as_uuid=False) if not IS_SQLITE else String, primary_key=True, server_default=func.gen_random_uuid() if not IS_SQLITE else None)
|
||||
|
||||
# Без FK constraints для тестування
|
||||
agent_id = Column(String, nullable=False, index=True)
|
||||
@@ -117,9 +140,9 @@ class AgentMemoryEvent(Base):
|
||||
|
||||
# Тіло події
|
||||
body_text = Column(Text, nullable=True)
|
||||
body_json = Column(JSONB, nullable=True)
|
||||
body_json = Column(JSONB_TYPE, nullable=True)
|
||||
|
||||
created_at = Column(TIMESTAMP(timezone=True), nullable=False, server_default=func.now())
|
||||
created_at = Column(TIMESTAMP(timezone=True) if not IS_SQLITE else TIMESTAMP, nullable=False, server_default=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("scope IN ('short_term', 'mid_term', 'long_term')", name="ck_agent_memory_scope"),
|
||||
@@ -136,18 +159,18 @@ class AgentMemoryFactsVector(Base):
|
||||
"""
|
||||
__tablename__ = "agent_memory_facts_vector"
|
||||
|
||||
id = Column(UUID(as_uuid=False), primary_key=True, server_default=func.gen_random_uuid())
|
||||
id = Column(UUID_TYPE(as_uuid=False) if not IS_SQLITE else String, primary_key=True, server_default=func.gen_random_uuid() if not IS_SQLITE else None)
|
||||
|
||||
# Без FK constraints для тестування
|
||||
agent_id = Column(String, nullable=False, index=True)
|
||||
team_id = Column(String, nullable=False, index=True)
|
||||
|
||||
fact_text = Column(Text, nullable=False)
|
||||
embedding = Column(Vector(1536), nullable=True) # OpenAI ada-002 embedding size
|
||||
embedding = Column(Vector(1536), nullable=True) if HAS_PGVECTOR else Column(Text, nullable=True) # OpenAI ada-002 embedding size
|
||||
|
||||
meta = Column(JSONB, nullable=False, server_default="{}")
|
||||
meta = Column(JSONB_TYPE, nullable=False, server_default="{}")
|
||||
|
||||
created_at = Column(TIMESTAMP(timezone=True), nullable=False, server_default=func.now())
|
||||
created_at = Column(TIMESTAMP(timezone=True) if not IS_SQLITE else TIMESTAMP, nullable=False, server_default=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_agent_memory_facts_vector_agent_team", "agent_id", "team_id"),
|
||||
|
||||
130
services/memory-service/app/monitor_events.py
Normal file
130
services/memory-service/app/monitor_events.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
Monitor Events API - Автоматичне збереження подій Monitor Agent
|
||||
Підтримує батчинг для оптимізації
|
||||
"""
|
||||
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from fastapi import Depends, Header
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.crud import create_agent_memory_event
|
||||
from app.schemas import AgentMemoryEventCreate, AgentMemoryEventResponse
|
||||
|
||||
# ========== Schemas ==========
|
||||
|
||||
class MonitorEventBatch(BaseModel):
|
||||
"""Батч подій для збереження"""
|
||||
node_id: str
|
||||
events: List[Dict[str, Any]]
|
||||
batch_size: Optional[int] = None
|
||||
|
||||
class MonitorEventResponse(BaseModel):
|
||||
"""Відповідь на збереження подій"""
|
||||
saved: int
|
||||
failed: int
|
||||
node_id: str
|
||||
timestamp: str
|
||||
|
||||
# ========== Functions ==========
|
||||
|
||||
async def save_monitor_events_batch(
|
||||
batch: MonitorEventBatch,
|
||||
db: Session,
|
||||
authorization: Optional[str] = None
|
||||
) -> MonitorEventResponse:
|
||||
"""
|
||||
Зберегти батч подій Monitor Agent
|
||||
Оптимізовано для збору метрик з багатьох нод
|
||||
Зберігає події в загальну пам'ять (monitor) та специфічну пам'ять (monitor-node-{node_id} або monitor-microdao-{microdao_id})
|
||||
"""
|
||||
saved = 0
|
||||
failed = 0
|
||||
|
||||
# Визначаємо agent_id на основі node_id
|
||||
# Формат: monitor-node-{node_id} для ноди, monitor-microdao-{microdao_id} для мікроДАО
|
||||
if "microdao" in batch.node_id:
|
||||
specific_agent_id = batch.node_id # Вже в форматі monitor-microdao-{id}
|
||||
else:
|
||||
specific_agent_id = f"monitor-node-{batch.node_id}"
|
||||
|
||||
# Загальний agent_id для всіх Monitor Agent
|
||||
global_agent_id = "monitor"
|
||||
|
||||
for event_data in batch.events:
|
||||
try:
|
||||
# 1. Зберегти в специфічну пам'ять (monitor-node-{node_id} або monitor-microdao-{microdao_id})
|
||||
specific_event = AgentMemoryEventCreate(
|
||||
agent_id=specific_agent_id,
|
||||
team_id=event_data.get("team_id", "system"),
|
||||
channel_id=event_data.get("channel_id"),
|
||||
user_id=event_data.get("user_id"),
|
||||
scope=event_data.get("scope", "long_term"),
|
||||
kind=event_data.get("kind", "system_event"),
|
||||
body_text=event_data.get("body_text", ""),
|
||||
body_json=event_data.get("body_json", {})
|
||||
)
|
||||
create_agent_memory_event(db, specific_event)
|
||||
|
||||
# 2. Зберегти в загальну пам'ять (monitor) - тільки важливі події
|
||||
# Зберігаємо всі події в загальну пам'ять для агрегації
|
||||
global_event = AgentMemoryEventCreate(
|
||||
agent_id=global_agent_id,
|
||||
team_id=event_data.get("team_id", "system"),
|
||||
channel_id=event_data.get("channel_id"),
|
||||
user_id=event_data.get("user_id"),
|
||||
scope=event_data.get("scope", "long_term"),
|
||||
kind=event_data.get("kind", "system_event"),
|
||||
body_text=event_data.get("body_text", ""),
|
||||
body_json={
|
||||
**event_data.get("body_json", {}),
|
||||
"source_node": batch.node_id, # Додаємо джерело події
|
||||
"specific_agent_id": specific_agent_id
|
||||
}
|
||||
)
|
||||
create_agent_memory_event(db, global_event)
|
||||
|
||||
saved += 2 # Збережено в обидві пам'яті
|
||||
|
||||
# TODO: Збереження в Qdrant, Milvus, Neo4j (асинхронно)
|
||||
# await save_to_qdrant(event_data)
|
||||
# await save_to_milvus(event_data)
|
||||
# await save_to_neo4j(event_data)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error saving event: {e}")
|
||||
failed += 1
|
||||
|
||||
return MonitorEventResponse(
|
||||
saved=saved,
|
||||
failed=failed,
|
||||
node_id=batch.node_id,
|
||||
timestamp=datetime.utcnow().isoformat()
|
||||
)
|
||||
|
||||
async def save_monitor_event_single(
|
||||
node_id: str,
|
||||
event: Dict[str, Any],
|
||||
db: Session,
|
||||
authorization: Optional[str] = None
|
||||
) -> AgentMemoryEventResponse:
|
||||
"""
|
||||
Зберегти одну подію Monitor Agent
|
||||
"""
|
||||
agent_id = f"monitor-{node_id}"
|
||||
|
||||
memory_event = AgentMemoryEventCreate(
|
||||
agent_id=agent_id,
|
||||
team_id=event.get("team_id", "system"),
|
||||
channel_id=event.get("channel_id"),
|
||||
user_id=event.get("user_id"),
|
||||
scope=event.get("scope", "long_term"),
|
||||
kind=event.get("kind", "system_event"),
|
||||
body_text=event.get("body_text", ""),
|
||||
body_json=event.get("body_json", {})
|
||||
)
|
||||
|
||||
db_event = create_agent_memory_event(db, memory_event)
|
||||
return AgentMemoryEventResponse.model_validate(db_event)
|
||||
|
||||
Reference in New Issue
Block a user