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:
468
services/router/agent_metrics.py
Normal file
468
services/router/agent_metrics.py
Normal file
@@ -0,0 +1,468 @@
|
||||
"""
|
||||
AgentOps Metrics
|
||||
================
|
||||
Prometheus метрики для моніторингу агентів.
|
||||
|
||||
Метрики:
|
||||
- Per-agent: latency, tokens, errors, tool calls, budget
|
||||
- Per-channel: RAG hit rate, index lag
|
||||
- Per-node: GPU util, VRAM, queue lag
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from contextlib import contextmanager
|
||||
from functools import wraps
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Try to import prometheus_client
|
||||
try:
|
||||
from prometheus_client import (
|
||||
Counter, Histogram, Gauge, Summary,
|
||||
CollectorRegistry, generate_latest, CONTENT_TYPE_LATEST
|
||||
)
|
||||
PROMETHEUS_AVAILABLE = True
|
||||
except ImportError:
|
||||
PROMETHEUS_AVAILABLE = False
|
||||
logger.warning("prometheus_client not installed, metrics disabled")
|
||||
|
||||
|
||||
# ==================== REGISTRY ====================
|
||||
|
||||
if PROMETHEUS_AVAILABLE:
|
||||
# Use default registry or create custom
|
||||
REGISTRY = CollectorRegistry(auto_describe=True)
|
||||
|
||||
# ==================== AGENT METRICS ====================
|
||||
|
||||
# Request latency
|
||||
AGENT_LATENCY = Histogram(
|
||||
'agent_latency_seconds',
|
||||
'Agent request latency in seconds',
|
||||
['agent_id', 'operation'],
|
||||
buckets=(0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0),
|
||||
registry=REGISTRY
|
||||
)
|
||||
|
||||
# Token usage
|
||||
AGENT_TOKENS_IN = Counter(
|
||||
'agent_tokens_in_total',
|
||||
'Total input tokens processed by agent',
|
||||
['agent_id', 'model'],
|
||||
registry=REGISTRY
|
||||
)
|
||||
|
||||
AGENT_TOKENS_OUT = Counter(
|
||||
'agent_tokens_out_total',
|
||||
'Total output tokens generated by agent',
|
||||
['agent_id', 'model'],
|
||||
registry=REGISTRY
|
||||
)
|
||||
|
||||
# Request counts
|
||||
AGENT_REQUESTS = Counter(
|
||||
'agent_requests_total',
|
||||
'Total agent requests',
|
||||
['agent_id', 'status'], # status: success, error, timeout
|
||||
registry=REGISTRY
|
||||
)
|
||||
|
||||
# Error rate
|
||||
AGENT_ERRORS = Counter(
|
||||
'agent_errors_total',
|
||||
'Total agent errors',
|
||||
['agent_id', 'error_type'],
|
||||
registry=REGISTRY
|
||||
)
|
||||
|
||||
# Tool calls
|
||||
AGENT_TOOL_CALLS = Counter(
|
||||
'agent_tool_calls_total',
|
||||
'Total tool calls by agent',
|
||||
['agent_id', 'tool'],
|
||||
registry=REGISTRY
|
||||
)
|
||||
|
||||
# Budget consumption
|
||||
AGENT_BUDGET = Gauge(
|
||||
'agent_budget_consumed',
|
||||
'Budget consumed by agent per user',
|
||||
['agent_id', 'user_id'],
|
||||
registry=REGISTRY
|
||||
)
|
||||
|
||||
# Active requests
|
||||
AGENT_ACTIVE_REQUESTS = Gauge(
|
||||
'agent_active_requests',
|
||||
'Number of active requests per agent',
|
||||
['agent_id'],
|
||||
registry=REGISTRY
|
||||
)
|
||||
|
||||
# ==================== CHANNEL METRICS ====================
|
||||
|
||||
# RAG hit rate
|
||||
CHANNEL_RAG_HITS = Counter(
|
||||
'channel_rag_hits_total',
|
||||
'Total RAG cache hits',
|
||||
['channel_id'],
|
||||
registry=REGISTRY
|
||||
)
|
||||
|
||||
CHANNEL_RAG_MISSES = Counter(
|
||||
'channel_rag_misses_total',
|
||||
'Total RAG cache misses',
|
||||
['channel_id'],
|
||||
registry=REGISTRY
|
||||
)
|
||||
|
||||
# Index lag
|
||||
CHANNEL_INDEX_LAG = Gauge(
|
||||
'channel_index_lag_seconds',
|
||||
'Time since last index update',
|
||||
['channel_id'],
|
||||
registry=REGISTRY
|
||||
)
|
||||
|
||||
# Message queue
|
||||
CHANNEL_QUEUE_SIZE = Gauge(
|
||||
'channel_queue_size',
|
||||
'Number of messages in channel queue',
|
||||
['channel_id'],
|
||||
registry=REGISTRY
|
||||
)
|
||||
|
||||
# ==================== NODE METRICS ====================
|
||||
|
||||
# GPU utilization
|
||||
NODE_GPU_UTIL = Gauge(
|
||||
'node_gpu_utilization',
|
||||
'GPU utilization percentage',
|
||||
['node_id', 'gpu_id'],
|
||||
registry=REGISTRY
|
||||
)
|
||||
|
||||
# VRAM usage
|
||||
NODE_VRAM_USED = Gauge(
|
||||
'node_vram_used_bytes',
|
||||
'VRAM used in bytes',
|
||||
['node_id', 'gpu_id'],
|
||||
registry=REGISTRY
|
||||
)
|
||||
|
||||
NODE_VRAM_TOTAL = Gauge(
|
||||
'node_vram_total_bytes',
|
||||
'Total VRAM in bytes',
|
||||
['node_id', 'gpu_id'],
|
||||
registry=REGISTRY
|
||||
)
|
||||
|
||||
# Queue lag
|
||||
NODE_QUEUE_LAG = Gauge(
|
||||
'node_queue_lag_seconds',
|
||||
'Queue processing lag',
|
||||
['node_id', 'queue'],
|
||||
registry=REGISTRY
|
||||
)
|
||||
|
||||
# NATS stream lag
|
||||
NODE_NATS_LAG = Gauge(
|
||||
'node_nats_stream_lag',
|
||||
'NATS stream consumer lag (pending messages)',
|
||||
['node_id', 'stream'],
|
||||
registry=REGISTRY
|
||||
)
|
||||
|
||||
# ==================== MEMORY METRICS ====================
|
||||
|
||||
MEMORY_OPERATIONS = Counter(
|
||||
'memory_operations_total',
|
||||
'Total memory operations',
|
||||
['operation', 'store'], # store: postgres, qdrant, neo4j, redis
|
||||
registry=REGISTRY
|
||||
)
|
||||
|
||||
MEMORY_LATENCY = Histogram(
|
||||
'memory_latency_seconds',
|
||||
'Memory operation latency',
|
||||
['operation', 'store'],
|
||||
buckets=(0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0),
|
||||
registry=REGISTRY
|
||||
)
|
||||
|
||||
# ==================== HANDOFF METRICS ====================
|
||||
|
||||
HANDOFF_REQUESTS = Counter(
|
||||
'handoff_requests_total',
|
||||
'Total handoff requests between agents',
|
||||
['from_agent', 'to_agent', 'status'],
|
||||
registry=REGISTRY
|
||||
)
|
||||
|
||||
HANDOFF_LATENCY = Histogram(
|
||||
'handoff_latency_seconds',
|
||||
'Handoff latency between agents',
|
||||
['from_agent', 'to_agent'],
|
||||
buckets=(0.1, 0.5, 1.0, 2.5, 5.0, 10.0),
|
||||
registry=REGISTRY
|
||||
)
|
||||
|
||||
|
||||
# ==================== METRIC HELPERS ====================
|
||||
|
||||
class MetricsCollector:
|
||||
"""
|
||||
Helper class for collecting metrics.
|
||||
|
||||
Usage:
|
||||
metrics = MetricsCollector(agent_id="helion")
|
||||
|
||||
with metrics.track_request("chat"):
|
||||
# Do work
|
||||
pass
|
||||
|
||||
metrics.record_tokens(input_tokens=100, output_tokens=50, model="qwen3:8b")
|
||||
metrics.record_tool_call("web_search")
|
||||
"""
|
||||
|
||||
def __init__(self, agent_id: str, node_id: str = "node1"):
|
||||
self.agent_id = agent_id
|
||||
self.node_id = node_id
|
||||
self._enabled = PROMETHEUS_AVAILABLE
|
||||
|
||||
@contextmanager
|
||||
def track_request(self, operation: str):
|
||||
"""Context manager to track request latency"""
|
||||
if not self._enabled:
|
||||
yield
|
||||
return
|
||||
|
||||
AGENT_ACTIVE_REQUESTS.labels(agent_id=self.agent_id).inc()
|
||||
start = time.time()
|
||||
status = "success"
|
||||
|
||||
try:
|
||||
yield
|
||||
except Exception as e:
|
||||
status = "error"
|
||||
AGENT_ERRORS.labels(
|
||||
agent_id=self.agent_id,
|
||||
error_type=type(e).__name__
|
||||
).inc()
|
||||
raise
|
||||
finally:
|
||||
duration = time.time() - start
|
||||
AGENT_LATENCY.labels(
|
||||
agent_id=self.agent_id,
|
||||
operation=operation
|
||||
).observe(duration)
|
||||
AGENT_REQUESTS.labels(
|
||||
agent_id=self.agent_id,
|
||||
status=status
|
||||
).inc()
|
||||
AGENT_ACTIVE_REQUESTS.labels(agent_id=self.agent_id).dec()
|
||||
|
||||
def record_tokens(self, input_tokens: int, output_tokens: int, model: str):
|
||||
"""Record token usage"""
|
||||
if not self._enabled:
|
||||
return
|
||||
|
||||
AGENT_TOKENS_IN.labels(
|
||||
agent_id=self.agent_id,
|
||||
model=model
|
||||
).inc(input_tokens)
|
||||
|
||||
AGENT_TOKENS_OUT.labels(
|
||||
agent_id=self.agent_id,
|
||||
model=model
|
||||
).inc(output_tokens)
|
||||
|
||||
def record_tool_call(self, tool: str):
|
||||
"""Record tool call"""
|
||||
if not self._enabled:
|
||||
return
|
||||
|
||||
AGENT_TOOL_CALLS.labels(
|
||||
agent_id=self.agent_id,
|
||||
tool=tool
|
||||
).inc()
|
||||
|
||||
def record_budget(self, user_id: str, amount: float):
|
||||
"""Record budget consumption"""
|
||||
if not self._enabled:
|
||||
return
|
||||
|
||||
AGENT_BUDGET.labels(
|
||||
agent_id=self.agent_id,
|
||||
user_id=user_id
|
||||
).set(amount)
|
||||
|
||||
def record_error(self, error_type: str):
|
||||
"""Record error"""
|
||||
if not self._enabled:
|
||||
return
|
||||
|
||||
AGENT_ERRORS.labels(
|
||||
agent_id=self.agent_id,
|
||||
error_type=error_type
|
||||
).inc()
|
||||
|
||||
def record_rag_hit(self, channel_id: str, hit: bool):
|
||||
"""Record RAG cache hit/miss"""
|
||||
if not self._enabled:
|
||||
return
|
||||
|
||||
if hit:
|
||||
CHANNEL_RAG_HITS.labels(channel_id=channel_id).inc()
|
||||
else:
|
||||
CHANNEL_RAG_MISSES.labels(channel_id=channel_id).inc()
|
||||
|
||||
def record_memory_operation(self, operation: str, store: str, duration: float):
|
||||
"""Record memory operation"""
|
||||
if not self._enabled:
|
||||
return
|
||||
|
||||
MEMORY_OPERATIONS.labels(operation=operation, store=store).inc()
|
||||
MEMORY_LATENCY.labels(operation=operation, store=store).observe(duration)
|
||||
|
||||
def record_handoff(self, to_agent: str, status: str, duration: float = None):
|
||||
"""Record handoff between agents"""
|
||||
if not self._enabled:
|
||||
return
|
||||
|
||||
HANDOFF_REQUESTS.labels(
|
||||
from_agent=self.agent_id,
|
||||
to_agent=to_agent,
|
||||
status=status
|
||||
).inc()
|
||||
|
||||
if duration is not None:
|
||||
HANDOFF_LATENCY.labels(
|
||||
from_agent=self.agent_id,
|
||||
to_agent=to_agent
|
||||
).observe(duration)
|
||||
|
||||
|
||||
def track_agent_request(agent_id: str, operation: str):
|
||||
"""Decorator for tracking agent requests"""
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
metrics = MetricsCollector(agent_id)
|
||||
with metrics.track_request(operation):
|
||||
return await func(*args, **kwargs)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
# ==================== GPU METRICS COLLECTOR ====================
|
||||
|
||||
async def collect_gpu_metrics(node_id: str = "node1"):
|
||||
"""
|
||||
Collect GPU metrics using nvidia-smi.
|
||||
Should be called periodically.
|
||||
"""
|
||||
if not PROMETHEUS_AVAILABLE:
|
||||
return
|
||||
|
||||
import subprocess
|
||||
|
||||
try:
|
||||
# Run nvidia-smi
|
||||
result = subprocess.run(
|
||||
['nvidia-smi', '--query-gpu=index,utilization.gpu,memory.used,memory.total',
|
||||
'--format=csv,noheader,nounits'],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return
|
||||
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
parts = line.split(',')
|
||||
if len(parts) >= 4:
|
||||
gpu_id = parts[0].strip()
|
||||
util = float(parts[1].strip())
|
||||
mem_used = int(parts[2].strip()) * 1024 * 1024 # MB to bytes
|
||||
mem_total = int(parts[3].strip()) * 1024 * 1024
|
||||
|
||||
NODE_GPU_UTIL.labels(node_id=node_id, gpu_id=gpu_id).set(util / 100.0)
|
||||
NODE_VRAM_USED.labels(node_id=node_id, gpu_id=gpu_id).set(mem_used)
|
||||
NODE_VRAM_TOTAL.labels(node_id=node_id, gpu_id=gpu_id).set(mem_total)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"GPU metrics collection failed: {e}")
|
||||
|
||||
|
||||
# ==================== FASTAPI INTEGRATION ====================
|
||||
|
||||
def setup_metrics_endpoint(app):
|
||||
"""
|
||||
Add /metrics endpoint to FastAPI app.
|
||||
|
||||
Usage:
|
||||
from agent_metrics import setup_metrics_endpoint
|
||||
setup_metrics_endpoint(app)
|
||||
"""
|
||||
if not PROMETHEUS_AVAILABLE:
|
||||
logger.warning("Prometheus not available, /metrics endpoint disabled")
|
||||
return
|
||||
|
||||
from fastapi import Response
|
||||
|
||||
@app.get("/metrics")
|
||||
async def metrics():
|
||||
return Response(
|
||||
content=generate_latest(REGISTRY),
|
||||
media_type=CONTENT_TYPE_LATEST
|
||||
)
|
||||
|
||||
logger.info("Metrics endpoint enabled at /metrics")
|
||||
|
||||
|
||||
# ==================== NATS STREAM LAG COLLECTOR ====================
|
||||
|
||||
async def collect_nats_metrics(node_id: str = "node1", nats_url: str = "nats://nats:4222"):
|
||||
"""
|
||||
Collect NATS JetStream metrics.
|
||||
"""
|
||||
if not PROMETHEUS_AVAILABLE:
|
||||
return
|
||||
|
||||
try:
|
||||
import nats
|
||||
nc = await nats.connect(nats_url)
|
||||
js = nc.jetstream()
|
||||
|
||||
# Get stream info
|
||||
streams = ["MESSAGES", "AGENT_OPS", "AUDIT", "MEMORY"]
|
||||
for stream_name in streams:
|
||||
try:
|
||||
info = await js.stream_info(stream_name)
|
||||
# Record pending messages as lag indicator
|
||||
pending = info.state.messages
|
||||
NODE_NATS_LAG.labels(node_id=node_id, stream=stream_name).set(pending)
|
||||
except:
|
||||
pass
|
||||
|
||||
await nc.close()
|
||||
except Exception as e:
|
||||
logger.debug(f"NATS metrics collection failed: {e}")
|
||||
|
||||
|
||||
# ==================== METRICS EXPORT ====================
|
||||
|
||||
def get_metrics():
|
||||
"""Return metrics in Prometheus format"""
|
||||
if not PROMETHEUS_AVAILABLE:
|
||||
return b"# prometheus_client not available"
|
||||
return generate_latest(REGISTRY)
|
||||
|
||||
|
||||
def get_content_type():
|
||||
"""Return Prometheus content type"""
|
||||
if not PROMETHEUS_AVAILABLE:
|
||||
return "text/plain"
|
||||
return CONTENT_TYPE_LATEST
|
||||
125
services/router/agent_tools_config.py
Normal file
125
services/router/agent_tools_config.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
Per-agent tool configuration.
|
||||
All agents have FULL standard stack + specialized tools.
|
||||
Each agent is a platform with own site, channels, database, users.
|
||||
"""
|
||||
|
||||
# FULL standard stack - available to ALL agents
|
||||
FULL_STANDARD_STACK = [
|
||||
# Search & Knowledge (Priority 1)
|
||||
"memory_search",
|
||||
"graph_query",
|
||||
|
||||
# Web Research (Priority 2)
|
||||
"web_search",
|
||||
"web_extract",
|
||||
"crawl4ai_scrape",
|
||||
|
||||
# Memory
|
||||
"remember_fact",
|
||||
|
||||
# Content Generation
|
||||
"image_generate",
|
||||
"tts_speak",
|
||||
|
||||
# Presentations
|
||||
"presentation_create",
|
||||
"presentation_status",
|
||||
"presentation_download",
|
||||
]
|
||||
|
||||
# Specialized tools per agent (on top of standard stack)
|
||||
AGENT_SPECIALIZED_TOOLS = {
|
||||
# Helion - Energy platform
|
||||
# Specialized: energy calculations, solar/wind analysis
|
||||
"helion": [],
|
||||
|
||||
# Alateya - R&D Lab OS
|
||||
# Specialized: experiment tracking, hypothesis testing
|
||||
"alateya": [],
|
||||
|
||||
# Nutra - Health & Nutrition
|
||||
# Specialized: nutrition calculations, supplement analysis
|
||||
"nutra": [],
|
||||
|
||||
# AgroMatrix - Agriculture
|
||||
# Specialized: crop analysis, weather integration, field mapping
|
||||
"agromatrix": [],
|
||||
|
||||
# GreenFood - Food & Eco
|
||||
# Specialized: recipe analysis, eco-scoring
|
||||
"greenfood": [],
|
||||
|
||||
# Druid - Knowledge Search
|
||||
# Specialized: deep RAG, document comparison
|
||||
"druid": [],
|
||||
|
||||
# DaarWizz - DAO Coordination
|
||||
# Specialized: governance tools, voting, treasury
|
||||
"daarwizz": [],
|
||||
|
||||
# Clan - Community
|
||||
# Specialized: event management, polls, member tracking
|
||||
"clan": [],
|
||||
|
||||
# Eonarch - Philosophy & Evolution
|
||||
# Specialized: concept mapping, timeline analysis
|
||||
"eonarch": [],
|
||||
}
|
||||
|
||||
# CrewAI team structure per agent (future implementation)
|
||||
AGENT_CREW_TEAMS = {
|
||||
"helion": {
|
||||
"team_name": "Energy Specialists",
|
||||
"agents": ["analyst", "engineer", "market_researcher", "communicator"]
|
||||
},
|
||||
"alateya": {
|
||||
"team_name": "Research Professors",
|
||||
"agents": ["prof_erudite", "prof_analyst", "prof_creative", "prof_optimizer", "prof_communicator"]
|
||||
},
|
||||
"nutra": {
|
||||
"team_name": "Health Advisors",
|
||||
"agents": ["nutritionist", "biochemist", "fitness_coach", "communicator"]
|
||||
},
|
||||
"agromatrix": {
|
||||
"team_name": "Agro Experts",
|
||||
"agents": ["agronomist", "soil_specialist", "weather_analyst", "market_analyst"]
|
||||
},
|
||||
"greenfood": {
|
||||
"team_name": "Food & Eco Team",
|
||||
"agents": ["chef", "nutritionist", "eco_analyst", "supply_chain"]
|
||||
},
|
||||
"druid": {
|
||||
"team_name": "Knowledge Seekers",
|
||||
"agents": ["researcher", "fact_checker", "synthesizer", "archivist"]
|
||||
},
|
||||
"daarwizz": {
|
||||
"team_name": "DAO Operations",
|
||||
"agents": ["governance", "treasury", "community", "tech_ops"]
|
||||
},
|
||||
"clan": {
|
||||
"team_name": "Community Spirits",
|
||||
"agents": ["welcomer", "mediator", "event_organizer", "historian"]
|
||||
},
|
||||
"eonarch": {
|
||||
"team_name": "Consciousness Guides",
|
||||
"agents": ["philosopher", "futurist", "integrator", "storyteller"]
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_agent_tools(agent_id: str) -> list:
|
||||
"""Get all tools for an agent: standard stack + specialized."""
|
||||
specialized = AGENT_SPECIALIZED_TOOLS.get(agent_id, [])
|
||||
return FULL_STANDARD_STACK + specialized
|
||||
|
||||
|
||||
def is_tool_allowed(agent_id: str, tool_name: str) -> bool:
|
||||
"""Check if a tool is allowed for an agent."""
|
||||
allowed = get_agent_tools(agent_id)
|
||||
return tool_name in allowed
|
||||
|
||||
|
||||
def get_agent_crew(agent_id: str) -> dict:
|
||||
"""Get CrewAI team configuration for an agent."""
|
||||
return AGENT_CREW_TEAMS.get(agent_id, {"team_name": "Default", "agents": []})
|
||||
257
services/router/agents_registry.yaml
Normal file
257
services/router/agents_registry.yaml
Normal file
@@ -0,0 +1,257 @@
|
||||
# ================================================
|
||||
# DAARION Platform - Agent Registry v1.0
|
||||
# ================================================
|
||||
|
||||
platform:
|
||||
name: "MicroDAO Daarion"
|
||||
version: "1.0.0"
|
||||
default_agent: "helion"
|
||||
fallback_agent: "helion"
|
||||
|
||||
# ================================================
|
||||
# REGISTERED AGENTS
|
||||
# ================================================
|
||||
agents:
|
||||
helion:
|
||||
display_name: "Helion"
|
||||
description: "Energy Union AI - енергетика, інфраструктура, DePIN"
|
||||
domain_tags:
|
||||
- energy
|
||||
- power
|
||||
- grid
|
||||
- depin
|
||||
- infrastructure
|
||||
- sensors
|
||||
- tariffs
|
||||
- dao
|
||||
capabilities:
|
||||
- chat
|
||||
- stt
|
||||
- tts
|
||||
- vision
|
||||
- image_gen
|
||||
- web_search
|
||||
- web_scrape
|
||||
- document
|
||||
entrypoint:
|
||||
type: "nats"
|
||||
subject: "agent.helion.invoke"
|
||||
memory_policy:
|
||||
qdrant_collections:
|
||||
read: ["helion_messages", "helion_docs", "helion_artifacts", "helion_memory_items"]
|
||||
write: ["helion_messages", "helion_docs", "helion_artifacts", "helion_memory_items"]
|
||||
neo4j_labels: ["HelionUser", "HelionTopic", "HelionProject", "HelionChannel"]
|
||||
neo4j_filter: "agent_id = 'helion'"
|
||||
redis_prefix: "helion:"
|
||||
data_policy:
|
||||
handoff_allow: ["nutra", "greenfood"] # Can share context with these agents
|
||||
handoff_fields: ["user_intent", "context_summary", "language"]
|
||||
sensitive_fields_block: ["api_keys", "internal_metrics", "wallet_addresses"]
|
||||
sla:
|
||||
priority: "high"
|
||||
rate_limit_rpm: 60
|
||||
max_tokens_per_request: 8000
|
||||
prompt_file: "/app/prompts/helion_prompt.txt"
|
||||
telegram_token_env: "HELION_BOT_TOKEN"
|
||||
active: true
|
||||
|
||||
nutra:
|
||||
display_name: "Nutra"
|
||||
description: "AI Nutrition Agent - харчування, здоров'я, дієти"
|
||||
domain_tags:
|
||||
- nutrition
|
||||
- food
|
||||
- diet
|
||||
- health
|
||||
- wellness
|
||||
- recipes
|
||||
- allergens
|
||||
- nutrients
|
||||
- calories
|
||||
capabilities:
|
||||
- chat
|
||||
- vision
|
||||
- document
|
||||
entrypoint:
|
||||
type: "nats"
|
||||
subject: "agent.nutra.invoke"
|
||||
memory_policy:
|
||||
qdrant_collections:
|
||||
read: ["nutra_messages", "nutra_docs", "nutra_food_knowledge", "nutra_memory_items"]
|
||||
write: ["nutra_messages", "nutra_docs", "nutra_food_knowledge", "nutra_memory_items"]
|
||||
neo4j_labels: ["NutraUser", "NutraGoal", "NutraProduct", "NutraRecipe", "NutraRestriction"]
|
||||
neo4j_filter: "agent_id = 'nutra'"
|
||||
redis_prefix: "nutra:"
|
||||
data_policy:
|
||||
handoff_allow: ["helion"]
|
||||
handoff_fields: ["user_intent", "context_summary", "language", "dietary_preferences"]
|
||||
sensitive_fields_block: ["medical_history", "health_conditions"]
|
||||
sla:
|
||||
priority: "medium"
|
||||
rate_limit_rpm: 30
|
||||
max_tokens_per_request: 4000
|
||||
prompt_file: "/app/prompts/nutra_prompt.txt"
|
||||
telegram_token_env: "NUTRA_BOT_TOKEN"
|
||||
active: true
|
||||
|
||||
greenfood:
|
||||
display_name: "GreenFood"
|
||||
description: "GreenFood DAO Agent - органічні продукти, ферми"
|
||||
domain_tags:
|
||||
- organic
|
||||
- farming
|
||||
- sustainable
|
||||
- local_food
|
||||
- farmers_market
|
||||
capabilities:
|
||||
- chat
|
||||
entrypoint:
|
||||
type: "nats"
|
||||
subject: "agent.greenfood.invoke"
|
||||
memory_policy:
|
||||
qdrant_collections:
|
||||
read: ["greenfood_messages", "greenfood_docs"]
|
||||
write: ["greenfood_messages", "greenfood_docs"]
|
||||
neo4j_labels: ["GreenFoodUser", "GreenFoodFarm", "GreenFoodProduct"]
|
||||
neo4j_filter: "agent_id = 'greenfood'"
|
||||
redis_prefix: "greenfood:"
|
||||
data_policy:
|
||||
handoff_allow: ["nutra", "helion"]
|
||||
handoff_fields: ["user_intent", "context_summary"]
|
||||
sensitive_fields_block: []
|
||||
sla:
|
||||
priority: "low"
|
||||
rate_limit_rpm: 20
|
||||
max_tokens_per_request: 4000
|
||||
prompt_file: "/app/prompts/greenfood_prompt.txt"
|
||||
telegram_token_env: "GREENFOOD_BOT_TOKEN"
|
||||
active: true
|
||||
|
||||
druid:
|
||||
display_name: "Druid"
|
||||
description: "Legal/Compliance Agent - юридичні питання, регуляція"
|
||||
domain_tags:
|
||||
- legal
|
||||
- compliance
|
||||
- contracts
|
||||
- regulations
|
||||
- kyc
|
||||
capabilities:
|
||||
- chat
|
||||
- document
|
||||
entrypoint:
|
||||
type: "nats"
|
||||
subject: "agent.druid.invoke"
|
||||
memory_policy:
|
||||
qdrant_collections:
|
||||
read: ["druid_messages", "druid_docs", "druid_legal_kb"]
|
||||
write: ["druid_messages", "druid_docs"]
|
||||
neo4j_labels: ["DruidUser", "DruidContract", "DruidRegulation"]
|
||||
neo4j_filter: "agent_id = 'druid'"
|
||||
redis_prefix: "druid:"
|
||||
data_policy:
|
||||
handoff_allow: ["helion"]
|
||||
handoff_fields: ["user_intent", "context_summary", "jurisdiction"]
|
||||
sensitive_fields_block: ["personal_id", "contracts_content"]
|
||||
sla:
|
||||
priority: "medium"
|
||||
rate_limit_rpm: 20
|
||||
max_tokens_per_request: 8000
|
||||
prompt_file: "/app/prompts/druid_prompt.txt"
|
||||
telegram_token_env: "DRUID_BOT_TOKEN"
|
||||
active: false
|
||||
|
||||
daarwizz:
|
||||
display_name: "DaarWizz"
|
||||
description: "Operations/DevOps Agent - інфраструктура, моніторинг"
|
||||
domain_tags:
|
||||
- devops
|
||||
- infrastructure
|
||||
- monitoring
|
||||
- deployment
|
||||
- servers
|
||||
capabilities:
|
||||
- chat
|
||||
- web_scrape
|
||||
entrypoint:
|
||||
type: "nats"
|
||||
subject: "agent.daarwizz.invoke"
|
||||
memory_policy:
|
||||
qdrant_collections:
|
||||
read: ["daarwizz_messages", "daarwizz_docs"]
|
||||
write: ["daarwizz_messages", "daarwizz_docs"]
|
||||
neo4j_labels: ["DaarwizzServer", "DaarwizzService", "DaarwizzAlert"]
|
||||
neo4j_filter: "agent_id = 'daarwizz'"
|
||||
redis_prefix: "daarwizz:"
|
||||
data_policy:
|
||||
handoff_allow: ["helion"]
|
||||
handoff_fields: ["user_intent", "context_summary"]
|
||||
sensitive_fields_block: ["credentials", "ssh_keys", "api_secrets"]
|
||||
sla:
|
||||
priority: "high"
|
||||
rate_limit_rpm: 100
|
||||
max_tokens_per_request: 4000
|
||||
prompt_file: "/app/prompts/daarwizz_prompt.txt"
|
||||
telegram_token_env: "DAARWIZZ_BOT_TOKEN"
|
||||
active: false
|
||||
|
||||
# ================================================
|
||||
# INTENT ROUTING RULES
|
||||
# ================================================
|
||||
routing:
|
||||
# Hard routes (explicit commands/channels)
|
||||
hard_routes:
|
||||
- pattern: "^/nutra"
|
||||
agent: "nutra"
|
||||
- pattern: "^/helion"
|
||||
agent: "helion"
|
||||
- pattern: "^/greenfood"
|
||||
agent: "greenfood"
|
||||
- pattern: "^/legal"
|
||||
agent: "druid"
|
||||
- pattern: "^/ops"
|
||||
agent: "daarwizz"
|
||||
|
||||
# Intent-based routing (keyword matching)
|
||||
intent_routes:
|
||||
- keywords: ["їжа", "продукт", "рецепт", "дієта", "калорі", "харчування", "вітамін",
|
||||
"food", "recipe", "diet", "nutrition", "calorie", "vitamin", "meal"]
|
||||
agent: "nutra"
|
||||
confidence_threshold: 0.7
|
||||
|
||||
- keywords: ["енергія", "електрика", "сонячн", "вітер", "батаре", "тариф", "мережа",
|
||||
"energy", "power", "solar", "wind", "battery", "grid", "tariff", "depin"]
|
||||
agent: "helion"
|
||||
confidence_threshold: 0.7
|
||||
|
||||
- keywords: ["органіч", "ферма", "фермер", "городни", "sustainable", "organic", "farm"]
|
||||
agent: "greenfood"
|
||||
confidence_threshold: 0.6
|
||||
|
||||
- keywords: ["юрист", "договір", "контракт", "legal", "compliance", "contract", "kyc"]
|
||||
agent: "druid"
|
||||
confidence_threshold: 0.8
|
||||
|
||||
# Fallback behavior
|
||||
fallback:
|
||||
agent: "helion"
|
||||
message: "Передаю до основного асистента Helion."
|
||||
|
||||
# ================================================
|
||||
# HANDOFF CONTRACT TEMPLATE
|
||||
# ================================================
|
||||
handoff_contract:
|
||||
required_fields:
|
||||
- user_intent
|
||||
- source_agent
|
||||
- target_agent
|
||||
- timestamp
|
||||
optional_fields:
|
||||
- context_summary
|
||||
- language
|
||||
- user_preferences
|
||||
- references
|
||||
- constraints
|
||||
- reason_for_handoff
|
||||
max_context_tokens: 500
|
||||
strip_sensitive: true
|
||||
145
services/router/crewai_client.py
Normal file
145
services/router/crewai_client.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
CrewAI Client for Router
|
||||
Handles decision: direct LLM vs CrewAI orchestration
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import httpx
|
||||
from typing import Dict, Any, Optional, Tuple, List
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CREWAI_URL = os.getenv("CREWAI_URL", "http://dagi-staging-crewai-service:9010")
|
||||
CREWAI_ENABLED = os.getenv("CREWAI_ENABLED", "true").lower() == "true"
|
||||
|
||||
CREWAI_AGENTS_PATH = os.getenv("CREWAI_AGENTS_PATH", "/config/crewai_agents.json")
|
||||
FALLBACK_CREWAI_PATH = "/app/config/crewai_agents.json"
|
||||
|
||||
MIN_PROMPT_LENGTH_FOR_CREW = 100
|
||||
COMPLEXITY_KEYWORDS = [
|
||||
"план", "plan", "аналіз", "analysis", "дослідження", "research",
|
||||
"порівняй", "compare", "розроби", "develop", "створи стратегію",
|
||||
"декомпозиція", "decompose", "крок за кроком", "step by step",
|
||||
"детально", "in detail", "комплексний", "comprehensive"
|
||||
]
|
||||
|
||||
_crewai_cache = None
|
||||
|
||||
|
||||
def load_crewai_config():
|
||||
global _crewai_cache
|
||||
if _crewai_cache is not None:
|
||||
return _crewai_cache
|
||||
|
||||
for path in [CREWAI_AGENTS_PATH, FALLBACK_CREWAI_PATH]:
|
||||
try:
|
||||
p = Path(path)
|
||||
if p.exists():
|
||||
with open(p, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
logger.info(f"Loaded CrewAI config from {path}")
|
||||
_crewai_cache = data
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load from {path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_agent_crewai_info(agent_id):
|
||||
config = load_crewai_config()
|
||||
if not config:
|
||||
return {"enabled": False, "orchestrator": False, "team": []}
|
||||
|
||||
orchestrators = config.get("orchestrators", [])
|
||||
teams = config.get("teams", {})
|
||||
|
||||
is_orchestrator = any(o.get("id") == agent_id for o in orchestrators)
|
||||
team_info = teams.get(agent_id, {})
|
||||
team_members = team_info.get("members", [])
|
||||
|
||||
return {
|
||||
"enabled": is_orchestrator,
|
||||
"orchestrator": is_orchestrator,
|
||||
"team": team_members
|
||||
}
|
||||
|
||||
|
||||
def should_use_crewai(agent_id, prompt, agent_config, force_crewai=False):
|
||||
"""
|
||||
Decide whether to use CrewAI orchestration or direct LLM.
|
||||
Returns: (use_crewai: bool, reason: str)
|
||||
"""
|
||||
if not CREWAI_ENABLED:
|
||||
return False, "crewai_disabled_globally"
|
||||
|
||||
if force_crewai:
|
||||
return True, "force_crewai_requested"
|
||||
|
||||
crewai_info = get_agent_crewai_info(agent_id)
|
||||
|
||||
if not crewai_info.get("enabled", False):
|
||||
return False, "agent_crewai_disabled"
|
||||
|
||||
if not crewai_info.get("orchestrator", False):
|
||||
return False, "agent_not_orchestrator"
|
||||
|
||||
team = crewai_info.get("team", [])
|
||||
if not team:
|
||||
return False, "agent_has_no_team"
|
||||
|
||||
if len(prompt) < MIN_PROMPT_LENGTH_FOR_CREW:
|
||||
return False, "prompt_too_short"
|
||||
|
||||
prompt_lower = prompt.lower()
|
||||
has_complexity = any(kw in prompt_lower for kw in COMPLEXITY_KEYWORDS)
|
||||
|
||||
if has_complexity:
|
||||
return True, "complexity_keywords_detected"
|
||||
|
||||
return False, "default_direct_llm"
|
||||
|
||||
|
||||
async def call_crewai(agent_id, task, context=None, team=None):
|
||||
try:
|
||||
if not team:
|
||||
crewai_info = get_agent_crewai_info(agent_id)
|
||||
team = crewai_info.get("team", [])
|
||||
|
||||
async with httpx.AsyncClient(timeout=180.0) as client:
|
||||
payload = {
|
||||
"task": task,
|
||||
"orchestrator": agent_id,
|
||||
"context": context or {},
|
||||
}
|
||||
if team:
|
||||
payload["team"] = [
|
||||
m.get("role", str(m)) if isinstance(m, dict) else m
|
||||
for m in team
|
||||
]
|
||||
|
||||
logger.info(f"CrewAI call: agent={agent_id}, team={len(team)} members")
|
||||
|
||||
response = await client.post(f"{CREWAI_URL}/crew/run", json=payload)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
success = data.get("success")
|
||||
logger.info(f"CrewAI response: success={success}")
|
||||
return data
|
||||
else:
|
||||
logger.error(f"CrewAI error: {response.status_code}")
|
||||
return {"success": False, "result": None, "agents_used": [], "error": f"HTTP {response.status_code}"}
|
||||
except Exception as e:
|
||||
logger.error(f"CrewAI exception: {e}")
|
||||
return {"success": False, "result": None, "agents_used": [], "error": str(e)}
|
||||
|
||||
|
||||
async def get_crewai_health():
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.get(f"{CREWAI_URL}/health")
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
91
services/router/handoff.py
Normal file
91
services/router/handoff.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
DAARION Platform - Agent Handoff Contract
|
||||
Manages secure context transfer between agents
|
||||
"""
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict, Any
|
||||
from enum import Enum
|
||||
import uuid
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class HandoffReason(Enum):
|
||||
DOMAIN_MISMATCH = "domain_mismatch"
|
||||
USER_REQUEST = "user_request"
|
||||
CAPABILITY_REQUIRED = "capability_required"
|
||||
ESCALATION = "escalation"
|
||||
|
||||
|
||||
@dataclass
|
||||
class HandoffContract:
|
||||
"""Secure contract for transferring context between agents."""
|
||||
handoff_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
source_agent: str = ""
|
||||
target_agent: str = ""
|
||||
user_intent: str = ""
|
||||
timestamp: str = field(default_factory=lambda: datetime.utcnow().isoformat())
|
||||
context_summary: Optional[str] = None
|
||||
language: str = "uk"
|
||||
user_id: Optional[str] = None
|
||||
channel_id: Optional[str] = None
|
||||
constraints: Dict[str, Any] = field(default_factory=dict)
|
||||
references: List[str] = field(default_factory=list)
|
||||
reason: HandoffReason = HandoffReason.DOMAIN_MISMATCH
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
d = asdict(self)
|
||||
d["reason"] = self.reason.value
|
||||
return d
|
||||
|
||||
|
||||
class HandoffManager:
|
||||
"""Manages agent handoffs with policy enforcement"""
|
||||
|
||||
def __init__(self, registry: Dict[str, Any]):
|
||||
self.registry = registry
|
||||
self._active_handoffs: Dict[str, HandoffContract] = {}
|
||||
|
||||
def can_handoff(self, source_agent: str, target_agent: str) -> bool:
|
||||
source_config = self.registry.get("agents", {}).get(source_agent, {})
|
||||
data_policy = source_config.get("data_policy", {})
|
||||
allowed = data_policy.get("handoff_allow", [])
|
||||
return target_agent in allowed
|
||||
|
||||
def create_handoff(
|
||||
self,
|
||||
source_agent: str,
|
||||
target_agent: str,
|
||||
user_intent: str,
|
||||
context_summary: Optional[str] = None,
|
||||
language: str = "uk",
|
||||
reason: HandoffReason = HandoffReason.DOMAIN_MISMATCH
|
||||
) -> Optional[HandoffContract]:
|
||||
if not self.can_handoff(source_agent, target_agent):
|
||||
logger.warning("handoff_blocked", source=source_agent, target=target_agent)
|
||||
return None
|
||||
|
||||
contract = HandoffContract(
|
||||
source_agent=source_agent,
|
||||
target_agent=target_agent,
|
||||
user_intent=user_intent[:500],
|
||||
context_summary=context_summary[:1000] if context_summary else None,
|
||||
language=language,
|
||||
reason=reason
|
||||
)
|
||||
|
||||
self._active_handoffs[contract.handoff_id] = contract
|
||||
logger.info("handoff_created", handoff_id=contract.handoff_id)
|
||||
return contract
|
||||
|
||||
def get_user_message(self, target_agent: str) -> str:
|
||||
names = {
|
||||
"helion": "Helion",
|
||||
"nutra": "Nutra",
|
||||
"greenfood": "GreenFood",
|
||||
"druid": "Druid"
|
||||
}
|
||||
name = names.get(target_agent, target_agent)
|
||||
return f"Передаю ваше питання до {name}."
|
||||
161
services/router/intent_router.py
Normal file
161
services/router/intent_router.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
DAARION Platform - Intent Router
|
||||
Routes messages to appropriate agents based on content analysis
|
||||
"""
|
||||
import re
|
||||
import yaml
|
||||
from typing import Optional, Dict, List, Tuple
|
||||
from pathlib import Path
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class IntentRouter:
|
||||
"""Routes messages to agents based on intent detection"""
|
||||
|
||||
def __init__(self, registry_path: str = "agents_registry.yaml"):
|
||||
self.registry_path = Path(registry_path)
|
||||
self.registry = self._load_registry()
|
||||
self.hard_routes = self._compile_hard_routes()
|
||||
self.intent_keywords = self._build_keyword_index()
|
||||
logger.info("intent_router_initialized",
|
||||
agents=len(self.registry.get("agents", {})),
|
||||
hard_routes=len(self.hard_routes))
|
||||
|
||||
def _load_registry(self) -> Dict:
|
||||
"""Load agent registry from YAML"""
|
||||
if not self.registry_path.exists():
|
||||
logger.warning("registry_not_found", path=str(self.registry_path))
|
||||
return {"agents": {}, "routing": {}}
|
||||
|
||||
with open(self.registry_path) as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
def _compile_hard_routes(self) -> List[Tuple[re.Pattern, str]]:
|
||||
"""Compile regex patterns for hard routes"""
|
||||
routes = []
|
||||
for route in self.registry.get("routing", {}).get("hard_routes", []):
|
||||
pattern = re.compile(route["pattern"], re.IGNORECASE)
|
||||
routes.append((pattern, route["agent"]))
|
||||
return routes
|
||||
|
||||
def _build_keyword_index(self) -> Dict[str, List[Tuple[str, float]]]:
|
||||
"""Build keyword → agent mapping with confidence"""
|
||||
index = {}
|
||||
for route in self.registry.get("routing", {}).get("intent_routes", []):
|
||||
agent = route["agent"]
|
||||
threshold = route.get("confidence_threshold", 0.5)
|
||||
for keyword in route["keywords"]:
|
||||
kw = keyword.lower()
|
||||
if kw not in index:
|
||||
index[kw] = []
|
||||
index[kw].append((agent, threshold))
|
||||
return index
|
||||
|
||||
def route(self, message: str, source_agent: Optional[str] = None) -> Tuple[str, float, str]:
|
||||
"""
|
||||
Route message to appropriate agent.
|
||||
|
||||
Returns:
|
||||
(agent_id, confidence, reason)
|
||||
"""
|
||||
message_lower = message.lower().strip()
|
||||
|
||||
# 1. Check hard routes first (commands)
|
||||
for pattern, agent in self.hard_routes:
|
||||
if pattern.match(message):
|
||||
logger.info("hard_route_matched", agent=agent, pattern=pattern.pattern)
|
||||
return (agent, 1.0, "hard_route")
|
||||
|
||||
# 2. Intent-based routing (keyword matching)
|
||||
agent_scores = {}
|
||||
matched_keywords = {}
|
||||
|
||||
for keyword, agents in self.intent_keywords.items():
|
||||
if keyword in message_lower:
|
||||
for agent, threshold in agents:
|
||||
if agent not in agent_scores:
|
||||
agent_scores[agent] = 0.0
|
||||
matched_keywords[agent] = []
|
||||
agent_scores[agent] += threshold
|
||||
matched_keywords[agent].append(keyword)
|
||||
|
||||
if agent_scores:
|
||||
# Normalize scores
|
||||
max_score = max(agent_scores.values())
|
||||
for agent in agent_scores:
|
||||
agent_scores[agent] /= max(1, len(matched_keywords[agent]))
|
||||
|
||||
# Select best agent
|
||||
best_agent = max(agent_scores, key=agent_scores.get)
|
||||
confidence = min(agent_scores[best_agent], 1.0)
|
||||
|
||||
# Check if active
|
||||
if self.is_agent_active(best_agent):
|
||||
logger.info("intent_route_matched",
|
||||
agent=best_agent,
|
||||
confidence=confidence,
|
||||
keywords=matched_keywords[best_agent])
|
||||
return (best_agent, confidence, f"keywords: {matched_keywords[best_agent][:3]}")
|
||||
|
||||
# 3. Fallback
|
||||
fallback = self.registry.get("routing", {}).get("fallback", {})
|
||||
fallback_agent = fallback.get("agent", "helion")
|
||||
logger.info("fallback_route", agent=fallback_agent)
|
||||
return (fallback_agent, 0.3, "fallback")
|
||||
|
||||
def is_agent_active(self, agent_id: str) -> bool:
|
||||
"""Check if agent is active"""
|
||||
agent = self.registry.get("agents", {}).get(agent_id)
|
||||
return agent and agent.get("active", False)
|
||||
|
||||
def get_agent_config(self, agent_id: str) -> Optional[Dict]:
|
||||
"""Get full agent configuration"""
|
||||
return self.registry.get("agents", {}).get(agent_id)
|
||||
|
||||
def get_memory_policy(self, agent_id: str) -> Dict:
|
||||
"""Get agent memory access policy"""
|
||||
agent = self.get_agent_config(agent_id)
|
||||
if not agent:
|
||||
return {}
|
||||
return agent.get("memory_policy", {})
|
||||
|
||||
def get_data_policy(self, agent_id: str) -> Dict:
|
||||
"""Get agent data sharing policy"""
|
||||
agent = self.get_agent_config(agent_id)
|
||||
if not agent:
|
||||
return {}
|
||||
return agent.get("data_policy", {})
|
||||
|
||||
def can_handoff(self, source_agent: str, target_agent: str) -> bool:
|
||||
"""Check if handoff is allowed between agents"""
|
||||
policy = self.get_data_policy(source_agent)
|
||||
allowed = policy.get("handoff_allow", [])
|
||||
return target_agent in allowed
|
||||
|
||||
def get_rate_limit(self, agent_id: str) -> int:
|
||||
"""Get rate limit (requests per minute) for agent"""
|
||||
agent = self.get_agent_config(agent_id)
|
||||
if not agent:
|
||||
return 30 # default
|
||||
return agent.get("sla", {}).get("rate_limit_rpm", 30)
|
||||
|
||||
def list_active_agents(self) -> List[str]:
|
||||
"""List all active agents"""
|
||||
return [
|
||||
agent_id
|
||||
for agent_id, config in self.registry.get("agents", {}).items()
|
||||
if config.get("active", False)
|
||||
]
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_router_instance = None
|
||||
|
||||
def get_intent_router() -> IntentRouter:
|
||||
"""Get or create intent router instance"""
|
||||
global _router_instance
|
||||
if _router_instance is None:
|
||||
_router_instance = IntentRouter()
|
||||
return _router_instance
|
||||
@@ -1,4 +1,5 @@
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel
|
||||
from typing import Literal, Optional, Dict, Any, List
|
||||
import asyncio
|
||||
@@ -7,6 +8,16 @@ import os
|
||||
import yaml
|
||||
import httpx
|
||||
import logging
|
||||
import time # For latency metrics
|
||||
|
||||
# CrewAI Integration
|
||||
try:
|
||||
from crewai_client import should_use_crewai, call_crewai, get_crewai_health
|
||||
CREWAI_CLIENT_AVAILABLE = True
|
||||
except ImportError:
|
||||
CREWAI_CLIENT_AVAILABLE = False
|
||||
should_use_crewai = None
|
||||
call_crewai = None
|
||||
from neo4j import AsyncGraphDatabase
|
||||
|
||||
# Memory Retrieval Pipeline v3.0
|
||||
@@ -41,6 +52,10 @@ OCR_URL = os.getenv("OCR_URL", "http://swapper-service:8890") # Swapper /ocr en
|
||||
DOCUMENT_URL = os.getenv("DOCUMENT_URL", "http://swapper-service:8890") # Swapper /document endpoint
|
||||
CITY_SERVICE_URL = os.getenv("CITY_SERVICE_URL", "http://daarion-city-service:7001")
|
||||
|
||||
# CrewAI Routing Configuration
|
||||
CREWAI_ROUTING_ENABLED = os.getenv("CREWAI_ROUTING_ENABLED", "true").lower() == "true"
|
||||
CREWAI_URL = os.getenv("CREWAI_URL", "http://dagi-staging-crewai-service:9010")
|
||||
|
||||
# Neo4j Configuration
|
||||
NEO4J_URI = os.getenv("NEO4J_BOLT_URL", "bolt://neo4j:7687")
|
||||
NEO4J_USER = os.getenv("NEO4J_USER", "neo4j")
|
||||
@@ -269,6 +284,21 @@ async def publish_agent_invocation(invocation: AgentInvocation):
|
||||
else:
|
||||
print(f"⚠️ NATS not available, invocation not published: {invocation.json()}")
|
||||
|
||||
|
||||
|
||||
# ==============================================================
|
||||
# PROMETHEUS METRICS ENDPOINT
|
||||
# ==============================================================
|
||||
@app.get("/metrics")
|
||||
async def prometheus_metrics():
|
||||
"""Prometheus metrics endpoint."""
|
||||
try:
|
||||
from agent_metrics import get_metrics, get_content_type
|
||||
return Response(content=get_metrics(), media_type=get_content_type())
|
||||
except Exception as e:
|
||||
logger.error(f"Metrics error: {e}")
|
||||
return Response(content=b"# Error generating metrics", media_type="text/plain")
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
"""Health check endpoint"""
|
||||
@@ -346,6 +376,31 @@ class InferResponse(BaseModel):
|
||||
image_base64: Optional[str] = None # Generated image in base64 format
|
||||
|
||||
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# INTERNAL LLM API (for CrewAI and internal services)
|
||||
# =========================================================================
|
||||
|
||||
class InternalLLMRequest(BaseModel):
|
||||
prompt: str
|
||||
system_prompt: Optional[str] = None
|
||||
llm_profile: Optional[str] = "reasoning"
|
||||
model: Optional[str] = None
|
||||
max_tokens: Optional[int] = 2048
|
||||
temperature: Optional[float] = 0.2
|
||||
role_context: Optional[str] = None
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class InternalLLMResponse(BaseModel):
|
||||
text: str
|
||||
model: str
|
||||
provider: str
|
||||
tokens_used: int = 0
|
||||
latency_ms: int = 0
|
||||
|
||||
|
||||
class BackendStatus(BaseModel):
|
||||
"""Status of a backend service"""
|
||||
name: str
|
||||
@@ -447,6 +502,100 @@ async def get_backends_status():
|
||||
return backends
|
||||
|
||||
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# INTERNAL LLM COMPLETE ENDPOINT (for CrewAI)
|
||||
# =========================================================================
|
||||
|
||||
@app.post("/internal/llm/complete", response_model=InternalLLMResponse)
|
||||
async def internal_llm_complete(request: InternalLLMRequest):
|
||||
"""
|
||||
Internal LLM completion endpoint.
|
||||
NO routing, NO CrewAI decision, NO agent selection.
|
||||
Used by CrewAI service for multi-role orchestration.
|
||||
"""
|
||||
import time as time_module
|
||||
t0 = time_module.time()
|
||||
|
||||
logger.info(f"Internal LLM: profile={request.llm_profile}, role={request.role_context}")
|
||||
|
||||
# Get LLM profile configuration
|
||||
llm_profiles = router_config.get("llm_profiles", {})
|
||||
profile_name = request.llm_profile or "reasoning"
|
||||
llm_profile = llm_profiles.get(profile_name, {})
|
||||
|
||||
provider = llm_profile.get("provider", "deepseek")
|
||||
model = request.model or llm_profile.get("model", "deepseek-chat")
|
||||
max_tokens = request.max_tokens or llm_profile.get("max_tokens", 2048)
|
||||
temperature = request.temperature or llm_profile.get("temperature", 0.2)
|
||||
|
||||
# Build messages
|
||||
messages = []
|
||||
if request.system_prompt:
|
||||
system_content = request.system_prompt
|
||||
if request.role_context:
|
||||
system_content = f"[Role: {request.role_context}]\n\n{system_content}"
|
||||
messages.append({"role": "system", "content": system_content})
|
||||
elif request.role_context:
|
||||
messages.append({"role": "system", "content": f"You are acting as {request.role_context}. Respond professionally."})
|
||||
|
||||
messages.append({"role": "user", "content": request.prompt})
|
||||
|
||||
# Cloud providers
|
||||
cloud_providers = [
|
||||
{"name": "deepseek", "api_key_env": "DEEPSEEK_API_KEY", "base_url": "https://api.deepseek.com", "model": "deepseek-chat", "timeout": 60},
|
||||
{"name": "mistral", "api_key_env": "MISTRAL_API_KEY", "base_url": "https://api.mistral.ai", "model": "mistral-large-latest", "timeout": 60},
|
||||
{"name": "grok", "api_key_env": "GROK_API_KEY", "base_url": "https://api.x.ai", "model": "grok-2-1212", "timeout": 60}
|
||||
]
|
||||
|
||||
if provider in ["deepseek", "mistral", "grok"]:
|
||||
cloud_providers = sorted(cloud_providers, key=lambda x: 0 if x["name"] == provider else 1)
|
||||
|
||||
# Try cloud providers
|
||||
for cloud in cloud_providers:
|
||||
api_key = os.getenv(cloud["api_key_env"])
|
||||
if not api_key:
|
||||
continue
|
||||
|
||||
try:
|
||||
logger.debug(f"Internal LLM trying {cloud['name']}")
|
||||
cloud_resp = await http_client.post(
|
||||
f"{cloud['base_url']}/v1/chat/completions",
|
||||
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
||||
json={"model": cloud["model"], "messages": messages, "max_tokens": max_tokens, "temperature": temperature, "stream": False},
|
||||
timeout=cloud["timeout"]
|
||||
)
|
||||
|
||||
if cloud_resp.status_code == 200:
|
||||
data = cloud_resp.json()
|
||||
response_text = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
tokens = data.get("usage", {}).get("total_tokens", 0)
|
||||
latency = int((time_module.time() - t0) * 1000)
|
||||
logger.info(f"Internal LLM success: {cloud['name']}, {tokens} tokens, {latency}ms")
|
||||
return InternalLLMResponse(text=response_text, model=cloud["model"], provider=cloud["name"], tokens_used=tokens, latency_ms=latency)
|
||||
except Exception as e:
|
||||
logger.warning(f"Internal LLM {cloud['name']} failed: {e}")
|
||||
continue
|
||||
|
||||
# Fallback to Ollama
|
||||
try:
|
||||
logger.info("Internal LLM fallback to Ollama")
|
||||
ollama_resp = await http_client.post(
|
||||
"http://172.18.0.1:11434/api/generate",
|
||||
json={"model": "qwen3:8b", "prompt": request.prompt, "system": request.system_prompt or "", "stream": False, "options": {"num_predict": max_tokens, "temperature": temperature}},
|
||||
timeout=120.0
|
||||
)
|
||||
if ollama_resp.status_code == 200:
|
||||
data = ollama_resp.json()
|
||||
latency = int((time_module.time() - t0) * 1000)
|
||||
return InternalLLMResponse(text=data.get("response", ""), model="qwen3:8b", provider="ollama", tokens_used=0, latency_ms=latency)
|
||||
except Exception as e:
|
||||
logger.error(f"Internal LLM Ollama failed: {e}")
|
||||
|
||||
raise HTTPException(status_code=503, detail="All LLM providers unavailable")
|
||||
|
||||
|
||||
@app.post("/v1/agents/{agent_id}/infer", response_model=InferResponse)
|
||||
async def agent_infer(agent_id: str, request: InferRequest):
|
||||
"""
|
||||
@@ -519,9 +668,73 @@ async def agent_infer(agent_id: str, request: InferRequest):
|
||||
system_prompt = agent_config.get("system_prompt")
|
||||
|
||||
# Determine which backend to use
|
||||
# Use router config to get default model for agent, fallback to qwen3-8b
|
||||
# Use router config to get default model for agent, fallback to qwen3:8b
|
||||
agent_config = router_config.get("agents", {}).get(agent_id, {})
|
||||
default_llm = agent_config.get("default_llm", "qwen3-8b")
|
||||
|
||||
# =========================================================================
|
||||
# CREWAI DECISION: Use orchestration or direct LLM?
|
||||
# =========================================================================
|
||||
if CREWAI_ROUTING_ENABLED and CREWAI_CLIENT_AVAILABLE:
|
||||
try:
|
||||
# Get agent CrewAI config from registry (or router_config fallback)
|
||||
crewai_cfg = agent_config.get("crewai", {})
|
||||
|
||||
use_crewai, crewai_reason = should_use_crewai(
|
||||
agent_id=agent_id,
|
||||
prompt=request.prompt,
|
||||
agent_config=agent_config,
|
||||
force_crewai=request.metadata.get("force_crewai", False) if request.metadata else False,
|
||||
|
||||
)
|
||||
|
||||
logger.info(f"🎭 CrewAI decision for {agent_id}: {use_crewai} ({crewai_reason})")
|
||||
|
||||
if use_crewai:
|
||||
t0 = time.time()
|
||||
crew_result = await call_crewai(
|
||||
agent_id=agent_id,
|
||||
task=request.prompt,
|
||||
context={
|
||||
"memory_brief": memory_brief_text,
|
||||
"system_prompt": system_prompt,
|
||||
"metadata": metadata,
|
||||
},
|
||||
team=crewai_cfg.get("team")
|
||||
)
|
||||
|
||||
latency = time.time() - t0
|
||||
|
||||
if crew_result.get("success") and crew_result.get("result"):
|
||||
logger.info(f"✅ CrewAI success for {agent_id}: {latency:.2f}s")
|
||||
|
||||
# Store interaction in memory
|
||||
if MEMORY_RETRIEVAL_AVAILABLE and memory_retrieval and chat_id and user_id:
|
||||
try:
|
||||
await memory_retrieval.store_interaction(
|
||||
channel=channel,
|
||||
chat_id=chat_id,
|
||||
user_id=user_id,
|
||||
agent_id=request_agent_id,
|
||||
username=username,
|
||||
user_message=request.prompt,
|
||||
assistant_response=crew_result["result"]
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Memory storage failed: {e}")
|
||||
|
||||
return InferResponse(
|
||||
response=crew_result["result"],
|
||||
model="crewai-" + agent_id,
|
||||
provider="crewai",
|
||||
tokens_used=0,
|
||||
latency_ms=int(latency * 1000)
|
||||
)
|
||||
else:
|
||||
logger.warning(f"⚠️ CrewAI failed, falling back to direct LLM")
|
||||
except Exception as e:
|
||||
logger.exception(f"❌ CrewAI error: {e}, falling back to direct LLM")
|
||||
|
||||
default_llm = agent_config.get("default_llm", "qwen3:8b")
|
||||
|
||||
# Check if there's a routing rule for this agent
|
||||
routing_rules = router_config.get("routing", [])
|
||||
@@ -542,7 +755,7 @@ async def agent_infer(agent_id: str, request: InferRequest):
|
||||
model = llm_profile.get("model", "deepseek-chat")
|
||||
else:
|
||||
# For local ollama, use swapper model name format
|
||||
model = request.model or "qwen3-8b"
|
||||
model = request.model or "qwen3:8b"
|
||||
|
||||
# =========================================================================
|
||||
# VISION PROCESSING (if images present)
|
||||
@@ -929,9 +1142,9 @@ async def agent_infer(agent_id: str, request: InferRequest):
|
||||
|
||||
# Check if default_llm is local
|
||||
if llm_profile.get("provider") == "ollama":
|
||||
# Extract model name and convert format (qwen3:8b → qwen3-8b for Swapper)
|
||||
# Extract model name and convert format (qwen3:8b → qwen3:8b for Swapper)
|
||||
ollama_model = llm_profile.get("model", "qwen3:8b")
|
||||
local_model = ollama_model.replace(":", "-") # qwen3:8b → qwen3-8b
|
||||
local_model = ollama_model.replace(":", "-") # qwen3:8b → qwen3:8b
|
||||
logger.debug(f"✅ Using agent's default local model: {local_model}")
|
||||
else:
|
||||
# Find first local model from config
|
||||
@@ -944,7 +1157,7 @@ async def agent_infer(agent_id: str, request: InferRequest):
|
||||
|
||||
# Final fallback if no local model found
|
||||
if not local_model:
|
||||
local_model = "qwen3-8b"
|
||||
local_model = "qwen3:8b"
|
||||
logger.warning(f"⚠️ No local model in config, using hardcoded fallback: {local_model}")
|
||||
|
||||
try:
|
||||
|
||||
367
services/router/privacy_gate.py
Normal file
367
services/router/privacy_gate.py
Normal file
@@ -0,0 +1,367 @@
|
||||
"""
|
||||
Privacy Gate Middleware
|
||||
=======================
|
||||
Обов'язковий middleware для контролю приватності на Router рівні.
|
||||
|
||||
Правила:
|
||||
- public: повний доступ + логування контенту
|
||||
- team: тільки для членів команди + логування metadata
|
||||
- confidential: тільки sanitized або з consent + без логування контенту
|
||||
- e2ee: НІКОЛИ plaintext на сервері
|
||||
"""
|
||||
|
||||
import re
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, Callable
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class PrivacyMode(str, Enum):
|
||||
"""Режими приватності"""
|
||||
PUBLIC = "public"
|
||||
TEAM = "team"
|
||||
CONFIDENTIAL = "confidential"
|
||||
E2EE = "e2ee"
|
||||
|
||||
class GateAction(str, Enum):
|
||||
"""Дії Privacy Gate"""
|
||||
ALLOW = "allow"
|
||||
SANITIZE = "sanitize"
|
||||
BLOCK = "block"
|
||||
REQUIRE_CONSENT = "require_consent"
|
||||
|
||||
@dataclass
|
||||
class GateResult:
|
||||
"""Результат перевірки Privacy Gate"""
|
||||
action: GateAction
|
||||
allow_content: bool
|
||||
log_content: bool
|
||||
transform: Optional[Callable[[str], str]] = None
|
||||
reason: str = ""
|
||||
consent_required: bool = False
|
||||
|
||||
# PII Patterns для sanitization
|
||||
PII_PATTERNS = [
|
||||
(r'\b\d{10,13}\b', '[PHONE]'), # Phone numbers
|
||||
(r'\b[\w.-]+@[\w.-]+\.\w+\b', '[EMAIL]'), # Emails
|
||||
(r'\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b', '[CARD]'), # Credit cards
|
||||
(r'\b\d{8,10}\b', '[ID]'), # IDs
|
||||
(r'\b(?:\+38)?0\d{9}\b', '[UA_PHONE]'), # UA phone numbers
|
||||
(r'\b\d{10}\b', '[IPN]'), # Ukrainian IPN
|
||||
]
|
||||
|
||||
def remove_pii(text: str) -> str:
|
||||
"""Видаляє PII з тексту"""
|
||||
result = text
|
||||
for pattern, replacement in PII_PATTERNS:
|
||||
result = re.sub(pattern, replacement, result)
|
||||
return result
|
||||
|
||||
def sanitize_context(context: str, max_length: int = 200) -> str:
|
||||
"""
|
||||
Sanitizes context for confidential mode.
|
||||
|
||||
1. Видаляє PII
|
||||
2. Обмежує довжину
|
||||
3. Узагальнює
|
||||
"""
|
||||
if not context:
|
||||
return "[Empty context]"
|
||||
|
||||
# Step 1: Remove PII
|
||||
sanitized = remove_pii(context)
|
||||
|
||||
# Step 2: Truncate
|
||||
if len(sanitized) > max_length:
|
||||
sanitized = sanitized[:max_length] + "..."
|
||||
|
||||
# Step 3: Add marker
|
||||
return f"[Sanitized] {sanitized}"
|
||||
|
||||
def create_summary(context: str, max_words: int = 20) -> str:
|
||||
"""
|
||||
Створює короткий summary для handoff.
|
||||
"""
|
||||
if not context:
|
||||
return "[No context]"
|
||||
|
||||
# Remove PII first
|
||||
clean = remove_pii(context)
|
||||
|
||||
# Take first N words
|
||||
words = clean.split()[:max_words]
|
||||
summary = " ".join(words)
|
||||
|
||||
if len(clean.split()) > max_words:
|
||||
summary += "..."
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
class PrivacyGate:
|
||||
"""
|
||||
Privacy Gate - контролює доступ до контенту на основі mode.
|
||||
|
||||
Usage:
|
||||
gate = PrivacyGate()
|
||||
result = gate.check(request)
|
||||
|
||||
if result.action == GateAction.BLOCK:
|
||||
raise PrivacyViolation(result.reason)
|
||||
|
||||
if result.transform:
|
||||
request.context = result.transform(request.context)
|
||||
"""
|
||||
|
||||
def __init__(self, config: Dict[str, Any] = None):
|
||||
self.config = config or {}
|
||||
self._enabled = self.config.get("enabled", True)
|
||||
|
||||
def check(self,
|
||||
mode: str,
|
||||
user_id: str = None,
|
||||
team_id: str = None,
|
||||
user_consent: bool = False,
|
||||
team_members: list = None) -> GateResult:
|
||||
"""
|
||||
Перевіряє запит і повертає рішення.
|
||||
|
||||
Args:
|
||||
mode: Режим приватності (public/team/confidential/e2ee)
|
||||
user_id: ID користувача
|
||||
team_id: ID команди
|
||||
user_consent: Чи є згода користувача
|
||||
team_members: Список членів команди (для team mode)
|
||||
|
||||
Returns:
|
||||
GateResult з рішенням
|
||||
"""
|
||||
if not self._enabled:
|
||||
return GateResult(
|
||||
action=GateAction.ALLOW,
|
||||
allow_content=True,
|
||||
log_content=True,
|
||||
reason="Privacy Gate disabled"
|
||||
)
|
||||
|
||||
try:
|
||||
privacy_mode = PrivacyMode(mode.lower())
|
||||
except ValueError:
|
||||
# Unknown mode -> treat as confidential
|
||||
privacy_mode = PrivacyMode.CONFIDENTIAL
|
||||
logger.warning(f"Unknown privacy mode: {mode}, treating as confidential")
|
||||
|
||||
# Route to specific handler
|
||||
if privacy_mode == PrivacyMode.PUBLIC:
|
||||
return self._check_public()
|
||||
elif privacy_mode == PrivacyMode.TEAM:
|
||||
return self._check_team(user_id, team_id, team_members)
|
||||
elif privacy_mode == PrivacyMode.CONFIDENTIAL:
|
||||
return self._check_confidential(user_consent)
|
||||
elif privacy_mode == PrivacyMode.E2EE:
|
||||
return self._check_e2ee()
|
||||
|
||||
# Default: block unknown
|
||||
return GateResult(
|
||||
action=GateAction.BLOCK,
|
||||
allow_content=False,
|
||||
log_content=False,
|
||||
reason="Unknown privacy mode"
|
||||
)
|
||||
|
||||
def _check_public(self) -> GateResult:
|
||||
"""Public mode: повний доступ"""
|
||||
return GateResult(
|
||||
action=GateAction.ALLOW,
|
||||
allow_content=True,
|
||||
log_content=True,
|
||||
reason="Public mode - full access"
|
||||
)
|
||||
|
||||
def _check_team(self, user_id: str, team_id: str,
|
||||
team_members: list = None) -> GateResult:
|
||||
"""Team mode: тільки для членів команди"""
|
||||
# Check membership
|
||||
if team_members and user_id and user_id not in team_members:
|
||||
return GateResult(
|
||||
action=GateAction.BLOCK,
|
||||
allow_content=False,
|
||||
log_content=False,
|
||||
reason=f"User {user_id} is not a member of team {team_id}"
|
||||
)
|
||||
|
||||
return GateResult(
|
||||
action=GateAction.ALLOW,
|
||||
allow_content=True,
|
||||
log_content=False, # Тільки metadata
|
||||
reason="Team mode - metadata logging only"
|
||||
)
|
||||
|
||||
def _check_confidential(self, user_consent: bool) -> GateResult:
|
||||
"""Confidential mode: sanitized або з consent"""
|
||||
if user_consent:
|
||||
# З consent - дозволяємо, але без логування контенту
|
||||
return GateResult(
|
||||
action=GateAction.ALLOW,
|
||||
allow_content=True,
|
||||
log_content=False,
|
||||
reason="Confidential mode - user consent given"
|
||||
)
|
||||
else:
|
||||
# Без consent - тільки sanitized
|
||||
return GateResult(
|
||||
action=GateAction.SANITIZE,
|
||||
allow_content=True,
|
||||
log_content=False,
|
||||
transform=sanitize_context,
|
||||
consent_required=True,
|
||||
reason="Confidential mode - sanitizing content"
|
||||
)
|
||||
|
||||
def _check_e2ee(self) -> GateResult:
|
||||
"""E2EE mode: НІКОЛИ plaintext на сервері"""
|
||||
return GateResult(
|
||||
action=GateAction.BLOCK,
|
||||
allow_content=False,
|
||||
log_content=False,
|
||||
reason="E2EE mode - server-side processing not allowed"
|
||||
)
|
||||
|
||||
def apply_transform(self, content: str, result: GateResult) -> str:
|
||||
"""Застосовує transform якщо потрібно"""
|
||||
if result.transform and content:
|
||||
return result.transform(content)
|
||||
return content
|
||||
|
||||
|
||||
class PrivacyViolation(Exception):
|
||||
"""Виняток при порушенні приватності"""
|
||||
pass
|
||||
|
||||
|
||||
# ==================== MIDDLEWARE ====================
|
||||
|
||||
async def privacy_gate_middleware(request, call_next, gate: PrivacyGate = None):
|
||||
"""
|
||||
FastAPI/Starlette middleware для Privacy Gate.
|
||||
|
||||
Usage:
|
||||
from privacy_gate import privacy_gate_middleware, PrivacyGate
|
||||
|
||||
gate = PrivacyGate()
|
||||
|
||||
@app.middleware("http")
|
||||
async def privacy_middleware(request, call_next):
|
||||
return await privacy_gate_middleware(request, call_next, gate)
|
||||
"""
|
||||
if gate is None:
|
||||
gate = PrivacyGate()
|
||||
|
||||
# Extract mode from request
|
||||
mode = "public" # Default
|
||||
user_consent = False
|
||||
|
||||
# Try to get from headers
|
||||
if hasattr(request, 'headers'):
|
||||
mode = request.headers.get("X-Privacy-Mode", "public")
|
||||
user_consent = request.headers.get("X-User-Consent", "false").lower() == "true"
|
||||
|
||||
# Check privacy
|
||||
result = gate.check(
|
||||
mode=mode,
|
||||
user_consent=user_consent
|
||||
)
|
||||
|
||||
# Handle based on result
|
||||
if result.action == GateAction.BLOCK:
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={"error": "Privacy violation", "reason": result.reason}
|
||||
)
|
||||
|
||||
# Add privacy info to request state
|
||||
if hasattr(request, 'state'):
|
||||
request.state.privacy_result = result
|
||||
request.state.log_content = result.log_content
|
||||
|
||||
# Continue with request
|
||||
response = await call_next(request)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
# ==================== ROUTER INTEGRATION ====================
|
||||
|
||||
def check_privacy_for_handoff(
|
||||
source_agent: str,
|
||||
target_agent: str,
|
||||
context: str,
|
||||
mode: str = "public",
|
||||
user_consent: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Перевіряє приватність перед handoff між агентами.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"allowed": bool,
|
||||
"context": str (можливо sanitized),
|
||||
"reason": str,
|
||||
"consent_required": bool
|
||||
}
|
||||
"""
|
||||
gate = PrivacyGate()
|
||||
result = gate.check(mode=mode, user_consent=user_consent)
|
||||
|
||||
if result.action == GateAction.BLOCK:
|
||||
return {
|
||||
"allowed": False,
|
||||
"context": None,
|
||||
"reason": result.reason,
|
||||
"consent_required": False
|
||||
}
|
||||
|
||||
# Apply transform if needed
|
||||
final_context = gate.apply_transform(context, result)
|
||||
|
||||
return {
|
||||
"allowed": True,
|
||||
"context": final_context,
|
||||
"reason": result.reason,
|
||||
"consent_required": result.consent_required,
|
||||
"was_sanitized": result.transform is not None
|
||||
}
|
||||
|
||||
|
||||
# ==================== AUDIT HELPERS ====================
|
||||
|
||||
def create_privacy_audit_event(
|
||||
action: str,
|
||||
mode: str,
|
||||
user_id: str,
|
||||
result: GateResult,
|
||||
content_hash: str = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Створює audit event для Privacy Gate.
|
||||
|
||||
Note: НІКОЛИ не включає контент, тільки hash якщо потрібно.
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
return {
|
||||
"event_type": "privacy.check",
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"action": action,
|
||||
"mode": mode,
|
||||
"user_id": user_id,
|
||||
"gate_action": result.action.value,
|
||||
"allow_content": result.allow_content,
|
||||
"log_content": result.log_content,
|
||||
"consent_required": result.consent_required,
|
||||
"reason": result.reason,
|
||||
"content_hash": content_hash # Hash, not content
|
||||
}
|
||||
99
services/router/registry_loader.py
Normal file
99
services/router/registry_loader.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
Router Agent Registry Loader
|
||||
Loads agent routing configs from centralized registry JSON.
|
||||
Feature flag controlled.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REGISTRY_ENABLED = os.getenv("AGENT_REGISTRY_ENABLED", "true").lower() == "true"
|
||||
REGISTRY_PATH = Path(__file__).parent.parent.parent / "config" / "router_agents.json"
|
||||
|
||||
_cache = None
|
||||
_loaded = False
|
||||
|
||||
|
||||
def load_registry() -> Optional[Dict[str, Any]]:
|
||||
"""Load router agents from registry JSON."""
|
||||
global _cache, _loaded
|
||||
|
||||
if _loaded:
|
||||
return _cache
|
||||
|
||||
if not REGISTRY_ENABLED:
|
||||
logger.info("Router registry disabled by feature flag")
|
||||
_loaded = True
|
||||
return None
|
||||
|
||||
if not REGISTRY_PATH.exists():
|
||||
logger.warning(f"Router registry not found: {REGISTRY_PATH}")
|
||||
_loaded = True
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(REGISTRY_PATH) as f:
|
||||
_cache = json.load(f)
|
||||
logger.info(f"Router registry loaded: {len(_cache)} agents")
|
||||
_loaded = True
|
||||
return _cache
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load router registry: {e}")
|
||||
_loaded = True
|
||||
return None
|
||||
|
||||
|
||||
def get_agent_routing(agent_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get routing config for agent."""
|
||||
registry = load_registry()
|
||||
if not registry:
|
||||
return None
|
||||
return registry.get(agent_id)
|
||||
|
||||
|
||||
def get_agent_keywords(agent_id: str) -> List[str]:
|
||||
"""Get routing keywords for agent."""
|
||||
config = get_agent_routing(agent_id)
|
||||
if config:
|
||||
return config.get("keywords", [])
|
||||
return []
|
||||
|
||||
|
||||
def get_agent_domains(agent_id: str) -> List[str]:
|
||||
"""Get domains for agent."""
|
||||
config = get_agent_routing(agent_id)
|
||||
if config:
|
||||
return config.get("domains", [])
|
||||
return []
|
||||
|
||||
|
||||
def get_all_visible_agents() -> List[str]:
|
||||
"""Get list of visible (non-internal) agents."""
|
||||
registry = load_registry()
|
||||
if not registry:
|
||||
return []
|
||||
|
||||
return [
|
||||
agent_id for agent_id, config in registry.items()
|
||||
if config.get("visibility") != "internal"
|
||||
]
|
||||
|
||||
|
||||
def get_agent_llm_profile(agent_id: str) -> str:
|
||||
"""Get LLM profile for agent."""
|
||||
config = get_agent_routing(agent_id)
|
||||
if config:
|
||||
return config.get("default_llm", "fast")
|
||||
return "fast"
|
||||
|
||||
|
||||
def reload_registry():
|
||||
"""Force reload registry."""
|
||||
global _cache, _loaded
|
||||
_cache = None
|
||||
_loaded = False
|
||||
return load_registry()
|
||||
@@ -14,3 +14,4 @@ qdrant-client>=1.7.0,<1.8.0 # Must match server version 1.7.x
|
||||
|
||||
|
||||
|
||||
prometheus-client>=0.20.0
|
||||
|
||||
@@ -132,7 +132,7 @@ orchestrator_providers:
|
||||
agents:
|
||||
devtools:
|
||||
description: "DevTools Agent - помічник з кодом, тестами й інфраструктурою"
|
||||
default_llm: local_qwen3_8b
|
||||
default_llm: cloud_deepseek
|
||||
system_prompt: |
|
||||
Ти - DevTools Agent в екосистемі DAARION.city.
|
||||
Ти допомагаєш розробникам з:
|
||||
@@ -161,7 +161,7 @@ agents:
|
||||
|
||||
microdao_orchestrator:
|
||||
description: "Multi-agent orchestrator for MicroDAO workflows"
|
||||
default_llm: qwen3_strategist_8b
|
||||
default_llm: cloud_deepseek
|
||||
system_prompt: |
|
||||
You are the central router/orchestrator for DAARION.city MicroDAO.
|
||||
Coordinate multiple agents, respect RBAC, escalate only when needed.
|
||||
@@ -169,7 +169,7 @@ agents:
|
||||
|
||||
daarwizz:
|
||||
description: "DAARWIZZ — головний оркестратор DAARION Core"
|
||||
default_llm: qwen3_strategist_8b
|
||||
default_llm: cloud_deepseek
|
||||
system_prompt: |
|
||||
Ти — DAARWIZZ, головний стратег MicroDAO DAARION.city.
|
||||
Тримаєш контекст roadmap, delegation, crew-команд.
|
||||
@@ -178,7 +178,7 @@ agents:
|
||||
|
||||
greenfood:
|
||||
description: "GREENFOOD Assistant - ERP orchestrator"
|
||||
default_llm: mistral_community_7b
|
||||
default_llm: cloud_deepseek
|
||||
system_prompt: |
|
||||
Ти — GREENFOOD Assistant, фронтовий оркестратор ERP-системи для крафтових виробників.
|
||||
Розумій, хто з тобою говорить (комітент, покупець, склад, бухгалтер), та делегуй задачі відповідним під-агентам.
|
||||
@@ -203,21 +203,21 @@ agents:
|
||||
|
||||
agromatrix:
|
||||
description: "AgroMatrix — агроаналітика та кооперація"
|
||||
default_llm: qwen3_science_8b
|
||||
default_llm: cloud_deepseek
|
||||
system_prompt: |
|
||||
Ти — AgroMatrix, AI-агент для агроаналітики, планування сезонів та кооперації фермерів.
|
||||
Відповідай лаконічно, давай практичні поради для агросектору.
|
||||
|
||||
alateya:
|
||||
description: "Alateya — R&D та біотех інновації"
|
||||
default_llm: qwen3_science_8b
|
||||
default_llm: cloud_deepseek
|
||||
system_prompt: |
|
||||
Ти — Alateya, AI-агент для R&D, біотеху та інноваційних досліджень.
|
||||
Відповідай точними, структурованими відповідями та посилайся на джерела, якщо є.
|
||||
|
||||
clan:
|
||||
description: "CLAN — комунікації кооперативів"
|
||||
default_llm: mistral_community_7b
|
||||
default_llm: cloud_deepseek
|
||||
system_prompt: |
|
||||
Ти — CLAN, координуєш комунікацію, оголошення та community operations.
|
||||
Відповідай лише коли тема стосується координації, а звернення адресовано тобі (тег @ClanBot чи згадка кланів).
|
||||
@@ -225,7 +225,7 @@ agents:
|
||||
|
||||
soul:
|
||||
description: "SOUL / Spirit — духовний гід комʼюніті"
|
||||
default_llm: mistral_community_7b
|
||||
default_llm: cloud_deepseek
|
||||
system_prompt: |
|
||||
Ти — Spirit/SOUL, ментор живої операційної системи.
|
||||
Пояснюй місію, підтримуй мораль, працюй із soft-skills.
|
||||
@@ -233,7 +233,7 @@ agents:
|
||||
|
||||
druid:
|
||||
description: "DRUID — R&D агент з косметології та eco design"
|
||||
default_llm: qwen3_science_8b
|
||||
default_llm: cloud_deepseek
|
||||
system_prompt: |
|
||||
Ти — DRUID AI, експерт з космецевтики, біохімії та сталого дизайну.
|
||||
Працюй з формулами, стехіометрією, етичними ланцюгами постачання.
|
||||
@@ -269,7 +269,7 @@ agents:
|
||||
|
||||
nutra:
|
||||
description: "NUTRA — нутріцевтичний агент"
|
||||
default_llm: qwen3_science_8b
|
||||
default_llm: cloud_deepseek
|
||||
system_prompt: |
|
||||
Ти — NUTRA, допомагаєш з формулами нутрієнтів, біомедичних добавок та лабораторних інтерпретацій.
|
||||
Відповідай з науковою точністю, посилайся на джерела, якщо можливо.
|
||||
@@ -298,7 +298,7 @@ agents:
|
||||
|
||||
eonarch:
|
||||
description: "EONARCH — мультимодальний агент (vision + chat)"
|
||||
default_llm: mistral_community_7b
|
||||
default_llm: cloud_deepseek
|
||||
system_prompt: |
|
||||
Ти — EONARCH, аналізуєш зображення, PDF та текстові запити.
|
||||
Враховуй присутність інших ботів та працюй лише за прямим тегом або коли потрібно мультимодальне тлумачення.
|
||||
@@ -318,7 +318,7 @@ agents:
|
||||
|
||||
helion:
|
||||
description: "Helion - AI agent for Energy Union platform"
|
||||
default_llm: qwen3_science_8b
|
||||
default_llm: cloud_deepseek
|
||||
system_prompt: |
|
||||
Ти - Helion, AI-агент платформи Energy Union.
|
||||
Допомагай користувачам з технологіями EcoMiner/BioMiner, токеномікою та DAO governance.
|
||||
@@ -398,7 +398,7 @@ agents:
|
||||
|
||||
yaromir:
|
||||
description: "Yaromir CrewAI (Вождь/Проводник/Домир/Создатель)"
|
||||
default_llm: qwen3_strategist_8b
|
||||
default_llm: cloud_deepseek
|
||||
system_prompt: |
|
||||
Ти — Yaromir Crew. Стратегія, наставництво, психологічна підтримка команди.
|
||||
Розрізняй інших ботів за ніком та відповідай лише на стратегічні запити.
|
||||
@@ -654,3 +654,10 @@ policies:
|
||||
enabled: true
|
||||
audit_mode:
|
||||
enabled: false
|
||||
|
||||
senpai:
|
||||
description: "SenpAI — Gordon Senpai, trading advisor"
|
||||
default_llm: cloud_deepseek
|
||||
system_prompt: |
|
||||
Ты — Гордон Сэнпай: советник по рынкам капитала и цифровым активам.
|
||||
Помогай мыслить как профессионал: строить систему, управлять риском, оценивать сценарии.
|
||||
|
||||
@@ -4,6 +4,7 @@ Implements OpenAI-compatible function calling for DeepSeek, Mistral, Grok
|
||||
"""
|
||||
|
||||
import os
|
||||
from agent_tools_config import get_agent_tools, is_tool_allowed
|
||||
import json
|
||||
import logging
|
||||
import httpx
|
||||
@@ -233,6 +234,58 @@ TOOL_DEFINITIONS = [
|
||||
"required": ["artifact_id"]
|
||||
}
|
||||
}
|
||||
},
|
||||
# PRIORITY 5: Web Scraping tools
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "crawl4ai_scrape",
|
||||
"description": "🕷️ Глибокий скрейпінг веб-сторінки через Crawl4AI. Витягує повний контент, структуровані дані, медіа. Використовуй для детального аналізу сайтів.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "URL сторінки для скрейпінгу"
|
||||
},
|
||||
"extract_links": {
|
||||
"type": "boolean",
|
||||
"description": "Витягувати посилання зі сторінки",
|
||||
"default": True
|
||||
},
|
||||
"extract_images": {
|
||||
"type": "boolean",
|
||||
"description": "Витягувати зображення",
|
||||
"default": False
|
||||
}
|
||||
},
|
||||
"required": ["url"]
|
||||
}
|
||||
}
|
||||
},
|
||||
# PRIORITY 6: TTS tools
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "tts_speak",
|
||||
"description": "🔊 Перетворити текст на аудіо (Text-to-Speech). Повертає аудіо файл. Використовуй коли користувач просить озвучити текст.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "Текст для озвучення"
|
||||
},
|
||||
"language": {
|
||||
"type": "string",
|
||||
"enum": ["uk", "en", "ru"],
|
||||
"description": "Мова озвучення",
|
||||
"default": "uk"
|
||||
}
|
||||
},
|
||||
"required": ["text"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -267,13 +320,33 @@ class ToolManager:
|
||||
}
|
||||
return tools
|
||||
|
||||
def get_tool_definitions(self) -> List[Dict]:
|
||||
"""Get tool definitions for function calling"""
|
||||
return TOOL_DEFINITIONS
|
||||
def get_tool_definitions(self, agent_id: str = None) -> List[Dict]:
|
||||
"""Get tool definitions for function calling, filtered by agent permissions"""
|
||||
if not agent_id:
|
||||
return TOOL_DEFINITIONS
|
||||
|
||||
# Get allowed tools for this agent
|
||||
allowed_tools = get_agent_tools(agent_id)
|
||||
|
||||
# Filter tool definitions
|
||||
filtered = []
|
||||
for tool_def in TOOL_DEFINITIONS:
|
||||
tool_name = tool_def.get("function", {}).get("name")
|
||||
if tool_name in allowed_tools:
|
||||
filtered.append(tool_def)
|
||||
|
||||
tool_names = [t.get("function", {}).get("name") for t in filtered]
|
||||
logger.debug(f"Agent {agent_id} has {len(filtered)} tools: {tool_names}")
|
||||
return filtered
|
||||
|
||||
async def execute_tool(self, tool_name: str, arguments: Dict[str, Any]) -> ToolResult:
|
||||
"""Execute a tool and return result"""
|
||||
logger.info(f"🔧 Executing tool: {tool_name} with args: {arguments}")
|
||||
async def execute_tool(self, tool_name: str, arguments: Dict[str, Any], agent_id: str = None) -> ToolResult:
|
||||
"""Execute a tool and return result. Optionally checks agent permissions."""
|
||||
logger.info(f"🔧 Executing tool: {tool_name} for agent={agent_id} with args: {arguments}")
|
||||
|
||||
# Check agent permission if agent_id provided
|
||||
if agent_id and not is_tool_allowed(agent_id, tool_name):
|
||||
logger.warning(f"⚠️ Tool {tool_name} not allowed for agent {agent_id}")
|
||||
return ToolResult(success=False, result=None, error=f"Tool {tool_name} not available for this agent")
|
||||
|
||||
try:
|
||||
# Priority 1: Memory/Knowledge tools
|
||||
@@ -297,6 +370,12 @@ class ToolManager:
|
||||
return await self._presentation_status(arguments)
|
||||
elif tool_name == "presentation_download":
|
||||
return await self._presentation_download(arguments)
|
||||
# Priority 5: Web scraping tools
|
||||
elif tool_name == "crawl4ai_scrape":
|
||||
return await self._crawl4ai_scrape(arguments)
|
||||
# Priority 6: TTS tools
|
||||
elif tool_name == "tts_speak":
|
||||
return await self._tts_speak(arguments)
|
||||
else:
|
||||
return ToolResult(success=False, result=None, error=f"Unknown tool: {tool_name}")
|
||||
except Exception as e:
|
||||
@@ -652,6 +731,101 @@ class ToolManager:
|
||||
except Exception as e:
|
||||
return ToolResult(success=False, result=None, error=str(e))
|
||||
|
||||
|
||||
async def _crawl4ai_scrape(self, args: Dict) -> ToolResult:
|
||||
"""Deep scrape a web page using Crawl4AI - PRIORITY 5"""
|
||||
url = args.get("url")
|
||||
extract_links = args.get("extract_links", True)
|
||||
extract_images = args.get("extract_images", False)
|
||||
|
||||
if not url:
|
||||
return ToolResult(success=False, result=None, error="URL is required")
|
||||
|
||||
try:
|
||||
crawl4ai_url = os.getenv("CRAWL4AI_URL", "http://dagi-crawl4ai-node1:11235")
|
||||
|
||||
payload = {
|
||||
"urls": [url],
|
||||
"priority": 5,
|
||||
"session_id": f"agent_scrape_{hash(url) % 10000}"
|
||||
}
|
||||
|
||||
resp = await self.http_client.post(
|
||||
f"{crawl4ai_url}/crawl",
|
||||
json=payload,
|
||||
timeout=60.0
|
||||
)
|
||||
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
results = data.get("results", []) if isinstance(data, dict) else []
|
||||
if not results and isinstance(data, dict):
|
||||
results = [data]
|
||||
|
||||
if results:
|
||||
result = results[0] if isinstance(results, list) else results
|
||||
markdown = result.get("markdown", "") or result.get("cleaned_html", "") or result.get("text", "")
|
||||
title = result.get("title", url)
|
||||
|
||||
if len(markdown) > 3000:
|
||||
markdown = markdown[:3000] + "... (скорочено)"
|
||||
|
||||
response_parts = [f"**{title}**", "", markdown]
|
||||
|
||||
if extract_links:
|
||||
links = result.get("links", [])
|
||||
if links:
|
||||
response_parts.append("")
|
||||
response_parts.append("**Посилання:**")
|
||||
for link in links[:10]:
|
||||
if isinstance(link, dict):
|
||||
link_url = link.get("href", "")
|
||||
else:
|
||||
link_url = str(link)
|
||||
if link_url:
|
||||
response_parts.append(f"- {link_url}")
|
||||
|
||||
return ToolResult(success=True, result="\n".join(response_parts))
|
||||
else:
|
||||
return ToolResult(success=False, result=None, error="No content extracted")
|
||||
else:
|
||||
return ToolResult(success=False, result=None, error=f"Crawl failed: {resp.status_code}")
|
||||
except Exception as e:
|
||||
logger.error(f"Crawl4AI scrape failed: {e}")
|
||||
return ToolResult(success=False, result=None, error=str(e))
|
||||
|
||||
async def _tts_speak(self, args: Dict) -> ToolResult:
|
||||
"""Convert text to speech using Swapper TTS - PRIORITY 6"""
|
||||
text = args.get("text")
|
||||
language = args.get("language", "uk")
|
||||
|
||||
if not text:
|
||||
return ToolResult(success=False, result=None, error="Text is required")
|
||||
|
||||
try:
|
||||
if len(text) > 1000:
|
||||
text = text[:1000]
|
||||
|
||||
resp = await self.http_client.post(
|
||||
f"{self.swapper_url}/tts",
|
||||
json={"text": text, "language": language},
|
||||
timeout=60.0
|
||||
)
|
||||
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
audio_url = data.get("audio_url") or data.get("url")
|
||||
|
||||
if audio_url:
|
||||
return ToolResult(success=True, result=f"Аудіо: {audio_url}")
|
||||
else:
|
||||
return ToolResult(success=True, result="TTS completed")
|
||||
else:
|
||||
return ToolResult(success=False, result=None, error=f"TTS failed: {resp.status_code}")
|
||||
except Exception as e:
|
||||
logger.error(f"TTS failed: {e}")
|
||||
return ToolResult(success=False, result=None, error=str(e))
|
||||
|
||||
async def close(self):
|
||||
await self.http_client.aclose()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user