""" City Backend API Routes """ from fastapi import APIRouter, HTTPException, Depends, Body, Header, Query, Request, UploadFile, File, Form from pydantic import BaseModel from typing import List, Optional import logging import httpx import os import io import uuid from PIL import Image import shutil from models_city import ( CityRoomRead, CityRoomCreate, CityRoomDetail, CityRoomMessageRead, CityRoomMessageCreate, CityFeedEventRead, CityMapRoom, CityMapConfig, CityMapResponse, AgentRead, AgentPresence, AgentSummary, MicrodaoBadge, HomeNodeView, NodeProfile, PublicCitizenSummary, PublicCitizenProfile, CitizenInteractionInfo, CitizenAskRequest, CitizenAskResponse, AgentMicrodaoMembership, MicrodaoSummary, MicrodaoDetail, MicrodaoAgentView, MicrodaoChannelView, MicrodaoCitizenView, MicrodaoOption, CityRoomSummary, MicrodaoRoomsList, MicrodaoRoomUpdate, AttachExistingRoomRequest ) import repo_city from common.redis_client import PresenceRedis, get_redis from matrix_client import create_matrix_room, find_matrix_room_by_alias from dagi_router_client import get_dagi_router_client, DagiRouterClient logger = logging.getLogger(__name__) # JWT validation (simplified for MVP) AUTH_SERVICE_URL = os.getenv("AUTH_SERVICE_URL", "http://daarion-auth:7020") MATRIX_GATEWAY_URL = os.getenv("MATRIX_GATEWAY_URL", "http://daarion-matrix-gateway:7025") # Helper for image processing def process_image(image_bytes: bytes, target_size: tuple = (256, 256)) -> tuple[bytes, bytes]: """ Process image: 1. Convert to PNG 2. Resize/Crop to target_size (default 256x256) 3. Generate thumbnail 128x128 Returns (processed_bytes, thumb_bytes) """ with Image.open(io.BytesIO(image_bytes)) as img: # Convert to RGBA/RGB if img.mode in ('P', 'CMYK'): img = img.convert('RGBA') # Resize/Crop to target_size img_ratio = img.width / img.height target_ratio = target_size[0] / target_size[1] if img_ratio > target_ratio: # Wider than target new_height = target_size[1] new_width = int(new_height * img_ratio) else: # Taller than target new_width = target_size[0] new_height = int(new_width / img_ratio) img = img.resize((new_width, new_height), Image.Resampling.LANCZOS) # Center crop left = (new_width - target_size[0]) / 2 top = (new_height - target_size[1]) / 2 right = (new_width + target_size[0]) / 2 bottom = (new_height + target_size[1]) / 2 img = img.crop((left, top, right, bottom)) # Save processed processed_io = io.BytesIO() img.save(processed_io, format='PNG', optimize=True) processed_bytes = processed_io.getvalue() # Thumbnail img.thumbnail((128, 128)) thumb_io = io.BytesIO() img.save(thumb_io, format='PNG', optimize=True) thumb_bytes = thumb_io.getvalue() return processed_bytes, thumb_bytes router = APIRouter(prefix="/city", tags=["city"]) public_router = APIRouter(prefix="/public", tags=["public"]) api_router = APIRouter(prefix="/api/v1", tags=["api_v1"]) class MicrodaoMembershipPayload(BaseModel): microdao_id: str role: Optional[str] = None is_core: bool = False # ============================================================================= # Agents API (for Agent Console) # ============================================================================= @public_router.get("/agents") async def list_agents( kind: Optional[str] = Query(None, description="Filter by agent kind"), node_id: Optional[str] = Query(None, description="Filter by node_id"), microdao_id: Optional[str] = Query(None, description="Filter by microDAO id"), is_public: Optional[bool] = Query(None, description="Filter by public status"), visibility_scope: Optional[str] = Query(None, description="Filter by visibility: global, microdao, private"), include_system: bool = Query(True, description="Include system agents"), limit: int = Query(100, le=200), offset: int = Query(0, ge=0) ): """Список всіх агентів для Agent Console (unified API)""" try: kinds_list = [kind] if kind else None agents, total = await repo_city.list_agent_summaries( node_id=node_id, microdao_id=microdao_id, is_public=is_public, visibility_scope=visibility_scope, kinds=kinds_list, include_system=include_system, limit=limit, offset=offset ) items: List[AgentSummary] = [] for agent in agents: # Build home_node if available home_node_data = agent.get("home_node") home_node = None if home_node_data: home_node = HomeNodeView( id=home_node_data.get("id"), name=home_node_data.get("name"), hostname=home_node_data.get("hostname"), roles=home_node_data.get("roles", []), environment=home_node_data.get("environment") ) # Build microdao badges microdaos = [ MicrodaoBadge( id=m.get("id", ""), name=m.get("name", ""), slug=m.get("slug"), role=m.get("role") ) for m in agent.get("microdaos", []) ] items.append(AgentSummary( id=agent["id"], slug=agent.get("slug"), display_name=agent["display_name"], title=agent.get("title"), tagline=agent.get("tagline"), kind=agent.get("kind", "assistant"), avatar_url=agent.get("avatar_url"), status=agent.get("status", "offline"), node_id=agent.get("node_id"), node_label=agent.get("node_label"), home_node=home_node, # Governance & DAIS (A1, A2) gov_level=agent.get("gov_level"), dais_identity_id=agent.get("dais_identity_id"), visibility_scope=agent.get("visibility_scope", "city"), is_listed_in_directory=agent.get("is_listed_in_directory", True), is_system=agent.get("is_system", False), is_public=agent.get("is_public", False), is_orchestrator=agent.get("is_orchestrator", False), # MicroDAO (A3) primary_microdao_id=agent.get("primary_microdao_id"), primary_microdao_name=agent.get("primary_microdao_name"), primary_microdao_slug=agent.get("primary_microdao_slug"), home_microdao_id=agent.get("home_microdao_id"), home_microdao_name=agent.get("home_microdao_name"), home_microdao_slug=agent.get("home_microdao_slug"), district=agent.get("district"), microdaos=microdaos, microdao_memberships=agent.get("microdao_memberships", []), public_skills=agent.get("public_skills", []) )) return {"items": items, "total": total} except Exception as e: logger.error(f"Failed to list agents: {e}") raise HTTPException(status_code=500, detail="Failed to list agents") class AgentVisibilityPayload(BaseModel): """Agent visibility update payload (Task 039)""" is_public: bool public_title: Optional[str] = None public_tagline: Optional[str] = None public_slug: Optional[str] = None visibility_scope: Optional[str] = None # 'global' | 'microdao' | 'private' @router.put("/agents/{agent_id}/visibility") async def update_agent_visibility_endpoint( agent_id: str, payload: AgentVisibilityPayload ): """Оновити налаштування видимості агента (Task 039)""" try: # Validate visibility_scope if provided valid_scopes = ("global", "microdao", "private", "city", "owner_only") # support legacy too if payload.visibility_scope and payload.visibility_scope not in valid_scopes: raise HTTPException( status_code=400, detail=f"visibility_scope must be one of: {', '.join(valid_scopes)}" ) # Normalize legacy values scope = payload.visibility_scope if scope == "city": scope = "global" elif scope == "owner_only": scope = "private" # Validate: if is_public, slug is required if payload.is_public and not payload.public_slug: # If slug is missing but we have agent_id, maybe we can't generate it here safely without duplicate check? # The prompt says "якщо is_public = true → slug обовʼязковий" # But let's see if we can fetch existing slug if not provided? # For now, enforce requirement as per prompt raise HTTPException(status_code=400, detail="public_slug is required when is_public is true") # Validate slug format if provided if payload.public_slug: import re if not re.match(r'^[a-z0-9_-]+$', payload.public_slug.lower()): raise HTTPException(status_code=400, detail="public_slug must contain only lowercase letters, numbers, underscores, and hyphens") # Use unified update_agent_public_profile # We need to fetch existing values for fields not provided? # repo_city.update_agent_public_profile replaces values. # So we should probably fetch the agent first to preserve other fields if they are None in payload? # But the payload fields are optional. # Let's assume if they are passed as None, we might want to keep them or clear them? # Usually PUT replaces. PATCH updates. This is PUT. # But let's be safe and fetch current profile to merge if needed, or just update what we have. # Actually, update_agent_public_profile updates all passed fields. # Let's fetch current to ensure we don't wipe out existing data if payload sends None but we want to keep it. # However, the prompt implies these are the fields to update. current_profile = await repo_city.get_agent_public_profile(agent_id) if not current_profile: raise HTTPException(status_code=404, detail="Agent not found") result = await repo_city.update_agent_public_profile( agent_id=agent_id, is_public=payload.is_public, public_slug=payload.public_slug or current_profile.get("public_slug"), public_title=payload.public_title if payload.public_title is not None else current_profile.get("public_title"), public_tagline=payload.public_tagline if payload.public_tagline is not None else current_profile.get("public_tagline"), public_skills=current_profile.get("public_skills"), # Preserve skills public_district=current_profile.get("public_district"), # Preserve district public_primary_room_slug=current_profile.get("public_primary_room_slug") # Preserve room ) # Also update visibility_scope if provided if scope: await repo_city.update_agent_visibility( agent_id=agent_id, is_public=payload.is_public, visibility_scope=scope ) result["visibility_scope"] = scope return result except HTTPException: raise except Exception as e: logger.error(f"Failed to update agent visibility: {e}") raise HTTPException(status_code=500, detail="Failed to update visibility") # ============================================================================= # Assets & Branding API (Task 042) # ============================================================================= @router.post("/assets/upload") async def upload_asset( file: UploadFile = File(...), type: str = Form(...) # microdao_logo, microdao_banner, room_logo, room_banner ): """Upload asset (logo/banner) with auto-processing""" try: # Validate type if type not in ['microdao_logo', 'microdao_banner', 'room_logo', 'room_banner']: raise HTTPException(status_code=400, detail="Invalid asset type") # Validate file size (5MB limit) - done by reading content content = await file.read() if len(content) > 5 * 1024 * 1024: raise HTTPException(status_code=400, detail="File too large (max 5MB)") # Process image target_size = (256, 256) if 'banner' in type: target_size = (1200, 400) # Standard banner size processed_bytes, thumb_bytes = process_image(content, target_size=target_size) # Save to disk filename = f"{uuid.uuid4()}.png" filepath = f"static/uploads/{filename}" thumb_filepath = f"static/uploads/thumb_{filename}" with open(filepath, "wb") as f: f.write(processed_bytes) with open(thumb_filepath, "wb") as f: f.write(thumb_bytes) # Construct URLs base_url = "/static/uploads" return { "original_url": f"{base_url}/{filename}", "processed_url": f"{base_url}/{filename}", "thumb_url": f"{base_url}/thumb_{filename}" } except Exception as e: logger.error(f"Upload failed: {e}") raise HTTPException(status_code=500, detail="Upload failed") class BrandingUpdatePayload(BaseModel): logo_url: Optional[str] = None banner_url: Optional[str] = None @router.patch("/microdao/{slug}/branding") async def update_microdao_branding_endpoint(slug: str, payload: BrandingUpdatePayload): """Update MicroDAO branding""" try: # Check exists dao = await repo_city.get_microdao_by_slug(slug) if not dao: raise HTTPException(status_code=404, detail="MicroDAO not found") # Update result = await repo_city.update_microdao_branding( microdao_slug=slug, logo_url=payload.logo_url, banner_url=payload.banner_url ) return result except HTTPException: raise except Exception as e: logger.error(f"Failed to update branding: {e}") raise HTTPException(status_code=500, detail="Failed to update branding") @router.patch("/rooms/{room_id}/branding") async def update_room_branding_endpoint(room_id: str, payload: BrandingUpdatePayload): """Update Room branding""" try: # Check exists room = await repo_city.get_room_by_id(room_id) if not room: raise HTTPException(status_code=404, detail="Room not found") # Update result = await repo_city.update_room_branding( room_id=room_id, logo_url=payload.logo_url, banner_url=payload.banner_url ) return result except HTTPException: raise except Exception as e: logger.error(f"Failed to update room branding: {e}") raise HTTPException(status_code=500, detail="Failed to update room branding") # ============================================================================= # Nodes API (for Node Directory) # ============================================================================= @public_router.get("/nodes/join/instructions") async def get_node_join_instructions(): """ Отримати інструкції з підключення нової ноди. """ instructions = """ # Як підключити нову ноду до DAARION Вітаємо! Ви вирішили розширити обчислювальну потужність мережі DAARION. Цей гайд допоможе вам розгорнути власну ноду та підключити її до кластера. ## Вимоги до заліза (Мінімальні) - **CPU**: 4 cores - **RAM**: 16 GB (рекомендовано 32+ GB для LLM) - **Disk**: 100 GB SSD - **OS**: Ubuntu 22.04 LTS / Debian 11+ - **Network**: Статична IP адреса, відкриті порти ## Крок 1: Підготовка сервера Встановіть Docker та Docker Compose: ```bash curl -fsSL https://get.docker.com -o get-docker.sh sudo sh get-docker.sh ``` ## Крок 2: Отримання токенів Для підключення вам знадобляться: 1. **NATS Connection URL** (від адміністратора) 2. **NATS Credentials File** (`.creds`) (від адміністратора) Зверніться до адміністраторів мережі у [Discord/Matrix], щоб отримати доступ. ## Крок 3: Розгортання Node Runtime Створіть директорію `daarion-node` та файл `docker-compose.yml`: ```yaml version: '3.8' services: # 1. NATS Leaf Node (міст до ядра) nats-leaf: image: nats:2.10-alpine volumes: - ./nats.conf:/etc/nats/nats.conf - ./creds:/etc/nats/creds ports: - "4222:4222" # 2. Node Registry (реєстрація в мережі) node-registry: image: daarion/node-registry:latest environment: - NODE_ID=my-node-01 # Змініть на унікальне ім'я - NATS_URL=nats://nats-leaf:4222 - REGION=eu-central depends_on: - nats-leaf # 3. Ollama (AI Runtime) ollama: image: ollama/ollama:latest volumes: - ollama_data:/root/.ollama deploy: resources: reservations: devices: - driver: nvidia count: 1 capabilities: [gpu] volumes: ollama_data: ``` ## Крок 4: Запуск ```bash docker compose up -d ``` ## Крок 5: Перевірка Перейдіть у консоль **Nodes** на https://app.daarion.space/nodes. Ваша нода має з'явитися у списку зі статусом **Online**. """ return {"content": instructions} @public_router.get("/nodes") async def list_nodes(): """Список всіх нод мережі""" try: nodes = await repo_city.get_all_nodes() items: List[NodeProfile] = [] for node in nodes: items.append(NodeProfile( node_id=node["node_id"], name=node["name"], hostname=node.get("hostname"), roles=list(node.get("roles") or []), environment=node.get("environment", "unknown"), status=node.get("status", "offline"), gpu_info=node.get("gpu"), agents_total=node.get("agents_total", 0), agents_online=node.get("agents_online", 0), last_heartbeat=str(node["last_heartbeat"]) if node.get("last_heartbeat") else None )) return {"items": items, "total": len(items)} except Exception as e: logger.error(f"Failed to list nodes: {e}") raise HTTPException(status_code=500, detail="Failed to list nodes") @public_router.get("/nodes/{node_id}") async def get_node_profile(node_id: str): """Отримати профіль ноди з Guardian та Steward агентами""" try: node = await repo_city.get_node_by_id(node_id) if not node: raise HTTPException(status_code=404, detail="Node not found") # Build guardian agent summary guardian_agent = None if node.get("guardian_agent"): from models_city import NodeAgentSummary guardian_agent = NodeAgentSummary( id=node["guardian_agent"]["id"], name=node["guardian_agent"]["name"], kind=node["guardian_agent"].get("kind"), slug=node["guardian_agent"].get("slug") ) # Build steward agent summary steward_agent = None if node.get("steward_agent"): from models_city import NodeAgentSummary steward_agent = NodeAgentSummary( id=node["steward_agent"]["id"], name=node["steward_agent"]["name"], kind=node["steward_agent"].get("kind"), slug=node["steward_agent"].get("slug") ) return NodeProfile( node_id=node["node_id"], name=node["name"], hostname=node.get("hostname"), roles=list(node.get("roles") or []), environment=node.get("environment", "unknown"), status=node.get("status", "offline"), gpu_info=node.get("gpu"), agents_total=node.get("agents_total", 0), agents_online=node.get("agents_online", 0), last_heartbeat=str(node["last_heartbeat"]) if node.get("last_heartbeat") else None, guardian_agent_id=node.get("guardian_agent_id"), steward_agent_id=node.get("steward_agent_id"), guardian_agent=guardian_agent, steward_agent=steward_agent ) except HTTPException: raise except Exception as e: logger.error(f"Failed to get node {node_id}: {e}") raise HTTPException(status_code=500, detail="Failed to get node") # ============================================================================= # Public Citizens API # ============================================================================= @public_router.get("/citizens") async def list_public_citizens( district: Optional[str] = Query(None, description="Filter by district"), kind: Optional[str] = Query(None, description="Filter by agent kind"), q: Optional[str] = Query(None, description="Search by display name or title"), limit: int = Query(50, le=100), offset: int = Query(0, ge=0) ): """Публічний список громадян з фільтрами""" try: citizens, total = await repo_city.get_public_citizens( district=district, kind=kind, q=q, limit=limit, offset=offset ) items: List[PublicCitizenSummary] = [] for citizen in citizens: # Build home_node if available home_node_data = citizen.get("home_node") home_node = None if home_node_data: home_node = HomeNodeView( id=home_node_data.get("id"), name=home_node_data.get("name"), hostname=home_node_data.get("hostname"), roles=home_node_data.get("roles", []), environment=home_node_data.get("environment") ) items.append(PublicCitizenSummary( slug=citizen["public_slug"], display_name=citizen["display_name"], public_title=citizen.get("public_title"), public_tagline=citizen.get("public_tagline"), avatar_url=citizen.get("avatar_url"), kind=citizen.get("kind"), district=citizen.get("public_district"), primary_room_slug=citizen.get("public_primary_room_slug"), public_skills=citizen.get("public_skills", []), online_status=citizen.get("online_status"), status=citizen.get("status"), home_node=home_node )) return {"items": items, "total": total} except Exception as e: logger.error(f"Failed to list public citizens: {e}") raise HTTPException(status_code=500, detail="Failed to list public citizens") @public_router.get("/citizens/{slug}") async def get_public_citizen(slug: str, request: Request): """Отримати публічний профіль громадянина""" try: include_admin_url = False authorization = request.headers.get("Authorization") if authorization: user_info = await validate_jwt_token(authorization) if user_info: roles = user_info.get("roles", []) if any(role in ["admin", "architect"] for role in roles): include_admin_url = True citizen = await repo_city.get_public_citizen_by_slug(slug) if not citizen: raise HTTPException(status_code=404, detail=f"Citizen not found: {slug}") if not include_admin_url: citizen["admin_panel_url"] = None return PublicCitizenProfile(**citizen) except HTTPException: raise except Exception as e: logger.error(f"Failed to get public citizen {slug}: {e}") raise HTTPException(status_code=500, detail="Failed to get citizen") @public_router.get("/citizens/{slug}/interaction", response_model=CitizenInteractionInfo) async def get_citizen_interaction_info(slug: str): """Отримати інформацію для взаємодії з громадянином""" try: agent = await repo_city.get_public_agent_by_slug(slug) if not agent: raise HTTPException(status_code=404, detail=f"Citizen not found: {slug}") matrix_config = await repo_city.get_agent_matrix_config(agent["id"]) matrix_user_id = matrix_config.get("matrix_user_id") if matrix_config else None primary_room_slug = agent.get("public_primary_room_slug") or agent.get("primary_room_slug") primary_room_id = matrix_config.get("primary_room_id") if matrix_config else None primary_room_name = None room_record = None if primary_room_id: room_record = await repo_city.get_room_by_id(primary_room_id) elif primary_room_slug: room_record = await repo_city.get_room_by_slug(primary_room_slug) if room_record: primary_room_id = room_record.get("id") primary_room_name = room_record.get("name") primary_room_slug = room_record.get("slug") or primary_room_slug microdao = await repo_city.get_microdao_for_agent(agent["id"]) return CitizenInteractionInfo( slug=slug, display_name=agent["display_name"], primary_room_slug=primary_room_slug, primary_room_id=primary_room_id, primary_room_name=primary_room_name, matrix_user_id=matrix_user_id, district=agent.get("public_district"), microdao_slug=microdao.get("slug") if microdao else None, microdao_name=microdao.get("name") if microdao else None, ) except HTTPException: raise except Exception as e: logger.error(f"Failed to get interaction info for citizen {slug}: {e}") raise HTTPException(status_code=500, detail="Failed to load interaction info") @public_router.post("/citizens/{slug}/ask", response_model=CitizenAskResponse) async def ask_citizen( slug: str, payload: CitizenAskRequest, router_client: DagiRouterClient = Depends(get_dagi_router_client), ): """Надіслати запитання громадянину через DAGI Router""" question = (payload.question or "").strip() if not question: raise HTTPException(status_code=400, detail="Question is required") try: agent = await repo_city.get_public_agent_by_slug(slug) if not agent: raise HTTPException(status_code=404, detail=f"Citizen not found: {slug}") router_response = await router_client.ask_agent( agent_id=agent["id"], prompt=question, system_prompt=payload.context, ) answer = ( router_response.get("response") or router_response.get("answer") or router_response.get("result") ) if answer: answer = answer.strip() if not answer: answer = "Вибач, агент наразі не може відповісти." return CitizenAskResponse( answer=answer, agent_display_name=agent["display_name"], agent_id=agent["id"], ) except HTTPException: raise except httpx.HTTPError as e: logger.error(f"DAGI Router request failed for citizen {slug}: {e}") raise HTTPException(status_code=502, detail="Citizen is temporarily unavailable") except Exception as e: logger.error(f"Failed to ask citizen {slug}: {e}") raise HTTPException(status_code=500, detail="Failed to ask citizen") # ============================================================================= # API v1 — Node Dashboard # ============================================================================= @api_router.get("/nodes/{node_id}/dashboard") async def get_node_dashboard(node_id: str): """ Отримати мінімальний Dashboard для ноди (MVP). Повертає базову інформацію + placeholder для метрик: - node_id, name, kind, status - tags (roles) - agents_total, agents_online - uptime (null — placeholder) - metrics_available (false — placeholder) """ try: node = await repo_city.get_node_by_id(node_id) if not node: raise HTTPException(status_code=404, detail=f"Node not found: {node_id}") return { "node_id": node["node_id"], "name": node["name"], "kind": node.get("kind"), "status": node.get("status", "unknown"), "tags": list(node.get("roles") or []), "agents_total": node.get("agents_total", 0), "agents_online": node.get("agents_online", 0), "uptime": None, # placeholder — буде заповнено коли з'явиться Prometheus/NATS "metrics_available": False # прапорець, що розширеного дашборду ще немає } except HTTPException: raise except Exception as e: logger.error(f"Failed to get node dashboard for {node_id}: {e}") raise HTTPException(status_code=500, detail="Failed to get node dashboard") # Legacy endpoint for frontend compatibility @api_router.get("/node/dashboard") async def get_node_dashboard_legacy(node_id: str = Query(..., description="Node ID")): """ Legacy endpoint: /api/v1/node/dashboard?nodeId=... Redirects to canonical /api/v1/nodes/{node_id}/dashboard """ return await get_node_dashboard(node_id) # ============================================================================= # API v1 — MicroDAO Membership # ============================================================================= @api_router.get("/microdao/options") async def get_microdao_options(): """Отримати список MicroDAO для селектора""" try: options = await repo_city.get_microdao_options() items = [MicrodaoOption(**option) for option in options] return {"items": items} except Exception as e: logger.error(f"Failed to get microdao options: {e}") raise HTTPException(status_code=500, detail="Failed to get microdao options") @api_router.put("/agents/{agent_id}/microdao-membership") async def assign_agent_microdao_membership( agent_id: str, payload: MicrodaoMembershipPayload, authorization: Optional[str] = Header(None) ): """Призначити/оновити членство агента в MicroDAO""" await ensure_architect_or_admin(authorization) try: membership = await repo_city.upsert_agent_microdao_membership( agent_id=agent_id, microdao_id=payload.microdao_id, role=payload.role, is_core=payload.is_core ) if not membership: raise HTTPException(status_code=404, detail="MicroDAO not found") return membership except HTTPException: raise except Exception as e: logger.error(f"Failed to assign microdao membership: {e}") raise HTTPException(status_code=500, detail="Failed to assign microdao membership") @api_router.delete("/agents/{agent_id}/microdao-membership/{microdao_id}") async def delete_agent_microdao_membership( agent_id: str, microdao_id: str, authorization: Optional[str] = Header(None) ): """Видалити членство агента в MicroDAO""" await ensure_architect_or_admin(authorization) try: deleted = await repo_city.remove_agent_microdao_membership(agent_id, microdao_id) if not deleted: raise HTTPException(status_code=404, detail="Membership not found") return {"status": "deleted"} except HTTPException: raise except Exception as e: logger.error(f"Failed to delete microdao membership: {e}") raise HTTPException(status_code=500, detail="Failed to delete microdao membership") # ============================================================================= # City Rooms API # ============================================================================= @router.get("/rooms", response_model=List[CityRoomRead]) async def get_city_rooms(limit: int = 100, offset: int = 0): """ Отримати список всіх City Rooms """ try: rooms = await repo_city.get_all_rooms(limit=limit, offset=offset) # Додати online count (приблизно) online_count = await PresenceRedis.get_online_count() result = [] for room in rooms: result.append({ **room, "members_online": online_count if room.get("is_default") else max(1, online_count // 2), "last_event": None # TODO: з останнього повідомлення }) return result except Exception as e: logger.error(f"Failed to get city rooms: {e}") raise HTTPException(status_code=500, detail="Failed to get city rooms") @router.post("/rooms", response_model=CityRoomRead) async def create_city_room(payload: CityRoomCreate): """ Створити нову City Room (автоматично створює Matrix room) """ try: # TODO: витягнути user_id з JWT created_by = "u_system" # Mock для MVP # Перевірити чи не існує вже existing = await repo_city.get_room_by_slug(payload.slug) if existing: raise HTTPException(status_code=409, detail="Room with this slug already exists") # Створити Matrix room matrix_room_id, matrix_room_alias = await create_matrix_room( slug=payload.slug, name=payload.name, visibility="public" ) if not matrix_room_id: logger.warning(f"Failed to create Matrix room for {payload.slug}, proceeding without Matrix") room = await repo_city.create_room( slug=payload.slug, name=payload.name, description=payload.description, created_by=created_by, matrix_room_id=matrix_room_id, matrix_room_alias=matrix_room_alias ) # Додати початкове повідомлення await repo_city.create_room_message( room_id=room["id"], body=f"Кімната '{payload.name}' створена! Ласкаво просимо! 🎉", author_agent_id="ag_system" ) # Додати в feed await repo_city.create_feed_event( kind="system", room_id=room["id"], payload={"action": "room_created", "room_name": payload.name, "matrix_room_id": matrix_room_id} ) return {**room, "members_online": 1, "last_event": None} except HTTPException: raise except Exception as e: logger.error(f"Failed to create city room: {e}") raise HTTPException(status_code=500, detail="Failed to create city room") @router.get("/rooms/{room_id}", response_model=CityRoomDetail) async def get_city_room(room_id: str): """ Отримати деталі City Room з повідомленнями """ try: room = await repo_city.get_room_by_id(room_id) if not room: raise HTTPException(status_code=404, detail="Room not found") messages = await repo_city.get_room_messages(room_id, limit=50) # Додати username до повідомлень for msg in messages: if msg.get("author_user_id"): msg["username"] = f"User-{msg['author_user_id'][-4:]}" # Mock elif msg.get("author_agent_id"): msg["username"] = "System Agent" else: msg["username"] = "Anonymous" online_users = await PresenceRedis.get_all_online() return { **room, "members_online": len(online_users), "last_event": None, "messages": messages, "online_members": online_users[:20] # Перші 20 } except HTTPException: raise except Exception as e: logger.error(f"Failed to get city room: {e}") raise HTTPException(status_code=500, detail="Failed to get city room") @router.post("/rooms/{room_id}/messages", response_model=CityRoomMessageRead) async def send_city_room_message(room_id: str, payload: CityRoomMessageCreate): """ Надіслати повідомлення в City Room """ try: # Перевірити чи кімната існує room = await repo_city.get_room_by_id(room_id) if not room: raise HTTPException(status_code=404, detail="Room not found") # TODO: витягнути user_id з JWT author_user_id = "u_mock_user" # Mock для MVP # Створити повідомлення message = await repo_city.create_room_message( room_id=room_id, body=payload.body, author_user_id=author_user_id ) # Додати в feed await repo_city.create_feed_event( kind="room_message", room_id=room_id, user_id=author_user_id, payload={"body": payload.body[:100], "message_id": message["id"]} ) # TODO: Broadcast WS event # await ws_manager.broadcast_to_room(room_id, { # "event": "room.message", # "message": message # }) # Додати username message["username"] = f"User-{author_user_id[-4:]}" return message except HTTPException: raise except Exception as e: logger.error(f"Failed to send room message: {e}") raise HTTPException(status_code=500, detail="Failed to send message") @router.post("/rooms/{room_id}/join") async def join_city_room(room_id: str): """ Приєднатися до City Room (для tracking) """ # TODO: витягнути user_id з JWT user_id = "u_mock_user" # Для MVP просто повертаємо success # У production можна зберігати active memberships в Redis logger.info(f"User {user_id} joined room {room_id}") return {"status": "joined", "room_id": room_id} @router.post("/rooms/{room_id}/leave") async def leave_city_room(room_id: str): """ Покинути City Room """ # TODO: витягнути user_id з JWT user_id = "u_mock_user" logger.info(f"User {user_id} left room {room_id}") return {"status": "left", "room_id": room_id} # ============================================================================= # Matrix Backfill API (Internal) # ============================================================================= @router.post("/matrix/backfill") async def backfill_matrix_rooms(): """ Backfill Matrix rooms for existing City Rooms that don't have Matrix integration. This is an internal endpoint for admin use. """ try: rooms_without_matrix = await repo_city.get_rooms_without_matrix() results = { "processed": 0, "created": 0, "found": 0, "failed": 0, "details": [] } for room in rooms_without_matrix: results["processed"] += 1 slug = room["slug"] name = room["name"] room_id = room["id"] # Спочатку спробувати знайти існуючу Matrix room alias = f"#city_{slug}:daarion.space" matrix_room_id, matrix_room_alias = await find_matrix_room_by_alias(alias) if matrix_room_id: # Знайдено існуючу await repo_city.update_room_matrix(room_id, matrix_room_id, matrix_room_alias) results["found"] += 1 results["details"].append({ "room_id": room_id, "slug": slug, "status": "found", "matrix_room_id": matrix_room_id }) else: # Створити нову matrix_room_id, matrix_room_alias = await create_matrix_room(slug, name, "public") if matrix_room_id: await repo_city.update_room_matrix(room_id, matrix_room_id, matrix_room_alias) results["created"] += 1 results["details"].append({ "room_id": room_id, "slug": slug, "status": "created", "matrix_room_id": matrix_room_id }) else: results["failed"] += 1 results["details"].append({ "room_id": room_id, "slug": slug, "status": "failed", "error": "Could not create Matrix room" }) logger.info(f"Matrix backfill completed: {results['processed']} processed, " f"{results['created']} created, {results['found']} found, {results['failed']} failed") return results except Exception as e: logger.error(f"Matrix backfill failed: {e}") raise HTTPException(status_code=500, detail=f"Backfill failed: {str(e)}") # ============================================================================= # Chat Bootstrap API (Matrix Integration) # ============================================================================= async def validate_jwt_token(authorization: str) -> Optional[dict]: """Validate JWT token via auth-service introspect endpoint.""" if not authorization or not authorization.startswith("Bearer "): return None token = authorization.replace("Bearer ", "") async with httpx.AsyncClient(timeout=10.0) as client: try: resp = await client.post( f"{AUTH_SERVICE_URL}/api/auth/introspect", json={"token": token} ) if resp.status_code == 200: data = resp.json() if data.get("active"): return {"user_id": data.get("sub"), "email": data.get("email"), "roles": data.get("roles", [])} return None except Exception as e: logger.error(f"JWT validation error: {e}") return None async def ensure_architect_or_admin(authorization: Optional[str]) -> dict: """Переконатися, що користувач має роль architect/admin""" if not authorization: raise HTTPException(status_code=403, detail="Missing authorization token") user_info = await validate_jwt_token(authorization) if not user_info: raise HTTPException(status_code=403, detail="Invalid authorization token") roles = user_info.get("roles", []) if not any(role in ["admin", "architect"] for role in roles): raise HTTPException(status_code=403, detail="Insufficient permissions") return user_info @router.get("/chat/bootstrap") async def chat_bootstrap( room_slug: str = Query(..., description="City room slug"), authorization: Optional[str] = Header(None) ): """ Bootstrap Matrix chat for a city room. Returns Matrix credentials and room info for the authenticated user. """ # Validate JWT user_info = await validate_jwt_token(authorization) if not user_info: raise HTTPException(status_code=401, detail="Invalid or missing authorization token") user_id = user_info.get("user_id") if not user_id: raise HTTPException(status_code=401, detail="Invalid token: missing user_id") # Get room by slug room = await repo_city.get_room_by_slug(room_slug) if not room: raise HTTPException(status_code=404, detail=f"Room '{room_slug}' not found") # Check if room has Matrix integration matrix_room_id = room.get("matrix_room_id") matrix_room_alias = room.get("matrix_room_alias") if not matrix_room_id: raise HTTPException( status_code=400, detail="Room does not have Matrix integration. Run /city/matrix/backfill first." ) # Get Matrix user token from matrix-gateway async with httpx.AsyncClient(timeout=30.0) as client: try: token_resp = await client.post( f"{MATRIX_GATEWAY_URL}/internal/matrix/users/token", json={"user_id": user_id} ) if token_resp.status_code != 200: error = token_resp.json() logger.error(f"Failed to get Matrix token: {error}") raise HTTPException(status_code=500, detail="Failed to get Matrix credentials") matrix_creds = token_resp.json() except httpx.RequestError as e: logger.error(f"Matrix gateway request error: {e}") raise HTTPException(status_code=503, detail="Matrix service unavailable") # Return bootstrap data return { "matrix_hs_url": f"https://app.daarion.space", # Through nginx proxy "matrix_user_id": matrix_creds["matrix_user_id"], "matrix_access_token": matrix_creds["access_token"], "matrix_device_id": matrix_creds["device_id"], "matrix_room_id": matrix_room_id, "matrix_room_alias": matrix_room_alias, "room": { "id": room["id"], "slug": room["slug"], "name": room["name"], "description": room.get("description") } } # ============================================================================= # Chat Room API (TASK_PHASE_AGENT_CHAT_WIDGET_MVP) # ============================================================================= @api_router.get("/agents/{agent_id}/chat-room") async def get_agent_chat_room(agent_id: str): """ Отримати інформацію про кімнату чату для агента. Повертає room_id, agent info для ініціалізації чату. """ try: agent = await repo_city.get_agent_by_id(agent_id) if not agent: raise HTTPException(status_code=404, detail=f"Agent not found: {agent_id}") # Get agent's primary room or create room slug room_slug = f"agent-console-{agent.get('public_slug') or agent_id}" room = await repo_city.get_room_by_slug(room_slug) # If room doesn't exist, try to get agent's primary room from dashboard if not room: rooms = await repo_city.get_agent_rooms(agent_id) if rooms and len(rooms) > 0: room = rooms[0] room_slug = room.get("slug", room_slug) return { "agent_id": agent_id, "agent_display_name": agent.get("display_name"), "agent_avatar_url": agent.get("avatar_url"), "agent_status": agent.get("status", "offline"), "agent_kind": agent.get("kind"), "room_slug": room_slug, "room_id": room.get("id") if room else None, "matrix_room_id": room.get("matrix_room_id") if room else None, "chat_available": room is not None and room.get("matrix_room_id") is not None } except HTTPException: raise except Exception as e: logger.error(f"Failed to get agent chat room for {agent_id}: {e}") raise HTTPException(status_code=500, detail="Failed to get agent chat room") @api_router.get("/nodes/{node_id}/chat-room") async def get_node_chat_room(node_id: str): """ Отримати інформацію про кімнату чату для ноди. Повертає room_id, guardian/steward agents info. """ try: node = await repo_city.get_node_by_id(node_id) if not node: raise HTTPException(status_code=404, detail=f"Node not found: {node_id}") # Get node support room node_slug = node_id.replace("node-", "").replace("-", "_") room_slug = f"node-support-{node_slug}" room = await repo_city.get_room_by_slug(room_slug) # Get guardian and steward agents guardian_agent = None steward_agent = None if node.get("guardian_agent"): guardian_agent = { "id": node["guardian_agent"].get("id"), "display_name": node["guardian_agent"].get("name"), "kind": node["guardian_agent"].get("kind"), "role": "guardian" } if node.get("steward_agent"): steward_agent = { "id": node["steward_agent"].get("id"), "display_name": node["steward_agent"].get("name"), "kind": node["steward_agent"].get("kind"), "role": "steward" } return { "node_id": node_id, "node_name": node.get("name"), "node_status": node.get("status", "offline"), "room_slug": room_slug, "room_id": room.get("id") if room else None, "matrix_room_id": room.get("matrix_room_id") if room else None, "chat_available": room is not None and room.get("matrix_room_id") is not None, "agents": [a for a in [guardian_agent, steward_agent] if a is not None] } except HTTPException: raise except Exception as e: logger.error(f"Failed to get node chat room for {node_id}: {e}") raise HTTPException(status_code=500, detail="Failed to get node chat room") @api_router.get("/microdaos/{slug}/chat-room") async def get_microdao_chat_room(slug: str): """ Отримати інформацію про кімнату чату для MicroDAO. Повертає room_id, orchestrator agent info. """ try: dao = await repo_city.get_microdao_by_slug(slug) if not dao: raise HTTPException(status_code=404, detail=f"MicroDAO not found: {slug}") # Get MicroDAO lobby room room_slug = f"microdao-lobby-{slug}" room = await repo_city.get_room_by_slug(room_slug) # If no lobby room, try to get primary room if not room: rooms = await repo_city.get_microdao_rooms(dao["id"]) if rooms and len(rooms) > 0: # Find primary room or first room primary = next((r for r in rooms if r.get("room_role") == "primary"), rooms[0]) room = primary room_slug = room.get("slug", room_slug) # Get orchestrator agent orchestrator = None orchestrator_id = dao.get("orchestrator_agent_id") if orchestrator_id: orch_agent = await repo_city.get_agent_by_id(orchestrator_id) if orch_agent: orchestrator = { "id": orchestrator_id, "display_name": orch_agent.get("display_name"), "avatar_url": orch_agent.get("avatar_url"), "kind": orch_agent.get("kind"), "role": "orchestrator" } return { "microdao_id": dao["id"], "microdao_slug": slug, "microdao_name": dao.get("name"), "room_slug": room_slug, "room_id": room.get("id") if room else None, "matrix_room_id": room.get("matrix_room_id") if room else None, "chat_available": room is not None and room.get("matrix_room_id") is not None, "orchestrator": orchestrator } except HTTPException: raise except Exception as e: logger.error(f"Failed to get microdao chat room for {slug}: {e}") raise HTTPException(status_code=500, detail="Failed to get microdao chat room") # ============================================================================= # Presence API (TASK_PHASE_AGENT_PRESENCE_INDICATORS_MVP) # ============================================================================= @api_router.get("/agents/presence") async def get_agents_presence(): """ Отримати presence статус всіх активних агентів. Повертає Matrix presence + DAGI router health. """ try: # Get all agents from DB agents = await repo_city.list_agents_summaries(limit=1000) # Get Matrix presence from matrix-presence-aggregator matrix_presence = await get_matrix_presence_status() # Get DAGI router health (simplified for MVP) dagi_health = await get_dagi_router_health() # Combine presence data presence_data = [] for agent in agents: agent_id = agent["id"] node_id = agent.get("node_id") # Matrix presence matrix_status = matrix_presence.get(agent_id, {}).get("status", "offline") last_seen = matrix_presence.get(agent_id, {}).get("last_seen") # DAGI router presence (node-level) dagi_status = "unknown" if node_id and node_id in dagi_health: dagi_status = dagi_health[node_id].get("router_status", "unknown") presence_data.append({ "agent_id": agent_id, "display_name": agent.get("display_name", agent_id), "matrix_presence": matrix_status, "dagi_router_presence": dagi_status, "last_seen": last_seen, "node_id": node_id }) return {"presence": presence_data} except Exception as e: logger.error(f"Failed to get agents presence: {e}") raise HTTPException(status_code=500, detail="Failed to get agents presence") async def get_matrix_presence_status(): """ Get Matrix presence from matrix-presence-aggregator. Returns dict: agent_id -> {status: 'online'|'unavailable'|'offline', last_seen: timestamp} """ try: async with httpx.AsyncClient(timeout=5.0) as client: response = await client.get("http://matrix-presence-aggregator:8080/api/presence/agents") if response.status_code == 200: data = response.json() return data.get("agents", {}) else: logger.warning(f"Matrix presence aggregator returned {response.status_code}") return {} except Exception as e: logger.warning(f"Failed to get Matrix presence: {e}") return {} async def get_dagi_router_health(): """ Get DAGI router health from node-registry or direct ping. Returns dict: node_id -> {router_status: 'healthy'|'degraded'|'offline'} """ try: # Try to get from node-registry first async with httpx.AsyncClient(timeout=5.0) as client: response = await client.get("http://dagi-node-registry:9205/api/v1/health") if response.status_code == 200: data = response.json() return data.get("nodes", {}) else: logger.warning(f"Node registry returned {response.status_code}") except Exception as e: logger.warning(f"Failed to get DAGI router health from node-registry: {e}") # Fallback: try direct ping to known nodes try: known_nodes = ["node-1-hetzner-gex44", "node-2-macbook-m4max"] health_data = {} for node_id in known_nodes: try: async with httpx.AsyncClient(timeout=2.0) as client: response = await client.get(f"http://dagi-router-{node_id}:8080/health") if response.status_code == 200: health_data[node_id] = {"router_status": "healthy"} else: health_data[node_id] = {"router_status": "degraded"} except Exception: health_data[node_id] = {"router_status": "offline"} return health_data except Exception as e: logger.warning(f"Failed to get DAGI router health: {e}") return {} # ============================================================================= # City Feed API # ============================================================================= @router.get("/feed", response_model=List[CityFeedEventRead]) async def get_city_feed(limit: int = 20, offset: int = 0): """ Отримати City Feed (останні події) """ try: events = await repo_city.get_feed_events(limit=limit, offset=offset) return events except Exception as e: logger.error(f"Failed to get city feed: {e}") raise HTTPException(status_code=500, detail="Failed to get city feed") # ============================================================================= # City Map API (2D Map) # ============================================================================= @router.get("/map", response_model=CityMapResponse) async def get_city_map(): """ Отримати дані для 2D мапи міста. Повертає: - config: розміри сітки та налаштування - rooms: список кімнат з координатами """ try: # Отримати конфігурацію config_data = await repo_city.get_map_config() config = CityMapConfig( grid_width=config_data.get("grid_width", 6), grid_height=config_data.get("grid_height", 3), cell_size=config_data.get("cell_size", 100), background_url=config_data.get("background_url") ) # Отримати кімнати з координатами rooms_data = await repo_city.get_rooms_for_map() rooms = [] for room in rooms_data: rooms.append(CityMapRoom( id=room["id"], slug=room["slug"], name=room["name"], description=room.get("description"), room_type=room.get("room_type", "public"), zone=room.get("zone", "central"), icon=room.get("icon"), color=room.get("color"), x=room.get("map_x", 0), y=room.get("map_y", 0), w=room.get("map_w", 1), h=room.get("map_h", 1), matrix_room_id=room.get("matrix_room_id") )) return CityMapResponse(config=config, rooms=rooms) except Exception as e: logger.error(f"Failed to get city map: {e}") raise HTTPException(status_code=500, detail="Failed to get city map") # ============================================================================= # Agents API # ============================================================================= @router.put("/agents/{agent_id}/public-profile") async def update_agent_public_profile(agent_id: str, request: Request): """ Оновити публічний профіль агента. Тільки для Architect/Admin. """ try: # Check agent exists agent = await repo_city.get_agent_by_id(agent_id) if not agent: raise HTTPException(status_code=404, detail=f"Agent not found: {agent_id}") # Parse body body = await request.json() is_public = body.get("is_public", False) public_slug = body.get("public_slug") public_title = body.get("public_title") public_tagline = body.get("public_tagline") public_skills = body.get("public_skills", []) public_district = body.get("public_district") public_primary_room_slug = body.get("public_primary_room_slug") # Validate: if is_public, slug is required if is_public and not public_slug: raise HTTPException(status_code=400, detail="public_slug is required when is_public is true") # Validate slug format if public_slug: import re if not re.match(r'^[a-z0-9_-]+$', public_slug.lower()): raise HTTPException(status_code=400, detail="public_slug must contain only lowercase letters, numbers, underscores, and hyphens") # Validate skills (max 10, max 64 chars each) if public_skills: public_skills = [s[:64] for s in public_skills[:10]] # Update result = await repo_city.update_agent_public_profile( agent_id=agent_id, is_public=is_public, public_slug=public_slug, public_title=public_title, public_tagline=public_tagline, public_skills=public_skills, public_district=public_district, public_primary_room_slug=public_primary_room_slug ) logger.info(f"Updated public profile for agent {agent_id}: is_public={is_public}, slug={public_slug}") return result except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except HTTPException: raise except Exception as e: logger.error(f"Failed to update agent public profile: {e}") import traceback traceback.print_exc() raise HTTPException(status_code=500, detail="Failed to update agent public profile") @router.get("/citizens") async def get_public_citizens_legacy(limit: int = 50, offset: int = 0): """ Отримати список публічних громадян DAARION City. """ try: citizens, total = await repo_city.get_public_citizens(limit=limit, offset=offset) return {"citizens": citizens, "total": total} except Exception as e: logger.error(f"Failed to get public citizens: {e}") raise HTTPException(status_code=500, detail="Failed to get public citizens") @router.get("/citizens/{slug}") async def get_citizen_by_slug(slug: str, request: Request): """ Отримати публічного громадянина за slug. Для адмінів/архітекторів додається admin_panel_url. """ try: include_admin_url = True # legacy endpoint доступний тільки з адмінської панелі citizen = await repo_city.get_public_citizen_by_slug(slug) if not citizen: raise HTTPException(status_code=404, detail=f"Citizen not found: {slug}") if not include_admin_url: citizen["admin_panel_url"] = None return citizen except HTTPException: raise except Exception as e: logger.error(f"Failed to get citizen: {e}") raise HTTPException(status_code=500, detail="Failed to get citizen") @router.put("/agents/{agent_id}/prompts/{kind}") async def update_agent_prompt(agent_id: str, kind: str, request: Request): """ Оновити системний промт агента. Тільки для Architect/Admin. kind: core | safety | governance | tools """ try: # Validate kind valid_kinds = ["core", "safety", "governance", "tools"] if kind not in valid_kinds: raise HTTPException(status_code=400, detail=f"Invalid kind. Must be one of: {valid_kinds}") # Check agent exists agent = await repo_city.get_agent_by_id(agent_id) if not agent: raise HTTPException(status_code=404, detail=f"Agent not found: {agent_id}") # Parse body body = await request.json() content = body.get("content") note = body.get("note") if not content or not content.strip(): raise HTTPException(status_code=400, detail="Content is required") # TODO: Get user from JWT and check permissions # For now, use a placeholder created_by = "ARCHITECT" # Will be replaced with actual user from auth # Update prompt result = await repo_city.update_agent_prompt( agent_id=agent_id, kind=kind, content=content.strip(), created_by=created_by, note=note ) logger.info(f"Updated {kind} prompt for agent {agent_id} to version {result['version']}") return result except HTTPException: raise except Exception as e: logger.error(f"Failed to update agent prompt: {e}") import traceback traceback.print_exc() raise HTTPException(status_code=500, detail="Failed to update agent prompt") @router.get("/agents/{agent_id}/prompts/{kind}/history") async def get_agent_prompt_history(agent_id: str, kind: str, limit: int = 10): """ Отримати історію версій промту агента. """ try: valid_kinds = ["core", "safety", "governance", "tools"] if kind not in valid_kinds: raise HTTPException(status_code=400, detail=f"Invalid kind. Must be one of: {valid_kinds}") history = await repo_city.get_agent_prompt_history(agent_id, kind, limit) return {"agent_id": agent_id, "kind": kind, "history": history} except Exception as e: logger.error(f"Failed to get prompt history: {e}") raise HTTPException(status_code=500, detail="Failed to get prompt history") @router.get("/agents/{agent_id}/dashboard") async def get_agent_dashboard(agent_id: str): """ Отримати повний dashboard агента (DAIS Profile + Node + Metrics) """ try: # Get agent profile agent = await repo_city.get_agent_by_id(agent_id) if not agent: raise HTTPException(status_code=404, detail=f"Agent not found: {agent_id}") # Get agent's rooms rooms = await repo_city.get_agent_rooms(agent_id) # Build DAIS profile profile = { "agent_id": agent["id"], "display_name": agent["display_name"], "kind": agent.get("kind", "assistant"), "model": agent.get("model"), "avatar_url": agent.get("avatar_url"), "status": agent.get("status", "offline"), "node_id": agent.get("node_id"), "is_public": agent.get("is_public", False), "public_slug": agent.get("public_slug"), "is_orchestrator": agent.get("is_orchestrator", False), "primary_microdao_id": agent.get("primary_microdao_id"), "primary_microdao_name": agent.get("primary_microdao_name"), "primary_microdao_slug": agent.get("primary_microdao_slug"), "crew_info": agent.get("crew_info"), "roles": [agent.get("role")] if agent.get("role") else [], "tags": [], "dais": { "core": { "title": agent.get("display_name"), "bio": f"{agent.get('kind', 'assistant').title()} agent in DAARION", "version": "1.0.0" }, "vis": { "avatar_url": agent.get("avatar_url"), "color_primary": agent.get("color", "#22D3EE") }, "cog": { "base_model": agent.get("model", "default"), "provider": "ollama", "node_id": agent.get("node_id") }, "act": { "tools": agent.get("capabilities", []) } }, "city_presence": { "primary_room_slug": agent.get("primary_room_slug"), "district": agent.get("home_district"), "rooms": rooms } } # Get node info (detailed) node_info = None if agent.get("node_id"): node_data = await repo_city.get_node_by_id(agent["node_id"]) if node_data: node_info = { "node_id": node_data["node_id"], "name": node_data["name"], "hostname": node_data.get("hostname"), "roles": node_data.get("roles", []), "environment": node_data.get("environment"), "status": node_data.get("status", "offline"), "guardian_agent": node_data.get("guardian_agent"), "steward_agent": node_data.get("steward_agent") } else: node_info = { "node_id": agent["node_id"], "status": "unknown", "name": "Unknown Node" } # Get system prompts system_prompts = await repo_city.get_agent_prompts(agent_id) # Get public profile public_profile = await repo_city.get_agent_public_profile(agent_id) # MicroDAO memberships memberships_raw = await repo_city.get_agent_microdao_memberships(agent_id) memberships = [ AgentMicrodaoMembership( microdao_id=item["microdao_id"], microdao_slug=item.get("microdao_slug"), microdao_name=item.get("microdao_name"), logo_url=item.get("logo_url"), role=item.get("role"), is_core=item.get("is_core", False) ) for item in memberships_raw ] # Get primary city room for agent primary_city_room = None # Priority 1: agent's primary room from city_rooms if rooms and len(rooms) > 0: primary_room = rooms[0] # First room as primary primary_city_room = { "id": primary_room.get("id"), "slug": primary_room.get("slug"), "name": primary_room.get("name"), "matrix_room_id": primary_room.get("matrix_room_id") } # Priority 2: Get from primary MicroDAO's main room elif agent.get("primary_microdao_id"): microdao_room = await repo_city.get_microdao_primary_room(agent["primary_microdao_id"]) if microdao_room: primary_city_room = microdao_room # Build dashboard response dashboard = { "profile": profile, "node": node_info, "primary_city_room": primary_city_room, "runtime": { "health": "healthy" if agent.get("status") == "online" else "unknown", "last_success_at": None, "last_error_at": None }, "metrics": { "tasks_1h": 0, "tasks_24h": 0, "errors_24h": 0, "avg_latency_ms_1h": 0, "success_rate_24h": 1.0 }, "recent_activity": [], "system_prompts": system_prompts, "public_profile": public_profile, "microdao_memberships": memberships } return dashboard except HTTPException: raise except Exception as e: logger.error(f"Failed to get agent dashboard: {e}") import traceback traceback.print_exc() raise HTTPException(status_code=500, detail="Failed to get agent dashboard") @router.get("/agents", response_model=List[AgentRead]) async def get_agents(): """ Отримати список всіх агентів """ try: agents = await repo_city.get_all_agents() result = [] for agent in agents: capabilities = agent.get("capabilities", []) if isinstance(capabilities, str): import json capabilities = json.loads(capabilities) result.append(AgentRead( id=agent["id"], display_name=agent["display_name"], kind=agent.get("kind", "assistant"), avatar_url=agent.get("avatar_url"), color=agent.get("color", "cyan"), status=agent.get("status", "offline"), current_room_id=agent.get("current_room_id"), capabilities=capabilities )) return result except Exception as e: logger.error(f"Failed to get agents: {e}") raise HTTPException(status_code=500, detail="Failed to get agents") @router.get("/agents/online", response_model=List[AgentPresence]) async def get_online_agents(): """ Отримати список онлайн агентів (для presence) """ try: agents = await repo_city.get_online_agents() result = [] for agent in agents: result.append(AgentPresence( agent_id=agent["id"], display_name=agent["display_name"], kind=agent.get("kind", "assistant"), status=agent.get("status", "offline"), room_id=agent.get("current_room_id"), color=agent.get("color", "cyan") )) return result except Exception as e: logger.error(f"Failed to get online agents: {e}") raise HTTPException(status_code=500, detail="Failed to get online agents") @router.get("/rooms/{room_id}/agents", response_model=List[AgentPresence]) async def get_room_agents(room_id: str): """ Отримати агентів у конкретній кімнаті """ try: agents = await repo_city.get_agents_by_room(room_id) result = [] for agent in agents: result.append(AgentPresence( agent_id=agent["id"], display_name=agent["display_name"], kind=agent.get("kind", "assistant"), status=agent.get("status", "offline"), room_id=room_id, color=agent.get("color", "cyan"), node_id=agent.get("node_id"), model=agent.get("model"), role=agent.get("role") )) return result except Exception as e: logger.error(f"Failed to get room agents: {e}") raise HTTPException(status_code=500, detail="Failed to get room agents") @router.get("/agents/presence-snapshot") async def get_agents_presence_snapshot(): """ Отримати snapshot всіх агентів для presence (50 агентів по 10 districts) """ try: snapshot = await repo_city.get_agents_presence_snapshot() return snapshot except Exception as e: logger.error(f"Failed to get agents presence snapshot: {e}") raise HTTPException(status_code=500, detail="Failed to get agents presence snapshot") # ============================================================================= # MicroDAO API # ============================================================================= @router.get("/microdao", response_model=List[MicrodaoSummary]) async def get_microdaos( district: Optional[str] = Query(None, description="Filter by district"), is_public: Optional[bool] = Query(True, description="Filter by public status (default: True)"), is_platform: Optional[bool] = Query(None, description="Filter by platform status"), q: Optional[str] = Query(None, description="Search by name/description"), include_all: bool = Query(False, description="Include non-public (admin only)"), limit: int = Query(50, le=100), offset: int = Query(0, ge=0) ): """ Отримати список MicroDAOs. - **district**: фільтр по дістрікту (Core, Energy, Green, Labs, etc.) - **is_public**: фільтр по публічності (за замовчуванням True) - **is_platform**: фільтр по типу (платформа/дістрікт) - **q**: пошук по назві або опису - **include_all**: включити всі (для адмінів) """ try: # If include_all is True (admin mode), don't filter by is_public public_filter = None if include_all else is_public daos = await repo_city.list_microdao_summaries( district=district, is_public=public_filter, is_platform=is_platform, q=q, limit=limit, offset=offset ) result = [] for dao in daos: result.append(MicrodaoSummary( id=dao["id"], slug=dao["slug"], name=dao["name"], description=dao.get("description"), district=dao.get("district"), is_public=dao.get("is_public", True), is_platform=dao.get("is_platform", False), is_active=dao.get("is_active", True), orchestrator_agent_id=dao.get("orchestrator_agent_id"), orchestrator_agent_name=dao.get("orchestrator_agent_name"), parent_microdao_id=dao.get("parent_microdao_id"), parent_microdao_slug=dao.get("parent_microdao_slug"), logo_url=dao.get("logo_url"), member_count=dao.get("member_count", 0), agents_count=dao.get("agents_count", 0), room_count=dao.get("room_count", 0), rooms_count=dao.get("rooms_count", 0), channels_count=dao.get("channels_count", 0) )) return result except Exception as e: logger.error(f"Failed to get microdaos: {e}") import traceback traceback.print_exc() raise HTTPException(status_code=500, detail="Failed to get microdaos") @router.get("/microdao/{slug}", response_model=MicrodaoDetail) async def get_microdao_by_slug(slug: str): """ Отримати детальну інформацію про MicroDAO. Включає: - Базову інформацію про DAO - Список агентів (з ролями) - Список каналів (Telegram, Matrix, City rooms, CrewAI) """ try: dao = await repo_city.get_microdao_by_slug(slug) if not dao: raise HTTPException(status_code=404, detail=f"MicroDAO not found: {slug}") # Build agents list agents = [] for agent in dao.get("agents", []): agents.append(MicrodaoAgentView( agent_id=agent["agent_id"], display_name=agent.get("display_name", agent["agent_id"]), role=agent.get("role"), is_core=agent.get("is_core", False) )) # Build channels list channels = [] for channel in dao.get("channels", []): channels.append(MicrodaoChannelView( kind=channel["kind"], ref_id=channel["ref_id"], display_name=channel.get("display_name"), is_primary=channel.get("is_primary", False) )) public_citizens = [] for citizen in dao.get("public_citizens", []): public_citizens.append(MicrodaoCitizenView( slug=citizen["slug"], display_name=citizen["display_name"], public_title=citizen.get("public_title"), public_tagline=citizen.get("public_tagline"), avatar_url=citizen.get("avatar_url"), district=citizen.get("public_district"), primary_room_slug=citizen.get("public_primary_room_slug") )) # Build child microDAOs list child_microdaos = [] for child in dao.get("child_microdaos", []): child_microdaos.append(MicrodaoSummary( id=child["id"], slug=child["slug"], name=child["name"], is_public=child.get("is_public", True), is_platform=child.get("is_platform", False) )) # Get all rooms for MicroDAO (multi-room support) all_rooms = await repo_city.get_microdao_rooms(dao["id"]) rooms_list = [ CityRoomSummary( id=room["id"], slug=room["slug"], name=room["name"], matrix_room_id=room.get("matrix_room_id"), microdao_id=room.get("microdao_id"), microdao_slug=room.get("microdao_slug"), room_role=room.get("room_role"), is_public=room.get("is_public", True), sort_order=room.get("sort_order", 100) ) for room in all_rooms ] # Get primary city room (first room with role='primary' or first by sort_order) primary_room_summary = None if rooms_list: primary = next((r for r in rooms_list if r.room_role == 'primary'), rooms_list[0]) primary_room_summary = primary return MicrodaoDetail( id=dao["id"], slug=dao["slug"], name=dao["name"], description=dao.get("description"), district=dao.get("district"), is_public=dao.get("is_public", True), is_platform=dao.get("is_platform", False), is_active=dao.get("is_active", True), orchestrator_agent_id=dao.get("orchestrator_agent_id"), orchestrator_display_name=dao.get("orchestrator_display_name"), parent_microdao_id=dao.get("parent_microdao_id"), parent_microdao_slug=dao.get("parent_microdao_slug"), child_microdaos=child_microdaos, logo_url=dao.get("logo_url"), agents=agents, channels=channels, public_citizens=public_citizens, primary_city_room=primary_room_summary, rooms=rooms_list ) except HTTPException: raise except Exception as e: logger.error(f"Failed to get microdao {slug}: {e}") import traceback traceback.print_exc() raise HTTPException(status_code=500, detail="Failed to get microdao") # ============================================================================= # MicroDAO Multi-Room API (Task 034) # ============================================================================= @router.get("/microdao/{slug}/rooms", response_model=MicrodaoRoomsList) async def get_microdao_rooms_endpoint(slug: str): """ Отримати всі кімнати MicroDAO (Task 034). Повертає список кімнат, впорядкованих за sort_order. """ try: result = await repo_city.get_microdao_rooms_by_slug(slug) if not result: raise HTTPException(status_code=404, detail=f"MicroDAO not found: {slug}") rooms = [ CityRoomSummary( id=room["id"], slug=room["slug"], name=room["name"], matrix_room_id=room.get("matrix_room_id"), microdao_id=room.get("microdao_id"), microdao_slug=room.get("microdao_slug"), room_role=room.get("room_role"), is_public=room.get("is_public", True), sort_order=room.get("sort_order", 100) ) for room in result["rooms"] ] return MicrodaoRoomsList( microdao_id=result["microdao_id"], microdao_slug=result["microdao_slug"], rooms=rooms ) except HTTPException: raise except Exception as e: logger.error(f"Failed to get microdao rooms for {slug}: {e}") raise HTTPException(status_code=500, detail="Failed to get microdao rooms") @router.post("/microdao/{slug}/rooms/attach-existing", response_model=CityRoomSummary) async def attach_existing_room_endpoint( slug: str, payload: AttachExistingRoomRequest ): """ Прив'язати існуючу кімнату до MicroDAO (Task 036). Потребує прав адміністратора або оркестратора MicroDAO. """ try: # Get microdao by slug dao = await repo_city.get_microdao_by_slug(slug) if not dao: raise HTTPException(status_code=404, detail=f"MicroDAO not found: {slug}") # TODO: Add authorization check (assert_can_manage_microdao) result = await repo_city.attach_room_to_microdao( microdao_id=dao["id"], room_id=payload.room_id, room_role=payload.room_role, is_public=payload.is_public, sort_order=payload.sort_order ) if not result: raise HTTPException(status_code=404, detail="Room not found") return CityRoomSummary( id=result["id"], slug=result["slug"], name=result["name"], matrix_room_id=result.get("matrix_room_id"), microdao_id=result.get("microdao_id"), room_role=result.get("room_role"), is_public=result.get("is_public", True), sort_order=result.get("sort_order", 100) ) except HTTPException: raise except Exception as e: logger.error(f"Failed to attach room to microdao {slug}: {e}") raise HTTPException(status_code=500, detail="Failed to attach room") @router.patch("/microdao/{slug}/rooms/{room_id}", response_model=CityRoomSummary) async def update_microdao_room_endpoint( slug: str, room_id: str, payload: MicrodaoRoomUpdate ): """ Оновити налаштування кімнати MicroDAO (Task 036). Потребує прав адміністратора або оркестратора MicroDAO. """ try: # Get microdao by slug dao = await repo_city.get_microdao_by_slug(slug) if not dao: raise HTTPException(status_code=404, detail=f"MicroDAO not found: {slug}") # TODO: Add authorization check (assert_can_manage_microdao) result = await repo_city.update_microdao_room( microdao_id=dao["id"], room_id=room_id, room_role=payload.room_role, is_public=payload.is_public, sort_order=payload.sort_order, set_primary=payload.set_primary or False ) if not result: raise HTTPException(status_code=404, detail="Room not found or not attached to this MicroDAO") return CityRoomSummary( id=result["id"], slug=result["slug"], name=result["name"], matrix_room_id=result.get("matrix_room_id"), microdao_id=result.get("microdao_id"), room_role=result.get("room_role"), is_public=result.get("is_public", True), sort_order=result.get("sort_order", 100) ) except HTTPException: raise except Exception as e: logger.error(f"Failed to update room {room_id} for microdao {slug}: {e}") raise HTTPException(status_code=500, detail="Failed to update room") # ============================================================================= # MicroDAO Visibility & Creation (Task 029) # ============================================================================= class MicrodaoVisibilityPayload(BaseModel): """MicroDAO visibility update payload""" is_public: bool is_platform: Optional[bool] = None @router.put("/microdao/{microdao_id}/visibility") async def update_microdao_visibility_endpoint( microdao_id: str, payload: MicrodaoVisibilityPayload ): """Оновити налаштування видимості MicroDAO (Task 029)""" try: result = await repo_city.update_microdao_visibility( microdao_id=microdao_id, is_public=payload.is_public, is_platform=payload.is_platform, ) if not result: raise HTTPException(status_code=404, detail="MicroDAO not found") return { "status": "ok", "microdao_id": result.get("id"), "slug": result.get("slug"), "is_public": result.get("is_public"), "is_platform": result.get("is_platform"), } except HTTPException: raise except Exception as e: logger.error(f"Failed to update microdao visibility: {e}") raise HTTPException(status_code=500, detail="Failed to update visibility") class MicrodaoCreatePayload(BaseModel): """Create MicroDAO from agent (orchestrator flow)""" name: str slug: str description: Optional[str] = None make_platform: bool = False is_public: bool = True parent_microdao_id: Optional[str] = None create_rooms: Optional[dict] = None # {"primary_lobby": bool, "governance": bool, "crew_team": bool} @router.post("/agents/{agent_id}/microdao", response_model=dict) async def create_microdao_for_agent_endpoint( agent_id: str, payload: MicrodaoCreatePayload ): """ Створити MicroDAO для агента (зробити його оркестратором). Цей endpoint: 1. Створює новий MicroDAO 2. Призначає агента оркестратором 3. Додає агента як члена DAO 4. Встановлює primary_microdao_id якщо порожній 5. Опціонально створює кімнати (primary/governance/crew) """ try: # Check if agent exists and is not archived agent = await repo_city.get_agent_by_id(agent_id) if not agent: raise HTTPException(status_code=404, detail="Agent not found") # Check if slug is unique existing = await repo_city.get_microdao_by_slug(payload.slug) if existing: raise HTTPException(status_code=400, detail=f"MicroDAO with slug '{payload.slug}' already exists") # Create MicroDAO result = await repo_city.create_microdao_for_agent( orchestrator_agent_id=agent_id, name=payload.name, slug=payload.slug, description=payload.description, make_platform=payload.make_platform, is_public=payload.is_public, parent_microdao_id=payload.parent_microdao_id, ) if not result: raise HTTPException(status_code=500, detail="Failed to create MicroDAO") microdao_id = result["id"] created_rooms = [] # Create Rooms if requested if payload.create_rooms: # Helper to create room async def create_room_helper(room_slug: str, name: str, role: str, is_public: bool = True): # Create room matrix_room_id, matrix_room_alias = await create_matrix_room( slug=room_slug, name=name, visibility="public" if is_public else "private" ) room = await repo_city.create_room( slug=room_slug, name=name, description=f"{role.title()} room for {payload.name}", created_by="u_system", # TODO: use actual user if available matrix_room_id=matrix_room_id, matrix_room_alias=matrix_room_alias ) # Attach to MicroDAO attached = await repo_city.attach_room_to_microdao( microdao_id=microdao_id, room_id=room["id"], room_role=role, is_public=is_public, sort_order=10 if role == 'primary' else 50 ) created_rooms.append(attached) # Primary Lobby if payload.create_rooms.get("primary_lobby"): await create_room_helper( room_slug=f"{payload.slug}-lobby", name=f"{payload.name} Lobby", role="primary", is_public=True ) # Governance if payload.create_rooms.get("governance"): await create_room_helper( room_slug=f"{payload.slug}-gov", name=f"{payload.name} Governance", role="governance", is_public=True ) # Crew Team if payload.create_rooms.get("crew_team"): await create_room_helper( room_slug=f"{payload.slug}-team", name=f"{payload.name} Team", role="team", is_public=False # Team rooms usually private/internal ) return { "status": "ok", "microdao": result, "agent_id": agent_id, "created_rooms": created_rooms } except HTTPException: raise except Exception as e: logger.error(f"Failed to create microdao for agent {agent_id}: {e}") import traceback traceback.print_exc() raise HTTPException(status_code=500, detail="Failed to create MicroDAO") class AttachToMicrodaoPayload(BaseModel): agent_id: str role: str = "member" # orchestrator | member @router.post("/microdao/{slug}/attach-agent") async def attach_agent_to_microdao_endpoint( slug: str, payload: AttachToMicrodaoPayload ): """ Приєднати агента до існуючого MicroDAO (Task 040). """ try: # Check MicroDAO dao = await repo_city.get_microdao_by_slug(slug) if not dao: raise HTTPException(status_code=404, detail=f"MicroDAO not found: {slug}") # Check Agent agent = await repo_city.get_agent_by_id(payload.agent_id) if not agent: raise HTTPException(status_code=404, detail="Agent not found") # Determine flags is_core = False if payload.role == "orchestrator": is_core = True # Upsert membership result = await repo_city.upsert_agent_microdao_membership( agent_id=payload.agent_id, microdao_id=dao["id"], role=payload.role, is_core=is_core ) # If role is orchestrator, we might want to update the main record too if it's missing an orchestrator, # or just rely on the membership table. # The current repo_city.create_microdao_for_agent sets orchestrator_agent_id on the microdao table. # Let's check if we should update that. if payload.role == "orchestrator" and not dao.get("orchestrator_agent_id"): # TODO: Update microdao table orchestrator_agent_id = payload.agent_id # For now, memberships are the source of truth for "orchestrators list" pass return result except HTTPException: raise except Exception as e: logger.error(f"Failed to attach agent to microdao: {e}") raise HTTPException(status_code=500, detail="Failed to attach agent") @router.post("/microdao/{slug}/ensure-orchestrator-room", response_model=CityRoomSummary) async def ensure_orchestrator_room( slug: str, authorization: Optional[str] = Header(None) ): """ Забезпечити існування кімнати команди оркестратора для MicroDAO. Створює нову кімнату в Matrix та БД, якщо її ще немає. Доступно для: Адмінів, Оркестратора MicroDAO. """ # 1. Validate JWT user_info = await validate_jwt_token(authorization) if not user_info: raise HTTPException(status_code=401, detail="Invalid or missing authorization token") user_id = user_info.get("user_id") if not user_id: raise HTTPException(status_code=401, detail="Invalid token: missing user_id") # 2. Get MicroDAO dao = await repo_city.get_microdao_by_slug(slug) if not dao: raise HTTPException(status_code=404, detail="MicroDAO not found") microdao_id = dao["id"] orchestrator_id = dao.get("orchestrator_agent_id") # 3. Check permissions (mock logic for now, assuming valid user if token is present) # TODO: In real app, check if user_id matches owner or has admin role # For now, we assume if they can call this (protected by UI/proxy), they are authorized. try: room = await repo_city.get_or_create_orchestrator_team_room(microdao_id) if not room: raise HTTPException(status_code=500, detail="Failed to create or retrieve orchestrator room") return CityRoomSummary( id=room["id"], slug=room["slug"], name=room["name"], matrix_room_id=room.get("matrix_room_id"), microdao_id=room.get("microdao_id"), microdao_slug=slug, # Ensure slug is passed room_role=room.get("room_role"), is_public=room.get("is_public", False), sort_order=room.get("sort_order", 100), logo_url=room.get("logo_url"), banner_url=room.get("banner_url") ) except HTTPException: raise except Exception as e: logger.error(f"Error ensuring orchestrator room for {slug}: {e}") raise HTTPException(status_code=500, detail="Internal server error")