529 lines
20 KiB
Python
529 lines
20 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 = ""
|
|
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
|
|
|
|
self.session = DebateSession(
|
|
topic=topic, 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,
|
|
})
|
|
|
|
# ⑤ Working Document 통합 편집 (Flash)
|
|
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에 통합 편집."""
|
|
# 현재 working document 읽기
|
|
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:
|
|
# gemini 폴더에 저장 (sync에서 양쪽 복사)
|
|
wd_path.parent.mkdir(parents=True, exist_ok=True)
|
|
wd_path.write_text(updated, encoding="utf-8")
|
|
logger.info(f"Working Document 업데이트: {len(updated)}자")
|
|
else:
|
|
logger.warning("Working Document 업데이트 실패 — Flash 응답 부족")
|
|
except Exception as e:
|
|
logger.error(f"Working Document 업데이트 오류: {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)
|