From 541764dbaed56477c813fe9292769bae1761541a Mon Sep 17 00:00:00 2001 From: Variet Agent Date: Thu, 19 Mar 2026 22:08:13 +0900 Subject: [PATCH] =?UTF-8?q?feat(debate):=20=ED=8C=8C=EC=9D=BC=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=ED=86=A0=EB=A1=A0=20=ED=9D=90=EB=A6=84=20=E2=80=94?= =?UTF-8?q?=20AG=EA=B0=80=20response.md=EC=97=90=20=EC=9E=91=EC=84=B1,=20?= =?UTF-8?q?=EB=B4=87=EC=9D=B4=20=EC=9D=BD=EA=B3=A0=20=EB=B9=84=EC=9A=B0?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- handlers/debate_handler.py | 223 ++++++++++++++--------------- prompts/debate/participant_base.md | 44 ++---- 2 files changed, 121 insertions(+), 146 deletions(-) diff --git a/handlers/debate_handler.py b/handlers/debate_handler.py index 978a0b9..09dc898 100644 --- a/handlers/debate_handler.py +++ b/handlers/debate_handler.py @@ -1,13 +1,18 @@ """Debate Room Handler — AG 인스턴스 간 토론 중재. -#variet-debate 채널에서 !debate-start로 시작. -사회자(이 봇)가 #ag-debate_gemini와 #ag-debate_opus 사이에서 턴을 관리. +흐름: + 1. AG가 로컬 response.md에 전문 작성 + 2. Discord에 "작성 완료" 시그널 + 3. 사회자(봇)가 response.md 읽기 → NC transcript에 append → response.md 비우기 + 4. Discord #debate에 요약 게시 + 5. 상대 AG 채널에 지시 전달 """ import asyncio import logging import random from dataclasses import dataclass, field +from pathlib import Path from typing import Optional import discord @@ -21,30 +26,30 @@ DEBATE_AGENTS = { "opus": 1484156521209401476, # #ag-debate_opus } -# Embed 색상 -AGENT_COLORS = { - "gemini": 0x2ECC71, # 초록 - "opus": 0x3498DB, # 파랑 -} -AGENT_EMOJI = { - "gemini": "🟢", - "opus": "🔵", +# ── 로컬 프로젝트 경로 ── +AGENT_PATHS = { + "gemini": Path(r"C:\Users\Variet-Worker\Desktop\debate_gemini"), + "opus": Path(r"C:\Users\Variet-Worker\Desktop\debate_opus"), } +# Embed 색상 / 이모지 +AGENT_COLORS = {"gemini": 0x2ECC71, "opus": 0x3498DB} +AGENT_EMOJI = {"gemini": "🟢", "opus": "🔵"} + MAX_ROUNDS = 5 -RESPONSE_TIMEOUT = 180 # AG 응답 대기 (초) +RESPONSE_TIMEOUT = 300 # 긴 답변 대기 (5분) +FILE_POLL_INTERVAL = 5 # 파일 확인 간격 (초) @dataclass class DebateSession: topic: str = "" + topic_short: 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: @@ -60,23 +65,37 @@ class DebateHandler: await ctx.reply("⚠️ 이미 진행 중인 토론이 있습니다. `!debate-stop`으로 먼저 종료하세요.") return + # 약어 생성 (첫 10자, 공백은 _) + short = topic[:10].replace(" ", "_").replace("/", "_") + self.session = DebateSession( topic=topic, + topic_short=short, active=True, max_rounds=MAX_ROUNDS, ) + # 각 AG 프로젝트에 response.md 초기화 + for name, path in AGENT_PATHS.items(): + resp_file = path / "response.md" + resp_file.write_text("", encoding="utf-8") + # 시작 알림 embed = discord.Embed( title="🏛️ Debate Room 시작", - description=f"**주제**: {topic}\n**최대 라운드**: {MAX_ROUNDS}\n**참여자**: 🟢 Gemini · 🔵 Opus", + description=( + f"**주제**: {topic}\n" + f"**최대 라운드**: {MAX_ROUNDS}\n" + f"**참여자**: 🟢 Gemini · 🔵 Opus\n\n" + f"AG는 `response.md`에 답변을 작성합니다." + ), 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): @@ -84,7 +103,6 @@ class DebateHandler: if not self.session.active: await ctx.reply("진행 중인 토론이 없습니다.") return - self.session.active = False await self._post_conclusion(ctx.channel) @@ -94,72 +112,65 @@ class DebateHandler: await ctx.reply("진행 중인 토론이 없습니다.") return - self.session.history.append({ - "speaker": "user", - "content": opinion, - }) + self.session.history.append({"speaker": "user", "content": opinion}) embed = discord.Embed( - title="👤 사용자 의견", - description=opinion, - color=0xF39C12, + 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 채널에서 응답 수신 시 호출.""" + """AG 채널에서 메시지 수신 — response.md 파일 완성 시그널로 사용.""" 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: + if not agent_name or agent_name != self.session.current_speaker: return - # 현재 대기 중인 에이전트의 응답만 처리 - if agent_name != self.session.current_speaker: - return - - # 응답 저장 (여러 메시지 합치기 - 마지막 메시지로) - self.session._last_response = message.content - self.session._response_event.set() + # response.md에 내용이 있는지 확인 + resp_file = AGENT_PATHS[agent_name] / "response.md" + if resp_file.exists(): + content = resp_file.read_text(encoding="utf-8").strip() + if content: + await self._process_response(agent_name, content) # ── 내부 로직 ── 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 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 - # 프롬프트 구성 + # response.md 비우기 + resp_file = AGENT_PATHS[speaker] / "response.md" + resp_file.write_text("", encoding="utf-8") + + # 사회자(Flash)가 프롬프트 생성 prompt = await self._build_prompt(speaker, user_injected) # 진행 상태 표시 @@ -167,158 +178,134 @@ class DebateHandler: if control_ch: await control_ch.send( f"{AGENT_EMOJI[speaker]} **Round {self.session.round}** — " - f"`{speaker}` 발언 대기 중..." + f"`{speaker}` 답변 대기 중...\n" + f"📝 `response.md`에 작성 후 Discord에 알려주세요." ) - # AG 채널에 프롬프트 전송 + # AG 채널에 프롬프트 전송 (분할) agent_ch = self.bot.get_channel(DEBATE_AGENTS[speaker]) if not agent_ch: logger.error(f"토론 채널 접근 불가: {speaker}") return - await agent_ch.send(prompt) + # 2000자씩 분할 전송 + for i in range(0, len(prompt), 1900): + await agent_ch.send(prompt[i:i+1900]) - # 응답 대기 - 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 - - # 응답 기록 + async def _process_response(self, speaker: str, content: str): + """AG 응답 처리 — 기록 + 게시 + 파일 비우기.""" + # 히스토리에 추가 self.session.history.append({ "speaker": speaker, - "content": response, + "content": content, }) - # #variet-debate에 게시 + # response.md 비우기 + resp_file = AGENT_PATHS[speaker] / "response.md" + resp_file.write_text("", encoding="utf-8") + + # NC에 transcript 저장 (TODO: nc_client 연동) + # await self._append_to_nc_transcript(speaker, content) + + # #variet-debate에 요약 게시 + control_ch = self.bot.get_channel(DEBATE_CONTROL_CH) if control_ch: + # 요약 (첫 500자) + summary = content[:500] + if len(content) > 500: + summary += f"\n\n... (전문 {len(content)}자)" + embed = discord.Embed( title=f"{AGENT_EMOJI[speaker]} Round {self.session.round} — {speaker}", - description=response[:4000], + description=summary, 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"**다음 턴?**\n" f"✅ `!debate-next` — 계속\n" f"✏️ `!debate-inject 의견` — 의견 추가 후 계속\n" f"⏹️ `!debate-stop` — 종료" ) async def _build_prompt(self, speaker: str, user_injected: str = "") -> str: - """사회자(Flash)가 에이전트에게 보낼 프롬프트를 생성.""" + """사회자(Flash)가 에이전트에게 보낼 프롬프트 생성.""" other = "opus" if speaker == "gemini" else "gemini" - # 직전 상대 발언 가져오기 prev_opinions = [h for h in self.session.history if h["speaker"] != "user"] last_opponent = prev_opinions[-1] if prev_opinions else None - - # 사용자 발언 가져오기 user_opinions = [h for h in self.session.history if h["speaker"] == "user"] last_user = user_opinions[-1] if user_opinions else None - # 사회자에게 보내는 메타 프롬프트 moderator_prompt = f"""당신은 AI 토론 사회자입니다. -두 AI 토론자({', '.join(DEBATE_AGENTS.keys())}) 사이에서 턴을 관리합니다. ## 현재 상황 - 토론 주제: {self.session.topic} -- 현재 라운드: {self.session.round}/{self.session.max_rounds} +- 라운드: {self.session.round}/{self.session.max_rounds} - 다음 발언자: {speaker} - 상대: {other} """ - if last_opponent: moderator_prompt += f""" ## 상대방({last_opponent['speaker']})의 직전 발언 (전문): {last_opponent['content']} """ - if last_user: moderator_prompt += f""" -## 사용자의 의견/판단: +## 사용자의 의견: {last_user['content']} """ - if user_injected: moderator_prompt += f""" -## 사용자가 방금 추가한 의견: +## 사용자 추가 의견: {user_injected} """ - moderator_prompt += f""" ## 지시 다음 발언자({speaker})에게 보낼 메시지를 작성하세요. 규칙: -1. 상대방의 발언이 있으면 **전문을 그대로 포함**하세요 (요약하지 마세요) -2. 이 발언이 **사용자의 어떤 판단이나 의견에 기반**하는지 설명하세요 -3. 상대 발언의 **오류나 논리적 허점을 확인**하고, 개선된 방향이나 잘못된 부분을 지적하라고 지시하세요 -4. 현재 토론의 **핵심 쟁점**이 무엇인지 방향을 제시하세요 -5. 첫 발언이면, 주제를 잘 설명하고 자유롭게 의견을 제시하라고 안내하세요 +1. 상대 발언이 있으면 **전문 그대로 포함** +2. 사용자의 어떤 판단에 기반하는지 설명 +3. 오류/논리적 허점 확인 + 개선 방향 지시 +4. 핵심 쟁점 방향 제시 +5. 첫 발언이면 주제 설명 + 자유 의견 안내 +6. **중요: 답변을 response.md 파일에 작성하라고 안내** -(사회자의 메시지만 출력하세요. 다른 설명은 불필요합니다.) +(사회자 메시지만 출력) """ try: from core.gemini_caller import GeminiCaller caller = GeminiCaller() - moderated = await caller.call_simple(moderator_prompt, timeout=60) - return moderated + return await caller.call_simple(moderator_prompt, timeout=60) except Exception as e: - logger.error(f"사회자 Flash 호출 실패: {e}, 기본 프롬프트 사용") - # fallback: 기본 프롬프트 + logger.error(f"Flash 호출 실패: {e}") return self._build_fallback_prompt(speaker, user_injected) def _build_fallback_prompt(self, speaker: str, user_injected: str = "") -> str: - """Flash 호출 실패 시 기본 프롬프트.""" - parts = [f"## 토론 주제: {self.session.topic}\n"] - parts.append(f"현재 라운드: {self.session.round}/{self.session.max_rounds}\n") + """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") - parts.append(last["content"]) - parts.append("\n\n---\n") - parts.append( - "위 발언에 오류가 없는지 확인하고, " - "더 개선된 방향이나 잘못된 부분을 지적하세요.\n" - ) + parts.append(f"### 상대방({last['speaker']})의 발언:\n{last['content']}") + parts.append("\n---\n오류를 확인하고 개선점을 지적하세요.") if user_injected: - parts.append(f"\n### 사용자 의견:\n{user_injected}\n") + parts.append(f"\n### 사용자 의견:\n{user_injected}") if not prev and not user_injected: - parts.append("당신이 첫 번째 발언자입니다. 주제에 대해 의견을 제시하세요.\n") + parts.append("첫 번째 발언자입니다. 주제에 대해 의견을 제시하세요.") + parts.append("\n\n**답변은 response.md 파일에 작성하세요.**") return "\n".join(parts) async def _post_conclusion(self, channel): - """토론 종료 — 결론 정리.""" + """토론 종료.""" self.session.active = False summary_parts = [] @@ -329,9 +316,11 @@ class DebateHandler: embed = discord.Embed( title="🏛️ 토론 종료", - description=f"**주제**: {self.session.topic}\n" - f"**라운드**: {self.session.round}\n\n" - + "\n\n".join(summary_parts[-6:]), # 최근 6개만 + description=( + f"**주제**: {self.session.topic}\n" + f"**라운드**: {self.session.round}\n\n" + + "\n\n".join(summary_parts[-6:]) + ), color=0x9B59B6, ) await channel.send(embed=embed) diff --git a/prompts/debate/participant_base.md b/prompts/debate/participant_base.md index afcccf8..e69a04d 100644 --- a/prompts/debate/participant_base.md +++ b/prompts/debate/participant_base.md @@ -2,37 +2,22 @@ 당신은 AI 토론 참여자입니다. 사회자의 지시에 따라 주어진 주제에 대해 의견을 제시하고, 상대방의 주장을 검증합니다. +## ⚠️ 답변 방식 (필수) + +**답변은 반드시 프로젝트 루트의 `response.md` 파일에 작성하세요.** + +- Discord에는 "답변을 response.md에 작성했습니다"라고만 게시 +- response.md에는 길이 제한 없이 전문을 작성 +- "이하생략", "나머지는 생략" 절대 금지 — 전부 작성 + ## 행동 규칙 -1. **사회자 지시 우선** — 사회자가 보내는 메시지에 주제, 상대 의견, 페르소나, 방향이 포함됩니다. 이를 따르세요. -2. **전문으로 답변** — 요약하지 마세요. 논거를 구체적으로 전개하세요. -3. **상대 의견 검증** — 상대 의견이 있을 때, 오류·누락·논리적 허점을 먼저 확인하세요. -4. **개선안 제시** — 단순 반론이 아니라 대안/보강을 함께 제시하세요. -5. **근거 명시** — 주장에는 기술적 근거, 사례, 레퍼런스를 포함하세요. -6. **합의 가능 시 인정** — 상대 의견이 맞으면 솔직히 인정하고 발전시키세요. - -## 응답 형식 - -반드시 아래 JSON 형식으로 응답하세요: - -```json -{ - "opinion": "여기에 전문 의견 작성 (마크다운 가능)", - "wiki_action": { - "page": "Debates/{주제}/Working-Document", - "section": "섹션명", - "action": "append|replace", - "content": "Wiki에 추가할 내용 (생략 가능)" - }, - "agreement_level": "disagree|partial|agree", - "key_points": ["핵심 논점 1", "핵심 논점 2"], - "questions_for_opponent": ["상대에게 묻고 싶은 질문 (선택)"] -} -``` - -- `wiki_action`: Wiki 문서 수정이 필요하면 포함. 불필요하면 `null`. -- `agreement_level`: 상대 의견에 대한 동의 수준. -- `questions_for_opponent`: 상대에게 던지는 질문 (다음 턴에 전달됨). +1. **사회자 지시 우선** — 사회자가 보내는 메시지에 주제, 상대 의견, 방향이 포함됩니다 +2. **전문으로 답변** — 요약하지 마세요. 논거를 구체적으로 전개하세요 +3. **상대 의견 검증** — 오류·누락·논리적 허점을 먼저 확인하세요 +4. **개선안 제시** — 단순 반론이 아니라 대안/보강을 함께 제시하세요 +5. **근거 명시** — 주장에는 기술적 근거, 사례, 레퍼런스를 포함하세요 +6. **합의 가능 시 인정** — 상대 의견이 맞으면 솔직히 인정하고 발전시키세요 ## 금지 사항 @@ -40,3 +25,4 @@ - ❌ 사회자 지시 무시 - ❌ 주제에서 벗어난 발언 - ❌ 근거 없는 주장 +- ❌ "이하생략" 또는 답변 축약