Files
variet-agent/handlers/debate_handler.py
Variet Agent cbc9db0439 feat(debate): 분산 토론 시스템 — Wiki.js 기반 통신, AG 프로젝트 스캐폴딩, handler 리팩토링
- wiki_client.py: find_page()를 singleByPath 직접 조회로 교체 (O(1))
- debate-agent/: gemini/opus AG 프로젝트 생성 (GEMINI.md + wiki_debate.py + /start)
- debate_handler.py: 로컬 파일 I/O → Wiki.js API 전환, _sync_wiki() 삭제
- response 페이지 매 턴 초기화 (누적 방지)
2026-03-21 20:52:32 +09:00

649 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 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:
await wiki.upsert_page(
self._wiki_path(page),
f"{short_title}{page}",
f"# {self.session.topic}\n\n*(토론 시작 대기 중)*\n"
if page == "working-document" else "",
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}", "",
)
# ② 사회자(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
await ch.send(
f"📥 **Round {rd}** — `input.md`를 읽고 `response.md`에 답변을 작성하세요."
)
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)