feat: Citizens Layer + Citizen Interact Layer + CityChatWidget
This commit is contained in:
47
services/city-service/dagi_router_client.py
Normal file
47
services/city-service/dagi_router_client.py
Normal 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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user