feat(debate): MVP 구현 — debate_handler + 커맨드 연동 (start/stop/next/inject)

This commit is contained in:
2026-03-19 21:40:50 +09:00
parent 834d2c7560
commit f07cb3a522
2 changed files with 325 additions and 7 deletions

View File

@@ -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
View 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)