357 lines
14 KiB
Python
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}
|