"""Debate Room Handler v2 — 파일 기반 자동 토론. 흐름: 1. 사회자가 input.md + wiki/ 동기화 2. Discord 시그널로 AG에게 알림 3. AG가 response.md에 전문 작성 4. Discord 시그널 감지 → response.md 읽기 5. 합의 판정 (Flash) 6. 자동 반복 or 사용자 질문 or 종료 """ import asyncio import json import logging import random import time from dataclasses import dataclass, field from pathlib import Path from typing import Optional import discord logger = logging.getLogger("variet.debate") # ── 채널 ID ── DEBATE_CONTROL_CH = 1484167157620146227 # #variet-debate DEBATE_AGENTS = { "gemini": 1484156194187771905, "opus": 1484156521209401476, } # ── 로컬 프로젝트 경로 ── AGENT_PATHS = { "gemini": Path(r"C:\Users\Variet-Worker\Desktop\debate_gemini"), "opus": Path(r"C:\Users\Variet-Worker\Desktop\debate_opus"), } AGENT_COLORS = {"gemini": 0x2ECC71, "opus": 0x3498DB} AGENT_EMOJI = {"gemini": "🟢", "opus": "🔵"} MAX_ROUNDS = 10 RESPONSE_TIMEOUT = 300 # 5분 FILE_CHECK_INTERVAL = 3 # 파일 확인 간격 (초) FILE_STABLE_DELAY = 5 # 파일 작성 완료 대기 (초) @dataclass class DebateSession: topic: str = "" round: int = 0 max_rounds: int = MAX_ROUNDS active: bool = False paused: bool = False # 사용자 응답 대기 중 current_speaker: str = "" history: list = field(default_factory=list) pending_question: str = "" # 사용자에게 보낸 질문 class DebateHandler: def __init__(self, bot: discord.ext.commands.Bot): self.bot = bot self.session = DebateSession() self._debate_task: Optional[asyncio.Task] = None # ═══════════════════════════════════════════ # 공개 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, ) # 폴더 초기화 for name, path in AGENT_PATHS.items(): (path / "response.md").write_text("", encoding="utf-8") (path / "input.md").write_text("", encoding="utf-8") wiki_dir = path / "wiki" wiki_dir.mkdir(exist_ok=True) # 시작 알림 ctrl = self.bot.get_channel(DEBATE_CONTROL_CH) if ctrl: embed = discord.Embed( title="🏛️ Debate Room 시작", description=( f"**주제**: {topic}\n" f"**최대 라운드**: {MAX_ROUNDS}\n" f"**참여자**: 🟢 Gemini · 🔵 Opus\n" f"자동 진행 — 합의 시 또는 의견 필요 시 알립니다." ), color=0x9B59B6, ) await ctrl.send(embed=embed) # 자동 루프 시작 self._debate_task = asyncio.create_task(self._auto_loop()) async def stop(self, ctx): """토론 중단.""" if not self.session.active: await ctx.reply("진행 중인 토론이 없습니다.") return self.session.active = False if self._debate_task: self._debate_task.cancel() 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}) ctrl = self.bot.get_channel(DEBATE_CONTROL_CH) if ctrl: embed = discord.Embed( title="👤 사용자 의견 삽입", description=opinion[:500], color=0xF39C12, ) await ctrl.send(embed=embed) # 일시정지 상태였으면 재개 if self.session.paused: self.session.paused = False self.session.pending_question = "" async def on_agent_message(self, message: discord.Message): """AG 채널 메시지 감지 — response.md 체크 트리거.""" # 별도 처리 불필요 — auto_loop에서 파일 폴링으로 처리 pass # ═══════════════════════════════════════════ # 자동 토론 루프 # ═══════════════════════════════════════════ async def _auto_loop(self): """합의까지 자동 반복하는 메인 루프.""" try: while self.session.active: # 일시정지 상태면 대기 while self.session.paused: await asyncio.sleep(2) if not self.session.active: return self.session.round += 1 if self.session.round > self.session.max_rounds: ctrl = self.bot.get_channel(DEBATE_CONTROL_CH) if ctrl: await ctrl.send("⏹️ 최대 라운드 도달.") await self._post_conclusion(ctrl) return # 발언자 선택 (교대) speaker = self._pick_speaker() # ① input.md 작성 + wiki/ 동기화 await self._prepare_input(speaker) # ② Discord 시그널 전송 await self._send_signal(speaker) # ③ response.md 대기 response = await self._wait_for_response(speaker) if not response: continue # ④ 기록 self.session.history.append({ "speaker": speaker, "content": response, }) # ⑤ #debate에 요약 게시 await self._post_summary(speaker, response) # ⑥ 합의 판정 decision = await self._judge_consensus(speaker, response) if decision == "conclude": ctrl = self.bot.get_channel(DEBATE_CONTROL_CH) if ctrl: await ctrl.send("✅ **양측 합의 도달!** Wiki에 최종 기록합니다.") await self._post_conclusion(ctrl) return elif decision == "ask_user": self.session.paused = True ctrl = self.bot.get_channel(DEBATE_CONTROL_CH) if ctrl: await ctrl.send( f"❓ **사용자 의견 필요**\n" f"{self.session.pending_question}\n\n" f"`!debate-inject 의견` 으로 응답해주세요." ) # paused — 루프 상단에서 대기 # continue — 다음 턴 자동 진행 except asyncio.CancelledError: logger.info("토론 루프 취소됨") except Exception as e: logger.error(f"토론 루프 오류: {e}", exc_info=True) ctrl = self.bot.get_channel(DEBATE_CONTROL_CH) if ctrl: await ctrl.send(f"⚠️ 토론 오류: {str(e)[:200]}") # ═══════════════════════════════════════════ # 턴 관리 # ═══════════════════════════════════════════ def _pick_speaker(self) -> str: """교대 발언자 선택.""" 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 return speaker async def _prepare_input(self, speaker: str): """사회자가 input.md + wiki/ 작성.""" # 사회자(Flash) 프롬프트 생성 moderated_prompt = await self._build_moderator_prompt(speaker) # input.md 작성 input_path = AGENT_PATHS[speaker] / "input.md" input_path.write_text(moderated_prompt, encoding="utf-8") # wiki/ 동기화 (TODO: Wiki.js에서 가져와서 로컬에 쓰기) # 현재는 히스토리 기반으로 working_document 생성 wiki_dir = AGENT_PATHS[speaker] / "wiki" wiki_dir.mkdir(exist_ok=True) working_doc = self._build_working_document() (wiki_dir / "working_document.md").write_text( working_doc, encoding="utf-8", ) async def _send_signal(self, speaker: str): """AG에게 Discord 시그널.""" ch = self.bot.get_channel(DEBATE_AGENTS[speaker]) if not ch: return rd = self.session.round await ch.send( f"📥 **Round {rd}** — `input.md`를 읽고 `response.md`에 답변을 작성하세요." ) async def _wait_for_response(self, speaker: str) -> str: """response.md에 내용이 채워질 때까지 폴링.""" resp_path = AGENT_PATHS[speaker] / "response.md" # response.md 비우기 resp_path.write_text("", encoding="utf-8") ctrl = self.bot.get_channel(DEBATE_CONTROL_CH) if ctrl: await ctrl.send( f"{AGENT_EMOJI[speaker]} **Round {self.session.round}** — " f"`{speaker}` 답변 대기 중..." ) start = time.time() last_size = 0 stable_since = 0 while time.time() - start < RESPONSE_TIMEOUT: if not self.session.active: return "" await asyncio.sleep(FILE_CHECK_INTERVAL) if not resp_path.exists(): continue content = resp_path.read_text(encoding="utf-8").strip() if not content: continue current_size = len(content) if current_size == last_size and current_size > 0: # 파일 크기 안정 — 작성 완료로 판단 if stable_since == 0: stable_since = time.time() elif time.time() - stable_since >= FILE_STABLE_DELAY: return content else: last_size = current_size stable_since = 0 # 타임아웃 if ctrl: await ctrl.send(f"⏰ `{speaker}` 응답 시간 초과 ({RESPONSE_TIMEOUT}초)") return "" # ═══════════════════════════════════════════ # 사회자 로직 (Flash) # ═══════════════════════════════════════════ async def _build_moderator_prompt(self, speaker: str) -> str: """사회자(Flash)가 에이전트에게 보낼 input 생성.""" other = "opus" if speaker == "gemini" else "gemini" prev = [h for h in self.session.history if h["speaker"] != "user"] last_opponent = prev[-1] if prev else None user_msgs = [h for h in self.session.history if h["speaker"] == "user"] last_user = user_msgs[-1] if user_msgs else None meta = f"""당신은 AI 토론 사회자입니다. ## 상황 - 주제: {self.session.topic} - 라운드: {self.session.round}/{self.session.max_rounds} - 발언자: {speaker} / 상대: {other} """ if last_opponent: meta += f"\n## 상대방({last_opponent['speaker']}) 직전 발언 (전문):\n{last_opponent['content']}\n" if last_user: meta += f"\n## 사용자 의견:\n{last_user['content']}\n" meta += f""" ## 지시 발언자({speaker})에게 보낼 메시지를 작성하세요. 1. 상대 발언이 있으면 **전문 포함** (요약 금지) 2. 사용자의 판단이 있으면 맥락 설명 3. 오류/허점 확인 + 개선 방향 지시 4. 핵심 쟁점 방향 제시 5. 첫 발언이면 주제 설명 + 자유 의견 안내 6. **답변은 response.md에 작성하라고 안내** 7. **참고할 합의 사항은 wiki/working_document.md에 있다고 안내** (사회자 메시지만 출력) """ try: from core.gemini_caller import GeminiCaller caller = GeminiCaller() return await caller.call_simple(meta, timeout=90) except Exception as e: logger.error(f"Flash 호출 실패: {e}") return self._fallback_prompt(speaker) async def _judge_consensus(self, speaker: str, response: str) -> str: """합의 판정 — continue / ask_user / conclude.""" prev = [h for h in self.session.history if h["speaker"] != "user"] if len(prev) < 2: return "continue" judge_prompt = f"""당신은 AI 토론 심판입니다. ## 토론 주제: {self.session.topic} ## 라운드: {self.session.round}/{self.session.max_rounds} ## 최근 2개 발언: ### {prev[-2]['speaker']}: {prev[-2]['content'][:2000]} ### {prev[-1]['speaker']}: {prev[-1]['content'][:2000]} ## 판정 기준: - 양쪽이 핵심 사항에서 동의하고 있으면 → conclude - 한쪽 또는 양쪽이 사용자 의견을 구하고 있으면 → ask_user - 아직 대립 중이면 → continue 반드시 아래 JSON만 출력: {{"decision": "continue|ask_user|conclude", "reason": "판정 근거", "question": "사용자에게 물을 질문 (ask_user일 때)"}} """ try: from core.gemini_caller import GeminiCaller caller = GeminiCaller() raw = await caller.call_simple(judge_prompt, timeout=60) # JSON 파싱 raw = raw.strip() if "```" in raw: raw = raw.split("```")[1] if raw.startswith("json"): raw = raw[4:] raw = raw.strip() result = json.loads(raw) decision = result.get("decision", "continue") if decision == "ask_user": self.session.pending_question = result.get("question", "의견을 주세요.") logger.info(f"합의 판정: {decision} — {result.get('reason', '')}") return decision except Exception as e: logger.warning(f"합의 판정 실패: {e}, continue로 처리") return "continue" # ═══════════════════════════════════════════ # 출력 # ═══════════════════════════════════════════ async def _post_summary(self, speaker: str, content: str): """#debate에 요약 게시.""" ctrl = self.bot.get_channel(DEBATE_CONTROL_CH) if not ctrl: return summary = content[:800] if len(content) > 800: summary += f"\n\n... *(전문 {len(content)}자 — response.md 참조)*" embed = discord.Embed( title=f"{AGENT_EMOJI[speaker]} Round {self.session.round} — {speaker}", description=summary, color=AGENT_COLORS[speaker], ) await ctrl.send(embed=embed) async def _post_conclusion(self, channel): """토론 종료.""" self.session.active = False parts = [] for h in self.session.history[-8:]: emoji = AGENT_EMOJI.get(h["speaker"], "👤") parts.append(f"{emoji} **{h['speaker']}**: {h['content'][:200]}...") embed = discord.Embed( title="🏛️ 토론 종료", description=( f"**주제**: {self.session.topic}\n" f"**라운드**: {self.session.round}\n\n" + "\n\n".join(parts) ), color=0x9B59B6, ) await channel.send(embed=embed) # ═══════════════════════════════════════════ # 유틸 # ═══════════════════════════════════════════ def _build_working_document(self) -> str: """현재까지 합의/논의 사항을 working document로 생성.""" lines = [f"# {self.session.topic} — Working Document\n"] lines.append(f"라운드: {self.session.round}/{self.session.max_rounds}\n") lines.append("---\n") for h in self.session.history: emoji = AGENT_EMOJI.get(h["speaker"], "👤") lines.append(f"## {emoji} {h['speaker']} (Round {self.session.history.index(h)+1})") lines.append(h["content"][:3000]) lines.append("\n---\n") return "\n".join(lines) def _fallback_prompt(self, speaker: str) -> str: """Flash 실패 시 기본 프롬프트.""" parts = [f"## 토론 주제: {self.session.topic}"] 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{last['content']}") parts.append("\n---\n오류를 확인하고 개선점을 지적하세요.") else: parts.append("첫 번째 발언자입니다. 주제에 대해 의견을 제시하세요.") parts.append("\n\n**답변은 response.md에 작성하세요.**") parts.append("**합의 사항은 wiki/working_document.md를 참고하세요.**") return "\n".join(parts)