feat: Citizens Layer + Citizen Interact Layer + CityChatWidget

This commit is contained in:
Apple
2025-11-28 03:10:47 -08:00
parent 94bb222c9c
commit 06d0cba7d4
55 changed files with 5035 additions and 310 deletions

View File

@@ -0,0 +1,47 @@
import os
from typing import Optional
import httpx
class DagiRouterClient:
"""HTTP клієнт для DAGI Router"""
def __init__(self, base_url: str):
self.base_url = base_url.rstrip("/")
self._client = httpx.AsyncClient(timeout=60.0)
async def ask_agent(
self,
agent_id: str,
prompt: str,
system_prompt: Optional[str] = None,
) -> dict:
payload = {
"prompt": prompt,
}
if system_prompt:
payload["system_prompt"] = system_prompt
response = await self._client.post(
f"{self.base_url}/v1/agents/{agent_id}/infer",
json=payload,
)
response.raise_for_status()
return response.json()
_router_client: Optional[DagiRouterClient] = None
def get_dagi_router_client() -> DagiRouterClient:
"""Dependency factory for FastAPI"""
global _router_client
if _router_client is None:
base_url = os.getenv("DAGI_ROUTER_URL", "http://localhost:9102")
_router_client = DagiRouterClient(base_url)
return _router_client

View File

@@ -43,6 +43,8 @@ app.add_middleware(
# Include routers
app.include_router(routes_city.router)
app.include_router(routes_city.public_router)
app.include_router(routes_city.api_router)
# ============================================================================
# Models

View File

@@ -3,7 +3,7 @@ Pydantic Models для City Backend
"""
from pydantic import BaseModel, Field
from typing import Optional, List
from typing import Optional, List, Dict, Any
from datetime import datetime
@@ -176,10 +176,91 @@ class AgentPresence(BaseModel):
avatar_url: Optional[str] = None
# =============================================================================
# Citizens
# =============================================================================
class CityPresenceRoomView(BaseModel):
room_id: Optional[str] = None
slug: Optional[str] = None
name: Optional[str] = None
class CityPresenceView(BaseModel):
primary_room_slug: Optional[str] = None
rooms: List[CityPresenceRoomView] = []
class PublicCitizenSummary(BaseModel):
slug: str
display_name: str
public_title: Optional[str] = None
public_tagline: Optional[str] = None
avatar_url: Optional[str] = None
kind: Optional[str] = None
district: Optional[str] = None
primary_room_slug: Optional[str] = None
public_skills: List[str] = []
online_status: Optional[str] = "unknown"
status: Optional[str] = None # backward compatibility
class PublicCitizenProfile(BaseModel):
slug: str
display_name: str
kind: Optional[str] = None
public_title: Optional[str] = None
public_tagline: Optional[str] = None
district: Optional[str] = None
avatar_url: Optional[str] = None
status: Optional[str] = None
node_id: Optional[str] = None
public_skills: List[str] = []
city_presence: Optional[CityPresenceView] = None
dais_public: Dict[str, Any]
interaction: Dict[str, Any]
metrics_public: Dict[str, Any]
admin_panel_url: Optional[str] = None
microdao: Optional[Dict[str, Any]] = None
class CitizenInteractionInfo(BaseModel):
slug: str
display_name: str
primary_room_slug: Optional[str] = None
primary_room_id: Optional[str] = None
primary_room_name: Optional[str] = None
matrix_user_id: Optional[str] = None
district: Optional[str] = None
microdao_slug: Optional[str] = None
microdao_name: Optional[str] = None
class CitizenAskRequest(BaseModel):
question: str
context: Optional[str] = None
class CitizenAskResponse(BaseModel):
answer: str
agent_display_name: str
agent_id: str
# =============================================================================
# MicroDAO
# =============================================================================
class MicrodaoCitizenView(BaseModel):
slug: str
display_name: str
public_title: Optional[str] = None
public_tagline: Optional[str] = None
avatar_url: Optional[str] = None
district: Optional[str] = None
primary_room_slug: Optional[str] = None
class MicrodaoSummary(BaseModel):
"""MicroDAO summary for list view"""
id: str
@@ -225,4 +306,21 @@ class MicrodaoDetail(BaseModel):
logo_url: Optional[str] = None
agents: List[MicrodaoAgentView]
channels: List[MicrodaoChannelView]
public_citizens: List[MicrodaoCitizenView] = []
class AgentMicrodaoMembership(BaseModel):
microdao_id: str
microdao_slug: str
microdao_name: str
role: Optional[str] = None
is_core: bool = False
class MicrodaoOption(BaseModel):
id: str
slug: str
name: str
district: Optional[str] = None
is_active: bool = True

View File

@@ -4,7 +4,7 @@ Repository для City Backend (PostgreSQL)
import os
import asyncpg
from typing import Optional, List
from typing import Optional, List, Dict, Any, Tuple
from datetime import datetime
import secrets
@@ -37,6 +37,21 @@ def generate_id(prefix: str) -> str:
return f"{prefix}_{secrets.token_urlsafe(12)}"
def _normalize_capabilities(value: Any) -> List[str]:
"""Ensure capabilities are returned as a list."""
if value is None:
return []
if isinstance(value, list):
return value
if isinstance(value, str):
import json
try:
return json.loads(value)
except Exception:
return []
return list(value)
# =============================================================================
# City Rooms Repository
# =============================================================================
@@ -348,6 +363,497 @@ async def update_agent_status(agent_id: str, status: str, room_id: Optional[str]
return dict(row) if row else None
async def get_agent_by_id(agent_id: str) -> Optional[dict]:
"""Отримати агента по ID"""
pool = await get_pool()
query = """
SELECT
a.id,
a.display_name,
a.kind,
a.status,
a.node_id,
a.role,
a.avatar_url,
COALESCE(a.color_hint, a.color, 'cyan') AS color,
a.capabilities,
a.primary_room_slug,
a.public_primary_room_slug,
a.public_district,
a.public_title,
a.public_tagline,
a.public_skills,
a.public_slug,
a.is_public,
a.district AS home_district
FROM agents a
WHERE a.id = $1
"""
row = await pool.fetchrow(query, agent_id)
if not row:
return None
agent = dict(row)
agent["capabilities"] = _normalize_capabilities(agent.get("capabilities"))
if agent.get("public_skills") is None:
agent["public_skills"] = []
return agent
async def get_agent_public_profile(agent_id: str) -> Optional[dict]:
"""Отримати публічний профіль агента"""
pool = await get_pool()
query = """
SELECT
is_public,
public_slug,
public_title,
public_tagline,
public_skills,
public_district,
public_primary_room_slug
FROM agents
WHERE id = $1
"""
row = await pool.fetchrow(query, agent_id)
if not row:
return None
result = dict(row)
if result.get("public_skills") is None:
result["public_skills"] = []
return result
async def update_agent_public_profile(
agent_id: str,
is_public: bool,
public_slug: Optional[str],
public_title: Optional[str],
public_tagline: Optional[str],
public_skills: Optional[List[str]],
public_district: Optional[str],
public_primary_room_slug: Optional[str]
) -> Optional[dict]:
"""Оновити публічний профіль агента"""
pool = await get_pool()
query = """
UPDATE agents
SET
is_public = $2,
public_slug = $3,
public_title = $4,
public_tagline = $5,
public_skills = $6,
public_district = $7,
public_primary_room_slug = $8,
updated_at = NOW()
WHERE id = $1
RETURNING
is_public,
public_slug,
public_title,
public_tagline,
public_skills,
public_district,
public_primary_room_slug
"""
row = await pool.fetchrow(
query,
agent_id,
is_public,
public_slug,
public_title,
public_tagline,
public_skills,
public_district,
public_primary_room_slug
)
if not row:
return None
result = dict(row)
if result.get("public_skills") is None:
result["public_skills"] = []
return result
async def get_agent_rooms(agent_id: str) -> List[dict]:
"""Отримати список кімнат агента (primary/public)"""
pool = await get_pool()
query = """
SELECT primary_room_slug, public_primary_room_slug
FROM agents
WHERE id = $1
"""
row = await pool.fetchrow(query, agent_id)
if not row:
return []
slugs = []
if row.get("primary_room_slug"):
slugs.append(row["primary_room_slug"])
if row.get("public_primary_room_slug") and row["public_primary_room_slug"] not in slugs:
slugs.append(row["public_primary_room_slug"])
if not slugs:
return []
rooms_query = """
SELECT id, slug, name
FROM city_rooms
WHERE slug = ANY($1::text[])
"""
rooms = await pool.fetch(rooms_query, slugs)
return [dict(room) for room in rooms]
async def get_agent_matrix_config(agent_id: str) -> Optional[dict]:
"""Отримати Matrix-конфіг агента"""
pool = await get_pool()
query = """
SELECT agent_id, matrix_user_id, primary_room_id
FROM agent_matrix_config
WHERE agent_id = $1
"""
row = await pool.fetchrow(query, agent_id)
return dict(row) if row else None
async def get_public_agent_by_slug(slug: str) -> Optional[dict]:
"""Отримати базову інформацію про публічного агента"""
pool = await get_pool()
query = """
SELECT
id,
display_name,
public_primary_room_slug,
primary_room_slug,
public_district,
public_title,
public_tagline
FROM agents
WHERE public_slug = $1
AND is_public = true
LIMIT 1
"""
row = await pool.fetchrow(query, slug)
return dict(row) if row else None
async def get_microdao_for_agent(agent_id: str) -> Optional[dict]:
"""Отримати MicroDAO для агента (аліас get_agent_microdao)"""
return await get_agent_microdao(agent_id)
# =============================================================================
# Citizens Repository
# =============================================================================
async def get_public_citizens(
district: Optional[str] = None,
kind: Optional[str] = None,
q: Optional[str] = None,
limit: int = 50,
offset: int = 0
) -> Tuple[List[dict], int]:
"""Отримати публічних громадян"""
pool = await get_pool()
params: List[Any] = []
where_clauses = ["a.is_public = true", "a.public_slug IS NOT NULL"]
if district:
params.append(district)
where_clauses.append(f"a.public_district = ${len(params)}")
if kind:
params.append(kind)
where_clauses.append(f"a.kind = ${len(params)}")
if q:
params.append(f"%{q}%")
where_clauses.append(
f"(a.display_name ILIKE ${len(params)} OR a.public_title ILIKE ${len(params)} OR a.public_tagline ILIKE ${len(params)})"
)
where_sql = " AND ".join(where_clauses)
query = f"""
SELECT
a.id,
a.public_slug,
a.display_name,
a.public_title,
a.public_tagline,
a.avatar_url,
a.kind,
a.public_district,
a.public_primary_room_slug,
COALESCE(a.public_skills, '{{}}'::text[]) AS public_skills,
COALESCE(a.status, 'unknown') AS status,
COUNT(*) OVER() AS total_count
FROM agents a
WHERE {where_sql}
ORDER BY a.display_name
LIMIT ${len(params) + 1} OFFSET ${len(params) + 2}
"""
params.append(limit)
params.append(offset)
rows = await pool.fetch(query, *params)
if not rows:
return [], 0
total = rows[0]["total_count"]
items = []
for row in rows:
data = dict(row)
data.pop("total_count", None)
data["public_skills"] = list(data.get("public_skills") or [])
data["online_status"] = data.get("status") or "unknown"
items.append(data)
return items, total
async def get_agent_microdao(agent_id: str) -> Optional[dict]:
"""Отримати MicroDAO, до якого належить агент (перший збіг)"""
pool = await get_pool()
query = """
SELECT
m.id,
m.slug,
m.name,
m.district
FROM microdao_agents ma
JOIN microdaos m ON m.id = ma.microdao_id
WHERE ma.agent_id = $1
ORDER BY ma.is_core DESC, m.name
LIMIT 1
"""
row = await pool.fetchrow(query, agent_id)
return dict(row) if row else None
async def get_microdao_public_citizens(microdao_id: str) -> List[dict]:
"""Отримати публічних громадян конкретного MicroDAO"""
pool = await get_pool()
query = """
SELECT
a.public_slug AS slug,
a.display_name,
a.public_title,
a.public_tagline,
a.avatar_url,
a.public_district,
a.public_primary_room_slug
FROM microdao_agents ma
JOIN agents a ON a.id = ma.agent_id
WHERE ma.microdao_id = $1
AND a.is_public = true
AND a.public_slug IS NOT NULL
ORDER BY a.display_name
"""
rows = await pool.fetch(query, microdao_id)
result = []
for row in rows:
data = dict(row)
result.append(data)
return result
async def get_public_citizen_by_slug(slug: str) -> Optional[dict]:
"""Отримати детальний профіль громадянина"""
pool = await get_pool()
query = """
SELECT
a.id,
a.display_name,
a.kind,
a.status,
a.node_id,
a.avatar_url,
a.public_slug,
a.public_title,
a.public_tagline,
COALESCE(a.public_skills, '{{}}'::text[]) AS public_skills,
a.public_district,
a.public_primary_room_slug,
a.primary_room_slug
FROM agents a
WHERE a.public_slug = $1
AND a.is_public = true
LIMIT 1
"""
agent_row = await pool.fetchrow(query, slug)
if not agent_row:
return None
agent = dict(agent_row)
agent["public_skills"] = list(agent.get("public_skills") or [])
rooms = await get_agent_rooms(agent["id"])
primary_room = agent.get("public_primary_room_slug") or agent.get("primary_room_slug")
city_presence = {
"primary_room_slug": primary_room,
"rooms": rooms
} if rooms else {
"primary_room_slug": primary_room,
"rooms": []
}
dais_public = {
"core": {
"archetype": agent.get("kind"),
"bio_short": agent.get("public_tagline")
},
"phenotype": {
"visual": {
"avatar_url": agent.get("avatar_url"),
"color": None
}
},
"memex": {},
"economics": {}
}
interaction = {
"matrix_user": None,
"primary_room_slug": primary_room,
"actions": ["chat", "ask_for_help"]
}
metrics_public: Dict[str, Any] = {}
microdao = await get_agent_microdao(agent["id"])
return {
"slug": agent["public_slug"],
"display_name": agent["display_name"],
"kind": agent.get("kind"),
"public_title": agent.get("public_title"),
"public_tagline": agent.get("public_tagline"),
"district": agent.get("public_district"),
"avatar_url": agent.get("avatar_url"),
"status": agent.get("status"),
"node_id": agent.get("node_id"),
"public_skills": agent.get("public_skills"),
"city_presence": city_presence,
"dais_public": dais_public,
"interaction": interaction,
"metrics_public": metrics_public,
"microdao": microdao,
"admin_panel_url": f"/agents/{agent['id']}"
}
# =============================================================================
# MicroDAO Membership Repository
# =============================================================================
async def get_microdao_options() -> List[dict]:
"""Отримати список активних MicroDAO для селектора"""
pool = await get_pool()
query = """
SELECT id, slug, name, district, is_active
FROM microdaos
WHERE is_active = true
ORDER BY name
"""
rows = await pool.fetch(query)
return [dict(row) for row in rows]
async def get_agent_microdao_memberships(agent_id: str) -> List[dict]:
"""Отримати всі членства агента в MicroDAO"""
pool = await get_pool()
query = """
SELECT
ma.microdao_id,
m.slug AS microdao_slug,
m.name AS microdao_name,
ma.role,
ma.is_core
FROM microdao_agents ma
JOIN microdaos m ON m.id = ma.microdao_id
WHERE ma.agent_id = $1
ORDER BY ma.is_core DESC, m.name
"""
rows = await pool.fetch(query, agent_id)
return [dict(row) for row in rows]
async def upsert_agent_microdao_membership(
agent_id: str,
microdao_id: str,
role: Optional[str],
is_core: bool
) -> Optional[dict]:
"""Призначити або оновити членство агента в MicroDAO"""
pool = await get_pool()
query = """
WITH upsert AS (
INSERT INTO microdao_agents (microdao_id, agent_id, role, is_core)
VALUES ($1, $2, $3, $4)
ON CONFLICT (microdao_id, agent_id)
DO UPDATE SET role = EXCLUDED.role, is_core = EXCLUDED.is_core
RETURNING microdao_id, agent_id, role, is_core
)
SELECT
u.microdao_id,
m.slug AS microdao_slug,
m.name AS microdao_name,
u.role,
u.is_core
FROM upsert u
JOIN microdaos m ON m.id = u.microdao_id
"""
row = await pool.fetchrow(query, microdao_id, agent_id, role, is_core)
return dict(row) if row else None
async def remove_agent_microdao_membership(agent_id: str, microdao_id: str) -> bool:
"""Видалити членство агента в MicroDAO"""
pool = await get_pool()
result = await pool.execute(
"DELETE FROM microdao_agents WHERE agent_id = $1 AND microdao_id = $2",
agent_id,
microdao_id
)
# asyncpg returns strings like "DELETE 1"
return result.split(" ")[-1] != "0"
# =============================================================================
# MicroDAO Repository
# =============================================================================
@@ -458,5 +964,8 @@ async def get_microdao_by_slug(slug: str) -> Optional[dict]:
channels_rows = await pool.fetch(query_channels, dao_id)
result["channels"] = [dict(row) for row in channels_rows]
public_citizens = await get_microdao_public_citizens(dao_id)
result["public_citizens"] = public_citizens
return result

View File

@@ -3,6 +3,7 @@ City Backend API Routes
"""
from fastapi import APIRouter, HTTPException, Depends, Body, Header, Query, Request
from pydantic import BaseModel
from typing import List, Optional
import logging
import httpx
@@ -20,14 +21,23 @@ from models_city import (
CityMapResponse,
AgentRead,
AgentPresence,
PublicCitizenSummary,
PublicCitizenProfile,
CitizenInteractionInfo,
CitizenAskRequest,
CitizenAskResponse,
AgentMicrodaoMembership,
MicrodaoSummary,
MicrodaoDetail,
MicrodaoAgentView,
MicrodaoChannelView
MicrodaoChannelView,
MicrodaoCitizenView,
MicrodaoOption
)
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__)
@@ -36,6 +46,242 @@ 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")
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
# =============================================================================
# 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:
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")
))
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 — 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")
# =============================================================================
@@ -335,6 +581,22 @@ async def validate_jwt_token(authorization: str) -> Optional[dict]:
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"),
@@ -542,13 +804,13 @@ async def update_agent_public_profile(agent_id: str, request: Request):
@router.get("/citizens")
async def get_public_citizens(limit: int = 50, offset: int = 0):
async def get_public_citizens_legacy(limit: int = 50, offset: int = 0):
"""
Отримати список публічних громадян DAARION City.
"""
try:
citizens = await repo_city.get_public_citizens(limit, offset)
return {"citizens": citizens, "total": len(citizens)}
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")
@@ -561,13 +823,15 @@ async def get_citizen_by_slug(slug: str, request: Request):
Для адмінів/архітекторів додається 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']
include_admin_url = True # legacy endpoint доступний тільки з адмінської панелі
citizen = await repo_city.get_citizen_by_slug(slug, include_admin_url=include_admin_url)
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
@@ -709,6 +973,19 @@ async def get_agent_dashboard(agent_id: str):
# 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"),
role=item.get("role"),
is_core=item.get("is_core", False)
)
for item in memberships_raw
]
# Build dashboard response
dashboard = {
"profile": profile,
@@ -727,7 +1004,8 @@ async def get_agent_dashboard(agent_id: str):
},
"recent_activity": [],
"system_prompts": system_prompts,
"public_profile": public_profile
"public_profile": public_profile,
"microdao_memberships": memberships
}
return dashboard
@@ -922,6 +1200,18 @@ async def get_microdao_by_slug(slug: str):
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")
))
return MicrodaoDetail(
id=dao["id"],
slug=dao["slug"],
@@ -934,7 +1224,8 @@ async def get_microdao_by_slug(slug: str):
is_public=dao.get("is_public", True),
logo_url=dao.get("logo_url"),
agents=agents,
channels=channels
channels=channels,
public_citizens=public_citizens
)
except HTTPException: