From cbc9db0439299cebf3391130e60514bd760f0352 Mon Sep 17 00:00:00 2001 From: Variet Agent Date: Sat, 21 Mar 2026 20:52:12 +0900 Subject: [PATCH] =?UTF-8?q?feat(debate):=20=EB=B6=84=EC=82=B0=20=ED=86=A0?= =?UTF-8?q?=EB=A1=A0=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=E2=80=94=20Wiki.js?= =?UTF-8?q?=20=EA=B8=B0=EB=B0=98=20=ED=86=B5=EC=8B=A0,=20AG=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=EC=A0=9D=ED=8A=B8=20=EC=8A=A4=EC=BA=90=ED=8F=B4?= =?UTF-8?q?=EB=94=A9,=20handler=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 페이지 매 턴 초기화 (누적 방지) --- .agent/references/STATUS.md | 8 +- .../debate-gemini/.agent/workflows/start.md | 47 +++++ debate-agent/debate-gemini/GEMINI.md | 42 +++++ .../debate-gemini/tools/wiki_debate.py | 172 ++++++++++++++++++ .../debate-opus/.agent/workflows/start.md | 47 +++++ debate-agent/debate-opus/GEMINI.md | 42 +++++ debate-agent/debate-opus/tools/wiki_debate.py | 172 ++++++++++++++++++ docs/devlog/2026-03-21.md | 1 + handlers/debate_handler.py | 166 +++++++++-------- tools/wiki_client.py | 25 ++- 10 files changed, 633 insertions(+), 89 deletions(-) create mode 100644 debate-agent/debate-gemini/.agent/workflows/start.md create mode 100644 debate-agent/debate-gemini/GEMINI.md create mode 100644 debate-agent/debate-gemini/tools/wiki_debate.py create mode 100644 debate-agent/debate-opus/.agent/workflows/start.md create mode 100644 debate-agent/debate-opus/GEMINI.md create mode 100644 debate-agent/debate-opus/tools/wiki_debate.py diff --git a/.agent/references/STATUS.md b/.agent/references/STATUS.md index 6a20233..214a380 100644 --- a/.agent/references/STATUS.md +++ b/.agent/references/STATUS.md @@ -68,11 +68,13 @@ Discord 메시지 | NC 4모듈 | `tools/nc_*.py` | ✅ | Files/Calendar/Mail/Contacts | | AI Foreman | `core/foreman.py` | ✅ | 목표 분해 + 상담 + Vikunja 등록 | | 에이전트 워크플로우 | `.agent/` | ✅ | STATUS.md + 수칙 업데이트 | -| Wiki.js | `tools/wiki_client.py` | ✅ | GraphQL CRUD + 대시보드 | -| AI Debate Room | `handlers/debate_handler.py` | 🚧 | 파일 기반 자동 토론 + Wiki.js 동기화 | +| Wiki.js | `tools/wiki_client.py` | ✅ | GraphQL CRUD + 대시보드 + singleByPath | +| 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-18**: Nextcloud 4모듈 + NC핸들러 + AI Foreman v0.1 + unified 분류→라우팅 구현 - **2026-03-17**: 커뮤니티 트렌드 스크래퍼 구현 (DCInside 인기글 + Gemini Skill) @@ -85,5 +87,5 @@ Discord 메시지 ## 현재 미해결 - **핫딜 통합 수집기** (#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 환경 필요 diff --git a/debate-agent/debate-gemini/.agent/workflows/start.md b/debate-agent/debate-gemini/.agent/workflows/start.md new file mode 100644 index 0000000..fcfced8 --- /dev/null +++ b/debate-agent/debate-gemini/.agent/workflows/start.md @@ -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 작성 완료"를 게시하여 사회자에게 알립니다. diff --git a/debate-agent/debate-gemini/GEMINI.md b/debate-agent/debate-gemini/GEMINI.md new file mode 100644 index 0000000..a79f44e --- /dev/null +++ b/debate-agent/debate-gemini/GEMINI.md @@ -0,0 +1,42 @@ +# Debate Participant — Gemini + +당신은 AI 토론 참여자 (Gemini)입니다. 사회자의 지시에 따라 주어진 주제에 대해 의견을 제시하고, 상대방의 주장을 검증합니다. + +## ⚠️ Wiki.js 기반 답변 (필수) + +### 도구 +- `python tools/wiki_debate.py read ` — Wiki.js 페이지 읽기 +- `python tools/wiki_debate.py write ` — Wiki.js 페이지 쓰기 +- `python tools/wiki_debate.py write-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 페이지에) diff --git a/debate-agent/debate-gemini/tools/wiki_debate.py b/debate-agent/debate-gemini/tools/wiki_debate.py new file mode 100644 index 0000000..878953d --- /dev/null +++ b/debate-agent/debate-gemini/tools/wiki_debate.py @@ -0,0 +1,172 @@ +"""Wiki.js Debate Tool — AG용 Wiki.js 읽기/쓰기 CLI. + +사용법: + python tools/wiki_debate.py read + python tools/wiki_debate.py write + python tools/wiki_debate.py write-file + +예시: + 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()) diff --git a/debate-agent/debate-opus/.agent/workflows/start.md b/debate-agent/debate-opus/.agent/workflows/start.md new file mode 100644 index 0000000..54625f0 --- /dev/null +++ b/debate-agent/debate-opus/.agent/workflows/start.md @@ -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 작성 완료"를 게시하여 사회자에게 알립니다. diff --git a/debate-agent/debate-opus/GEMINI.md b/debate-agent/debate-opus/GEMINI.md new file mode 100644 index 0000000..53891ec --- /dev/null +++ b/debate-agent/debate-opus/GEMINI.md @@ -0,0 +1,42 @@ +# Debate Participant — Opus + +당신은 AI 토론 참여자 (Opus)입니다. 사회자의 지시에 따라 주어진 주제에 대해 의견을 제시하고, 상대방의 주장을 검증합니다. + +## ⚠️ Wiki.js 기반 답변 (필수) + +### 도구 +- `python tools/wiki_debate.py read ` — Wiki.js 페이지 읽기 +- `python tools/wiki_debate.py write ` — Wiki.js 페이지 쓰기 +- `python tools/wiki_debate.py write-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 페이지에) diff --git a/debate-agent/debate-opus/tools/wiki_debate.py b/debate-agent/debate-opus/tools/wiki_debate.py new file mode 100644 index 0000000..878953d --- /dev/null +++ b/debate-agent/debate-opus/tools/wiki_debate.py @@ -0,0 +1,172 @@ +"""Wiki.js Debate Tool — AG용 Wiki.js 읽기/쓰기 CLI. + +사용법: + python tools/wiki_debate.py read + python tools/wiki_debate.py write + python tools/wiki_debate.py write-file + +예시: + 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()) diff --git a/docs/devlog/2026-03-21.md b/docs/devlog/2026-03-21.md index cf01471..cd2103e 100644 --- a/docs/devlog/2026-03-21.md +++ b/docs/devlog/2026-03-21.md @@ -1,3 +1,4 @@ | # | 시간 | 작업 | 커밋 | 상태 | |----|------|------|------|------| | 001 | 12:00 | 자막 다운로드 Flow 정밀검증 — 과다 다운로드/리네임 미실행/오류 표현 5건 수정 | `94fb4e6` | ✅ | +| 002 | 20:50 | debate-agent 분산 토론 시스템 — Wiki.js 기반 통신, AG 프로젝트 스캐폴딩, handler 리팩토링 | `9fedba3` | ✅ | diff --git a/handlers/debate_handler.py b/handlers/debate_handler.py index 36fcb27..fd00473 100644 --- a/handlers/debate_handler.py +++ b/handlers/debate_handler.py @@ -15,7 +15,6 @@ import logging import random import time from dataclasses import dataclass, field -from pathlib import Path from typing import Optional import discord @@ -29,12 +28,6 @@ DEBATE_AGENTS = { "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": "🔵"} @@ -64,6 +57,18 @@ class DebateHandler: 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 @@ -101,12 +106,21 @@ class DebateHandler: 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) + # 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) @@ -222,8 +236,6 @@ class DebateHandler: # ⑥ Working Document 통합 편집 (Flash + Wiki.js) await self._update_working_document(speaker, response) - # ⑦ 양쪽 wiki/ 동기화 - self._sync_wiki() # ⑧ #debate에 요약 게시 await self._post_summary(speaker, response) @@ -273,11 +285,19 @@ class DebateHandler: return speaker async def _prepare_input(self, speaker: str): - """사회자가 input.md + wiki/ 작성.""" - # 사회자(Flash) 해설 생성 + """사회자가 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) - # 상대방 전문을 코드에서 직접 삽입 (Flash의 요약에 의존하지 않음) + # ③ 상대방 전문을 코드에서 직접 삽입 prev = [h for h in self.session.history if h["speaker"] != "user"] opponent_section = "" if prev: @@ -289,20 +309,13 @@ class DebateHandler: f"---\n" ) - # input.md = 사회자 해설 + 상대방 전문 (코드가 보장) + # ④ input = 사회자 해설 + 상대방 전문 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", - ) + 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 시그널.""" @@ -315,11 +328,7 @@ class DebateHandler: ) 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") - + """AG의 Discord 완료 메시지 대기 후 Wiki.js response 읽기.""" ctrl = self.bot.get_channel(DEBATE_CONTROL_CH) if ctrl: await ctrl.send( @@ -330,21 +339,23 @@ class DebateHandler: self._response_event.clear() try: - # Discord에서 "작업 종료" 메시지가 올 때까지 대기 + # Discord에서 "step XX 종료" 또는 "작성 완료" 메시지가 올 때까지 대기 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`가 비어있습니다.") - + # 완료 시그널을 받으면 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: @@ -476,11 +487,15 @@ class DebateHandler: """토론 종료 — Discord + Wiki.js에 결론 기록.""" self.session.active = False - # Working Document 최종본 읽기 - wd_path = AGENT_PATHS["gemini"] / "wiki" / "working_document.md" + # Working Document 최종본 읽기 (Wiki.js) + wiki = self._get_wiki() final_doc = "" - if wd_path.exists(): - final_doc = wd_path.read_text(encoding="utf-8").strip() + 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 = [] @@ -518,11 +533,16 @@ class DebateHandler: # ═══════════════════════════════════════════ 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" + """Flash가 AG 의견을 Working Document에 통합 편집 (Wiki.js).""" + # 현재 문서 읽기 (Wiki.js) + wiki = self._get_wiki() current_doc = "" - if wd_path.exists(): - current_doc = wd_path.read_text(encoding="utf-8").strip() + 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"""당신은 문서 편집자입니다. @@ -554,12 +574,9 @@ class DebateHandler: 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", + self._wiki_path("working-document"), f"{self.session.wiki_title} — Working Document", updated, ) @@ -569,12 +586,16 @@ class DebateHandler: 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" - # 기존 내용 읽기 + """Round Log에 대화 전문 append (Wiki.js).""" + # 기존 내용 읽기 (Wiki.js) + wiki = self._get_wiki() existing = "" - if log_path.exists(): - existing = log_path.read_text(encoding="utf-8") + 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 = ( @@ -583,12 +604,9 @@ class DebateHandler: 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", + self._wiki_path("round-log"), f"{self.session.wiki_title} — Round Log", updated_log, ) @@ -608,18 +626,6 @@ class DebateHandler: 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" diff --git a/tools/wiki_client.py b/tools/wiki_client.py index 83c8df1..8305f71 100644 --- a/tools/wiki_client.py +++ b/tools/wiki_client.py @@ -110,12 +110,25 @@ class WikiClient: ) async def find_page(self, path: str) -> Optional[WikiPage]: - """경로로 페이지 찾기 (없으면 None).""" - pages = await self.list_pages() - for p in pages: - if p.path == path: - return await self.get_page(p.id) - return None + """경로로 페이지 찾기 (없으면 None). singleByPath 직접 조회.""" + query = """ + query ($path: String!) { + pages { singleByPath(path: $path, locale: "ko") { + 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 + 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, + ) # ────────────────────────────────────── # 생성 / 수정 / 삭제