feat(debate): MVP 구현 — debate_handler + 커맨드 연동 (start/stop/next/inject)
This commit is contained in:
@@ -21,6 +21,7 @@ from core.workspace import WorkspaceManager
|
||||
from core.gemini_caller import GeminiCaller, GeminiCallError
|
||||
from core.foreman import Foreman
|
||||
from handlers.nc_handler import NCHandler
|
||||
from handlers.debate_handler import DebateHandler, DEBATE_AGENTS, DEBATE_CONTROL_CH
|
||||
|
||||
logger = logging.getLogger("variet.discord")
|
||||
|
||||
@@ -282,10 +283,16 @@ async def on_command_error(ctx, error):
|
||||
@bot.event
|
||||
async def on_message(message: discord.Message):
|
||||
"""메시지 수신 — 워크스페이스 채널이면 자동 응답."""
|
||||
if message.author == bot.user or message.author.bot:
|
||||
# Debate: AG 봇 응답 감지 (봇 메시지도 처리해야 함)
|
||||
if message.author.bot and message.author != bot.user:
|
||||
if message.channel.id in DEBATE_AGENTS.values():
|
||||
await _debate_handler.on_agent_message(message)
|
||||
return
|
||||
|
||||
# ! 명령어 처리
|
||||
if message.author == bot.user:
|
||||
return
|
||||
|
||||
# ! 명령어 처리 (debate 채널 포함)
|
||||
if message.content.startswith(config.DISCORD_COMMAND_PREFIX):
|
||||
await bot.process_commands(message)
|
||||
return
|
||||
@@ -1208,17 +1215,15 @@ async def info_command(ctx: commands.Context):
|
||||
# Debate Room — 토론 채널 연동
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
DEBATE_CHANNELS = {
|
||||
"gemini": 1484156194187771905,
|
||||
"opus": 1484156521209401476,
|
||||
}
|
||||
# Debate handler 초기화
|
||||
_debate_handler = DebateHandler(bot)
|
||||
|
||||
|
||||
@bot.command(name="debate-test")
|
||||
async def debate_test(ctx: commands.Context):
|
||||
"""토론 채널 연결 테스트."""
|
||||
results = []
|
||||
for name, ch_id in DEBATE_CHANNELS.items():
|
||||
for name, ch_id in DEBATE_AGENTS.items():
|
||||
ch = bot.get_channel(ch_id)
|
||||
if ch is None:
|
||||
try:
|
||||
@@ -1240,6 +1245,39 @@ async def debate_test(ctx: commands.Context):
|
||||
await ctx.reply(embed=embed)
|
||||
|
||||
|
||||
@bot.command(name="debate-start")
|
||||
async def debate_start(ctx: commands.Context, *, topic: str = ""):
|
||||
"""토론 시작. 사용법: !debate-start 주제"""
|
||||
if not topic:
|
||||
await ctx.reply("⚠️ 주제를 입력하세요: `!debate-start 주제`")
|
||||
return
|
||||
await _debate_handler.start(ctx, topic)
|
||||
|
||||
|
||||
@bot.command(name="debate-stop")
|
||||
async def debate_stop(ctx: commands.Context):
|
||||
"""토론 중단."""
|
||||
await _debate_handler.stop(ctx)
|
||||
|
||||
|
||||
@bot.command(name="debate-next")
|
||||
async def debate_next(ctx: commands.Context):
|
||||
"""다음 턴 진행."""
|
||||
if not _debate_handler.session.active:
|
||||
await ctx.reply("진행 중인 토론이 없습니다.")
|
||||
return
|
||||
await _debate_handler._next_turn()
|
||||
|
||||
|
||||
@bot.command(name="debate-inject")
|
||||
async def debate_inject(ctx: commands.Context, *, opinion: str = ""):
|
||||
"""사용자 의견 삽입 후 다음 턴. 사용법: !debate-inject 의견"""
|
||||
if not opinion:
|
||||
await ctx.reply("⚠️ 의견을 입력하세요: `!debate-inject 의견`")
|
||||
return
|
||||
await _debate_handler.inject(ctx, opinion)
|
||||
|
||||
|
||||
async def start_bot():
|
||||
"""Discord Bot 시작."""
|
||||
token = config.DISCORD_BOT_TOKEN
|
||||
|
||||
280
handlers/debate_handler.py
Normal file
280
handlers/debate_handler.py
Normal file
@@ -0,0 +1,280 @@
|
||||
"""Debate Room Handler — AG 인스턴스 간 토론 중재.
|
||||
|
||||
#variet-debate 채널에서 !debate-start로 시작.
|
||||
사회자(이 봇)가 #ag-debate_gemini와 #ag-debate_opus 사이에서 턴을 관리.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
import discord
|
||||
|
||||
logger = logging.getLogger("variet.debate")
|
||||
|
||||
# ── 채널 ID ──
|
||||
DEBATE_CONTROL_CH = 1484167157620146227 # #variet-debate
|
||||
DEBATE_AGENTS = {
|
||||
"gemini": 1484156194187771905, # #ag-debate_gemini
|
||||
"opus": 1484156521209401476, # #ag-debate_opus
|
||||
}
|
||||
|
||||
# Embed 색상
|
||||
AGENT_COLORS = {
|
||||
"gemini": 0x2ECC71, # 초록
|
||||
"opus": 0x3498DB, # 파랑
|
||||
}
|
||||
AGENT_EMOJI = {
|
||||
"gemini": "🟢",
|
||||
"opus": "🔵",
|
||||
}
|
||||
|
||||
MAX_ROUNDS = 5
|
||||
RESPONSE_TIMEOUT = 180 # AG 응답 대기 (초)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DebateSession:
|
||||
topic: str = ""
|
||||
round: int = 0
|
||||
max_rounds: int = MAX_ROUNDS
|
||||
active: bool = False
|
||||
current_speaker: str = ""
|
||||
history: list = field(default_factory=list)
|
||||
_response_event: Optional[asyncio.Event] = field(default=None, repr=False)
|
||||
_last_response: str = ""
|
||||
|
||||
|
||||
class DebateHandler:
|
||||
def __init__(self, bot: discord.ext.commands.Bot):
|
||||
self.bot = bot
|
||||
self.session = DebateSession()
|
||||
|
||||
# ── 공개 API ──
|
||||
|
||||
async def start(self, ctx, topic: str):
|
||||
"""토론 시작."""
|
||||
if self.session.active:
|
||||
await ctx.reply("⚠️ 이미 진행 중인 토론이 있습니다. `!debate-stop`으로 먼저 종료하세요.")
|
||||
return
|
||||
|
||||
self.session = DebateSession(
|
||||
topic=topic,
|
||||
active=True,
|
||||
max_rounds=MAX_ROUNDS,
|
||||
)
|
||||
|
||||
# 시작 알림
|
||||
embed = discord.Embed(
|
||||
title="🏛️ Debate Room 시작",
|
||||
description=f"**주제**: {topic}\n**최대 라운드**: {MAX_ROUNDS}\n**참여자**: 🟢 Gemini · 🔵 Opus",
|
||||
color=0x9B59B6,
|
||||
)
|
||||
control_ch = self.bot.get_channel(DEBATE_CONTROL_CH)
|
||||
if control_ch:
|
||||
await control_ch.send(embed=embed)
|
||||
|
||||
# 첫 턴 시작
|
||||
await self._next_turn()
|
||||
|
||||
async def stop(self, ctx):
|
||||
"""토론 중단."""
|
||||
if not self.session.active:
|
||||
await ctx.reply("진행 중인 토론이 없습니다.")
|
||||
return
|
||||
|
||||
self.session.active = False
|
||||
await self._post_conclusion(ctx.channel)
|
||||
|
||||
async def inject(self, ctx, opinion: str):
|
||||
"""사용자 의견 삽입."""
|
||||
if not self.session.active:
|
||||
await ctx.reply("진행 중인 토론이 없습니다.")
|
||||
return
|
||||
|
||||
self.session.history.append({
|
||||
"speaker": "user",
|
||||
"content": opinion,
|
||||
})
|
||||
|
||||
embed = discord.Embed(
|
||||
title="👤 사용자 의견",
|
||||
description=opinion,
|
||||
color=0xF39C12,
|
||||
)
|
||||
control_ch = self.bot.get_channel(DEBATE_CONTROL_CH)
|
||||
if control_ch:
|
||||
await control_ch.send(embed=embed)
|
||||
|
||||
# 다음 턴에 반영
|
||||
await self._next_turn(user_injected=opinion)
|
||||
|
||||
async def on_agent_message(self, message: discord.Message):
|
||||
"""AG 채널에서 응답 수신 시 호출."""
|
||||
if not self.session.active:
|
||||
return
|
||||
if not self.session._response_event:
|
||||
return
|
||||
|
||||
# 어떤 에이전트가 응답했는지 확인
|
||||
agent_name = None
|
||||
for name, ch_id in DEBATE_AGENTS.items():
|
||||
if message.channel.id == ch_id:
|
||||
agent_name = name
|
||||
break
|
||||
|
||||
if not agent_name:
|
||||
return
|
||||
|
||||
# 현재 대기 중인 에이전트의 응답만 처리
|
||||
if agent_name != self.session.current_speaker:
|
||||
return
|
||||
|
||||
# 응답 저장 (여러 메시지 합치기 - 마지막 메시지로)
|
||||
self.session._last_response = message.content
|
||||
self.session._response_event.set()
|
||||
|
||||
# ── 내부 로직 ──
|
||||
|
||||
async def _next_turn(self, user_injected: str = ""):
|
||||
"""다음 턴 실행."""
|
||||
if not self.session.active:
|
||||
return
|
||||
|
||||
self.session.round += 1
|
||||
|
||||
if self.session.round > self.session.max_rounds:
|
||||
control_ch = self.bot.get_channel(DEBATE_CONTROL_CH)
|
||||
if control_ch:
|
||||
await control_ch.send("⏹️ 최대 라운드 도달. 토론을 종료합니다.")
|
||||
await self._post_conclusion(control_ch)
|
||||
return
|
||||
|
||||
# 발언자 선택 (랜덤, 단 직전과 다른 에이전트)
|
||||
agents = list(DEBATE_AGENTS.keys())
|
||||
if self.session.current_speaker:
|
||||
agents.remove(self.session.current_speaker)
|
||||
speaker = random.choice(agents)
|
||||
self.session.current_speaker = speaker
|
||||
|
||||
# 프롬프트 구성
|
||||
prompt = self._build_prompt(speaker, user_injected)
|
||||
|
||||
# 진행 상태 표시
|
||||
control_ch = self.bot.get_channel(DEBATE_CONTROL_CH)
|
||||
if control_ch:
|
||||
await control_ch.send(
|
||||
f"{AGENT_EMOJI[speaker]} **Round {self.session.round}** — "
|
||||
f"`{speaker}` 발언 대기 중..."
|
||||
)
|
||||
|
||||
# AG 채널에 프롬프트 전송
|
||||
agent_ch = self.bot.get_channel(DEBATE_AGENTS[speaker])
|
||||
if not agent_ch:
|
||||
logger.error(f"토론 채널 접근 불가: {speaker}")
|
||||
return
|
||||
|
||||
await agent_ch.send(prompt)
|
||||
|
||||
# 응답 대기
|
||||
self.session._response_event = asyncio.Event()
|
||||
self.session._last_response = ""
|
||||
|
||||
try:
|
||||
# 첫 응답 후 추가 메시지가 있을 수 있으므로 약간의 딜레이
|
||||
await asyncio.wait_for(
|
||||
self.session._response_event.wait(),
|
||||
timeout=RESPONSE_TIMEOUT,
|
||||
)
|
||||
# AG가 여러 메시지를 보낼 수 있으므로 잠시 대기
|
||||
await asyncio.sleep(5)
|
||||
except asyncio.TimeoutError:
|
||||
if control_ch:
|
||||
await control_ch.send(f"⏰ `{speaker}` 응답 시간 초과 ({RESPONSE_TIMEOUT}초)")
|
||||
return
|
||||
|
||||
response = self.session._last_response
|
||||
if not response:
|
||||
return
|
||||
|
||||
# 응답 기록
|
||||
self.session.history.append({
|
||||
"speaker": speaker,
|
||||
"content": response,
|
||||
})
|
||||
|
||||
# #variet-debate에 게시
|
||||
if control_ch:
|
||||
embed = discord.Embed(
|
||||
title=f"{AGENT_EMOJI[speaker]} Round {self.session.round} — {speaker}",
|
||||
description=response[:4000],
|
||||
color=AGENT_COLORS[speaker],
|
||||
)
|
||||
if len(response) > 4000:
|
||||
embed.add_field(
|
||||
name="(계속)",
|
||||
value=response[4000:4000+1024] + "...",
|
||||
inline=False,
|
||||
)
|
||||
await control_ch.send(embed=embed)
|
||||
|
||||
# 사용자에게 계속 진행 여부 물어보기
|
||||
await control_ch.send(
|
||||
f"**다음 턴으로 진행할까요?**\n"
|
||||
f"✅ `!debate-next` — 계속\n"
|
||||
f"✏️ `!debate-inject 의견` — 의견 추가 후 계속\n"
|
||||
f"⏹️ `!debate-stop` — 종료"
|
||||
)
|
||||
|
||||
def _build_prompt(self, speaker: str, user_injected: str = "") -> str:
|
||||
"""에이전트에게 보낼 프롬프트 구성."""
|
||||
other = "opus" if speaker == "gemini" else "gemini"
|
||||
|
||||
parts = [f"## 토론 주제: {self.session.topic}\n"]
|
||||
parts.append(f"현재 라운드: {self.session.round}/{self.session.max_rounds}\n")
|
||||
|
||||
# 직전 발언 포함
|
||||
prev = [h for h in self.session.history if h["speaker"] != "user"]
|
||||
if prev:
|
||||
last = prev[-1]
|
||||
parts.append(f"### 상대방({last['speaker']})의 직전 발언:\n")
|
||||
parts.append(last["content"])
|
||||
parts.append("\n\n---\n")
|
||||
parts.append(
|
||||
f"위 발언에 오류가 없는지 확인하고, "
|
||||
f"더 개선된 방향이나 잘못된 부분을 지적하세요.\n"
|
||||
)
|
||||
|
||||
# 사용자 의견
|
||||
if user_injected:
|
||||
parts.append(f"\n### 사용자 의견:\n{user_injected}\n")
|
||||
parts.append("사용자의 의견을 반영하여 답변하세요.\n")
|
||||
|
||||
if not prev and not user_injected:
|
||||
parts.append(
|
||||
"당신이 첫 번째 발언자입니다. "
|
||||
"주제에 대해 자유롭게 의견을 제시하세요.\n"
|
||||
)
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
async def _post_conclusion(self, channel):
|
||||
"""토론 종료 — 결론 정리."""
|
||||
self.session.active = False
|
||||
|
||||
summary_parts = []
|
||||
for h in self.session.history:
|
||||
emoji = AGENT_EMOJI.get(h["speaker"], "👤")
|
||||
content = h["content"][:300]
|
||||
summary_parts.append(f"{emoji} **{h['speaker']}**: {content}...")
|
||||
|
||||
embed = discord.Embed(
|
||||
title="🏛️ 토론 종료",
|
||||
description=f"**주제**: {self.session.topic}\n"
|
||||
f"**라운드**: {self.session.round}\n\n"
|
||||
+ "\n\n".join(summary_parts[-6:]), # 최근 6개만
|
||||
color=0x9B59B6,
|
||||
)
|
||||
await channel.send(embed=embed)
|
||||
Reference in New Issue
Block a user