diff --git a/.agent/references/known-issues.md b/.agent/references/known-issues.md index 7db4c49..4ceba24 100644 --- a/.agent/references/known-issues.md +++ b/.agent/references/known-issues.md @@ -75,3 +75,9 @@ - **원인**: 기존 자막 파일 존재 여부를 확인하지 않고 전 에피소드 자막 다운로드 시도 - **해결**: NAS 폴더의 기존 자막 파일을 에피소드별로 스캔, 이미 있으면 스킵 - **주의**: 자막 처리 시 사용자 수동 입력 파일의 보존을 항상 고려 + +### [2026-03-15] Wiki.js GraphQL — update mutation에 tags 누락 시 에러 +- **증상**: `update_page()` 호출 시 `Cannot read properties of undefined` 백엔드 에러 +- **원인**: Wiki.js `update` mutation이 `tags` 파라미터 생략 시 내부적으로 undefined 처리하여 crash +- **해결**: `update_page()`에서 `tags`가 None이면 `get_page()`로 기존 tags를 먼저 조회하여 항상 전달 +- **주의**: Wiki.js GraphQL mutation은 optional로 보이는 필드도 생략 시 에러 가능. 항상 모든 필드를 명시적으로 전달 diff --git a/api/discord_bot.py b/api/discord_bot.py index 87e0fdd..ee3146a 100644 --- a/api/discord_bot.py +++ b/api/discord_bot.py @@ -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 # ────────────────────────────────────────────── diff --git a/docs/devlog/2026-03-15.md b/docs/devlog/2026-03-15.md index 19639c6..f609dfc 100644 --- a/docs/devlog/2026-03-15.md +++ b/docs/devlog/2026-03-15.md @@ -5,3 +5,4 @@ | 1 | 07:00~08:24 | 애니 파이프라인 중복 다운로드 버그 5건 수정 (v2 정규식, 릴리스 그룹 필터, 자막 보호, 배치 다운로드, 타임아웃) | `42d0d81` | ✅ | | 2 | 17:14~18:11 | Blogspot Atom Feed 자막 발견, AniList 시즌 오프셋 자동 감지, 자막 pre-skip, offset-aware 리네임. 버그 9건 수정 + 기능 3건 추가 | `3618387` | ✅ | | 3 | 18:25~18:31 | 애니 파이프라인 아키텍처 & 운영 가이드 문서 작성 (docs + wiki) | `9e06c01` | ✅ | +| 4 | 21:30~22:40 | wiki_client update_page tags 버그 수정 + discord_bot 레거시 NLU/핸들러 800줄 제거 | `920ed14` | ✅ | diff --git a/tests/cli_test_output.txt b/tests/cli_test_output.txt index 73ed6c9..b76c514 100644 --- a/tests/cli_test_output.txt +++ b/tests/cli_test_output.txt @@ -34,28 +34,29 @@ accessible: True total folders: 322 current quarter: 7 anime - [26_1분기]29세 독신 중견 모험가의 일상 vid:6 sub:6 - [26_1분기]공주님고문의시간입니다2기 vid:8 sub:6 - [26_1분기]귀족전생-축복받은태생으로- vid:9 sub:0 - [26_1분기]너따위가마왕을이길수있다고생각하지마-용사파티추방 vid:9 sub:9 - [26_1분기]용사파티에서쫒겨난다재무능 vid:10 sub:8 + [26_1분기]29세 독신 중견 모험가의 일상 vid:10 sub:10 + [26_1분기]공주님고문의시간입니다2기 vid:10 sub:9 + [26_1분기]귀족전생-축복받은태생으로- vid:10 sub:0 + [26_1분기]너따위가마왕을이길수있다고생각하지마-용사파티추방 vid:10 sub:20 + [26_1분기]용사파티에서쫒겨난다재무능 vid:11 sub:11 === Anissia Search === - 'frieren': 2 results - 'sousou': 1 results + 'frieren': 1 results + 'sousou': 0 results === Nyaa Search === 'Sousou no Frieren ASW HEVC': 36 results - top: [ASW] [ASW] Sousou no Frieren S2 - 08 [1080p HEVC x265 1 S:717 + top: [ASW] [ASW] Sousou no Frieren S2 - 08 [1080p HEVC x265 1 S:626 === Pipeline Search === - success: True - anime: 장송의 프리렌 2기 - captions: 2 - torrents: 20 - nas_folder: [26_1분기]장송의 프리렌 2기 + success: False + anime: None + captions: 0 + torrents: 0 + nas_folder: === qBittorrent === - connected: False + connected: True + version: v5.1.0 === ALL TESTS DONE === \ No newline at end of file diff --git a/tools/wiki_client.py b/tools/wiki_client.py index 8cc263d..83c8df1 100644 --- a/tools/wiki_client.py +++ b/tools/wiki_client.py @@ -34,6 +34,7 @@ class WikiPage: content: str = "" updated_at: str = "" description: str = "" + tags: list[str] = None class WikiClient: @@ -93,7 +94,7 @@ class WikiClient: query = """ query ($id: Int!) { pages { single(id: $id) { - id, path, title, content, updatedAt, description + id, path, title, content, updatedAt, description, tags { tag } }} } """ @@ -101,10 +102,11 @@ class WikiClient: p = data["pages"]["single"] if not p: raise RuntimeError(f"페이지 ID {page_id}를 찾을 수 없습니다.") + 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", ""), + description=p.get("description", ""), tags=tags, ) async def find_page(self, path: str) -> Optional[WikiPage]: @@ -156,25 +158,32 @@ class WikiClient: title: str = None, description: str = None, tags: list[str] = None, ) -> bool: """기존 페이지 수정.""" - query = """ - mutation ($id: Int!, $content: String!, $title: String, - $description: String, $tags: [String!]) { - pages { update( - id: $id, content: $content, title: $title, - description: $description, tags: $tags - ) { - responseResult { succeeded, message } - }} - } - """ - variables = {"id": page_id, "content": content} + if tags is None: + existing = await self.get_page(page_id) + tags = existing.tags + + args_def = ["$id: Int!", "$content: String!", "$tags: [String!]"] + args_pass = ["id: $id", "content: $content", "tags: $tags"] + variables = {"id": page_id, "content": content, "tags": tags} + if title: + args_def.append("$title: String") + args_pass.append("title: $title") variables["title"] = title if description: + args_def.append("$description: String") + args_pass.append("description: $description") variables["description"] = description - if tags is not None: - variables["tags"] = tags + query = f""" + mutation ({", ".join(args_def)}) {{ + pages {{ update( + {", ".join(args_pass)} + ) {{ + responseResult {{ succeeded, message }} + }}}} + }} + """ data = await self._query(query, variables) result = data["pages"]["update"] ok = result["responseResult"]["succeeded"]