Files
variet-agent/handlers/debate_handler.py

583 lines
22 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 경로용
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
# ═══════════════════════════════════════════
# 공개 API
# ═══════════════════════════════════════════
async def start(self, ctx, topic: str):
"""토론 시작 — 자동 루프 개시."""
if self.session.active:
await ctx.reply("⚠️ 이미 진행 중. `!debate-stop`으로 먼저 종료.")
return
# slug 생성
try:
from tools.wiki_client import WikiClient
slug = WikiClient.slugify(topic)
except Exception:
slug = topic[:15].replace(" ", "-").lower()
self.session = DebateSession(
topic=topic, topic_slug=slug,
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 채널 메시지 감지 — response.md 체크 트리거."""
# 별도 처리 불필요 — auto_loop에서 파일 폴링으로 처리
pass
# ═══════════════════════════════════════════
# 자동 토론 루프
# ═══════════════════════════════════════════
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:
"""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}` 답변 대기 중..."
)
start = time.time()
last_size = 0
stable_since = 0
while time.time() - start < RESPONSE_TIMEOUT:
if not self.session.active:
return ""
await asyncio.sleep(FILE_CHECK_INTERVAL)
if not resp_path.exists():
continue
content = resp_path.read_text(encoding="utf-8").strip()
if not content:
continue
current_size = len(content)
if current_size == last_size and current_size > 0:
# 파일 크기 안정 — 작성 완료로 판단
if stable_since == 0:
stable_since = time.time()
elif time.time() - stable_since >= FILE_STABLE_DELAY:
return content
else:
last_size = current_size
stable_since = 0
# 타임아웃
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. 첫 발언이면 주제 설명 + 자유 의견 안내
6. **답변은 response.md에 작성하라고 안내**
7. **참고할 합의 사항은 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.topic} — 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.topic} — Round Log",
updated_log,
)
async def _wiki_upsert(self, path: str, title: str, content: str):
"""Wiki.js에 페이지 upsert."""
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오류를 확인하고 개선점을 지적하세요.")
else:
parts.append("첫 번째 발언자입니다. 주제에 대해 의견을 제시하세요.")
parts.append("\n\n**답변은 response.md에 작성하세요.**")
parts.append("**합의 사항은 wiki/working_document.md를 참고하세요.**")
return "\n".join(parts)