"""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 = "" 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() # ═══════════════════════════════════════════ # 공개 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, ) # 폴더 초기화 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 채널 메시지 감지 — 완료 시그널이면 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) # ⑥ 양쪽 wiki/ 동기화 self._sync_wiki() # ⑦ #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: """AG의 Discord 완료 메시지 대기 후 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}` 답변 대기 중..." ) self._response_event.clear() try: # Discord에서 "작업 종료" 메시지가 올 때까지 대기 await asyncio.wait_for(self._response_event.wait(), timeout=RESPONSE_TIMEOUT) if not self.session.active: return "" # 완료 시그널을 받으면 파일 읽기 if resp_path.exists(): content = resp_path.read_text(encoding="utf-8").strip() if content: return content else: if ctrl: await ctrl.send(f"⚠️ `{speaker}` 완료 신호를 받았으나 `response.md`가 비어있습니다.") 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})에게 보낼 메시지를 작성하세요. 1. 사용자의 판단이 있으면 맥락 설명 2. 상대방의 최근 발언을 바탕으로 오류/허점 확인 + 개선 방향 지시 3. 핵심 쟁점 방향 제시 4. 첫 발언이면 주제 설명 + 자유 의견 안내 5. **답변은 전문으로 response.md에 작성하라고 안내** 6. **진행 상황과 상대측 의견 내용은 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) # ═══════════════════════════════════════════ # 유틸 # ═══════════════════════════════════════════ async def _update_working_document(self, speaker: str, response: str): """Flash가 AG 의견을 Working Document에 통합 편집 + Wiki.js 업로드.""" wd_path = AGENT_PATHS["gemini"] / "wiki" / "working_document.md" current_doc = "" if wd_path.exists(): current_doc = wd_path.read_text(encoding="utf-8").strip() 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: wd_path.parent.mkdir(parents=True, exist_ok=True) wd_path.write_text(updated, encoding="utf-8") logger.info(f"Working Document 업데이트: {len(updated)}자") # Wiki.js에 업로드 await self._wiki_upsert( f"debates/{self.session.topic_slug}/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 업로드.""" log_path = AGENT_PATHS["gemini"] / "wiki" / "round_log.md" # 기존 내용 읽기 existing = "" if log_path.exists(): existing = log_path.read_text(encoding="utf-8") 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 log_path.parent.mkdir(parents=True, exist_ok=True) log_path.write_text(updated_log, encoding="utf-8") # Wiki.js에 업로드 await self._wiki_upsert( f"debates/{self.session.topic_slug}/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 _sync_wiki(self): """gemini wiki/ 폴더 내용을 opus wiki/에 동기화.""" src = AGENT_PATHS["gemini"] / "wiki" dst = AGENT_PATHS["opus"] / "wiki" dst.mkdir(parents=True, exist_ok=True) for f in src.iterdir(): if f.is_file(): (dst / f.name).write_text( f.read_text(encoding="utf-8"), encoding="utf-8", ) 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: parts.append("\n---\n상대방의 의견에 대한 오류를 확인하고 개선점을 지적하세요.") else: parts.append("첫 번째 발언자입니다. 주제에 대해 의견을 제시하세요.") parts.append("\n\n**답변은 response.md에 전문으로 작성하세요.**") parts.append("**상대방의 의견 내용과 합의 사항은 wiki/working_document.md를 참고하세요.**") return "\n".join(parts)