guardia-itsm/routers/agent_collab.py
2026-06-07 08:13:43 +09:00

357 lines
14 KiB
Python

"""
GUARDiA 에이전트 간 의견 교환 (Agent Collaboration)
에이전트끼리 메시지를 주고받고, 공동 의견을 형성하며, 다수결/토론으로 결정하는 채널.
"""
import asyncio
import json
import uuid
from datetime import datetime
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, HTTPException, Query
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
router = APIRouter(prefix="/api/agent-collab", tags=["Agent Collaboration"])
# ── 에이전트 레지스트리 ────────────────────────────────────────────────────
REGISTERED_AGENTS = {
"sr-manager": {"role": "SR 관리", "model": "llama3", "color": "#003366"},
"incident-responder":{"role": "인시던트 대응", "model": "llama3", "color": "#dc3545"},
"deploy-engineer": {"role": "배포 엔지니어", "model": "codellama","color": "#28a745"},
"sla-guardian": {"role": "SLA 감시", "model": "llama3", "color": "#ffc107"},
"csap-auditor": {"role": "CSAP 감사", "model": "llama3", "color": "#17a2b8"},
"dr-coordinator": {"role": "DR 조율", "model": "llama3", "color": "#6f42c1"},
"network-guardian": {"role": "네트워크 관리", "model": "llama3", "color": "#fd7e14"},
"ai-analyst": {"role": "AI 분석", "model": "llama3", "color": "#20c997"},
"vibe-coder": {"role": "바이브코딩", "model": "codellama","color": "#0dcaf0"},
"design-ai": {"role": "디자인 AI", "model": "llava", "color": "#d63384"},
}
# ── 인메모리 토론방 ───────────────────────────────────────────────────────
class DiscussionRoom:
def __init__(self, room_id: str, topic: str, agents: List[str], mode: str = "discuss"):
self.room_id = room_id
self.topic = topic
self.agents = agents
self.mode = mode # discuss | vote | review | brainstorm
self.messages: List[Dict] = []
self.votes: Dict[str, str] = {} # agent → option
self.conclusion: Optional[str] = None
self.created_at = datetime.utcnow().isoformat()
self._ws_clients: List[WebSocket] = []
def add_message(self, agent: str, content: str, msg_type: str = "opinion") -> Dict:
msg = {
"id": str(uuid.uuid4()),
"room_id": self.room_id,
"agent": agent,
"role": REGISTERED_AGENTS.get(agent, {}).get("role", agent),
"content": content,
"type": msg_type, # opinion | question | answer | vote | conclusion | system
"timestamp": datetime.utcnow().isoformat(),
}
self.messages.append(msg)
return msg
async def broadcast(self, msg: Dict):
dead = []
for ws in self._ws_clients:
try:
await ws.send_json(msg)
except Exception:
dead.append(ws)
for ws in dead:
self._ws_clients.remove(ws)
_rooms: Dict[str, DiscussionRoom] = {}
# ── Pydantic 모델 ──────────────────────────────────────────────────────────
class RoomCreateRequest(BaseModel):
topic: str
agents: List[str]
mode: str = "discuss" # discuss | vote | review | brainstorm
class MessageRequest(BaseModel):
agent: str
content: str
msg_type: str = "opinion"
class VoteRequest(BaseModel):
agent: str
option: str
class OllamaOpinionRequest(BaseModel):
room_id: str
agent: str
context_limit: int = 10 # 최근 N개 메시지를 컨텍스트로 사용
class ConsensusRequest(BaseModel):
room_id: str
method: str = "majority" # majority | weighted | llm-summary
# ── 토론방 관리 ────────────────────────────────────────────────────────────
@router.post("/rooms")
async def create_room(req: RoomCreateRequest):
"""에이전트 토론방 생성."""
room_id = f"ROOM-{datetime.utcnow().strftime('%H%M%S')}-{uuid.uuid4().hex[:6].upper()}"
room = DiscussionRoom(room_id, req.topic, req.agents, req.mode)
_rooms[room_id] = room
# 시스템 입장 메시지
room.add_message("system", f"토론 시작: {req.topic} | 참여 에이전트: {', '.join(req.agents)}", "system")
return {
"room_id": room_id,
"topic": req.topic,
"mode": req.mode,
"agents": req.agents,
"created_at": room.created_at,
}
@router.get("/rooms")
async def list_rooms():
return {
"rooms": [
{"room_id": r.room_id, "topic": r.topic, "mode": r.mode,
"agents": r.agents, "message_count": len(r.messages), "has_conclusion": bool(r.conclusion)}
for r in _rooms.values()
]
}
@router.get("/rooms/{room_id}")
async def get_room(room_id: str):
room = _rooms.get(room_id)
if not room:
raise HTTPException(404, f"Room not found: {room_id}")
return {
"room_id": room.room_id,
"topic": room.topic,
"mode": room.mode,
"agents": room.agents,
"messages": room.messages,
"votes": room.votes,
"conclusion": room.conclusion,
}
@router.delete("/rooms/{room_id}")
async def close_room(room_id: str):
if room_id not in _rooms:
raise HTTPException(404)
del _rooms[room_id]
return {"closed": True, "room_id": room_id}
# ── 메시지 발신 ────────────────────────────────────────────────────────────
@router.post("/rooms/{room_id}/messages")
async def post_message(room_id: str, req: MessageRequest):
"""에이전트 메시지 발신 (수동)."""
room = _rooms.get(room_id)
if not room:
raise HTTPException(404)
msg = room.add_message(req.agent, req.content, req.msg_type)
await room.broadcast(msg)
return msg
@router.get("/rooms/{room_id}/messages")
async def get_messages(room_id: str, limit: int = Query(50, le=200)):
room = _rooms.get(room_id)
if not room:
raise HTTPException(404)
return {"messages": room.messages[-limit:], "total": len(room.messages)}
# ── Ollama 기반 AI 에이전트 자동 의견 생성 ────────────────────────────────
@router.post("/rooms/{room_id}/ai-opinion")
async def ai_agent_opinion(room_id: str, req: OllamaOpinionRequest):
"""Ollama를 통해 에이전트 AI 의견 자동 생성."""
room = _rooms.get(room_id)
if not room:
raise HTTPException(404)
agent_info = REGISTERED_AGENTS.get(req.agent, {"role": req.agent, "model": "llama3"})
model = agent_info["model"]
# 컨텍스트 구성
recent = room.messages[-req.context_limit:] if room.messages else []
context = "\n".join([f"[{m['agent']}({m['role']})]: {m['content']}" for m in recent])
prompt = (
f"당신은 GUARDiA AI 에이전트 '{req.agent}'({agent_info['role']})입니다.\n"
f"토론 주제: {room.topic}\n\n"
f"현재 논의 내용:\n{context}\n\n"
f"당신의 역할({agent_info['role']}) 관점에서 구체적이고 전문적인 의견을 2~3문장으로 제시하라."
)
import httpx
async with httpx.AsyncClient(timeout=60.0) as c:
r = await c.post(
"http://localhost:11434/api/generate",
json={"model": model, "prompt": prompt, "stream": False,
"options": {"temperature": 0.7, "num_predict": 300}},
)
if r.status_code != 200:
raise HTTPException(503, "Ollama 불가")
opinion = r.json().get("response", "의견 생성 실패")
msg = room.add_message(req.agent, opinion.strip(), "opinion")
await room.broadcast(msg)
return msg
# ── 브레인스토밍 (모든 에이전트 순차 의견) ───────────────────────────────
@router.post("/rooms/{room_id}/brainstorm")
async def brainstorm(room_id: str, context_limit: int = 5):
"""토론방 모든 에이전트가 순차적으로 AI 의견 생성."""
room = _rooms.get(room_id)
if not room:
raise HTTPException(404)
results = []
for agent in room.agents:
req = OllamaOpinionRequest(room_id=room_id, agent=agent, context_limit=context_limit)
msg = await ai_agent_opinion(room_id, req)
results.append(msg)
return {"brainstorm_results": results, "count": len(results)}
# ── 투표 ────────────────────────────────────────────────────────────────────
@router.post("/rooms/{room_id}/vote")
async def cast_vote(room_id: str, req: VoteRequest):
room = _rooms.get(room_id)
if not room:
raise HTTPException(404)
room.votes[req.agent] = req.option
msg = room.add_message(req.agent, f"투표: {req.option}", "vote")
await room.broadcast(msg)
return {"agent": req.agent, "voted": req.option, "total_votes": len(room.votes)}
@router.get("/rooms/{room_id}/vote/result")
async def vote_result(room_id: str):
room = _rooms.get(room_id)
if not room:
raise HTTPException(404)
tally: Dict[str, int] = {}
for v in room.votes.values():
tally[v] = tally.get(v, 0) + 1
winner = max(tally, key=tally.get) if tally else None
return {"votes": room.votes, "tally": tally, "winner": winner, "total": len(room.votes)}
# ── 합의 도출 ─────────────────────────────────────────────────────────────
@router.post("/rooms/{room_id}/consensus")
async def derive_consensus(room_id: str, req: ConsensusRequest):
"""토론 내용을 바탕으로 최종 합의 도출."""
room = _rooms.get(room_id)
if not room:
raise HTTPException(404)
if req.method == "majority" and room.votes:
tally: Dict[str, int] = {}
for v in room.votes.values():
tally[v] = tally.get(v, 0) + 1
conclusion = f"다수결 결과: {max(tally, key=tally.get)} (득표: {tally})"
elif req.method == "llm-summary":
full_discussion = "\n".join(
[f"[{m['agent']}]: {m['content']}" for m in room.messages if m["type"] != "system"]
)
import httpx
async with httpx.AsyncClient(timeout=60.0) as c:
r = await c.post(
"http://localhost:11434/api/generate",
json={
"model": "llama3",
"prompt": (
f"다음 GUARDiA 에이전트 토론({room.topic})에서 핵심 합의사항을 3줄로 요약하라:\n\n{full_discussion}"
),
"stream": False,
"options": {"temperature": 0.3},
},
)
conclusion = r.json().get("response", "합의 도출 실패") if r.status_code == 200 else "합의 도출 실패"
else:
conclusion = f"토론 종료: {len(room.messages)}개 메시지, {len(room.votes)}개 투표"
room.conclusion = conclusion
msg = room.add_message("system", f"[합의] {conclusion}", "conclusion")
await room.broadcast(msg)
return {"conclusion": conclusion, "method": req.method, "room_id": room_id}
# ── WebSocket 실시간 스트림 ────────────────────────────────────────────────
@router.websocket("/ws/{room_id}")
async def ws_room(websocket: WebSocket, room_id: str):
"""토론방 실시간 WebSocket 연결."""
await websocket.accept()
room = _rooms.get(room_id)
if not room:
await websocket.send_json({"error": f"Room {room_id} not found"})
await websocket.close()
return
room._ws_clients.append(websocket)
# 연결 시 이전 메시지 전송
await websocket.send_json({"type": "history", "messages": room.messages[-20:]})
try:
while True:
raw = await websocket.receive_text()
data = json.loads(raw)
if data.get("action") == "message":
msg = room.add_message(data.get("agent", "anonymous"), data.get("content", ""), "opinion")
await room.broadcast(msg)
except WebSocketDisconnect:
pass
finally:
if websocket in room._ws_clients:
room._ws_clients.remove(websocket)
# ── SSE 스트림 ─────────────────────────────────────────────────────────────
@router.get("/rooms/{room_id}/stream")
async def sse_stream(room_id: str):
"""토론방 SSE 스트림."""
room = _rooms.get(room_id)
if not room:
raise HTTPException(404)
q: asyncio.Queue = asyncio.Queue(maxsize=100)
room._ws_clients # SSE는 별도 구현 — 여기서는 히스토리만
async def gen():
yield f"data: {json.dumps({'type': 'connected', 'room_id': room_id})}\n\n"
for m in room.messages[-20:]:
yield f"data: {json.dumps(m)}\n\n"
while True:
await asyncio.sleep(30)
yield "data: {\"type\":\"heartbeat\"}\n\n"
return StreamingResponse(gen(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
# ── 에이전트 디렉토리 ─────────────────────────────────────────────────────
@router.get("/agents")
async def list_agents():
return {"agents": REGISTERED_AGENTS, "count": len(REGISTERED_AGENTS)}
@router.get("/agents/{agent_id}")
async def get_agent(agent_id: str):
agent = REGISTERED_AGENTS.get(agent_id)
if not agent:
raise HTTPException(404, f"Agent not found: {agent_id}")
return {"agent_id": agent_id, **agent}