"""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 typing import Optional import discord logger = logging.getLogger("variet.debate") # ── 채널 ID ── DEBATE_CONTROL_CH = 1484167157620146227 # #variet-debate DEBATE_AGENTS = { "gemini": 1484156194187771905, "opus": 1484156521209401476, } 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 = "" topic_slug: str = "" # Wiki 경로용 wiki_title: str = "" # Wiki 페이지 짧은 제목 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 self._response_event = asyncio.Event() self._wiki = None # lazy init def _get_wiki(self): """WikiClient lazy 초기화.""" if self._wiki is None: from tools.wiki_client import WikiClient self._wiki = WikiClient() return self._wiki def _wiki_path(self, page: str) -> str: """Wiki.js debate 페이지 경로 생성.""" return f"debates/{self.session.topic_slug}/{page}" # ═══════════════════════════════════════════ # 공개 API # ═══════════════════════════════════════════ async def start(self, ctx, topic: str): """토론 시작 — 자동 루프 개시.""" if self.session.active: await ctx.reply("⚠️ 이미 진행 중. `!debate-stop`으로 먼저 종료.") return # slug 및 짧은 제목 생성 try: from core.gemini_caller import GeminiCaller caller = GeminiCaller() short_title = await caller.call_simple( f"다음 토론 주제를 3~5단어의 짧고 명확한 제목으로 요약해주세요. 마크다운 기호 없이 텍스트만 출력하세요.\n\n주제: {topic}", timeout=30 ) short_title = short_title.strip() if len(short_title) > 50: short_title = short_title[:50] from tools.wiki_client import WikiClient slug = WikiClient.slugify(short_title) if len(slug) > 80: slug = slug[:80].rstrip("-") except Exception as e: logger.warning(f"짧은 제목 생성 실패: {e}") short_title = topic[:20] slug = short_title.replace(" ", "-").lower() self.session = DebateSession( topic=topic, topic_slug=slug, wiki_title=short_title, active=True, max_rounds=MAX_ROUNDS, ) # Wiki.js 초기 페이지 생성 wiki = self._get_wiki() for page in ["working-document", "round-log", "control", "input-gemini", "input-opus", "response-gemini", "response-opus"]: try: if page == "working-document": init_content = f"# {self.session.topic}\n\n*(토론 시작 대기 중)*\n" else: init_content = f"*(대기 중)*\n" await wiki.upsert_page( self._wiki_path(page), f"{short_title} — {page}", init_content, tags=["debate", slug], ) except Exception as e: logger.warning(f"Wiki 초기화 실패 ({page}): {e}") # 시작 알림 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 채널 메시지 감지 — 완료 시그널이면 event set.""" if not self.session.active: return active_ch_id = DEBATE_AGENTS.get(self.session.current_speaker) if message.channel.id != active_ch_id: return content = message.content or "" embed_texts = " ".join([e.description for e in message.embeds if e.description]) full_text = content + " " + embed_texts if "작업 종료" in full_text or "작성 완료" in full_text: logger.info(f"[{self.session.current_speaker}] 완료 시그널 감지: {full_text[:50]}") self._response_event.set() # ═══════════════════════════════════════════ # 자동 토론 루프 # ═══════════════════════════════════════════ 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, }) # ⑤ Round Log에 대화 전문 기록 (로컬 + Wiki.js) await self._append_round_log(speaker, response) # ⑥ Working Document 통합 편집 (Flash + Wiki.js) await self._update_working_document(speaker, 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): """사회자가 Wiki.js input 페이지 작성 + response 초기화.""" wiki = self._get_wiki() # ① response 페이지 초기화 (이전 답변 누적 방지) await self._wiki_upsert( self._wiki_path(f"response-{speaker}"), f"Response - {speaker}", "*(응답 대기 중)*\n", ) # ② 사회자(Flash) 해설 생성 moderator_commentary = await self._build_moderator_prompt(speaker) # ③ 상대방 전문을 코드에서 직접 삽입 prev = [h for h in self.session.history if h["speaker"] != "user"] opponent_section = "" if prev: last = prev[-1] opponent_section = ( f"\n\n---\n\n" f"## 상대방({last['speaker']})의 발언 원본 (전문)\n\n" f"{last['content']}\n\n" f"---\n" ) # ④ input = 사회자 해설 + 상대방 전문 full_input = moderator_commentary + opponent_section await self._wiki_upsert( self._wiki_path(f"input-{speaker}"), f"Input - {speaker} (Round {self.session.round})", full_input, ) 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 slug = self.session.topic_slug await ch.send( f"📥 **Round {rd}** — slug: `{slug}`\n" f"Wiki.js에서 `debates/{slug}/input-{speaker}`를 읽고 " f"`debates/{slug}/response-{speaker}`에 답변을 작성하세요." ) async def _wait_for_response(self, speaker: str) -> str: """AG의 Discord 완료 메시지 대기 후 Wiki.js response 읽기.""" ctrl = self.bot.get_channel(DEBATE_CONTROL_CH) if ctrl: await ctrl.send( f"{AGENT_EMOJI[speaker]} **Round {self.session.round}** — " f"`{speaker}` 답변 대기 중..." ) self._response_event.clear() try: # Discord에서 "step XX 종료" 또는 "작성 완료" 메시지가 올 때까지 대기 await asyncio.wait_for(self._response_event.wait(), timeout=RESPONSE_TIMEOUT) if not self.session.active: return "" # 완료 시그널을 받으면 Wiki.js에서 response 읽기 wiki = self._get_wiki() page = await wiki.find_page(self._wiki_path(f"response-{speaker}")) if page and page.content.strip(): return page.content.strip() else: if ctrl: await ctrl.send( f"⚠️ `{speaker}` 완료 신호를 받았으나 " f"`response-{speaker}` 페이지가 비어있습니다." ) return "" except asyncio.TimeoutError: 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})에게 보낼 **사회자 해설/방향 지시**를 작성하세요. (상대방 발언 전문은 시스템이 별도로 `input.md`에 포함합니다 — 여기서 복사할 필요 없음) 1. 사용자의 판단이 있으면 맥락 설명 2. 상대방 발언의 핵심 포인트 정리 및 검증 방향 안내 3. 현재 `wiki/working_document.md`에 사회자가 내용을 올바르게 취합/합의했는지 검증하라고 지시 4. 상대방 주장의 오류/허점 확인 + 개선 방향 지시 5. 핵심 쟁점 방향 제시 6. 첫 발언이면 주제 설명 + 자유 의견 안내 7. **답변은 전문으로 response.md에 작성하라고 안내** 8. **진행 상황과 합의된 전체 내용은 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): """토론 종료 — Discord + Wiki.js에 결론 기록.""" self.session.active = False # Working Document 최종본 읽기 (Wiki.js) wiki = self._get_wiki() final_doc = "" try: wd_page = await wiki.find_page(self._wiki_path("working-document")) if wd_page: final_doc = wd_page.content.strip() except Exception as e: logger.warning(f"Working Document 읽기 실패: {e}") # Discord에 요약 게시 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) # Wiki.js에 Conclusion 페이지 업로드 conclusion_content = ( f"# {self.session.topic} — 결론\n\n" f"**총 라운드**: {self.session.round}\n\n" f"---\n\n" f"## 최종 Working Document\n\n" f"{final_doc if final_doc else '(문서 없음)'}\n" ) await self._wiki_upsert( f"debates/{self.session.topic_slug}/conclusion", f"{self.session.wiki_title} — Conclusion", conclusion_content, ) # ═══════════════════════════════════════════ # 유틸 # ═══════════════════════════════════════════ async def _update_working_document(self, speaker: str, response: str): """Flash가 AG 의견을 Working Document에 통합 편집 (Wiki.js).""" # 현재 문서 읽기 (Wiki.js) wiki = self._get_wiki() current_doc = "" try: wd_page = await wiki.find_page(self._wiki_path("working-document")) if wd_page: current_doc = wd_page.content.strip() except Exception: pass merge_prompt = f"""당신은 문서 편집자입니다. ## 작업 아래 '현재 문서'에 토론자({speaker})의 의견을 **통합**하여 문서를 업데이트하세요. ## 규칙 1. 단순 대화 기록이 아니라 **구체적인 설계 문서/보고서** 형태로 작성 2. 토론자가 제안한 구체적 내용(아키텍처, 기술 선택, 구현 방안 등)을 문서에 반영 3. 이전에 합의된 내용과 충돌하면 **최신 의견을 우선** 반영하되 변경 이유를 명시 4. 아직 합의되지 않은 쟁점은 '미결 사항' 섹션에 정리 5. 문서는 깔끔한 마크다운으로 작성 6. **전체 문서를 출력** (변경 부분만이 아니라) ## 주제: {self.session.topic} ## 현재 라운드: {self.session.round}/{self.session.max_rounds} ## 현재 문서: {current_doc if current_doc else '(아직 없음 — 새로 작성하세요)'} ## {speaker}의 이번 라운드 의견: {response[:6000]} --- 업데이트된 전체 문서를 출력하세요. """ try: from core.gemini_caller import GeminiCaller caller = GeminiCaller() updated = await caller.call_simple(merge_prompt, timeout=120) if updated and len(updated) > 50: logger.info(f"Working Document 업데이트: {len(updated)}자") await self._wiki_upsert( self._wiki_path("working-document"), f"{self.session.wiki_title} — Working Document", updated, ) else: logger.warning("Working Document 업데이트 실패") except Exception as e: logger.error(f"Working Document 업데이트 오류: {e}") async def _append_round_log(self, speaker: str, response: str): """Round Log에 대화 전문 append (Wiki.js).""" # 기존 내용 읽기 (Wiki.js) wiki = self._get_wiki() existing = "" try: log_page = await wiki.find_page(self._wiki_path("round-log")) if log_page: existing = log_page.content except Exception: pass emoji = AGENT_EMOJI.get(speaker, "👤") entry = ( f"\n---\n\n" f"## Round {self.session.round} — {emoji} {speaker}\n\n" f"{response}\n" ) updated_log = existing + entry await self._wiki_upsert( self._wiki_path("round-log"), f"{self.session.wiki_title} — Round Log", updated_log, ) async def _wiki_upsert(self, path: str, title: str, content: str): """Wiki.js에 페이지 upsert.""" if len(title) > 200: title = title[:197] + "..." try: from tools.wiki_client import WikiClient client = WikiClient() await client.upsert_page( path=path, title=title, content=content, tags=["debate", self.session.topic_slug], ) logger.info(f"Wiki.js 업로드: {path}") except Exception as e: logger.warning(f"Wiki.js 업로드 실패 ({path}): {e}") def _build_working_document(self) -> str: """초기 working document 생성 (첫 라운드용).""" return f"# {self.session.topic}\n\n*(토론 진행 중 — 내용이 채워집니다)*\n" 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상대방의 의견 전문을 바탕으로, 사회자가 `working_document.md`에 내용을 누락 없이 올바르게 취합했는지 검증하고, 주장의 오류를 확인하여 개선점을 지적하세요.") else: parts.append("첫 번째 발언자입니다. 주제에 대해 의견을 제시하세요.") parts.append("\n\n**답변은 response.md에 전문으로 작성하세요.**") parts.append("**합의 사항과 현재 문서는 wiki/working_document.md를 참고하세요.**") return "\n".join(parts)