From f07cb3a52294f6f156ef54a9878794c6d99ddb2b Mon Sep 17 00:00:00 2001 From: Variet Agent Date: Thu, 19 Mar 2026 21:40:50 +0900 Subject: [PATCH] =?UTF-8?q?feat(debate):=20MVP=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=E2=80=94=20debate=5Fhandler=20+=20=EC=BB=A4=EB=A7=A8=EB=93=9C?= =?UTF-8?q?=20=EC=97=B0=EB=8F=99=20(start/stop/next/inject)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/discord_bot.py | 52 ++++++- handlers/debate_handler.py | 280 +++++++++++++++++++++++++++++++++++++ 2 files changed, 325 insertions(+), 7 deletions(-) create mode 100644 handlers/debate_handler.py diff --git a/api/discord_bot.py b/api/discord_bot.py index bfba843..3110072 100644 --- a/api/discord_bot.py +++ b/api/discord_bot.py @@ -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 diff --git a/handlers/debate_handler.py b/handlers/debate_handler.py new file mode 100644 index 0000000..a740cd0 --- /dev/null +++ b/handlers/debate_handler.py @@ -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)