feat: MicroDAO Registry API (read-only) - GET /city/microdao, GET /city/microdao/{slug}

This commit is contained in:
Apple
2025-11-28 01:31:20 -08:00
parent 2a6112fc42
commit 467c7fc83d
4 changed files with 620 additions and 3 deletions

View File

@@ -169,4 +169,60 @@ class AgentPresence(BaseModel):
status: str
room_id: Optional[str] = None
color: Optional[str] = None
node_id: Optional[str] = None
district: Optional[str] = None
model: Optional[str] = None
role: Optional[str] = None
avatar_url: Optional[str] = None
# =============================================================================
# MicroDAO
# =============================================================================
class MicrodaoSummary(BaseModel):
"""MicroDAO summary for list view"""
id: str
slug: str
name: str
description: Optional[str] = None
district: Optional[str] = None
orchestrator_agent_id: Optional[str] = None
is_active: bool
logo_url: Optional[str] = None
agents_count: int
rooms_count: int
channels_count: int
class MicrodaoChannelView(BaseModel):
"""Channel/integration view for MicroDAO"""
kind: str # 'matrix' | 'telegram' | 'city_room' | 'crew'
ref_id: str
display_name: Optional[str] = None
is_primary: bool
class MicrodaoAgentView(BaseModel):
"""Agent view within MicroDAO"""
agent_id: str
display_name: str
role: Optional[str] = None
is_core: bool
class MicrodaoDetail(BaseModel):
"""Full MicroDAO detail view"""
id: str
slug: str
name: str
description: Optional[str] = None
district: Optional[str] = None
orchestrator_agent_id: Optional[str] = None
orchestrator_display_name: Optional[str] = None
is_active: bool
is_public: bool
logo_url: Optional[str] = None
agents: List[MicrodaoAgentView]
channels: List[MicrodaoChannelView]

View File

@@ -347,3 +347,116 @@ async def update_agent_status(agent_id: str, status: str, room_id: Optional[str]
return dict(row) if row else None
# =============================================================================
# MicroDAO Repository
# =============================================================================
async def get_microdaos(district: Optional[str] = None, q: Optional[str] = None, limit: int = 50, offset: int = 0) -> List[dict]:
"""Отримати список MicroDAOs з агрегованою статистикою"""
pool = await get_pool()
params = []
where_clauses = ["m.is_public = true", "m.is_active = true"]
if district:
params.append(district)
where_clauses.append(f"m.district = ${len(params)}")
if q:
params.append(f"%{q}%")
where_clauses.append(f"(m.name ILIKE ${len(params)} OR m.description ILIKE ${len(params)})")
where_sql = " AND ".join(where_clauses)
query = f"""
SELECT
m.id,
m.slug,
m.name,
m.description,
m.district,
m.owner_agent_id as orchestrator_agent_id,
m.is_active,
m.logo_url,
COUNT(DISTINCT ma.agent_id) AS agents_count,
COUNT(DISTINCT mc.id) AS channels_count,
COUNT(DISTINCT CASE WHEN mc.kind = 'city_room' THEN mc.id END) AS rooms_count
FROM microdaos m
LEFT JOIN microdao_agents ma ON ma.microdao_id = m.id
LEFT JOIN microdao_channels mc ON mc.microdao_id = m.id
WHERE {where_sql}
GROUP BY m.id
ORDER BY m.name
LIMIT ${len(params) + 1} OFFSET ${len(params) + 2}
"""
# Append limit and offset to params
params.append(limit)
params.append(offset)
rows = await pool.fetch(query, *params)
return [dict(row) for row in rows]
async def get_microdao_by_slug(slug: str) -> Optional[dict]:
"""Отримати детальну інформацію про MicroDAO"""
pool = await get_pool()
# 1. Get main DAO info
query_dao = """
SELECT
m.id,
m.slug,
m.name,
m.description,
m.district,
m.owner_agent_id as orchestrator_agent_id,
m.is_active,
m.is_public,
m.logo_url,
a.display_name as orchestrator_display_name
FROM microdaos m
LEFT JOIN agents a ON m.owner_agent_id = a.id
WHERE m.slug = $1
"""
dao_row = await pool.fetchrow(query_dao, slug)
if not dao_row:
return None
result = dict(dao_row)
dao_id = result["id"]
# 2. Get Agents
query_agents = """
SELECT
ma.agent_id,
ma.role,
ma.is_core,
a.display_name
FROM microdao_agents ma
JOIN agents a ON ma.agent_id = a.id
WHERE ma.microdao_id = $1
ORDER BY ma.is_core DESC, ma.role
"""
agents_rows = await pool.fetch(query_agents, dao_id)
result["agents"] = [dict(row) for row in agents_rows]
# 3. Get Channels
query_channels = """
SELECT
kind,
ref_id,
display_name,
is_primary
FROM microdao_channels
WHERE microdao_id = $1
ORDER BY is_primary DESC, kind
"""
channels_rows = await pool.fetch(query_channels, dao_id)
result["channels"] = [dict(row) for row in channels_rows]
return result

View File

@@ -2,7 +2,7 @@
City Backend API Routes
"""
from fastapi import APIRouter, HTTPException, Depends, Body, Header, Query
from fastapi import APIRouter, HTTPException, Depends, Body, Header, Query, Request
from typing import List, Optional
import logging
import httpx
@@ -19,7 +19,11 @@ from models_city import (
CityMapConfig,
CityMapResponse,
AgentRead,
AgentPresence
AgentPresence,
MicrodaoSummary,
MicrodaoDetail,
MicrodaoAgentView,
MicrodaoChannelView
)
import repo_city
from common.redis_client import PresenceRedis, get_redis
@@ -473,6 +477,270 @@ async def 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(limit: int = 50, offset: int = 0):
"""
Отримати список публічних громадян DAARION City.
"""
try:
citizens = await repo_city.get_public_citizens(limit, offset)
return {"citizens": citizens, "total": len(citizens)}
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:
# TODO: Check user role from JWT
# For now, always include admin URL (will be filtered by frontend auth)
include_admin_url = True # Should be: user.role in ['admin', 'architect']
citizen = await repo_city.get_citizen_by_slug(slug, include_admin_url=include_admin_url)
if not citizen:
raise HTTPException(status_code=404, detail=f"Citizen not found: {slug}")
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"),
"status": agent.get("status", "offline"),
"node_id": agent.get("node_id"),
"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 (simplified)
node_info = None
if agent.get("node_id"):
node_info = {
"node_id": agent["node_id"],
"status": "online" # Would fetch from Node Registry in production
}
# 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)
# Build dashboard response
dashboard = {
"profile": profile,
"node": node_info,
"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
}
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():
"""
@@ -548,7 +816,10 @@ async def get_room_agents(room_id: str):
kind=agent.get("kind", "assistant"),
status=agent.get("status", "offline"),
room_id=room_id,
color=agent.get("color", "cyan")
color=agent.get("color", "cyan"),
node_id=agent.get("node_id"),
model=agent.get("model"),
role=agent.get("role")
))
return result
@@ -557,3 +828,120 @@ async def get_room_agents(room_id: str):
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"),
q: Optional[str] = Query(None, description="Search by name/description"),
limit: int = Query(50, le=100),
offset: int = Query(0, ge=0)
):
"""
Отримати список MicroDAOs.
- **district**: фільтр по дістрікту (Core, Energy, Green, Labs, etc.)
- **q**: пошук по назві або опису
"""
try:
daos = await repo_city.get_microdaos(district=district, 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"),
orchestrator_agent_id=dao.get("orchestrator_agent_id"),
is_active=dao.get("is_active", True),
logo_url=dao.get("logo_url"),
agents_count=dao.get("agents_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)
))
return MicrodaoDetail(
id=dao["id"],
slug=dao["slug"],
name=dao["name"],
description=dao.get("description"),
district=dao.get("district"),
orchestrator_agent_id=dao.get("orchestrator_agent_id"),
orchestrator_display_name=dao.get("orchestrator_display_name"),
is_active=dao.get("is_active", True),
is_public=dao.get("is_public", True),
logo_url=dao.get("logo_url"),
agents=agents,
channels=channels
)
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")