Files
variet-agent/handlers/debate_handler.py

643 lines
25 KiB
Python

"""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) 해설 생성
moderator_commentary = await self._build_moderator_prompt(speaker)
# 상대방 전문을 코드에서 직접 삽입 (Flash의 요약에 의존하지 않음)
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.md = 사회자 해설 + 상대방 전문 (코드가 보장)
full_input = moderator_commentary + opponent_section
input_path = AGENT_PATHS[speaker] / "input.md"
input_path.write_text(full_input, encoding="utf-8")
# wiki/ — 기존 working_document.md가 있으면 그대로 유지
wiki_dir = AGENT_PATHS[speaker] / "wiki"
wiki_dir.mkdir(exist_ok=True)
wd_path = wiki_dir / "working_document.md"
if not wd_path.exists():
# 첫 라운드에만 초기 스텁 생성
wd_path.write_text(
self._build_working_document(), 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})에게 보낼 **사회자 해설/방향 지시**를 작성하세요.
(상대방 발언 전문은 시스템이 별도로 `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 최종본 읽기
wd_path = AGENT_PATHS["gemini"] / "wiki" / "working_document.md"
final_doc = ""
if wd_path.exists():
final_doc = wd_path.read_text(encoding="utf-8").strip()
# 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 업로드."""
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:
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)