feat(debate): 파일 기반 토론 흐름 — AG가 response.md에 작성, 봇이 읽고 비우기

This commit is contained in:
2026-03-19 22:08:13 +09:00
parent 4ac8ba98cc
commit 541764dbae
2 changed files with 121 additions and 146 deletions

View File

@@ -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)

View File

@@ -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 @@
- ❌ 사회자 지시 무시
- ❌ 주제에서 벗어난 발언
- ❌ 근거 없는 주장
- ❌ "이하생략" 또는 답변 축약