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 페이지 매 턴 초기화 (누적 방지)
This commit is contained in:
2026-03-21 20:52:12 +09:00
parent 5fb4179857
commit cbc9db0439
10 changed files with 633 additions and 89 deletions

View File

@@ -68,11 +68,13 @@ Discord 메시지
| NC 4모듈 | `tools/nc_*.py` | ✅ | Files/Calendar/Mail/Contacts | | NC 4모듈 | `tools/nc_*.py` | ✅ | Files/Calendar/Mail/Contacts |
| AI Foreman | `core/foreman.py` | ✅ | 목표 분해 + 상담 + Vikunja 등록 | | AI Foreman | `core/foreman.py` | ✅ | 목표 분해 + 상담 + Vikunja 등록 |
| 에이전트 워크플로우 | `.agent/` | ✅ | STATUS.md + 수칙 업데이트 | | 에이전트 워크플로우 | `.agent/` | ✅ | STATUS.md + 수칙 업데이트 |
| Wiki.js | `tools/wiki_client.py` | ✅ | GraphQL CRUD + 대시보드 | | Wiki.js | `tools/wiki_client.py` | ✅ | GraphQL CRUD + 대시보드 + singleByPath |
| AI Debate Room | `handlers/debate_handler.py` | 🚧 | 파일 기반 자동 토론 + Wiki.js 동기화 | | AI Debate Room | `handlers/debate_handler.py` | 🚧 | Wiki.js 기반 분산 토론 (AG 분리) |
| Debate AG 프로젝트 | `debate-agent/` | 🚧 | gemini/opus AG 스캐폴딩 + wiki_debate.py |
## 최근 마일스톤 ## 최근 마일스톤
- **2026-03-21**: debate-agent 분산 토론 시스템 — Wiki.js 기반 통신, AG 프로젝트 스캐폴딩, handler 리팩토링
- **2026-03-20**: AI Debate Room v2 — 파일 기반 자동 토론 + Flash 합의 판정 + Wiki.js 동기화 - **2026-03-20**: AI Debate Room v2 — 파일 기반 자동 토론 + Flash 합의 판정 + Wiki.js 동기화
- **2026-03-18**: Nextcloud 4모듈 + NC핸들러 + AI Foreman v0.1 + unified 분류→라우팅 구현 - **2026-03-18**: Nextcloud 4모듈 + NC핸들러 + AI Foreman v0.1 + unified 분류→라우팅 구현
- **2026-03-17**: 커뮤니티 트렌드 스크래퍼 구현 (DCInside 인기글 + Gemini Skill) - **2026-03-17**: 커뮤니티 트렌드 스크래퍼 구현 (DCInside 인기글 + Gemini Skill)
@@ -85,5 +87,5 @@ Discord 메시지
## 현재 미해결 ## 현재 미해결
- **핫딜 통합 수집기** (#393) — 뽐뿌/퀘이사존/딜봄/클리앙 핫딜 통합 크롤링 + 트렌드 분석 - **핫딜 통합 수집기** (#393) — 뽐뿌/퀘이사존/딜봄/클리앙 핫딜 통합 크롤링 + 트렌드 분석
- **AI Debate Room** (#387) — 파일 기반 자동 토론 MVP 테스트 중 (합의 판정, response.md 규정 준수 검증 필요) - **AI Debate Room** (#387) — Wiki.js 기반 분산 토론 구현 완료, E2E 테스트 필요 (봇 `!debate` 명령 + AG `/start`)
- **CLI Bridge PoC** (#203) — Windows PTY 미지원으로 보류. pywinpty 또는 Docker 환경 필요 - **CLI Bridge PoC** (#203) — Windows PTY 미지원으로 보류. pywinpty 또는 Docker 환경 필요

View File

@@ -0,0 +1,47 @@
---
description: 토론 세션을 시작합니다. 사회자의 input을 읽고 response를 작성합니다.
---
# 토론 참여 시작
// turbo-all
## 절차
### 1. GEMINI.md 행동 규칙 확인
`GEMINI.md`를 읽어 토론 참여자 행동 규칙을 확인합니다.
### 2. 사회자 지시 읽기
```powershell
python tools/wiki_debate.py read debates/{slug}/input-gemini
```
> `{slug}`는 현재 진행 중인 토론의 slug입니다. `input-gemini` 페이지가 비어있으면 아직 사회자가 input을 작성하지 않은 것이니 대기하세요.
### 3. 합의 상태 확인
```powershell
python tools/wiki_debate.py read debates/{slug}/working-document
```
사회자가 정리한 현재 합의 상태를 확인합니다.
### 4. 답변 작성 및 업로드
답변을 작성한 후 response 페이지에 업로드:
```powershell
python tools/wiki_debate.py write debates/{slug}/response-gemini "답변 전문"
```
또는 긴 답변은 파일로:
```powershell
python tools/wiki_debate.py write-file debates/{slug}/response-gemini response_draft.md
```
### 5. Discord 완료 시그널
Discord에 "response 작성 완료"를 게시하여 사회자에게 알립니다.

View File

@@ -0,0 +1,42 @@
# Debate Participant — Gemini
당신은 AI 토론 참여자 (Gemini)입니다. 사회자의 지시에 따라 주어진 주제에 대해 의견을 제시하고, 상대방의 주장을 검증합니다.
## ⚠️ Wiki.js 기반 답변 (필수)
### 도구
- `python tools/wiki_debate.py read <path>` — Wiki.js 페이지 읽기
- `python tools/wiki_debate.py write <path> <content>` — Wiki.js 페이지 쓰기
- `python tools/wiki_debate.py write-file <path> <file>` — 파일 내용을 Wiki.js에 업로드
### 읽을 페이지 (순서대로)
1. **`debates/{slug}/input-gemini`** — 사회자가 작성한 지시 + 상대 의견 전문. **반드시 먼저 읽으세요.**
2. **`debates/{slug}/working-document`** — 현재까지의 합의 사항 (참고 + 취합 검증용)
3. **`debates/{slug}/round-log`** — 지금까지의 전체 토론 기록 (필요 시)
### 쓸 페이지
- **`debates/{slug}/response-gemini`** — 여기에만 전문 답변을 작성하세요.
### Discord 완료 시그널
- Wiki.js에 response 작성이 끝나면 Discord에 "response 작성 완료"를 게시하세요.
## 행동 규칙
1. **input 먼저 읽기** — 사회자의 지시, 상대 의견 전문, 방향이 담겨 있음
2. **전문으로 답변** — 요약하지 마세요. 논거를 구체적으로 전개하세요
3. **상대 의견 검증** — 오류·누락·논리적 허점을 확인하세요
4. **사회자 취합 검증**`working-document`에 사회자가 양측 의견을 올바르게 반영했는지 확인하고, 누락/오류가 있으면 지적하세요
5. **개선안 제시** — 단순 반론이 아니라 대안/보강을 함께 제시하세요
6. **근거 명시** — 기술적 근거, 사례, 레퍼런스를 포함하세요
7. **합의 가능 시 인정** — 상대 의견이 맞으면 솔직히 인정하고 발전시키세요
## 금지 사항
- ❌ 상대방의 `input-opus` 또는 `response-opus` 페이지 접근
-`working-document` 수정 (읽기만 — 사회자만 편집)
- ❌ 소스 코드 작성 (문서 수준 논의만)
- ❌ 사회자 지시 무시
- ❌ 주제에서 벗어난 발언
- ❌ 근거 없는 주장
- ❌ "이하생략" 또는 답변 축약
- ❌ Discord에 긴 답변 직접 게시 (반드시 Wiki.js response 페이지에)

View File

@@ -0,0 +1,172 @@
"""Wiki.js Debate Tool — AG용 Wiki.js 읽기/쓰기 CLI.
사용법:
python tools/wiki_debate.py read <page-path>
python tools/wiki_debate.py write <page-path> <content>
python tools/wiki_debate.py write-file <page-path> <file-path>
예시:
python tools/wiki_debate.py read debates/frtb/input-gemini
python tools/wiki_debate.py write debates/frtb/response-gemini "SA 방식을 제안합니다..."
python tools/wiki_debate.py write-file debates/frtb/response-gemini response_draft.md
"""
import asyncio
import httpx
import io
import os
import sys
from pathlib import Path
# .env 로드 (여러 후보 경로에서 탐색)
_candidates = [
Path(__file__).parent.parent / ".env", # tools/../.env (배포 위치)
Path.cwd() / ".env", # CWD/.env
Path.cwd().parent / ".env", # CWD/../.env
]
for _env_path in _candidates:
if _env_path.exists():
for line in _env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" in line:
key, _, value = line.partition("=")
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
value = value[1:-1]
os.environ.setdefault(key.strip(), value)
break # 첫 번째 .env만 사용
WIKI_URL = os.getenv("WIKI_URL", "https://wiki.variet.net")
WIKI_API_KEY = os.getenv("WIKI_API_KEY", "")
GRAPHQL_ENDPOINT = f"{WIKI_URL}/graphql"
async def _query(query: str, variables: dict = None) -> dict:
"""GraphQL 쿼리 실행."""
headers = {
"Authorization": f"Bearer {WIKI_API_KEY}",
"Content-Type": "application/json",
}
payload = {"query": query}
if variables:
payload["variables"] = variables
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(GRAPHQL_ENDPOINT, json=payload, headers=headers)
resp.raise_for_status()
data = resp.json()
if "errors" in data and data["errors"]:
raise RuntimeError(f"Wiki.js 오류: {data['errors'][0].get('message', data['errors'])}")
return data.get("data", {})
async def read_page(path: str) -> str:
"""경로로 페이지 읽기 (singleByPath)."""
query = """
query ($path: String!) {
pages { singleByPath(path: $path, locale: "ko") {
id, path, title, content
}}
}
"""
data = await _query(query, {"path": path})
page = data.get("pages", {}).get("singleByPath")
if not page:
return f"[오류] 페이지를 찾을 수 없습니다: {path}"
return page.get("content", "")
async def write_page(path: str, content: str) -> str:
"""경로에 페이지 쓰기 (upsert)."""
# 기존 페이지 확인
find_query = """
query ($path: String!) {
pages { singleByPath(path: $path, locale: "ko") {
id, tags { tag }
}}
}
"""
data = await _query(find_query, {"path": path})
existing = data.get("pages", {}).get("singleByPath")
if existing:
# 수정
tags = [t["tag"] for t in existing.get("tags", [])]
update_query = """
mutation ($id: Int!, $content: String!, $tags: [String!]) {
pages { update(id: $id, content: $content, tags: $tags) {
responseResult { succeeded, message }
}}
}
"""
result = await _query(update_query, {
"id": existing["id"], "content": content, "tags": tags,
})
ok = result["pages"]["update"]["responseResult"]["succeeded"]
msg = result["pages"]["update"]["responseResult"]["message"]
return f"✅ 수정 완료 (id: {existing['id']})" if ok else f"❌ 수정 실패: {msg}"
else:
# 생성
title = path.split("/")[-1].replace("-", " ").title()
create_query = """
mutation ($content: String!, $path: String!, $title: String!,
$description: String!, $tags: [String!]!) {
pages { create(
content: $content, description: $description,
editor: "markdown", isPublished: true, isPrivate: false,
locale: "ko", path: $path, tags: $tags, title: $title
) {
responseResult { succeeded, message }
page { id }
}}
}
"""
result = await _query(create_query, {
"content": content, "path": path, "title": title,
"description": title, "tags": ["debate"],
})
ok = result["pages"]["create"]["responseResult"]["succeeded"]
if ok:
pid = result["pages"]["create"]["page"]["id"]
return f"✅ 생성 완료 (id: {pid})"
return f"❌ 생성 실패: {result['pages']['create']['responseResult']['message']}"
async def main():
if sys.stdout.encoding != "utf-8":
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
args = sys.argv[1:]
if len(args) < 2:
print(__doc__)
return
cmd, path = args[0], args[1]
if cmd == "read":
content = await read_page(path)
print(content)
elif cmd == "write" and len(args) > 2:
content = " ".join(args[2:])
result = await write_page(path, content)
print(result)
elif cmd == "write-file" and len(args) > 2:
file_path = Path(args[2])
if not file_path.exists():
print(f"❌ 파일을 찾을 수 없습니다: {file_path}")
return
content = file_path.read_text(encoding="utf-8")
result = await write_page(path, content)
print(result)
else:
print(__doc__)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,47 @@
---
description: 토론 세션을 시작합니다. 사회자의 input을 읽고 response를 작성합니다.
---
# 토론 참여 시작
// turbo-all
## 절차
### 1. GEMINI.md 행동 규칙 확인
`GEMINI.md`를 읽어 토론 참여자 행동 규칙을 확인합니다.
### 2. 사회자 지시 읽기
```powershell
python tools/wiki_debate.py read debates/{slug}/input-opus
```
> `{slug}`는 현재 진행 중인 토론의 slug입니다. `input-opus` 페이지가 비어있으면 아직 사회자가 input을 작성하지 않은 것이니 대기하세요.
### 3. 합의 상태 확인
```powershell
python tools/wiki_debate.py read debates/{slug}/working-document
```
사회자가 정리한 현재 합의 상태를 확인합니다.
### 4. 답변 작성 및 업로드
답변을 작성한 후 response 페이지에 업로드:
```powershell
python tools/wiki_debate.py write debates/{slug}/response-opus "답변 전문"
```
또는 긴 답변은 파일로:
```powershell
python tools/wiki_debate.py write-file debates/{slug}/response-opus response_draft.md
```
### 5. Discord 완료 시그널
Discord에 "response 작성 완료"를 게시하여 사회자에게 알립니다.

View File

@@ -0,0 +1,42 @@
# Debate Participant — Opus
당신은 AI 토론 참여자 (Opus)입니다. 사회자의 지시에 따라 주어진 주제에 대해 의견을 제시하고, 상대방의 주장을 검증합니다.
## ⚠️ Wiki.js 기반 답변 (필수)
### 도구
- `python tools/wiki_debate.py read <path>` — Wiki.js 페이지 읽기
- `python tools/wiki_debate.py write <path> <content>` — Wiki.js 페이지 쓰기
- `python tools/wiki_debate.py write-file <path> <file>` — 파일 내용을 Wiki.js에 업로드
### 읽을 페이지 (순서대로)
1. **`debates/{slug}/input-opus`** — 사회자가 작성한 지시 + 상대 의견 전문. **반드시 먼저 읽으세요.**
2. **`debates/{slug}/working-document`** — 현재까지의 합의 사항 (참고 + 취합 검증용)
3. **`debates/{slug}/round-log`** — 지금까지의 전체 토론 기록 (필요 시)
### 쓸 페이지
- **`debates/{slug}/response-opus`** — 여기에만 전문 답변을 작성하세요.
### Discord 완료 시그널
- Wiki.js에 response 작성이 끝나면 Discord에 "response 작성 완료"를 게시하세요.
## 행동 규칙
1. **input 먼저 읽기** — 사회자의 지시, 상대 의견 전문, 방향이 담겨 있음
2. **전문으로 답변** — 요약하지 마세요. 논거를 구체적으로 전개하세요
3. **상대 의견 검증** — 오류·누락·논리적 허점을 확인하세요
4. **사회자 취합 검증**`working-document`에 사회자가 양측 의견을 올바르게 반영했는지 확인하고, 누락/오류가 있으면 지적하세요
5. **개선안 제시** — 단순 반론이 아니라 대안/보강을 함께 제시하세요
6. **근거 명시** — 기술적 근거, 사례, 레퍼런스를 포함하세요
7. **합의 가능 시 인정** — 상대 의견이 맞으면 솔직히 인정하고 발전시키세요
## 금지 사항
- ❌ 상대방의 `input-gemini` 또는 `response-gemini` 페이지 접근
-`working-document` 수정 (읽기만 — 사회자만 편집)
- ❌ 소스 코드 작성 (문서 수준 논의만)
- ❌ 사회자 지시 무시
- ❌ 주제에서 벗어난 발언
- ❌ 근거 없는 주장
- ❌ "이하생략" 또는 답변 축약
- ❌ Discord에 긴 답변 직접 게시 (반드시 Wiki.js response 페이지에)

View File

@@ -0,0 +1,172 @@
"""Wiki.js Debate Tool — AG용 Wiki.js 읽기/쓰기 CLI.
사용법:
python tools/wiki_debate.py read <page-path>
python tools/wiki_debate.py write <page-path> <content>
python tools/wiki_debate.py write-file <page-path> <file-path>
예시:
python tools/wiki_debate.py read debates/frtb/input-gemini
python tools/wiki_debate.py write debates/frtb/response-gemini "SA 방식을 제안합니다..."
python tools/wiki_debate.py write-file debates/frtb/response-gemini response_draft.md
"""
import asyncio
import httpx
import io
import os
import sys
from pathlib import Path
# .env 로드 (여러 후보 경로에서 탐색)
_candidates = [
Path(__file__).parent.parent / ".env", # tools/../.env (배포 위치)
Path.cwd() / ".env", # CWD/.env
Path.cwd().parent / ".env", # CWD/../.env
]
for _env_path in _candidates:
if _env_path.exists():
for line in _env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" in line:
key, _, value = line.partition("=")
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
value = value[1:-1]
os.environ.setdefault(key.strip(), value)
break # 첫 번째 .env만 사용
WIKI_URL = os.getenv("WIKI_URL", "https://wiki.variet.net")
WIKI_API_KEY = os.getenv("WIKI_API_KEY", "")
GRAPHQL_ENDPOINT = f"{WIKI_URL}/graphql"
async def _query(query: str, variables: dict = None) -> dict:
"""GraphQL 쿼리 실행."""
headers = {
"Authorization": f"Bearer {WIKI_API_KEY}",
"Content-Type": "application/json",
}
payload = {"query": query}
if variables:
payload["variables"] = variables
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(GRAPHQL_ENDPOINT, json=payload, headers=headers)
resp.raise_for_status()
data = resp.json()
if "errors" in data and data["errors"]:
raise RuntimeError(f"Wiki.js 오류: {data['errors'][0].get('message', data['errors'])}")
return data.get("data", {})
async def read_page(path: str) -> str:
"""경로로 페이지 읽기 (singleByPath)."""
query = """
query ($path: String!) {
pages { singleByPath(path: $path, locale: "ko") {
id, path, title, content
}}
}
"""
data = await _query(query, {"path": path})
page = data.get("pages", {}).get("singleByPath")
if not page:
return f"[오류] 페이지를 찾을 수 없습니다: {path}"
return page.get("content", "")
async def write_page(path: str, content: str) -> str:
"""경로에 페이지 쓰기 (upsert)."""
# 기존 페이지 확인
find_query = """
query ($path: String!) {
pages { singleByPath(path: $path, locale: "ko") {
id, tags { tag }
}}
}
"""
data = await _query(find_query, {"path": path})
existing = data.get("pages", {}).get("singleByPath")
if existing:
# 수정
tags = [t["tag"] for t in existing.get("tags", [])]
update_query = """
mutation ($id: Int!, $content: String!, $tags: [String!]) {
pages { update(id: $id, content: $content, tags: $tags) {
responseResult { succeeded, message }
}}
}
"""
result = await _query(update_query, {
"id": existing["id"], "content": content, "tags": tags,
})
ok = result["pages"]["update"]["responseResult"]["succeeded"]
msg = result["pages"]["update"]["responseResult"]["message"]
return f"✅ 수정 완료 (id: {existing['id']})" if ok else f"❌ 수정 실패: {msg}"
else:
# 생성
title = path.split("/")[-1].replace("-", " ").title()
create_query = """
mutation ($content: String!, $path: String!, $title: String!,
$description: String!, $tags: [String!]!) {
pages { create(
content: $content, description: $description,
editor: "markdown", isPublished: true, isPrivate: false,
locale: "ko", path: $path, tags: $tags, title: $title
) {
responseResult { succeeded, message }
page { id }
}}
}
"""
result = await _query(create_query, {
"content": content, "path": path, "title": title,
"description": title, "tags": ["debate"],
})
ok = result["pages"]["create"]["responseResult"]["succeeded"]
if ok:
pid = result["pages"]["create"]["page"]["id"]
return f"✅ 생성 완료 (id: {pid})"
return f"❌ 생성 실패: {result['pages']['create']['responseResult']['message']}"
async def main():
if sys.stdout.encoding != "utf-8":
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
args = sys.argv[1:]
if len(args) < 2:
print(__doc__)
return
cmd, path = args[0], args[1]
if cmd == "read":
content = await read_page(path)
print(content)
elif cmd == "write" and len(args) > 2:
content = " ".join(args[2:])
result = await write_page(path, content)
print(result)
elif cmd == "write-file" and len(args) > 2:
file_path = Path(args[2])
if not file_path.exists():
print(f"❌ 파일을 찾을 수 없습니다: {file_path}")
return
content = file_path.read_text(encoding="utf-8")
result = await write_page(path, content)
print(result)
else:
print(__doc__)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -1,3 +1,4 @@
| # | 시간 | 작업 | 커밋 | 상태 | | # | 시간 | 작업 | 커밋 | 상태 |
|----|------|------|------|------| |----|------|------|------|------|
| 001 | 12:00 | 자막 다운로드 Flow 정밀검증 — 과다 다운로드/리네임 미실행/오류 표현 5건 수정 | `94fb4e6` | ✅ | | 001 | 12:00 | 자막 다운로드 Flow 정밀검증 — 과다 다운로드/리네임 미실행/오류 표현 5건 수정 | `94fb4e6` | ✅ |
| 002 | 20:50 | debate-agent 분산 토론 시스템 — Wiki.js 기반 통신, AG 프로젝트 스캐폴딩, handler 리팩토링 | `9fedba3` | ✅ |

View File

@@ -15,7 +15,6 @@ import logging
import random import random
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional from typing import Optional
import discord import discord
@@ -29,12 +28,6 @@ DEBATE_AGENTS = {
"opus": 1484156521209401476, "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_COLORS = {"gemini": 0x2ECC71, "opus": 0x3498DB}
AGENT_EMOJI = {"gemini": "🟢", "opus": "🔵"} AGENT_EMOJI = {"gemini": "🟢", "opus": "🔵"}
@@ -64,6 +57,18 @@ class DebateHandler:
self.session = DebateSession() self.session = DebateSession()
self._debate_task: Optional[asyncio.Task] = None self._debate_task: Optional[asyncio.Task] = None
self._response_event = asyncio.Event() 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 # 공개 API
@@ -101,12 +106,21 @@ class DebateHandler:
active=True, max_rounds=MAX_ROUNDS, active=True, max_rounds=MAX_ROUNDS,
) )
# 폴더 초기화 # Wiki.js 초기 페이지 생성
for name, path in AGENT_PATHS.items(): wiki = self._get_wiki()
(path / "response.md").write_text("", encoding="utf-8") for page in ["working-document", "round-log", "control",
(path / "input.md").write_text("", encoding="utf-8") "input-gemini", "input-opus",
wiki_dir = path / "wiki" "response-gemini", "response-opus"]:
wiki_dir.mkdir(exist_ok=True) 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) ctrl = self.bot.get_channel(DEBATE_CONTROL_CH)
@@ -222,8 +236,6 @@ class DebateHandler:
# ⑥ Working Document 통합 편집 (Flash + Wiki.js) # ⑥ Working Document 통합 편집 (Flash + Wiki.js)
await self._update_working_document(speaker, response) await self._update_working_document(speaker, response)
# ⑦ 양쪽 wiki/ 동기화
self._sync_wiki()
# ⑧ #debate에 요약 게시 # ⑧ #debate에 요약 게시
await self._post_summary(speaker, response) await self._post_summary(speaker, response)
@@ -273,11 +285,19 @@ class DebateHandler:
return speaker return speaker
async def _prepare_input(self, speaker: str): async def _prepare_input(self, speaker: str):
"""사회자가 input.md + wiki/ 작성.""" """사회자가 Wiki.js input 페이지 작성 + response 초기화."""
# 사회자(Flash) 해설 생성 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) moderator_commentary = await self._build_moderator_prompt(speaker)
# 상대방 전문을 코드에서 직접 삽입 (Flash의 요약에 의존하지 않음) # 상대방 전문을 코드에서 직접 삽입
prev = [h for h in self.session.history if h["speaker"] != "user"] prev = [h for h in self.session.history if h["speaker"] != "user"]
opponent_section = "" opponent_section = ""
if prev: if prev:
@@ -289,19 +309,12 @@ class DebateHandler:
f"---\n" f"---\n"
) )
# input.md = 사회자 해설 + 상대방 전문 (코드가 보장) # input = 사회자 해설 + 상대방 전문
full_input = moderator_commentary + opponent_section full_input = moderator_commentary + opponent_section
input_path = AGENT_PATHS[speaker] / "input.md" await self._wiki_upsert(
input_path.write_text(full_input, encoding="utf-8") self._wiki_path(f"input-{speaker}"),
f"Input - {speaker} (Round {self.session.round})",
# wiki/ — 기존 working_document.md가 있으면 그대로 유지 full_input,
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): async def _send_signal(self, speaker: str):
@@ -315,11 +328,7 @@ class DebateHandler:
) )
async def _wait_for_response(self, speaker: str) -> str: async def _wait_for_response(self, speaker: str) -> str:
"""AG의 Discord 완료 메시지 대기 후 response.md 읽기.""" """AG의 Discord 완료 메시지 대기 후 Wiki.js response 읽기."""
resp_path = AGENT_PATHS[speaker] / "response.md"
# response.md 비우기
resp_path.write_text("", encoding="utf-8")
ctrl = self.bot.get_channel(DEBATE_CONTROL_CH) ctrl = self.bot.get_channel(DEBATE_CONTROL_CH)
if ctrl: if ctrl:
await ctrl.send( await ctrl.send(
@@ -330,21 +339,23 @@ class DebateHandler:
self._response_event.clear() self._response_event.clear()
try: try:
# Discord에서 "작업 종료" 메시지가 올 때까지 대기 # Discord에서 "step XX 종료" 또는 "작성 완료" 메시지가 올 때까지 대기
await asyncio.wait_for(self._response_event.wait(), timeout=RESPONSE_TIMEOUT) await asyncio.wait_for(self._response_event.wait(), timeout=RESPONSE_TIMEOUT)
if not self.session.active: if not self.session.active:
return "" return ""
# 완료 시그널을 받으면 파일 읽기 # 완료 시그널을 받으면 Wiki.js에서 response 읽기
if resp_path.exists(): wiki = self._get_wiki()
content = resp_path.read_text(encoding="utf-8").strip() page = await wiki.find_page(self._wiki_path(f"response-{speaker}"))
if content: if page and page.content.strip():
return content return page.content.strip()
else: else:
if ctrl: if ctrl:
await ctrl.send(f"⚠️ `{speaker}` 완료 신호를 받았으나 `response.md`가 비어있습니다.") await ctrl.send(
f"⚠️ `{speaker}` 완료 신호를 받았으나 "
f"`response-{speaker}` 페이지가 비어있습니다."
)
return "" return ""
except asyncio.TimeoutError: except asyncio.TimeoutError:
@@ -476,11 +487,15 @@ class DebateHandler:
"""토론 종료 — Discord + Wiki.js에 결론 기록.""" """토론 종료 — Discord + Wiki.js에 결론 기록."""
self.session.active = False self.session.active = False
# Working Document 최종본 읽기 # Working Document 최종본 읽기 (Wiki.js)
wd_path = AGENT_PATHS["gemini"] / "wiki" / "working_document.md" wiki = self._get_wiki()
final_doc = "" final_doc = ""
if wd_path.exists(): try:
final_doc = wd_path.read_text(encoding="utf-8").strip() 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에 요약 게시 # Discord에 요약 게시
parts = [] parts = []
@@ -518,11 +533,16 @@ class DebateHandler:
# ═══════════════════════════════════════════ # ═══════════════════════════════════════════
async def _update_working_document(self, speaker: str, response: str): async def _update_working_document(self, speaker: str, response: str):
"""Flash가 AG 의견을 Working Document에 통합 편집 + Wiki.js 업로드.""" """Flash가 AG 의견을 Working Document에 통합 편집 (Wiki.js)."""
wd_path = AGENT_PATHS["gemini"] / "wiki" / "working_document.md" # 현재 문서 읽기 (Wiki.js)
wiki = self._get_wiki()
current_doc = "" current_doc = ""
if wd_path.exists(): try:
current_doc = wd_path.read_text(encoding="utf-8").strip() 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"""당신은 문서 편집자입니다. merge_prompt = f"""당신은 문서 편집자입니다.
@@ -554,12 +574,9 @@ class DebateHandler:
caller = GeminiCaller() caller = GeminiCaller()
updated = await caller.call_simple(merge_prompt, timeout=120) updated = await caller.call_simple(merge_prompt, timeout=120)
if updated and len(updated) > 50: 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)}") logger.info(f"Working Document 업데이트: {len(updated)}")
# Wiki.js에 업로드
await self._wiki_upsert( await self._wiki_upsert(
f"debates/{self.session.topic_slug}/working-document", self._wiki_path("working-document"),
f"{self.session.wiki_title} — Working Document", f"{self.session.wiki_title} — Working Document",
updated, updated,
) )
@@ -569,12 +586,16 @@ class DebateHandler:
logger.error(f"Working Document 업데이트 오류: {e}") logger.error(f"Working Document 업데이트 오류: {e}")
async def _append_round_log(self, speaker: str, response: str): async def _append_round_log(self, speaker: str, response: str):
"""Round Log에 대화 전문 append + Wiki.js 업로드.""" """Round Log에 대화 전문 append (Wiki.js)."""
log_path = AGENT_PATHS["gemini"] / "wiki" / "round_log.md" # 기존 내용 읽기 (Wiki.js)
# 기존 내용 읽기 wiki = self._get_wiki()
existing = "" existing = ""
if log_path.exists(): try:
existing = log_path.read_text(encoding="utf-8") 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, "👤") emoji = AGENT_EMOJI.get(speaker, "👤")
entry = ( entry = (
@@ -583,12 +604,9 @@ class DebateHandler:
f"{response}\n" f"{response}\n"
) )
updated_log = existing + entry 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( await self._wiki_upsert(
f"debates/{self.session.topic_slug}/round-log", self._wiki_path("round-log"),
f"{self.session.wiki_title} — Round Log", f"{self.session.wiki_title} — Round Log",
updated_log, updated_log,
) )
@@ -608,18 +626,6 @@ class DebateHandler:
except Exception as e: except Exception as e:
logger.warning(f"Wiki.js 업로드 실패 ({path}): {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: def _build_working_document(self) -> str:
"""초기 working document 생성 (첫 라운드용).""" """초기 working document 생성 (첫 라운드용)."""
return f"# {self.session.topic}\n\n*(토론 진행 중 — 내용이 채워집니다)*\n" return f"# {self.session.topic}\n\n*(토론 진행 중 — 내용이 채워집니다)*\n"

View File

@@ -110,12 +110,25 @@ class WikiClient:
) )
async def find_page(self, path: str) -> Optional[WikiPage]: async def find_page(self, path: str) -> Optional[WikiPage]:
"""경로로 페이지 찾기 (없으면 None).""" """경로로 페이지 찾기 (없으면 None). singleByPath 직접 조회."""
pages = await self.list_pages() query = """
for p in pages: query ($path: String!) {
if p.path == path: pages { singleByPath(path: $path, locale: "ko") {
return await self.get_page(p.id) id, path, title, content, updatedAt, description,
tags { tag }
}}
}
"""
data = await self._query(query, {"path": path})
p = data.get("pages", {}).get("singleByPath")
if not p:
return None return None
tags = [t["tag"] for t in p.get("tags", [])]
return WikiPage(
id=p["id"], path=p["path"], title=p["title"],
content=p.get("content", ""), updated_at=p.get("updatedAt", ""),
description=p.get("description", ""), tags=tags,
)
# ────────────────────────────────────── # ──────────────────────────────────────
# 생성 / 수정 / 삭제 # 생성 / 수정 / 삭제