snapshot: NODE1 production state 2026-02-09
Complete snapshot of /opt/microdao-daarion/ from NODE1 (144.76.224.179).
This represents the actual running production code that has diverged
significantly from the previous main branch.
Key changes from old main:
- Gateway (http_api.py): expanded from ~40KB to 164KB with full agent support
- Router: new /v1/agents/{id}/infer endpoint with vision + DeepSeek routing
- Behavior Policy: SOWA v2.2 (3-level: FULL/ACK/SILENT)
- Agent Registry: config/agent_registry.yml as single source of truth
- 13 agents configured (was 3)
- Memory service integration
- CrewAI teams and roles
Excluded from snapshot: venv/, .env, data/, backups, .tgz archives
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
987
services/memory-service/app/main.py.backup
Normal file
987
services/memory-service/app/main.py.backup
Normal file
@@ -0,0 +1,987 @@
|
||||
"""
|
||||
DAARION Memory Service - FastAPI Application
|
||||
|
||||
Трирівнева пам'ять агентів:
|
||||
- Short-term: conversation events (робочий буфер)
|
||||
- Mid-term: thread summaries (сесійна/тематична)
|
||||
- Long-term: memory items (персональна/проектна)
|
||||
"""
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import List, Optional
|
||||
from fastapi import Depends, BackgroundTasks
|
||||
from uuid import UUID
|
||||
import structlog
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from .config import get_settings
|
||||
from .models import (
|
||||
CreateThreadRequest, AddEventRequest, CreateMemoryRequest,
|
||||
MemoryFeedbackRequest, RetrievalRequest, SummaryRequest,
|
||||
ThreadResponse, EventResponse, MemoryResponse,
|
||||
SummaryResponse, RetrievalResponse, RetrievalResult,
|
||||
ContextResponse, MemoryCategory, FeedbackAction
|
||||
)
|
||||
from .vector_store import vector_store
|
||||
from .database import db
|
||||
from .auth import get_current_service, get_current_service_optional
|
||||
|
||||
logger = structlog.get_logger()
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Startup and shutdown events"""
|
||||
# Startup
|
||||
logger.info("starting_memory_service")
|
||||
await db.connect()
|
||||
await vector_store.initialize()
|
||||
yield
|
||||
# Shutdown
|
||||
await db.disconnect()
|
||||
logger.info("memory_service_stopped")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="DAARION Memory Service",
|
||||
description="Agent memory management with PostgreSQL + Qdrant + Cohere",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# HEALTH
|
||||
# ============================================================================
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
"""Health check"""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": settings.service_name,
|
||||
"vector_store": await vector_store.get_collection_stats()
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# THREADS (Conversations)
|
||||
# ============================================================================
|
||||
|
||||
@app.post("/threads", response_model=ThreadResponse)
|
||||
async def create_thread(
|
||||
request: CreateThreadRequest,
|
||||
service: Optional[dict] = Depends(get_current_service_optional)
|
||||
):
|
||||
"""Create new conversation thread"""
|
||||
# Auth опціональний: якщо JWT надано, перевіряємо; якщо ні - дозволяємо (dev режим)
|
||||
thread = await db.create_thread(
|
||||
org_id=request.org_id,
|
||||
user_id=request.user_id,
|
||||
workspace_id=request.workspace_id,
|
||||
agent_id=request.agent_id,
|
||||
title=request.title,
|
||||
tags=request.tags,
|
||||
metadata=request.metadata
|
||||
)
|
||||
return thread
|
||||
|
||||
|
||||
@app.get("/threads/{thread_id}", response_model=ThreadResponse)
|
||||
async def get_thread(thread_id: UUID):
|
||||
"""Get thread by ID"""
|
||||
thread = await db.get_thread(thread_id)
|
||||
if not thread:
|
||||
raise HTTPException(status_code=404, detail="Thread not found")
|
||||
return thread
|
||||
|
||||
|
||||
@app.get("/threads", response_model=List[ThreadResponse])
|
||||
async def list_threads(
|
||||
user_id: UUID = Query(...),
|
||||
org_id: UUID = Query(...),
|
||||
workspace_id: Optional[UUID] = None,
|
||||
agent_id: Optional[UUID] = None,
|
||||
limit: int = Query(default=20, le=100)
|
||||
):
|
||||
"""List threads for user"""
|
||||
threads = await db.list_threads(
|
||||
org_id=org_id,
|
||||
user_id=user_id,
|
||||
workspace_id=workspace_id,
|
||||
agent_id=agent_id,
|
||||
limit=limit
|
||||
)
|
||||
return threads
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# EVENTS (Short-term Memory)
|
||||
# ============================================================================
|
||||
|
||||
@app.post("/events", response_model=EventResponse)
|
||||
async def add_event(
|
||||
request: AddEventRequest,
|
||||
service: Optional[dict] = Depends(get_current_service_optional)
|
||||
):
|
||||
"""Add event to conversation (message, tool call, etc.)"""
|
||||
event = await db.add_event(
|
||||
thread_id=request.thread_id,
|
||||
event_type=request.event_type,
|
||||
role=request.role,
|
||||
content=request.content,
|
||||
tool_name=request.tool_name,
|
||||
tool_input=request.tool_input,
|
||||
tool_output=request.tool_output,
|
||||
payload=request.payload,
|
||||
token_count=request.token_count,
|
||||
model_used=request.model_used,
|
||||
latency_ms=request.latency_ms,
|
||||
metadata=request.metadata
|
||||
)
|
||||
return event
|
||||
|
||||
|
||||
@app.get("/threads/{thread_id}/events", response_model=List[EventResponse])
|
||||
async def get_events(
|
||||
thread_id: UUID,
|
||||
limit: int = Query(default=50, le=200),
|
||||
offset: int = Query(default=0)
|
||||
):
|
||||
"""Get events for thread (most recent first)"""
|
||||
events = await db.get_events(thread_id, limit=limit, offset=offset)
|
||||
return events
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MEMORIES (Long-term Memory)
|
||||
# ============================================================================
|
||||
|
||||
@app.post("/memories", response_model=MemoryResponse)
|
||||
async def create_memory(
|
||||
request: CreateMemoryRequest,
|
||||
service: Optional[dict] = Depends(get_current_service_optional)
|
||||
):
|
||||
"""Create long-term memory item"""
|
||||
# Create in PostgreSQL
|
||||
memory = await db.create_memory(
|
||||
org_id=request.org_id,
|
||||
user_id=request.user_id,
|
||||
workspace_id=request.workspace_id,
|
||||
agent_id=request.agent_id,
|
||||
category=request.category,
|
||||
fact_text=request.fact_text,
|
||||
confidence=request.confidence,
|
||||
source_event_id=request.source_event_id,
|
||||
source_thread_id=request.source_thread_id,
|
||||
extraction_method=request.extraction_method,
|
||||
is_sensitive=request.is_sensitive,
|
||||
retention=request.retention,
|
||||
ttl_days=request.ttl_days,
|
||||
tags=request.tags,
|
||||
metadata=request.metadata
|
||||
)
|
||||
|
||||
# Index in Qdrant
|
||||
point_id = await vector_store.index_memory(
|
||||
memory_id=memory["memory_id"],
|
||||
text=request.fact_text,
|
||||
org_id=request.org_id,
|
||||
user_id=request.user_id,
|
||||
category=request.category,
|
||||
agent_id=request.agent_id,
|
||||
workspace_id=request.workspace_id,
|
||||
thread_id=request.source_thread_id
|
||||
)
|
||||
|
||||
# Update memory with embedding ID
|
||||
await db.update_memory_embedding_id(memory["memory_id"], point_id)
|
||||
|
||||
return memory
|
||||
|
||||
|
||||
@app.get("/memories/{memory_id}", response_model=MemoryResponse)
|
||||
async def get_memory(memory_id: UUID):
|
||||
"""Get memory by ID"""
|
||||
memory = await db.get_memory(memory_id)
|
||||
if not memory:
|
||||
raise HTTPException(status_code=404, detail="Memory not found")
|
||||
return memory
|
||||
|
||||
|
||||
@app.get("/memories", response_model=List[MemoryResponse])
|
||||
async def list_memories(
|
||||
user_id: UUID = Query(...),
|
||||
org_id: UUID = Query(...),
|
||||
agent_id: Optional[UUID] = None,
|
||||
workspace_id: Optional[UUID] = None,
|
||||
category: Optional[MemoryCategory] = None,
|
||||
include_global: bool = True,
|
||||
limit: int = Query(default=50, le=200)
|
||||
):
|
||||
"""List memories for user"""
|
||||
memories = await db.list_memories(
|
||||
org_id=org_id,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
workspace_id=workspace_id,
|
||||
category=category,
|
||||
include_global=include_global,
|
||||
limit=limit
|
||||
)
|
||||
return memories
|
||||
|
||||
|
||||
@app.post("/memories/{memory_id}/feedback")
|
||||
async def memory_feedback(memory_id: UUID, request: MemoryFeedbackRequest):
|
||||
"""User feedback on memory (confirm/reject/edit/delete)"""
|
||||
memory = await db.get_memory(memory_id)
|
||||
if not memory:
|
||||
raise HTTPException(status_code=404, detail="Memory not found")
|
||||
|
||||
# Record feedback
|
||||
await db.add_memory_feedback(
|
||||
memory_id=memory_id,
|
||||
user_id=request.user_id,
|
||||
action=request.action,
|
||||
old_value=memory["fact_text"],
|
||||
new_value=request.new_value,
|
||||
reason=request.reason
|
||||
)
|
||||
|
||||
# Apply action
|
||||
if request.action == FeedbackAction.CONFIRM:
|
||||
new_confidence = min(1.0, memory["confidence"] + settings.memory_confirm_boost)
|
||||
await db.update_memory_confidence(memory_id, new_confidence, verified=True)
|
||||
|
||||
elif request.action == FeedbackAction.REJECT:
|
||||
new_confidence = max(0.0, memory["confidence"] - settings.memory_reject_penalty)
|
||||
if new_confidence < settings.memory_min_confidence:
|
||||
# Mark as invalid
|
||||
await db.invalidate_memory(memory_id)
|
||||
await vector_store.delete_memory(memory_id)
|
||||
else:
|
||||
await db.update_memory_confidence(memory_id, new_confidence)
|
||||
|
||||
elif request.action == FeedbackAction.EDIT:
|
||||
if request.new_value:
|
||||
await db.update_memory_text(memory_id, request.new_value)
|
||||
# Re-index with new text
|
||||
await vector_store.delete_memory(memory_id)
|
||||
await vector_store.index_memory(
|
||||
memory_id=memory_id,
|
||||
text=request.new_value,
|
||||
org_id=memory["org_id"],
|
||||
user_id=memory["user_id"],
|
||||
category=memory["category"],
|
||||
agent_id=memory.get("agent_id"),
|
||||
workspace_id=memory.get("workspace_id")
|
||||
)
|
||||
|
||||
elif request.action == FeedbackAction.DELETE:
|
||||
await db.invalidate_memory(memory_id)
|
||||
await vector_store.delete_memory(memory_id)
|
||||
|
||||
return {"status": "ok", "action": request.action.value}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# RETRIEVAL (Semantic Search)
|
||||
# ============================================================================
|
||||
|
||||
@app.post("/retrieve", response_model=RetrievalResponse)
|
||||
async def retrieve_memories(request: RetrievalRequest):
|
||||
"""
|
||||
Semantic retrieval of relevant memories.
|
||||
|
||||
Performs multiple queries and deduplicates results.
|
||||
"""
|
||||
all_results = []
|
||||
seen_ids = set()
|
||||
|
||||
for query in request.queries:
|
||||
results = await vector_store.search_memories(
|
||||
query=query,
|
||||
org_id=request.org_id,
|
||||
user_id=request.user_id,
|
||||
agent_id=request.agent_id,
|
||||
workspace_id=request.workspace_id,
|
||||
categories=request.categories,
|
||||
include_global=request.include_global,
|
||||
top_k=request.top_k
|
||||
)
|
||||
|
||||
for r in results:
|
||||
memory_id = r.get("memory_id")
|
||||
if memory_id and memory_id not in seen_ids:
|
||||
seen_ids.add(memory_id)
|
||||
|
||||
# Get full memory from DB for confidence check
|
||||
memory = await db.get_memory(UUID(memory_id))
|
||||
if memory and memory["confidence"] >= request.min_confidence:
|
||||
all_results.append(RetrievalResult(
|
||||
memory_id=UUID(memory_id),
|
||||
fact_text=r["text"],
|
||||
category=MemoryCategory(r["category"]),
|
||||
confidence=memory["confidence"],
|
||||
relevance_score=r["score"],
|
||||
agent_id=UUID(r["agent_id"]) if r.get("agent_id") else None,
|
||||
is_global=r.get("agent_id") is None
|
||||
))
|
||||
|
||||
# Update usage stats
|
||||
await db.increment_memory_usage(UUID(memory_id))
|
||||
|
||||
# Sort by relevance
|
||||
all_results.sort(key=lambda x: x.relevance_score, reverse=True)
|
||||
|
||||
return RetrievalResponse(
|
||||
results=all_results[:request.top_k],
|
||||
query_count=len(request.queries),
|
||||
total_results=len(all_results)
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SUMMARIES (Mid-term Memory)
|
||||
# ============================================================================
|
||||
|
||||
@app.post("/threads/{thread_id}/summarize", response_model=SummaryResponse)
|
||||
async def create_summary(thread_id: UUID, request: SummaryRequest):
|
||||
"""
|
||||
Generate rolling summary for thread.
|
||||
|
||||
Compresses old events into a structured summary.
|
||||
"""
|
||||
thread = await db.get_thread(thread_id)
|
||||
if not thread:
|
||||
raise HTTPException(status_code=404, detail="Thread not found")
|
||||
|
||||
# Check if summary is needed
|
||||
if not request.force and thread["total_tokens"] < settings.summary_trigger_tokens:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Token count ({thread['total_tokens']}) below threshold ({settings.summary_trigger_tokens})"
|
||||
)
|
||||
|
||||
# Get events to summarize
|
||||
events = await db.get_events_for_summary(thread_id)
|
||||
|
||||
# TODO: Call LLM to generate summary
|
||||
# For now, create a placeholder
|
||||
summary_text = f"Summary of {len(events)} events. [Implement LLM summarization]"
|
||||
state = {
|
||||
"goals": [],
|
||||
"decisions": [],
|
||||
"open_questions": [],
|
||||
"next_steps": [],
|
||||
"key_facts": []
|
||||
}
|
||||
|
||||
# Create summary
|
||||
summary = await db.create_summary(
|
||||
thread_id=thread_id,
|
||||
summary_text=summary_text,
|
||||
state=state,
|
||||
events_from_seq=events[0]["sequence_num"] if events else 0,
|
||||
events_to_seq=events[-1]["sequence_num"] if events else 0,
|
||||
events_count=len(events)
|
||||
)
|
||||
|
||||
# Index summary in Qdrant
|
||||
await vector_store.index_summary(
|
||||
summary_id=summary["summary_id"],
|
||||
text=summary_text,
|
||||
thread_id=thread_id,
|
||||
org_id=thread["org_id"],
|
||||
user_id=thread["user_id"],
|
||||
agent_id=thread.get("agent_id"),
|
||||
workspace_id=thread.get("workspace_id")
|
||||
)
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
@app.get("/threads/{thread_id}/summary", response_model=Optional[SummaryResponse])
|
||||
async def get_latest_summary(thread_id: UUID):
|
||||
"""Get latest summary for thread"""
|
||||
summary = await db.get_latest_summary(thread_id)
|
||||
return summary
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CONTEXT (Full context for agent)
|
||||
# ============================================================================
|
||||
|
||||
@app.get("/threads/{thread_id}/context", response_model=ContextResponse)
|
||||
async def get_context(
|
||||
thread_id: UUID,
|
||||
queries: List[str] = Query(default=[]),
|
||||
top_k: int = Query(default=10)
|
||||
):
|
||||
"""
|
||||
Get full context for agent prompt.
|
||||
|
||||
Combines:
|
||||
- Latest summary (mid-term)
|
||||
- Recent messages (short-term)
|
||||
- Retrieved memories (long-term)
|
||||
"""
|
||||
thread = await db.get_thread(thread_id)
|
||||
if not thread:
|
||||
raise HTTPException(status_code=404, detail="Thread not found")
|
||||
|
||||
# Get summary
|
||||
summary = await db.get_latest_summary(thread_id)
|
||||
|
||||
# Get recent messages
|
||||
recent = await db.get_events(
|
||||
thread_id,
|
||||
limit=settings.short_term_window_messages
|
||||
)
|
||||
|
||||
# Retrieve memories if queries provided
|
||||
retrieved = []
|
||||
if queries:
|
||||
retrieval_response = await retrieve_memories(RetrievalRequest(
|
||||
org_id=thread["org_id"],
|
||||
user_id=thread["user_id"],
|
||||
agent_id=thread.get("agent_id"),
|
||||
workspace_id=thread.get("workspace_id"),
|
||||
queries=queries,
|
||||
top_k=top_k,
|
||||
include_global=True
|
||||
))
|
||||
retrieved = retrieval_response.results
|
||||
|
||||
# Estimate tokens
|
||||
token_estimate = sum(e.get("token_count", 0) or 0 for e in recent)
|
||||
if summary:
|
||||
token_estimate += summary.get("summary_tokens", 0) or 0
|
||||
|
||||
return ContextResponse(
|
||||
thread_id=thread_id,
|
||||
summary=summary,
|
||||
recent_messages=recent,
|
||||
retrieved_memories=retrieved,
|
||||
token_estimate=token_estimate
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# FACTS (Simple Key-Value storage for Gateway compatibility)
|
||||
# ============================================================================
|
||||
|
||||
from pydantic import BaseModel
|
||||
from typing import Any
|
||||
|
||||
class FactUpsertRequest(BaseModel):
|
||||
"""Request to upsert a user fact"""
|
||||
user_id: str
|
||||
fact_key: str
|
||||
fact_value: Optional[str] = None
|
||||
fact_value_json: Optional[dict] = None
|
||||
team_id: Optional[str] = None
|
||||
|
||||
@app.post("/facts/upsert")
|
||||
async def upsert_fact(request: FactUpsertRequest):
|
||||
"""
|
||||
Create or update a user fact.
|
||||
|
||||
This is a simple key-value store for Gateway compatibility.
|
||||
Facts are stored in PostgreSQL without vector indexing.
|
||||
"""
|
||||
try:
|
||||
# Ensure facts table exists (will be created on first call)
|
||||
await db.ensure_facts_table()
|
||||
|
||||
# Upsert the fact
|
||||
result = await db.upsert_fact(
|
||||
user_id=request.user_id,
|
||||
fact_key=request.fact_key,
|
||||
fact_value=request.fact_value,
|
||||
fact_value_json=request.fact_value_json,
|
||||
team_id=request.team_id
|
||||
)
|
||||
|
||||
logger.info(f"fact_upserted", user_id=request.user_id, fact_key=request.fact_key)
|
||||
return {"status": "ok", "fact_id": result.get("fact_id") if result else None}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"fact_upsert_failed", error=str(e), user_id=request.user_id)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.get("/facts/{fact_key}")
|
||||
async def get_fact(
|
||||
fact_key: str,
|
||||
user_id: str = Query(...),
|
||||
team_id: Optional[str] = None
|
||||
):
|
||||
"""Get a specific fact for a user"""
|
||||
try:
|
||||
fact = await db.get_fact(user_id=user_id, fact_key=fact_key, team_id=team_id)
|
||||
if not fact:
|
||||
raise HTTPException(status_code=404, detail="Fact not found")
|
||||
return fact
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"fact_get_failed", error=str(e))
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.get("/facts")
|
||||
async def list_facts(
|
||||
user_id: str = Query(...),
|
||||
team_id: Optional[str] = None
|
||||
):
|
||||
"""List all facts for a user"""
|
||||
try:
|
||||
facts = await db.list_facts(user_id=user_id, team_id=team_id)
|
||||
return {"facts": facts}
|
||||
except Exception as e:
|
||||
logger.error(f"facts_list_failed", error=str(e))
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.delete("/facts/{fact_key}")
|
||||
async def delete_fact(
|
||||
fact_key: str,
|
||||
user_id: str = Query(...),
|
||||
team_id: Optional[str] = None
|
||||
):
|
||||
"""Delete a fact"""
|
||||
try:
|
||||
deleted = await db.delete_fact(user_id=user_id, fact_key=fact_key, team_id=team_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Fact not found")
|
||||
return {"status": "ok", "deleted": True}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"fact_delete_failed", error=str(e))
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# AGENT MEMORY (Gateway compatibility endpoint)
|
||||
# ============================================================================
|
||||
|
||||
class AgentMemoryRequest(BaseModel):
|
||||
"""Request format from Gateway for saving chat history"""
|
||||
agent_id: str
|
||||
team_id: Optional[str] = None
|
||||
channel_id: Optional[str] = None
|
||||
user_id: str
|
||||
# Support both formats: new (content) and gateway (body_text)
|
||||
content: Optional[str] = None
|
||||
body_text: Optional[str] = None
|
||||
role: str = "user" # user, assistant, system
|
||||
# Support both formats: metadata and body_json
|
||||
metadata: Optional[dict] = None
|
||||
body_json: Optional[dict] = None
|
||||
context: Optional[str] = None
|
||||
scope: Optional[str] = None
|
||||
kind: Optional[str] = None # "message", "event", etc.
|
||||
|
||||
def get_content(self) -> str:
|
||||
"""Get content from either field"""
|
||||
return self.content or self.body_text or ""
|
||||
|
||||
def get_metadata(self) -> dict:
|
||||
"""Get metadata from either field"""
|
||||
return self.metadata or self.body_json or {}
|
||||
|
||||
@app.post("/agents/{agent_id}/memory")
|
||||
async def save_agent_memory(agent_id: str, request: AgentMemoryRequest, background_tasks: BackgroundTasks):
|
||||
"""
|
||||
Save chat turn to memory with full ingestion pipeline:
|
||||
1. Save to PostgreSQL (facts table)
|
||||
2. Create embedding via Cohere and save to Qdrant
|
||||
3. Update Knowledge Graph in Neo4j
|
||||
"""
|
||||
try:
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
# Create a unique key for this conversation event
|
||||
timestamp = datetime.utcnow().isoformat()
|
||||
message_id = str(uuid4())
|
||||
fact_key = f"chat_event:{request.channel_id}:{timestamp}"
|
||||
|
||||
# Store as a fact with JSON payload
|
||||
content = request.get_content()
|
||||
metadata = request.get_metadata()
|
||||
|
||||
# Skip empty messages
|
||||
if not content or content.startswith("[Photo:"):
|
||||
logger.debug("skipping_empty_or_photo_message", content=content[:50] if content else "")
|
||||
return {"status": "ok", "event_id": None, "indexed": False}
|
||||
|
||||
# Determine role from kind/body_json if not explicitly set
|
||||
role = request.role
|
||||
if request.body_json and request.body_json.get("type") == "agent_response":
|
||||
role = "assistant"
|
||||
|
||||
event_data = {
|
||||
"message_id": message_id,
|
||||
"agent_id": agent_id,
|
||||
"team_id": request.team_id,
|
||||
"channel_id": request.channel_id,
|
||||
"user_id": request.user_id,
|
||||
"role": role,
|
||||
"content": content,
|
||||
"metadata": metadata,
|
||||
"scope": request.scope,
|
||||
"kind": request.kind,
|
||||
"timestamp": timestamp
|
||||
}
|
||||
|
||||
# 1. Save to PostgreSQL
|
||||
await db.ensure_facts_table()
|
||||
result = await db.upsert_fact(
|
||||
user_id=request.user_id,
|
||||
fact_key=fact_key,
|
||||
fact_value_json=event_data,
|
||||
team_id=request.team_id
|
||||
)
|
||||
|
||||
logger.info("agent_memory_saved",
|
||||
agent_id=agent_id,
|
||||
user_id=request.user_id,
|
||||
role=role,
|
||||
channel_id=request.channel_id,
|
||||
content_len=len(content))
|
||||
|
||||
# 2. Index in Qdrant (async background task)
|
||||
background_tasks.add_task(
|
||||
index_message_in_qdrant,
|
||||
message_id=message_id,
|
||||
content=content,
|
||||
agent_id=agent_id,
|
||||
user_id=request.user_id,
|
||||
channel_id=request.channel_id,
|
||||
role=role,
|
||||
timestamp=timestamp
|
||||
)
|
||||
|
||||
# 3. Update Neo4j graph (async background task)
|
||||
background_tasks.add_task(
|
||||
update_neo4j_graph,
|
||||
message_id=message_id,
|
||||
content=content,
|
||||
agent_id=agent_id,
|
||||
user_id=request.user_id,
|
||||
channel_id=request.channel_id,
|
||||
role=role
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"event_id": result.get("fact_id") if result else None,
|
||||
"message_id": message_id,
|
||||
"indexed": True
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("agent_memory_save_failed", error=str(e), agent_id=agent_id)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
async def index_message_in_qdrant(
|
||||
message_id: str,
|
||||
content: str,
|
||||
agent_id: str,
|
||||
user_id: str,
|
||||
channel_id: str,
|
||||
role: str,
|
||||
timestamp: str
|
||||
):
|
||||
"""Index message in Qdrant for semantic search"""
|
||||
try:
|
||||
from .embedding import get_document_embeddings
|
||||
from qdrant_client.http import models as qmodels
|
||||
|
||||
# Skip very short messages
|
||||
if len(content) < 10:
|
||||
return
|
||||
|
||||
# Generate embedding
|
||||
embeddings = await get_document_embeddings([content])
|
||||
if not embeddings or not embeddings[0]:
|
||||
logger.warning("embedding_failed", message_id=message_id)
|
||||
return
|
||||
|
||||
vector = embeddings[0]
|
||||
|
||||
# Save to Qdrant
|
||||
vector_store.client.upsert(
|
||||
collection_name="helion_messages",
|
||||
points=[
|
||||
qmodels.PointStruct(
|
||||
id=message_id,
|
||||
vector=vector,
|
||||
payload={
|
||||
"message_id": message_id,
|
||||
"agent_id": agent_id,
|
||||
"user_id": user_id,
|
||||
"channel_id": channel_id,
|
||||
"role": role,
|
||||
"content": content,
|
||||
"timestamp": timestamp,
|
||||
"type": "chat_message"
|
||||
}
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
logger.info("message_indexed_qdrant",
|
||||
message_id=message_id,
|
||||
content_len=len(content),
|
||||
vector_dim=len(vector))
|
||||
|
||||
except Exception as e:
|
||||
logger.error("qdrant_indexing_failed", error=str(e), message_id=message_id)
|
||||
|
||||
|
||||
async def update_neo4j_graph(
|
||||
message_id: str,
|
||||
content: str,
|
||||
agent_id: str,
|
||||
user_id: str,
|
||||
channel_id: str,
|
||||
role: str
|
||||
):
|
||||
"""Update Knowledge Graph in Neo4j"""
|
||||
try:
|
||||
import httpx
|
||||
import os
|
||||
|
||||
neo4j_url = os.getenv("NEO4J_HTTP_URL", "http://neo4j:7474")
|
||||
neo4j_user = os.getenv("NEO4J_USER", "neo4j")
|
||||
neo4j_password = os.getenv("NEO4J_PASSWORD", "DaarionNeo4j2026!")
|
||||
|
||||
# Create/update User node and Message relationship
|
||||
cypher = """
|
||||
MERGE (u:User {user_id: $user_id})
|
||||
ON CREATE SET u.created_at = datetime()
|
||||
ON MATCH SET u.last_seen = datetime()
|
||||
|
||||
MERGE (ch:Channel {channel_id: $channel_id})
|
||||
ON CREATE SET ch.created_at = datetime()
|
||||
|
||||
MERGE (u)-[:PARTICIPATES_IN]->(ch)
|
||||
|
||||
CREATE (m:Message {
|
||||
message_id: $message_id,
|
||||
role: $role,
|
||||
content_preview: $content_preview,
|
||||
agent_id: $agent_id,
|
||||
created_at: datetime()
|
||||
})
|
||||
|
||||
CREATE (u)-[:SENT]->(m)
|
||||
CREATE (m)-[:IN_CHANNEL]->(ch)
|
||||
|
||||
RETURN m.message_id as id
|
||||
"""
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(
|
||||
f"{neo4j_url}/db/neo4j/tx/commit",
|
||||
auth=(neo4j_user, neo4j_password),
|
||||
json={
|
||||
"statements": [{
|
||||
"statement": cypher,
|
||||
"parameters": {
|
||||
"user_id": user_id,
|
||||
"channel_id": channel_id,
|
||||
"message_id": message_id,
|
||||
"role": role,
|
||||
"content_preview": content[:200] if content else "",
|
||||
"agent_id": agent_id
|
||||
}
|
||||
}]
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info("neo4j_graph_updated", message_id=message_id, user_id=user_id)
|
||||
else:
|
||||
logger.warning("neo4j_update_failed",
|
||||
status=response.status_code,
|
||||
response=response.text[:200])
|
||||
|
||||
except Exception as e:
|
||||
logger.error("neo4j_update_error", error=str(e), message_id=message_id)
|
||||
|
||||
|
||||
@app.get("/agents/{agent_id}/memory")
|
||||
async def get_agent_memory(
|
||||
agent_id: str,
|
||||
user_id: str = Query(...),
|
||||
channel_id: Optional[str] = None,
|
||||
limit: int = Query(default=20, le=100)
|
||||
):
|
||||
"""
|
||||
Get recent chat events for an agent/user.
|
||||
"""
|
||||
try:
|
||||
facts = await db.list_facts(user_id=user_id, limit=limit)
|
||||
|
||||
# Filter for chat events from this channel
|
||||
events = []
|
||||
for fact in facts:
|
||||
if fact.get("fact_key", "").startswith("chat_event:"):
|
||||
event_data = fact.get("fact_value_json", {})
|
||||
if channel_id is None or event_data.get("channel_id") == channel_id:
|
||||
if event_data.get("agent_id") == agent_id:
|
||||
events.append(event_data)
|
||||
|
||||
return {"events": events[:limit]}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("agent_memory_get_failed", error=str(e))
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ADMIN
|
||||
# ============================================================================
|
||||
|
||||
@app.get("/stats")
|
||||
async def get_stats():
|
||||
"""Get service statistics"""
|
||||
return {
|
||||
"vector_store": await vector_store.get_collection_stats(),
|
||||
"database": await db.get_stats()
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# IDENTITY & ACCOUNT LINKING
|
||||
# ============================================================================
|
||||
|
||||
import secrets
|
||||
from datetime import timedelta
|
||||
|
||||
class LinkStartRequest(BaseModel):
|
||||
account_id: str
|
||||
ttl_minutes: int = 10
|
||||
|
||||
|
||||
@app.post("/identity/link/start")
|
||||
async def start_link(request: LinkStartRequest):
|
||||
"""Generate a one-time link code for account linking."""
|
||||
try:
|
||||
link_code = secrets.token_urlsafe(16)[:20].upper()
|
||||
expires_at = datetime.utcnow() + timedelta(minutes=request.ttl_minutes)
|
||||
|
||||
await db.pool.execute(
|
||||
"""
|
||||
INSERT INTO link_codes (code, account_id, expires_at, generated_via)
|
||||
VALUES ($1, $2::uuid, $3, 'api')
|
||||
""",
|
||||
link_code, request.account_id, expires_at
|
||||
)
|
||||
|
||||
logger.info("link_code_generated", account_id=request.account_id)
|
||||
return {"link_code": link_code, "expires_at": expires_at.isoformat()}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("link_code_generation_failed", error=str(e))
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.get("/identity/resolve")
|
||||
async def resolve_telegram(telegram_user_id: int):
|
||||
"""Resolve Telegram user ID to Energy Union account ID."""
|
||||
try:
|
||||
row = await db.pool.fetchrow(
|
||||
"""
|
||||
SELECT account_id, linked_at
|
||||
FROM account_links
|
||||
WHERE telegram_user_id = $1 AND status = 'active'
|
||||
""",
|
||||
telegram_user_id
|
||||
)
|
||||
|
||||
if row:
|
||||
return {
|
||||
"account_id": str(row['account_id']),
|
||||
"linked": True,
|
||||
"linked_at": row['linked_at'].isoformat()
|
||||
}
|
||||
return {"account_id": None, "linked": False}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("resolve_failed", error=str(e))
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.get("/identity/user/{account_id}/timeline")
|
||||
async def get_user_timeline(account_id: str, limit: int = Query(default=20, le=100)):
|
||||
"""Get user interaction timeline across all channels."""
|
||||
try:
|
||||
rows = await db.pool.fetch(
|
||||
"""
|
||||
SELECT id, channel, channel_id, event_type, summary,
|
||||
metadata, importance_score, event_at
|
||||
FROM user_timeline
|
||||
WHERE account_id = $1::uuid
|
||||
ORDER BY event_at DESC
|
||||
LIMIT $2
|
||||
""",
|
||||
account_id, limit
|
||||
)
|
||||
|
||||
events = [{
|
||||
"id": str(r['id']),
|
||||
"channel": r['channel'],
|
||||
"event_type": r['event_type'],
|
||||
"summary": r['summary'],
|
||||
"event_at": r['event_at'].isoformat()
|
||||
} for r in rows]
|
||||
|
||||
return {"events": events, "count": len(events)}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("timeline_failed", error=str(e))
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/identity/timeline/add")
|
||||
async def add_timeline_event(
|
||||
account_id: str,
|
||||
channel: str,
|
||||
channel_id: str,
|
||||
event_type: str,
|
||||
summary: str,
|
||||
importance_score: float = 0.5
|
||||
):
|
||||
"""Add event to user timeline."""
|
||||
try:
|
||||
event_id = await db.pool.fetchval(
|
||||
"SELECT add_timeline_event($1::uuid, $2, $3, $4, $5, '{}', $6)",
|
||||
account_id, channel, channel_id, event_type, summary, importance_score
|
||||
)
|
||||
return {"event_id": str(event_id), "success": True}
|
||||
except Exception as e:
|
||||
logger.error("timeline_add_failed", error=str(e))
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
186
services/memory-service/app/service_auth.py
Normal file
186
services/memory-service/app/service_auth.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""
|
||||
Service-to-Service Authentication
|
||||
=================================
|
||||
JWT-based authentication between internal services.
|
||||
|
||||
Usage:
|
||||
from service_auth import create_service_token, verify_service_token, require_service_auth
|
||||
|
||||
# Create token for service
|
||||
token = create_service_token("router", "router")
|
||||
|
||||
# Verify in endpoint
|
||||
@app.get("/protected")
|
||||
@require_service_auth(allowed_roles=["router", "gateway"])
|
||||
async def protected_endpoint():
|
||||
return {"status": "ok"}
|
||||
"""
|
||||
|
||||
import os
|
||||
import jwt
|
||||
import time
|
||||
from typing import List, Optional, Dict, Any
|
||||
from functools import wraps
|
||||
from fastapi import HTTPException, Header, Request
|
||||
|
||||
# Configuration
|
||||
JWT_SECRET = os.getenv("JWT_SECRET", "change-me-in-production")
|
||||
JWT_ALGORITHM = "HS256"
|
||||
JWT_AUDIENCE = os.getenv("SERVICE_AUD", "microdao-internal")
|
||||
JWT_ISSUER = os.getenv("SERVICE_ISS", "microdao")
|
||||
|
||||
# Service roles and permissions
|
||||
SERVICE_ROLES = {
|
||||
"gateway": ["gateway", "router", "worker", "parser"],
|
||||
"router": ["router", "worker"],
|
||||
"worker": ["worker"],
|
||||
"memory": ["memory"],
|
||||
"control-plane": ["control-plane"],
|
||||
"parser": ["parser"],
|
||||
"ingest": ["ingest"]
|
||||
}
|
||||
|
||||
# Service-to-service access matrix
|
||||
SERVICE_ACCESS = {
|
||||
"gateway": ["memory", "control-plane", "router"],
|
||||
"router": ["memory", "control-plane", "swapper"],
|
||||
"worker": ["memory", "router"],
|
||||
"parser": ["memory"],
|
||||
"ingest": ["memory"]
|
||||
}
|
||||
|
||||
|
||||
def create_service_token(service_id: str, service_role: str, expires_in: int = 900) -> str:
|
||||
"""
|
||||
Create JWT token for service-to-service authentication.
|
||||
|
||||
Args:
|
||||
service_id: Unique service identifier (e.g., "router", "gateway")
|
||||
service_role: Service role (e.g., "router", "gateway")
|
||||
expires_in: Token expiration in seconds (default: 1 hour)
|
||||
|
||||
Returns:
|
||||
JWT token string
|
||||
"""
|
||||
now = int(time.time())
|
||||
payload = {
|
||||
"sub": service_id,
|
||||
"role": service_role,
|
||||
"aud": JWT_AUDIENCE,
|
||||
"iss": JWT_ISSUER,
|
||||
"iat": now,
|
||||
"exp": now + expires_in,
|
||||
"service_id": service_id,
|
||||
"service_role": service_role
|
||||
}
|
||||
|
||||
token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
|
||||
return token
|
||||
|
||||
|
||||
def verify_service_token(token: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Verify service JWT token.
|
||||
|
||||
Returns:
|
||||
Decoded token payload
|
||||
|
||||
Raises:
|
||||
HTTPException: If token is invalid
|
||||
"""
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
JWT_SECRET,
|
||||
algorithms=[JWT_ALGORITHM],
|
||||
audience=JWT_AUDIENCE,
|
||||
issuer=JWT_ISSUER
|
||||
)
|
||||
return payload
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(status_code=401, detail="Token expired")
|
||||
except jwt.InvalidTokenError as e:
|
||||
raise HTTPException(status_code=401, detail=f"Invalid token: {e}")
|
||||
|
||||
|
||||
def require_service_auth(allowed_roles: List[str] = None, allowed_services: List[str] = None):
|
||||
"""
|
||||
Decorator to require service authentication.
|
||||
|
||||
Args:
|
||||
allowed_roles: List of allowed service roles
|
||||
allowed_services: List of allowed service IDs
|
||||
"""
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
async def wrapper(request: Request, *args, **kwargs):
|
||||
# Get Authorization header
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
|
||||
if not auth_header.startswith("Bearer "):
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Missing or invalid Authorization header"
|
||||
)
|
||||
|
||||
token = auth_header.replace("Bearer ", "")
|
||||
|
||||
try:
|
||||
payload = verify_service_token(token)
|
||||
service_id = payload.get("service_id")
|
||||
service_role = payload.get("role")
|
||||
|
||||
# Check if service is allowed
|
||||
if allowed_roles and service_role not in allowed_roles:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Service role '{service_role}' not allowed"
|
||||
)
|
||||
|
||||
if allowed_services and service_id not in allowed_services:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Service '{service_id}' not allowed"
|
||||
)
|
||||
|
||||
# Add service info to request state
|
||||
request.state.service_id = service_id
|
||||
request.state.service_role = service_role
|
||||
|
||||
return await func(request, *args, **kwargs)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=401, detail=f"Authentication failed: {e}")
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def get_service_token() -> str:
|
||||
"""
|
||||
Get service token for current service (from environment).
|
||||
"""
|
||||
service_id = os.getenv("SERVICE_ID")
|
||||
service_role = os.getenv("SERVICE_ROLE", service_id)
|
||||
|
||||
if not service_id:
|
||||
raise ValueError("SERVICE_ID environment variable not set")
|
||||
|
||||
return create_service_token(service_id, service_role)
|
||||
|
||||
|
||||
# FastAPI dependency for service auth
|
||||
async def verify_service(request: Request, authorization: str = Header(None)):
|
||||
"""FastAPI dependency for service authentication"""
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="Missing Authorization header")
|
||||
|
||||
token = authorization.replace("Bearer ", "")
|
||||
payload = verify_service_token(token)
|
||||
|
||||
request.state.service_id = payload.get("service_id")
|
||||
request.state.service_role = payload.get("role")
|
||||
|
||||
return payload
|
||||
BIN
services/memory-service/memory.db
Normal file
BIN
services/memory-service/memory.db
Normal file
Binary file not shown.
400
services/memory-service/outbox.py
Normal file
400
services/memory-service/outbox.py
Normal file
@@ -0,0 +1,400 @@
|
||||
"""
|
||||
Outbox Pattern Implementation
|
||||
=============================
|
||||
Забезпечує надійну публікацію подій через Postgres + NATS.
|
||||
|
||||
Принцип:
|
||||
1. Записуємо подію в outbox таблицю (в тій же транзакції що і дані)
|
||||
2. Publisher воркер забирає pending події
|
||||
3. Публікуємо в NATS JetStream
|
||||
4. Помічаємо як published
|
||||
|
||||
Переваги:
|
||||
- Атомарність: дані + подія в одній транзакції
|
||||
- Надійність: якщо NATS недоступний, події залишаються в outbox
|
||||
- Ідемпотентність: подія публікується тільки один раз
|
||||
"""
|
||||
|
||||
import json
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional, List
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
import uuid
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class OutboxStatus(str, Enum):
|
||||
PENDING = "pending"
|
||||
PUBLISHED = "published"
|
||||
FAILED = "failed"
|
||||
|
||||
@dataclass
|
||||
class OutboxEvent:
|
||||
"""Подія в outbox"""
|
||||
id: str
|
||||
aggregate_type: str # "message", "attachment", "memory", etc.
|
||||
aggregate_id: str # ID об'єкту
|
||||
event_type: str # "created", "updated", "deleted"
|
||||
payload: Dict[str, Any]
|
||||
created_at: datetime = field(default_factory=datetime.utcnow)
|
||||
published_at: Optional[datetime] = None
|
||||
status: OutboxStatus = OutboxStatus.PENDING
|
||||
retry_count: int = 0
|
||||
last_error: Optional[str] = None
|
||||
|
||||
def to_nats_subject(self) -> str:
|
||||
"""Генерує NATS subject для події"""
|
||||
# Format: {aggregate_type}.{event_type}.{aggregate_id}
|
||||
return f"{self.aggregate_type}.{self.event_type}"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"aggregate_type": self.aggregate_type,
|
||||
"aggregate_id": self.aggregate_id,
|
||||
"event_type": self.event_type,
|
||||
"payload": self.payload,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"status": self.status.value,
|
||||
}
|
||||
|
||||
|
||||
# SQL для створення outbox таблиці
|
||||
OUTBOX_TABLE_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS outbox (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
aggregate_type VARCHAR(100) NOT NULL,
|
||||
aggregate_id VARCHAR(255) NOT NULL,
|
||||
event_type VARCHAR(100) NOT NULL,
|
||||
payload JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
published_at TIMESTAMPTZ,
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
retry_count INTEGER DEFAULT 0,
|
||||
last_error TEXT,
|
||||
|
||||
-- Indexes for efficient polling
|
||||
CONSTRAINT outbox_status_check CHECK (status IN ('pending', 'published', 'failed'))
|
||||
);
|
||||
|
||||
-- Index for efficient pending event polling
|
||||
CREATE INDEX IF NOT EXISTS idx_outbox_pending
|
||||
ON outbox(status, created_at)
|
||||
WHERE status = 'pending';
|
||||
|
||||
-- Index for cleanup of old published events
|
||||
CREATE INDEX IF NOT EXISTS idx_outbox_published
|
||||
ON outbox(published_at)
|
||||
WHERE status = 'published';
|
||||
"""
|
||||
|
||||
|
||||
class OutboxWriter:
|
||||
"""
|
||||
Записує події в outbox таблицю.
|
||||
|
||||
Використовується в Memory Service для запису подій.
|
||||
"""
|
||||
|
||||
def __init__(self, db_pool):
|
||||
"""
|
||||
Args:
|
||||
db_pool: asyncpg connection pool
|
||||
"""
|
||||
self.db_pool = db_pool
|
||||
|
||||
async def init_table(self):
|
||||
"""Створює outbox таблицю якщо не існує"""
|
||||
async with self.db_pool.acquire() as conn:
|
||||
await conn.execute(OUTBOX_TABLE_SQL)
|
||||
logger.info("Outbox table initialized")
|
||||
|
||||
async def write(self,
|
||||
aggregate_type: str,
|
||||
aggregate_id: str,
|
||||
event_type: str,
|
||||
payload: Dict[str, Any],
|
||||
conn=None) -> str:
|
||||
"""
|
||||
Записує подію в outbox.
|
||||
|
||||
ВАЖЛИВО: Викликати в тій же транзакції, що і основні дані!
|
||||
|
||||
Args:
|
||||
aggregate_type: Тип агрегату (message, attachment, memory)
|
||||
aggregate_id: ID агрегату
|
||||
event_type: Тип події (created, updated, deleted)
|
||||
payload: Дані події
|
||||
conn: Опціонально - існуюче з'єднання для транзакції
|
||||
|
||||
Returns:
|
||||
ID події
|
||||
"""
|
||||
event_id = str(uuid.uuid4())
|
||||
|
||||
sql = """
|
||||
INSERT INTO outbox (id, aggregate_type, aggregate_id, event_type, payload)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id
|
||||
"""
|
||||
|
||||
if conn:
|
||||
# Використовуємо існуюче з'єднання (для транзакції)
|
||||
await conn.execute(sql, event_id, aggregate_type, aggregate_id,
|
||||
event_type, json.dumps(payload))
|
||||
else:
|
||||
# Створюємо нове з'єднання
|
||||
async with self.db_pool.acquire() as conn:
|
||||
await conn.execute(sql, event_id, aggregate_type, aggregate_id,
|
||||
event_type, json.dumps(payload))
|
||||
|
||||
logger.debug(f"Outbox event written: {aggregate_type}.{event_type} [{event_id}]")
|
||||
return event_id
|
||||
|
||||
|
||||
class OutboxPublisher:
|
||||
"""
|
||||
Публікує події з outbox в NATS.
|
||||
|
||||
Запускається як background worker.
|
||||
"""
|
||||
|
||||
def __init__(self, db_pool, nats_client,
|
||||
batch_size: int = 100,
|
||||
poll_interval: float = 1.0,
|
||||
max_retries: int = 5):
|
||||
"""
|
||||
Args:
|
||||
db_pool: asyncpg connection pool
|
||||
nats_client: NATS client (з JetStream)
|
||||
batch_size: Кількість подій за раз
|
||||
poll_interval: Інтервал опитування (секунди)
|
||||
max_retries: Максимум спроб публікації
|
||||
"""
|
||||
self.db_pool = db_pool
|
||||
self.nats = nats_client
|
||||
self.js = None # JetStream context
|
||||
self.batch_size = batch_size
|
||||
self.poll_interval = poll_interval
|
||||
self.max_retries = max_retries
|
||||
self._running = False
|
||||
|
||||
async def start(self):
|
||||
"""Запускає publisher worker"""
|
||||
self._running = True
|
||||
self.js = self.nats.jetstream()
|
||||
logger.info("Outbox publisher started")
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
await self._process_batch()
|
||||
except Exception as e:
|
||||
logger.error(f"Outbox publisher error: {e}")
|
||||
|
||||
await asyncio.sleep(self.poll_interval)
|
||||
|
||||
async def stop(self):
|
||||
"""Зупиняє publisher worker"""
|
||||
self._running = False
|
||||
logger.info("Outbox publisher stopped")
|
||||
|
||||
async def _process_batch(self):
|
||||
"""Обробляє batch pending подій"""
|
||||
async with self.db_pool.acquire() as conn:
|
||||
# Fetch pending events
|
||||
events = await conn.fetch("""
|
||||
SELECT id, aggregate_type, aggregate_id, event_type,
|
||||
payload, created_at, retry_count
|
||||
FROM outbox
|
||||
WHERE status = 'pending'
|
||||
ORDER BY created_at
|
||||
LIMIT $1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
""", self.batch_size)
|
||||
|
||||
if not events:
|
||||
return
|
||||
|
||||
logger.debug(f"Processing {len(events)} outbox events")
|
||||
|
||||
for event in events:
|
||||
await self._publish_event(conn, event)
|
||||
|
||||
async def _publish_event(self, conn, event):
|
||||
"""Публікує одну подію в NATS"""
|
||||
event_id = str(event['id'])
|
||||
subject = f"{event['aggregate_type']}.{event['event_type']}"
|
||||
|
||||
try:
|
||||
# Prepare message
|
||||
message = {
|
||||
"event_id": event_id,
|
||||
"aggregate_type": event['aggregate_type'],
|
||||
"aggregate_id": event['aggregate_id'],
|
||||
"event_type": event['event_type'],
|
||||
"payload": json.loads(event['payload']) if isinstance(event['payload'], str) else event['payload'],
|
||||
"timestamp": event['created_at'].isoformat(),
|
||||
}
|
||||
|
||||
# Publish to JetStream
|
||||
ack = await self.js.publish(
|
||||
subject,
|
||||
json.dumps(message).encode(),
|
||||
headers={"Nats-Msg-Id": event_id} # For idempotency
|
||||
)
|
||||
|
||||
# Mark as published
|
||||
await conn.execute("""
|
||||
UPDATE outbox
|
||||
SET status = 'published', published_at = NOW()
|
||||
WHERE id = $1
|
||||
""", event['id'])
|
||||
|
||||
logger.debug(f"Published: {subject} [{event_id}] -> seq={ack.seq}")
|
||||
|
||||
except Exception as e:
|
||||
retry_count = event['retry_count'] + 1
|
||||
|
||||
if retry_count >= self.max_retries:
|
||||
# Mark as failed
|
||||
await conn.execute("""
|
||||
UPDATE outbox
|
||||
SET status = 'failed', retry_count = $2, last_error = $3
|
||||
WHERE id = $1
|
||||
""", event['id'], retry_count, str(e))
|
||||
logger.error(f"Outbox event failed permanently: {event_id} - {e}")
|
||||
else:
|
||||
# Increment retry count
|
||||
await conn.execute("""
|
||||
UPDATE outbox
|
||||
SET retry_count = $2, last_error = $3
|
||||
WHERE id = $1
|
||||
""", event['id'], retry_count, str(e))
|
||||
logger.warning(f"Outbox event retry {retry_count}: {event_id} - {e}")
|
||||
|
||||
|
||||
class OutboxCleaner:
|
||||
"""
|
||||
Очищає старі опубліковані події.
|
||||
|
||||
Запускається періодично.
|
||||
"""
|
||||
|
||||
def __init__(self, db_pool, retention_days: int = 7):
|
||||
self.db_pool = db_pool
|
||||
self.retention_days = retention_days
|
||||
|
||||
async def cleanup(self) -> int:
|
||||
"""
|
||||
Видаляє старі опубліковані події.
|
||||
|
||||
Returns:
|
||||
Кількість видалених подій
|
||||
"""
|
||||
async with self.db_pool.acquire() as conn:
|
||||
result = await conn.execute("""
|
||||
DELETE FROM outbox
|
||||
WHERE status = 'published'
|
||||
AND published_at < NOW() - INTERVAL '%s days'
|
||||
""" % self.retention_days)
|
||||
|
||||
count = int(result.split()[-1]) if result else 0
|
||||
if count > 0:
|
||||
logger.info(f"Cleaned {count} old outbox events")
|
||||
return count
|
||||
|
||||
|
||||
# ==================== INTEGRATION HELPERS ====================
|
||||
|
||||
async def create_outbox_infrastructure(db_pool, nats_url: str = "nats://nats:4222"):
|
||||
"""
|
||||
Створює всю інфраструктуру outbox.
|
||||
|
||||
Usage:
|
||||
writer, publisher = await create_outbox_infrastructure(db_pool)
|
||||
|
||||
# В основному коді:
|
||||
await writer.write("message", "123", "created", {"text": "Hello"})
|
||||
|
||||
# Запуск publisher як background task:
|
||||
asyncio.create_task(publisher.start())
|
||||
"""
|
||||
import nats
|
||||
|
||||
# Initialize table
|
||||
writer = OutboxWriter(db_pool)
|
||||
await writer.init_table()
|
||||
|
||||
# Connect to NATS
|
||||
nc = await nats.connect(nats_url)
|
||||
|
||||
# Create publisher
|
||||
publisher = OutboxPublisher(db_pool, nc)
|
||||
|
||||
return writer, publisher, nc
|
||||
|
||||
|
||||
# ==================== MEMORY SERVICE INTEGRATION ====================
|
||||
|
||||
class MemoryOutboxMixin:
|
||||
"""
|
||||
Mixin для Memory Service з outbox підтримкою.
|
||||
|
||||
Додає автоматичну публікацію подій при операціях з пам'яттю.
|
||||
"""
|
||||
|
||||
async def store_fact_with_event(self,
|
||||
user_id: str,
|
||||
fact: str,
|
||||
metadata: Dict[str, Any] = None) -> str:
|
||||
"""
|
||||
Зберігає факт і публікує подію.
|
||||
"""
|
||||
async with self.db_pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
# Store fact
|
||||
fact_id = await self._store_fact(conn, user_id, fact, metadata)
|
||||
|
||||
# Write outbox event
|
||||
await self.outbox_writer.write(
|
||||
aggregate_type="memory",
|
||||
aggregate_id=fact_id,
|
||||
event_type="fact.created",
|
||||
payload={
|
||||
"fact_id": fact_id,
|
||||
"user_id": user_id,
|
||||
"fact_preview": fact[:100], # Preview only
|
||||
"metadata": metadata,
|
||||
},
|
||||
conn=conn
|
||||
)
|
||||
|
||||
return fact_id
|
||||
|
||||
async def store_vector_with_event(self,
|
||||
collection: str,
|
||||
vector_id: str,
|
||||
vector: List[float],
|
||||
payload: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Зберігає вектор і публікує подію.
|
||||
"""
|
||||
# Store vector in Qdrant
|
||||
await self._store_vector(collection, vector_id, vector, payload)
|
||||
|
||||
# Write outbox event
|
||||
await self.outbox_writer.write(
|
||||
aggregate_type="memory",
|
||||
aggregate_id=vector_id,
|
||||
event_type="vector.indexed",
|
||||
payload={
|
||||
"vector_id": vector_id,
|
||||
"collection": collection,
|
||||
"payload_keys": list(payload.keys()),
|
||||
}
|
||||
)
|
||||
|
||||
return vector_id
|
||||
Reference in New Issue
Block a user