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:
17
services/secondme-service/Dockerfile
Normal file
17
services/secondme-service/Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# App files
|
||||
COPY . .
|
||||
|
||||
# Expose port
|
||||
EXPOSE 7003
|
||||
|
||||
# Run
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7003"]
|
||||
|
||||
59
services/secondme-service/main.py
Normal file
59
services/secondme-service/main.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
Second Me Service — Персональний агент користувача
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
import logging
|
||||
import os
|
||||
|
||||
import routes
|
||||
import repository
|
||||
|
||||
# Logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI(
|
||||
title="DAARION Second Me Service",
|
||||
version="1.0.0",
|
||||
description="Персональний цифровий двійник користувача"
|
||||
)
|
||||
|
||||
# CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Include routes
|
||||
app.include_router(routes.router)
|
||||
|
||||
# Health check
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "healthy", "service": "secondme-service"}
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
"""Startup event"""
|
||||
logger.info("🚀 Second Me Service starting...")
|
||||
logger.info(f"SECONDME_AGENT_ID: {os.getenv('SECONDME_AGENT_ID', 'ag_secondme_global')}")
|
||||
logger.info(f"AGENTS_SERVICE_URL: {os.getenv('AGENTS_SERVICE_URL', 'http://agents-service:7002')}")
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown():
|
||||
"""Shutdown event"""
|
||||
logger.info("🛑 Second Me Service shutting down...")
|
||||
await repository.close_pool()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=7003)
|
||||
|
||||
68
services/secondme-service/models.py
Normal file
68
services/secondme-service/models.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
Pydantic Models для Second Me Service
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Second Me Session
|
||||
# =============================================================================
|
||||
|
||||
class SecondMeSession(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
agent_id: Optional[str] = None
|
||||
created_at: datetime
|
||||
last_interaction_at: Optional[datetime] = None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Second Me Message
|
||||
# =============================================================================
|
||||
|
||||
class SecondMeMessageBase(BaseModel):
|
||||
content: str = Field(..., min_length=1, max_length=10000)
|
||||
|
||||
|
||||
class SecondMeMessageCreate(SecondMeMessageBase):
|
||||
role: str = Field(..., pattern="^(user|assistant)$")
|
||||
|
||||
|
||||
class SecondMeMessageRead(SecondMeMessageBase):
|
||||
id: str
|
||||
session_id: str
|
||||
user_id: str
|
||||
role: str
|
||||
tokens_used: Optional[int] = None
|
||||
latency_ms: Optional[int] = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Second Me Invoke
|
||||
# =============================================================================
|
||||
|
||||
class SecondMeInvokePayload(BaseModel):
|
||||
prompt: str = Field(..., min_length=1, max_length=5000)
|
||||
|
||||
|
||||
class SecondMeInvokeResponse(BaseModel):
|
||||
response: str
|
||||
tokens_used: int
|
||||
latency_ms: int
|
||||
history: List[dict] = []
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Second Me Profile
|
||||
# =============================================================================
|
||||
|
||||
class SecondMeProfile(BaseModel):
|
||||
user_id: str
|
||||
agent_id: str
|
||||
total_interactions: int
|
||||
last_interaction: Optional[datetime] = None
|
||||
|
||||
182
services/secondme-service/repository.py
Normal file
182
services/secondme-service/repository.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""
|
||||
Repository для Second Me Service
|
||||
"""
|
||||
|
||||
import os
|
||||
import asyncpg
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
import secrets
|
||||
|
||||
|
||||
# Database connection
|
||||
_pool: Optional[asyncpg.Pool] = None
|
||||
|
||||
|
||||
async def get_pool() -> asyncpg.Pool:
|
||||
"""Отримати connection pool"""
|
||||
global _pool
|
||||
|
||||
if _pool is None:
|
||||
database_url = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/daarion")
|
||||
_pool = await asyncpg.create_pool(database_url, min_size=2, max_size=10)
|
||||
|
||||
return _pool
|
||||
|
||||
|
||||
async def close_pool():
|
||||
"""Закрити connection pool"""
|
||||
global _pool
|
||||
if _pool is not None:
|
||||
await _pool.close()
|
||||
_pool = None
|
||||
|
||||
|
||||
def generate_id(prefix: str) -> str:
|
||||
"""Генерувати простий ID"""
|
||||
return f"{prefix}_{secrets.token_urlsafe(12)}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Second Me Sessions
|
||||
# =============================================================================
|
||||
|
||||
async def get_or_create_session(user_id: str, agent_id: Optional[str] = None) -> dict:
|
||||
"""
|
||||
Отримати або створити сесію для користувача
|
||||
Для MVP: одна активна сесія на користувача
|
||||
"""
|
||||
pool = await get_pool()
|
||||
|
||||
# Спробувати знайти останню сесію
|
||||
query_find = """
|
||||
SELECT id, user_id, agent_id, created_at, last_interaction_at
|
||||
FROM secondme_sessions
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
existing = await pool.fetchrow(query_find, user_id)
|
||||
|
||||
if existing:
|
||||
return dict(existing)
|
||||
|
||||
# Створити нову
|
||||
session_id = generate_id("smsess")
|
||||
|
||||
query_create = """
|
||||
INSERT INTO secondme_sessions (id, user_id, agent_id)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, user_id, agent_id, created_at, last_interaction_at
|
||||
"""
|
||||
|
||||
new_session = await pool.fetchrow(query_create, session_id, user_id, agent_id)
|
||||
return dict(new_session)
|
||||
|
||||
|
||||
async def update_session_interaction(session_id: str):
|
||||
"""Оновити час останньої взаємодії"""
|
||||
pool = await get_pool()
|
||||
|
||||
query = """
|
||||
UPDATE secondme_sessions
|
||||
SET last_interaction_at = NOW()
|
||||
WHERE id = $1
|
||||
"""
|
||||
|
||||
await pool.execute(query, session_id)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Second Me Messages
|
||||
# =============================================================================
|
||||
|
||||
async def get_session_messages(session_id: str, limit: int = 5) -> List[dict]:
|
||||
"""Отримати останні N повідомлень сесії"""
|
||||
pool = await get_pool()
|
||||
|
||||
query = """
|
||||
SELECT id, session_id, user_id, role, content, tokens_used, latency_ms, created_at
|
||||
FROM secondme_messages
|
||||
WHERE session_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2
|
||||
"""
|
||||
|
||||
rows = await pool.fetch(query, session_id, limit)
|
||||
# Reverse для правильного порядку
|
||||
return [dict(row) for row in reversed(rows)]
|
||||
|
||||
|
||||
async def get_user_messages(user_id: str, limit: int = 5) -> List[dict]:
|
||||
"""Отримати останні N повідомлень користувача з всіх сесій"""
|
||||
pool = await get_pool()
|
||||
|
||||
query = """
|
||||
SELECT sm.id, sm.session_id, sm.user_id, sm.role, sm.content, sm.tokens_used, sm.latency_ms, sm.created_at
|
||||
FROM secondme_messages sm
|
||||
JOIN secondme_sessions ss ON sm.session_id = ss.id
|
||||
WHERE ss.user_id = $1
|
||||
ORDER BY sm.created_at DESC
|
||||
LIMIT $2
|
||||
"""
|
||||
|
||||
rows = await pool.fetch(query, user_id, limit)
|
||||
# Reverse для правильного порядку
|
||||
return [dict(row) for row in reversed(rows)]
|
||||
|
||||
|
||||
async def create_message(
|
||||
session_id: str,
|
||||
user_id: str,
|
||||
role: str,
|
||||
content: str,
|
||||
tokens_used: Optional[int] = None,
|
||||
latency_ms: Optional[int] = None
|
||||
) -> dict:
|
||||
"""Створити повідомлення"""
|
||||
pool = await get_pool()
|
||||
|
||||
message_id = generate_id("smmsg")
|
||||
|
||||
query = """
|
||||
INSERT INTO secondme_messages (id, session_id, user_id, role, content, tokens_used, latency_ms)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, session_id, user_id, role, content, tokens_used, latency_ms, created_at
|
||||
"""
|
||||
|
||||
row = await pool.fetchrow(query, message_id, session_id, user_id, role, content, tokens_used, latency_ms)
|
||||
return dict(row)
|
||||
|
||||
|
||||
async def get_user_stats(user_id: str) -> dict:
|
||||
"""Отримати статистику користувача"""
|
||||
pool = await get_pool()
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
COUNT(DISTINCT ss.id) as total_sessions,
|
||||
COUNT(sm.id) as total_messages,
|
||||
MAX(sm.created_at) as last_interaction
|
||||
FROM secondme_sessions ss
|
||||
LEFT JOIN secondme_messages sm ON ss.id = sm.session_id
|
||||
WHERE ss.user_id = $1
|
||||
"""
|
||||
|
||||
row = await pool.fetchrow(query, user_id)
|
||||
return dict(row) if row else {"total_sessions": 0, "total_messages": 0, "last_interaction": None}
|
||||
|
||||
|
||||
async def clear_user_history(user_id: str):
|
||||
"""Очистити історію користувача"""
|
||||
pool = await get_pool()
|
||||
|
||||
# Видалити всі сесії (каскадно видаляться повідомлення)
|
||||
query = """
|
||||
DELETE FROM secondme_sessions
|
||||
WHERE user_id = $1
|
||||
"""
|
||||
|
||||
await pool.execute(query, user_id)
|
||||
|
||||
6
services/secondme-service/requirements.txt
Normal file
6
services/secondme-service/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
pydantic==2.5.0
|
||||
asyncpg==0.29.0
|
||||
httpx==0.25.2
|
||||
|
||||
98
services/secondme-service/routes.py
Normal file
98
services/secondme-service/routes.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
Second Me Service API Routes
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from typing import List
|
||||
import logging
|
||||
|
||||
from models import (
|
||||
SecondMeInvokePayload,
|
||||
SecondMeInvokeResponse,
|
||||
SecondMeProfile
|
||||
)
|
||||
import service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/secondme", tags=["secondme"])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Second Me API
|
||||
# =============================================================================
|
||||
|
||||
@router.post("/invoke", response_model=SecondMeInvokeResponse)
|
||||
async def invoke_secondme(payload: SecondMeInvokePayload):
|
||||
"""
|
||||
Викликати Second Me agent
|
||||
"""
|
||||
try:
|
||||
# TODO: витягнути user_id з JWT
|
||||
user_id = "u_mock_user" # Mock для MVP
|
||||
|
||||
result = await service.invoke_second_me(user_id, payload.prompt)
|
||||
|
||||
# Додати історію
|
||||
history = await service.get_user_history(user_id, limit=10)
|
||||
|
||||
return {
|
||||
**result,
|
||||
"history": history
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to invoke SecondMe: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to invoke SecondMe")
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
async def get_secondme_history(limit: int = 5):
|
||||
"""
|
||||
Отримати історію розмов з Second Me
|
||||
"""
|
||||
try:
|
||||
# TODO: витягнути user_id з JWT
|
||||
user_id = "u_mock_user" # Mock для MVP
|
||||
|
||||
history = await service.get_user_history(user_id, limit=limit)
|
||||
return history
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get SecondMe history: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to get history")
|
||||
|
||||
|
||||
@router.get("/profile", response_model=SecondMeProfile)
|
||||
async def get_secondme_profile():
|
||||
"""
|
||||
Отримати профіль Second Me користувача
|
||||
"""
|
||||
try:
|
||||
# TODO: витягнути user_id з JWT
|
||||
user_id = "u_mock_user" # Mock для MVP
|
||||
|
||||
profile = await service.get_user_profile(user_id)
|
||||
return profile
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get SecondMe profile: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to get profile")
|
||||
|
||||
|
||||
@router.post("/history/clear")
|
||||
async def clear_secondme_history():
|
||||
"""
|
||||
Очистити історію розмов з Second Me
|
||||
"""
|
||||
try:
|
||||
# TODO: витягнути user_id з JWT
|
||||
user_id = "u_mock_user" # Mock для MVP
|
||||
|
||||
await service.clear_user_history(user_id)
|
||||
return {"status": "cleared"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to clear SecondMe history: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to clear history")
|
||||
|
||||
178
services/secondme-service/service.py
Normal file
178
services/secondme-service/service.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""
|
||||
Second Me Service Logic
|
||||
"""
|
||||
|
||||
import os
|
||||
import httpx
|
||||
import time
|
||||
import logging
|
||||
from typing import List, Dict
|
||||
|
||||
import repository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Config
|
||||
SECONDME_AGENT_ID = os.getenv("SECONDME_AGENT_ID", "ag_secondme_global")
|
||||
AGENTS_SERVICE_URL = os.getenv("AGENTS_SERVICE_URL", "http://agents-service:7002")
|
||||
|
||||
|
||||
async def invoke_second_me(user_id: str, prompt: str) -> Dict:
|
||||
"""
|
||||
Викликати Second Me agent для користувача
|
||||
|
||||
1. Отримати або створити сесію
|
||||
2. Зберегти user prompt
|
||||
3. Зібрати контекст (останні N повідомлень)
|
||||
4. Викликати Agents Core
|
||||
5. Зберегти assistant відповідь
|
||||
6. Повернути результат
|
||||
"""
|
||||
start_time = time.monotonic()
|
||||
|
||||
# 1. Отримати/створити сесію
|
||||
session = await repository.get_or_create_session(user_id, agent_id=SECONDME_AGENT_ID)
|
||||
session_id = session["id"]
|
||||
|
||||
# 2. Зберегти user prompt
|
||||
await repository.create_message(
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
role="user",
|
||||
content=prompt
|
||||
)
|
||||
|
||||
# 3. Зібрати контекст
|
||||
messages = await repository.get_session_messages(session_id, limit=10)
|
||||
|
||||
# Сформувати контекст для LLM
|
||||
context_messages = []
|
||||
for msg in messages:
|
||||
context_messages.append({
|
||||
"role": msg["role"],
|
||||
"content": msg["content"]
|
||||
})
|
||||
|
||||
# 4. Викликати Agents Core
|
||||
try:
|
||||
response_text, tokens_used = await call_agents_core(
|
||||
agent_id=SECONDME_AGENT_ID,
|
||||
user_id=user_id,
|
||||
prompt=prompt,
|
||||
context=context_messages
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to call Agents Core: {e}")
|
||||
# Fallback до mock відповіді
|
||||
response_text = f"Я — твій Second Me. Ти запитав: '{prompt}'. На жаль, зараз я не можу підключитися до LLM, але я тут для тебе! 🤖"
|
||||
tokens_used = 50
|
||||
|
||||
latency_ms = int((time.monotonic() - start_time) * 1000)
|
||||
|
||||
# 5. Зберегти assistant відповідь
|
||||
await repository.create_message(
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
role="assistant",
|
||||
content=response_text,
|
||||
tokens_used=tokens_used,
|
||||
latency_ms=latency_ms
|
||||
)
|
||||
|
||||
# Оновити час останньої взаємодії
|
||||
await repository.update_session_interaction(session_id)
|
||||
|
||||
# 6. Повернути результат
|
||||
return {
|
||||
"response": response_text,
|
||||
"tokens_used": tokens_used,
|
||||
"latency_ms": latency_ms
|
||||
}
|
||||
|
||||
|
||||
async def call_agents_core(
|
||||
agent_id: str,
|
||||
user_id: str,
|
||||
prompt: str,
|
||||
context: List[Dict]
|
||||
) -> tuple[str, int]:
|
||||
"""
|
||||
Викликати Agents Core service
|
||||
|
||||
Returns: (response_text, tokens_used)
|
||||
"""
|
||||
|
||||
# Формуємо input з контекстом
|
||||
context_str = ""
|
||||
if context:
|
||||
for msg in context[-5:]: # Останні 5 повідомлень
|
||||
role = msg["role"]
|
||||
content = msg["content"]
|
||||
context_str += f"{role.capitalize()}: {content}\n"
|
||||
|
||||
input_text = f"""You are Second Me — персональний цифровий двійник користувача в DAARION City.
|
||||
|
||||
Контекст попередніх розмов:
|
||||
{context_str}
|
||||
|
||||
Поточне запитання користувача:
|
||||
{prompt}
|
||||
|
||||
Твоя відповідь (українською мовою):"""
|
||||
|
||||
payload = {
|
||||
"input": input_text,
|
||||
"context": {
|
||||
"user_id": user_id,
|
||||
"kind": "secondme",
|
||||
"agent_id": agent_id
|
||||
}
|
||||
}
|
||||
|
||||
url = f"{AGENTS_SERVICE_URL}/agents/invoke"
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(url, json={
|
||||
"agent_id": agent_id,
|
||||
"payload": payload
|
||||
})
|
||||
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# TODO: адаптувати під реальний формат відповіді Agents Core
|
||||
response_text = data.get("response", data.get("reply", "Немає відповіді"))
|
||||
tokens_used = data.get("tokens_used", 100)
|
||||
|
||||
return response_text, tokens_used
|
||||
|
||||
|
||||
async def get_user_history(user_id: str, limit: int = 5) -> List[Dict]:
|
||||
"""Отримати історію користувача"""
|
||||
messages = await repository.get_user_messages(user_id, limit=limit)
|
||||
return [
|
||||
{
|
||||
"role": msg["role"],
|
||||
"content": msg["content"],
|
||||
"created_at": msg["created_at"].isoformat()
|
||||
}
|
||||
for msg in messages
|
||||
]
|
||||
|
||||
|
||||
async def get_user_profile(user_id: str) -> Dict:
|
||||
"""Отримати профіль Second Me для користувача"""
|
||||
stats = await repository.get_user_stats(user_id)
|
||||
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"agent_id": SECONDME_AGENT_ID,
|
||||
"total_interactions": stats.get("total_messages", 0),
|
||||
"last_interaction": stats.get("last_interaction").isoformat() if stats.get("last_interaction") else None
|
||||
}
|
||||
|
||||
|
||||
async def clear_user_history(user_id: str):
|
||||
"""Очистити історію користувача"""
|
||||
await repository.clear_user_history(user_id)
|
||||
|
||||
Reference in New Issue
Block a user