fix(wiki): update_page tags 누락 에러 수정 + refactor(bot): 레거시 NLU/핸들러 800줄 제거

This commit is contained in:
2026-03-15 22:43:11 +09:00
parent 9daa165b0b
commit 6490ed4be4
5 changed files with 48 additions and 855 deletions

View File

@@ -291,39 +291,7 @@ async def on_message(message: discord.Message):
)
async with message.channel.typing():
# 1단계: NLU 빠른 분류 (Gemini text 모드, ~5초)
intent = await _classify_intent(user_text, ws.path)
logger.info(f"NLU 분류: '{user_text[:50]}'{intent}")
# 2단계: anime 의도면 직접 실행 (Gemini agent 우회)
if intent and intent.get("mode") == "anime":
if progress_msg:
await progress_msg.edit(embed=discord.Embed(
title="📦 애니 작업 실행 중...",
description=f"```{user_text[:200]}```",
color=0x3498DB,
))
await _handle_anime(message, intent)
if progress_msg:
try: await progress_msg.delete()
except Exception: pass
return
# 2-2단계: research 의도면 조사+위키 등록
if intent and intent.get("mode") == "research":
if progress_msg:
await progress_msg.edit(embed=discord.Embed(
title="🔍 리서치 진행 중...",
description=f"주제: **{intent.get('topic', user_text[:50])}**",
color=0x9B59B6,
))
await _handle_research(message, intent, ws.path)
if progress_msg:
try: await progress_msg.delete()
except Exception: pass
return
# 3단계: 일반 → Gemini agent 모드
# 일반 → Gemini agent 모드 (도구 자동 호출)
history = await _get_channel_history(message.channel, limit=10)
response = await _agent_call(user_text, history, ws.path)
@@ -369,798 +337,6 @@ async def on_message(message: discord.Message):
_running_tasks[channel_id] = task
# ──────────────────────────────────────────────
# NLU 빠른 분류 (Gemini text 모드)
# ──────────────────────────────────────────────
_NLU_PROMPT = """\
사용자 메시지를 분류하여 JSON으로 응답하세요. 반드시 JSON만 출력.
분류:
- 애니메이션 다운로드/검색/상태 → mode: "anime"
- 조사+위키 등록/정리 요청 → mode: "research"
- 그 외 → mode: "chat"
anime 필드: action ("batch"|"download"|"search"|"status"|"schedule"|"list"), title, episode, filter
research 필드:
- action: "research" (조사+등록), "organize" (기존 정리), "update" (페이지 수정)
- topic: 주제
예시:
"이번분기 애니 자막 다운받아줘"{"mode":"anime","action":"batch","filter":"자막"}
"FX Forward 조사해서 위키에 정리해줘"{"mode":"research","action":"research","topic":"FX Forward"}
"위키에 서버 관련 내용 정리해줘"{"mode":"research","action":"organize","topic":"서버"}
"GraphQL이 뭐야?"{"mode":"chat"}
"""
async def _classify_intent(text: str, project_path: str) -> dict | None:
"""Gemini text 모드로 빠른 의도 분류 (~5초)."""
try:
gemini = GeminiCaller(project_path)
raw = await gemini.call("unified", f"{_NLU_PROMPT}\n\n사용자: {text}", timeout=30)
import json as _json
m = re.search(r'\{[^}]+\}', raw, re.DOTALL)
if m:
return _json.loads(m.group(0))
except Exception as e:
logger.warning(f"NLU 분류 실패 (agent fallback): {e}")
return None
# ──────────────────────────────────────────────
# Anime 핸들러 (NLU 분류 결과로 직접 실행)
# ──────────────────────────────────────────────
async def _handle_anime(message: discord.Message, parsed: dict):
"""AI가 분류한 anime 의도를 실행."""
from tools.anime_pipeline import AnimePipeline
action = parsed.get("action", "search")
title = parsed.get("title", "")
episode = parsed.get("episode")
filter_str = parsed.get("filter", "")
summary = parsed.get("summary", "")
pipeline = AnimePipeline()
try:
if action == "status":
await _anime_status(message, pipeline)
elif action == "schedule":
await _anime_schedule(message, pipeline, filter_str)
elif action == "list":
await _anime_list(message, pipeline, filter_str)
elif action == "batch":
await _anime_batch_direct(message, filter_str or "자막 배치")
elif action in ("download", "sub_only", "video_only"):
# 필터에 batch 조건이 있으면 복수 다운로드
if not title and filter_str:
await _anime_batch(message, pipeline, action, filter_str)
else:
await _anime_download(message, pipeline, title, action, episode)
else: # search (기본)
if not title:
await message.reply("🔍 어떤 애니를 검색할까요? 제목을 알려주세요.")
return
await _anime_search(message, pipeline, title)
except Exception as e:
logger.error(f"Anime 핸들러 오류: {e}", exc_info=True)
await message.reply(f"❌ 오류가 발생했습니다: {str(e)[:300]}")
async def _anime_search(message, pipeline, title):
"""검색 결과 표시."""
result = await pipeline.search(title)
if not result.anime:
await message.reply(f"'{title}' 검색 결과가 없습니다.")
return
anime = result.anime
embed = discord.Embed(
title=f"🔍 {anime.subject}",
description=f"**원제**: {anime.original_subject}\n**장르**: {anime.genres}",
color=0x3498DB,
)
week_names = ['','','','','','','','기타']
embed.add_field(name="📅 편성", value=f"{week_names[anime.week]}요일 {anime.time}", inline=True)
embed.add_field(name="📁 NAS 폴더", value=f"`{result.nas_folder}`", inline=True)
if result.captions:
cap_lines = []
for c in result.captions[:5]:
url_text = f"[사이트]({c.website})" if c.website else "URL 없음"
cap_lines.append(f"• **{c.name}** — {c.episode}화 ({url_text})")
embed.add_field(name=f"📝 자막 ({len(result.captions)}명)", value="\n".join(cap_lines), inline=False)
if result.torrents:
tor_lines = []
for t in result.torrents[:5]:
ep = f"**{t.episode}화**" if t.episode else ""
tor_lines.append(f"• [{t.group}] {ep} {t.size} (🌱{t.seeders})")
embed.add_field(name=f"🎬 토렌트 ({len(result.torrents)}건)", value="\n".join(tor_lines), inline=False)
await safe_send_embed(message.channel, embed)
async def _anime_download(message, pipeline, title, mode, episode):
"""단일 애니 다운로드."""
if not title:
await message.reply("📥 어떤 애니를 다운받을까요? 제목을 알려주세요.")
return
embed = discord.Embed(title="⏳ 처리 중...", description=f"**{title}** 검색 및 다운로드", color=0xF39C12)
status_msg = await message.channel.send(embed=embed)
result = await pipeline.download(title, mode=mode, episode=episode)
if result.torrent_added or result.subtitles:
embed.title = "✅ 완료"
embed.color = 0x2ECC71
else:
embed.title = "⚠️ 부분 완료"
embed.color = 0xF39C12
embed.description = result.message[:4000]
await status_msg.edit(embed=embed)
async def _anime_batch(message, pipeline, action, filter_str):
"""필터 기반 복수 애니 다운로드 — batch_download() 사용."""
await _anime_batch_direct(message, f"{filter_str} {action}")
async def _anime_batch_direct(message, user_text: str):
"""애니 배치 다운로드 — Gemini 없이 직접 실행."""
from tools.anime_pipeline import AnimePipeline
t = user_text.lower()
mode = "auto"
if "자막만" in t or "sub_only" in t:
mode = "sub_only"
elif "영상만" in t or "video_only" in t:
mode = "video_only"
sub_filter = "자막" in t or "sub:yes" in t # 자막 언급 시 자막 필터 활성
embed = discord.Embed(
title="📦 배치 다운로드 시작",
description=f"모드: `{mode}` | 자막 필터: `{'ON' if sub_filter else 'OFF'}`\n⏳ NAS 스캔 + Anissia 로딩 중...",
color=0xF39C12,
)
status_msg = await message.channel.send(embed=embed)
pipeline = AnimePipeline()
try:
results = await pipeline.batch_download(mode=mode, sub_filter=sub_filter)
except Exception as e:
logger.error(f"배치 다운로드 오류: {e}", exc_info=True)
embed.title = "❌ 배치 다운로드 실패"
embed.description = str(e)[:500]
embed.color = 0xE74C3C
await status_msg.edit(embed=embed)
return
# 결과 정리
success = [r for r in results if r.success]
lines = []
for r in results:
icon = "" if r.success else ""
title = r.anime.subject if r.anime else "?"
detail = ""
if r.torrent_added:
detail += " 🎬토렌트추가"
if r.subtitles:
detail += f" 📝자막{len(r.subtitles)}"
if r.errors:
detail += f" ⚠️{r.errors[0][:50]}"
lines.append(f"{icon} {title}{detail}")
embed.title = f"📊 배치 결과: {len(success)}/{len(results)}건 성공"
embed.description = "\n".join(lines) or "처리할 애니가 없습니다."
embed.color = 0x2ECC71 if success else 0xF39C12
await status_msg.edit(embed=embed)
# ──────────────────────────────────────────────
# Research 핸들러 (조사+위키 등록 / 기존 정리)
# ──────────────────────────────────────────────
async def _handle_research(message: discord.Message, parsed: dict, project_path: str):
"""리서치 요청 처리 — 조사 후 위키 등록 또는 기존 위키 정리."""
from tools.wiki_client import WikiClient
action = parsed.get("action", "research")
topic = parsed.get("topic", "")
wiki = WikiClient()
if not topic:
await message.reply("🔍 어떤 주제를 조사할까요?")
return
try:
if action == "research":
await _research_and_publish(message, topic, wiki, project_path)
elif action == "organize":
await _organize_wiki(message, topic, wiki, project_path)
elif action == "update":
await _research_and_publish(message, topic, wiki, project_path)
else:
await message.reply(f"❓ 알 수 없는 리서치 액션: {action}")
except Exception as e:
logger.error(f"리서치 핸들러 오류: {e}", exc_info=True)
await message.reply(f"❌ 리서치 오류: {str(e)[:300]}")
async def _research_and_publish(
message: discord.Message, topic: str,
wiki, project_path: str,
):
"""주제 조사 → 위키 페이지 등록."""
from tools.wiki_client import WikiClient
status_msg = await message.channel.send(
embed=discord.Embed(
title="🔍 조사 중...",
description=f"**{topic}**\n\nGemini가 웹 검색 + 자료를 수집하고 있습니다.",
color=0x9B59B6,
)
)
# Gemini agent로 조사 (google_web_search 자동 사용)
gemini = GeminiCaller(project_path)
research_prompt = (
f"다음 주제에 대해 깊이 있게 조사하고, 위키 페이지용 마크다운으로 정리하세요.\n\n"
f"주제: {topic}\n\n"
f"요구사항:\n"
f"1. 반드시 웹 검색을 통해 최신 정보를 확인하세요\n"
f"2. 핵심 개념, 장단점, 실무 활용법을 포함하세요\n"
f"3. 출처 URL을 반드시 포함하세요\n"
f"4. 마크다운 형식으로 작성하세요 (# 제목부터)\n"
f"5. 한국어로 작성하세요\n"
)
try:
content = await gemini.call_agent(
"agent", research_prompt, cwd=project_path, timeout=300,
)
except GeminiCallError as e:
await status_msg.edit(embed=discord.Embed(
title="❌ 조사 실패", description=str(e)[:500], color=0xE74C3C,
))
return
if not content or len(content) < 50:
await status_msg.edit(embed=discord.Embed(
title="⚠️ 결과 부족", description="충분한 정보를 수집하지 못했습니다.",
color=0xF39C12,
))
return
# 위키 등록
slug = WikiClient.slugify(topic)
path = f"research/{slug}"
page = await wiki.upsert_page(path, topic, content, description=f"리서치: {topic}")
# 대시보드 갱신
await wiki.update_dashboard()
await status_msg.edit(embed=discord.Embed(
title=f"✅ 위키 등록 완료: {topic}",
description=(
f"📄 [{path}](https://wiki.variet.net/{path})\n"
f"📊 대시보드 갱신됨\n"
f"📝 {len(content)}자 작성"
),
color=0x2ECC71,
))
async def _organize_wiki(
message: discord.Message, topic: str,
wiki, project_path: str,
):
"""기존 위키 페이지들을 주제별로 재정리."""
status_msg = await message.channel.send(
embed=discord.Embed(
title="📋 위키 분석 중...",
description=f"**{topic}** 관련 페이지를 수집하고 있습니다.",
color=0x9B59B6,
)
)
# 관련 페이지 수집
all_pages = await wiki.list_pages()
topic_lower = topic.lower()
related = [p for p in all_pages if topic_lower in p.title.lower() or topic_lower in p.path.lower()]
if not related:
await status_msg.edit(embed=discord.Embed(
title="⚠️ 관련 페이지 없음",
description=f"'{topic}' 관련 위키 페이지를 찾지 못했습니다.",
color=0xF39C12,
))
return
# 각 페이지 내용 수집
contents = []
for p in related:
full_page = await wiki.get_page(p.id)
contents.append(f"## 페이지: {full_page.title} (/{full_page.path})\n{full_page.content}\n")
combined = "\n---\n".join(contents)
await status_msg.edit(embed=discord.Embed(
title="🔄 재정리 중...",
description=f"{len(related)}개 페이지를 Gemini가 통합 정리합니다.",
color=0x9B59B6,
))
# Gemini로 통합 정리
gemini = GeminiCaller(project_path)
organize_prompt = (
f"다음은 '{topic}' 관련 기존 위키 페이지들입니다.\n"
f"이 내용을 하나의 통합된 위키 페이지로 재정리하세요.\n\n"
f"요구사항:\n"
f"1. 중복 제거, 논리적 구조화\n"
f"2. 누락된 정보가 있으면 웹 검색으로 보완\n"
f"3. 마크다운 형식, 한국어\n\n"
f"=== 기존 페이지들 ===\n{combined[:15000]}"
)
try:
content = await gemini.call_agent(
"agent", organize_prompt, cwd=project_path, timeout=300,
)
except GeminiCallError as e:
await status_msg.edit(embed=discord.Embed(
title="❌ 정리 실패", description=str(e)[:500], color=0xE74C3C,
))
return
# 통합 페이지 등록
from tools.wiki_client import WikiClient
slug = WikiClient.slugify(topic)
path = f"research/{slug}"
page = await wiki.upsert_page(path, f"{topic} (통합)", content, description=f"통합 정리: {topic}")
await wiki.update_dashboard()
await status_msg.edit(embed=discord.Embed(
title=f"✅ 위키 정리 완료: {topic}",
description=(
f"📄 [{path}](https://wiki.variet.net/{path})\n"
f"🔗 원본 {len(related)}개 → 통합 1개\n"
f"📝 {len(content)}"
),
color=0x2ECC71,
))
async def _anime_schedule(message, pipeline, filter_str):
"""편성표 조회."""
# 요일 파싱
week = None
week_map = {"": 0, "": 1, "": 2, "": 3, "": 4, "": 5, "": 6}
for name, num in week_map.items():
if name in filter_str:
week = num
break
if "week:" in filter_str:
m = re.search(r'week:(\d)', filter_str)
if m:
week = int(m.group(1))
if week is not None:
schedule = await pipeline.anissia.get_schedule(week)
week_names = ['','','','','','','','기타']
title = f"📅 {week_names[week]}요일 편성표"
else:
schedule = await pipeline.anissia.get_all_schedule()
schedule = [a for a in schedule if a.status == "ON"]
title = f"📅 이번 분기 방영 중인 애니"
# 자막 있는것 필터
if "sub:yes" in filter_str or "자막" in filter_str:
schedule = [a for a in schedule if a.caption_count > 0]
title += " (자막 있음)"
lines = []
for a in schedule[:25]:
sub_icon = "📝" if a.caption_count > 0 else " "
lines.append(f"{sub_icon} **{a.subject}** — {a.time} (자막 {a.caption_count}명)")
embed = discord.Embed(
title=title,
description="\n".join(lines) if lines else "결과 없음",
color=0x3498DB,
)
if len(schedule) > 25:
embed.set_footer(text=f"{len(schedule)}개 중 25개 표시")
await safe_send_embed(message.channel, embed)
async def _anime_list(message, pipeline, filter_str):
"""NAS에 다운로드된 애니 목록."""
if not pipeline.nas.is_accessible():
await message.reply(f"❌ NAS 경로 접근 불가: `{pipeline.nas.base_path}`")
return
# 분기 필터링
year, quarter = None, None
if "quarter:current" in filter_str or "이번" in filter_str:
from datetime import date
today = date.today()
year = today.year % 100
quarter = (today.month - 1) // 3 + 1
folders = pipeline.nas.list_anime_folders(year=year, quarter=quarter)
if not folders:
q_text = f" ({year}{quarter}분기)" if year else ""
await message.reply(f"📂 다운로드된 애니가 없습니다{q_text}.")
return
total_vids = sum(f.video_count for f in folders)
total_subs = sum(f.subtitle_count for f in folders)
total_size = sum(f.total_size_gb for f in folders)
lines = []
for f in folders:
sub_icon = "📝" if f.subtitle_count > 0 else ""
lines.append(
f"• **{f.title}** — 🎬{f.video_count}{sub_icon}{f.subtitle_count}자막 "
f"({f.total_size_gb:.1f}GB)"
)
q_text = f"{year}{quarter}분기" if year else "전체"
embed = discord.Embed(
title=f"📂 다운로드된 애니 ({q_text}: {len(folders)}개)",
description="\n".join(lines[:25]),
color=0x2ECC71,
)
embed.set_footer(
text=f"{total_vids}개 영상 | {total_subs}개 자막 | {total_size:.1f}GB"
)
if len(folders) > 25:
embed.description += f"\n... 외 {len(folders)-25}"
await safe_send_embed(message.channel, embed)
async def _anime_status(message, pipeline):
"""qBittorrent 상태 표시."""
conn = await pipeline.qbit.test_connection()
if not conn.get("connected"):
await message.reply(f"❌ qBittorrent 연결 실패: {conn.get('error', '?')}")
return
torrents = await pipeline.get_status()
embed = discord.Embed(
title=f"📊 다운로드 큐 ({len(torrents)}건)",
description=f"qBittorrent {conn.get('version', '?')}",
color=0x3498DB,
)
for t in torrents[:10]:
icon = "" if t["progress"] == "100.0%" else ""
embed.add_field(
name=f"{icon} {t['name'][:50]}",
value=f"진행: {t['progress']} | 속도: {t['speed']} | ETA: {t['eta']}",
inline=False,
)
if not torrents:
embed.add_field(name="상태", value="다운로드 중인 항목 없음", inline=False)
await safe_send_embed(message.channel, embed)
# ──────────────────────────────────────────────
# 설정 경고
# ──────────────────────────────────────────────
async def _send_setup_warning(message: discord.Message, ws):
"""미설정 항목 안내."""
missing = ws.missing_configs
lines = []
for item in missing:
if item == "Git":
lines.append("❌ **Git** 미설정 → `/workspace git` 으로 설정")
elif item == "Vikunja":
lines.append("❌ **Vikunja** 미설정 → `/workspace vikunja` 으로 설정")
embed = discord.Embed(
title="⚠️ 워크스페이스 설정 미완료",
description=(
f"**{ws.name}** 워크스페이스의 설정이 완료되지 않아 작업을 실행할 수 없습니다.\n\n"
+ "\n".join(lines)
+ "\n\n설정 완료 후 다시 요청해주세요."
),
color=0xE74C3C,
)
await message.reply(embed=embed)
# ──────────────────────────────────────────────
# Task 핸들러 (파이프라인 실행)
# ──────────────────────────────────────────────
async def _handle_task(message: discord.Message, text: str, ws):
"""작업 요청 — 파이프라인 단계별 실행 + 진행 표시."""
import uuid
task_id = uuid.uuid4().hex[:8]
embed = discord.Embed(
title="📋 작업 접수",
description=f"```{text[:200]}```",
color=0x3498DB,
)
embed.set_footer(text=f"ID: {task_id} | {ws.name}")
status_msg = await message.channel.send(embed=embed)
try:
from core.task_pipeline import TaskPipeline, MAX_REVIEW_RETRIES
pipeline = TaskPipeline(
project_path=ws.path,
docs_subpath=ws.docs_path,
)
pipeline.setup()
# 1. Plan (direct 모드면 Planner가 직접 처리)
embed.title = "🔍 분석 중..."
embed.color = 0xF39C12
await status_msg.edit(embed=embed)
plan = await pipeline.plan(text)
# ── Direct 모드: Planner가 직접 처리 완료 ──
is_direct = plan.get("direct", False)
if isinstance(is_direct, str):
is_direct = is_direct.lower() in ("true", "yes")
if is_direct:
result_text = plan.get("result", plan.get("summary", "완료"))
direct_embed = discord.Embed(
title="" + plan.get("summary", "처리 완료"),
description=result_text[:2000],
color=0x2ECC71,
)
await safe_send_embed(message.channel, direct_embed)
# Reviewer 검증
review_embed = discord.Embed(
title="🔍 리뷰어 검토 중...",
color=0xF39C12,
)
review_msg = await message.channel.send(embed=review_embed)
direct_tasks = [{"title": plan.get("summary", "직접 처리"), "description": result_text}]
review = await pipeline.batch_review(direct_tasks, [result_text])
passed = review.get("passed", True)
if isinstance(passed, str):
passed = passed.lower() in ("true", "yes", "pass")
review_embed.title = f"{'' if passed else '⚠️'} 리뷰 결과"
review_embed.description = review.get("summary", str(review))[:500]
review_embed.color = 0x2ECC71 if passed else 0xE74C3C
review_embed.set_footer(text=f"ID: {task_id} | {ws.name} | direct")
await review_msg.edit(embed=review_embed)
pipeline.docs.record_session(text, {"summary": result_text}, plan)
return
# ── Tasks 모드: Coder에게 분배 ──
tasks = plan.get("tasks", [])
if tasks:
task_list = "\n".join(
f"{t.get('title', t.get('description', '?'))}"
for t in tasks[:10]
)
plan_embed = discord.Embed(
title="📝 작업 계획",
description=plan.get("summary", "")[:500],
color=0x2ECC71,
)
plan_embed.add_field(
name=f"태스크 ({len(tasks)}개)", value=task_list[:1000], inline=False,
)
await message.channel.send(embed=plan_embed)
else:
# 태스크가 없지만 summary가 있으면 결과로 표시 (분류 경계 케이스)
summary_text = plan.get("summary", "") or plan.get("result", "")
if summary_text:
await message.channel.send(
embed=discord.Embed(
title="📋 분석 결과",
description=summary_text[:4000],
color=0x3498DB,
)
)
pipeline.docs.record_session(text, {"summary": summary_text}, plan)
else:
await message.channel.send(
embed=discord.Embed(
title="⚠️ 실행할 태스크 없음",
description="요청을 더 구체적으로 해주세요.",
color=0xF39C12,
)
)
return
# 2. Planner 오케스트레이션 루프 (외부: 리뷰어, 내부: 자가검증)
MAX_PLANNER_LOOPS = 3 # Planner 내부 자가검증 반복 제한
review = None
all_code_outputs = []
for review_attempt in range(1 + MAX_REVIEW_RETRIES):
review_label = f" (리뷰 재시도 {review_attempt})" if review_attempt > 0 else ""
# 리뷰어 반려 시: 피드백으로 재계획
if review_attempt > 0:
feedback = review.get("summary", str(review))
await message.channel.send(
embed=discord.Embed(
title=f"🔄 리뷰어 피드백 반영 재계획",
description=feedback[:500],
color=0xE74C3C,
)
)
replan_request = (
f"## 원래 요청\n{text}\n\n"
f"## 리뷰어 피드백 (반드시 반영)\n{feedback}\n\n"
f"피드백을 분석하고 태스크를 재설계하세요."
)
plan = await pipeline.plan(replan_request)
tasks = plan.get("tasks", [])
if not tasks:
break
# ── Planner 내부 루프: 계획 → 코딩 → 자가검증 → 추가작업 ──
for planner_round in range(MAX_PLANNER_LOOPS):
round_label = f" (보완 {planner_round})" if planner_round > 0 else ""
# 코딩
code_embed = discord.Embed(
title=f"⚙️ 코딩 중...{review_label}{round_label} ({len(tasks)}개)",
description="\n".join(
f"{t.get('title', '?')[:60]}" for t in tasks[:10]
),
color=0xE67E22,
)
code_msg = await message.channel.send(embed=code_embed)
code_outputs = await pipeline.code_parallel(tasks)
all_code_outputs = code_outputs # 최신 결과 유지
error_count = sum(1 for o in code_outputs if o.startswith("[ERROR]"))
code_embed.title = f"✅ 코딩 완료{round_label} ({len(tasks) - error_count}/{len(tasks)})"
code_embed.color = 0x2ECC71 if error_count == 0 else 0xF39C12
await code_msg.edit(embed=code_embed)
# Planner 자가검증
verify_embed = discord.Embed(
title="🔍 Planner 자가 검증 중...",
color=0xF39C12,
)
verify_msg = await message.channel.send(embed=verify_embed)
verification = await pipeline.planner_verify(text, plan, code_outputs)
satisfied = verification.get("satisfied", True)
if isinstance(satisfied, str):
satisfied = satisfied.lower() in ("true", "yes")
verify_embed.title = f"{'' if satisfied else '🔄'} Planner 검증{round_label}"
verify_embed.description = verification.get("feedback", "")[:500]
verify_embed.color = 0x2ECC71 if satisfied else 0xF39C12
await verify_msg.edit(embed=verify_embed)
if satisfied:
break # Planner 만족 → 리뷰어에게 전달
# 추가 태스크가 있으면 계속
additional = verification.get("additional_tasks", [])
if additional:
tasks = additional
task_list = "\n".join(f"{t.get('title', '?')}" for t in tasks[:10])
await message.channel.send(
embed=discord.Embed(
title=f"📝 추가 작업 {len(additional)}",
color=0xF39C12,
).add_field(name="태스크", value=task_list[:1000], inline=False)
)
else:
break # 추가 태스크 없으면 종료
# ── 외부 리뷰어 ──
review_embed = discord.Embed(
title="🔍 리뷰어 검토 중...",
color=0xF39C12,
)
review_msg = await message.channel.send(embed=review_embed)
review = await pipeline.batch_review(tasks, all_code_outputs)
passed = review.get("passed", True)
if isinstance(passed, str):
passed = passed.lower() in ("true", "yes", "pass")
review_embed.title = f"{'' if passed else '⚠️'} 리뷰어 결과{review_label}"
review_embed.description = review.get("summary", str(review))[:500]
review_embed.color = 0x2ECC71 if passed else 0xE74C3C
await review_msg.edit(embed=review_embed)
if passed:
break
# 3. 총평
summary = await pipeline.summarize(text, plan, all_code_outputs, review)
summary_embed = discord.Embed(
title=f"📊 {summary.get('title', '작업 완료')}",
description=summary.get("summary", "완료"),
color=0x9B59B6,
)
for field_name, key in [
("변경 사항", "changes"),
("⚠️ 주의", "warnings"),
("🔜 다음 단계", "next_steps"),
]:
items = summary.get(key, [])
if items:
if key == "changes" and isinstance(items[0], dict):
val = "\n".join(
f"• `{c.get('file','?')}` - {c.get('description','')}"
for c in items[:10]
)
else:
val = "\n".join(f"{s}" for s in items)
summary_embed.add_field(name=field_name, value=val[:1000], inline=False)
summary_embed.set_footer(text=f"ID: {task_id} | {ws.name}")
await safe_send_embed(message.channel, summary_embed)
# 기록
pipeline.docs.record_session(text, summary, plan)
pipeline.docs.append_changelog(summary.get("title", text[:50]))
except GeminiCallError as e:
await message.channel.send(
embed=discord.Embed(
title="❌ AI 호출 오류",
description=(
f"```{str(e)[:300]}```\n\n"
f"💡 **대응 방법:**\n"
f"• 요청을 더 짧게/구체적으로 다시 시도\n"
f"• 복잡한 요청은 단계별로 나눠서 요청\n"
f"• 잠시 후 다시 시도"
),
color=0xE74C3C,
)
)
except Exception as e:
logger.error(f"작업 오류: {e}", exc_info=True)
await message.channel.send(
embed=discord.Embed(
title="❌ 예기치 않은 오류",
description=(
f"```{str(e)[:300]}```\n\n"
f"💡 다시 요청하시거나, 문제가 계속되면 관리자에게 문의하세요."
),
color=0xE74C3C,
)
)
# ──────────────────────────────────────────────
# 슬래시 커맨드: /workspace
# ──────────────────────────────────────────────