diff --git a/api/discord_bot.py b/api/discord_bot.py index 9c19907..6dc959a 100644 --- a/api/discord_bot.py +++ b/api/discord_bot.py @@ -352,74 +352,105 @@ async def _handle_task(message: discord.Message, text: str, ws): ) return - # 2. Code + Review (실패 시 재계획 루프) + # 2. Planner 오케스트레이션 루프 (외부: 리뷰어, 내부: 자가검증) + MAX_PLANNER_LOOPS = 3 # Planner 내부 자가검증 반복 제한 review = None - code_outputs = [] - for attempt in range(1 + MAX_REVIEW_RETRIES): - attempt_label = f" (재시도 {attempt})" if attempt > 0 else "" + all_code_outputs = [] - # 재시도 시: 리뷰 피드백으로 재계획 - if attempt > 0: + 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"🔄 재계획 {attempt}/{MAX_REVIEW_RETRIES}", - description="리뷰 피드백을 바탕으로 다시 계획합니다.", - color=0xF39C12, + title=f"🔄 리뷰어 피드백 반영 재계획", + description=feedback[:500], + color=0xE74C3C, ) ) - replan_request = ( f"## 원래 요청\n{text}\n\n" - f"## 이전 리뷰 피드백 (반드시 반영)\n{feedback}\n\n" - f"이전 시도가 실패했습니다. 피드백을 분석하고 태스크를 재설계하세요." + f"## 리뷰어 피드백 (반드시 반영)\n{feedback}\n\n" + f"피드백을 분석하고 태스크를 재설계하세요." ) plan = await pipeline.plan(replan_request) tasks = plan.get("tasks", []) + if not tasks: + break - if tasks: - task_list = "\n".join( - f"• {t.get('title', '?')}" for t in tasks[:10] - ) + # ── 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"📝 재계획 결과", - description=f"태스크 {len(tasks)}개", - color=0x2ECC71, + title=f"📝 추가 작업 {len(additional)}개", + color=0xF39C12, ).add_field(name="태스크", value=task_list[:1000], inline=False) ) + else: + break # 추가 태스크 없으면 종료 - # 코딩 진행 표시 - code_embed = discord.Embed( - title=f"⚙️ 코딩 중...{attempt_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) - - error_count = sum(1 for o in code_outputs if o.startswith("[ERROR]")) - code_embed.title = f"✅ 코딩 완료{attempt_label} ({len(tasks) - error_count}/{len(tasks)} 성공)" - code_embed.color = 0x2ECC71 if error_count == 0 else 0xF39C12 - await code_msg.edit(embed=code_embed) - - # 리뷰 + # ── 외부 리뷰어 ── review_embed = discord.Embed( - title="🔍 리뷰 중...", + title="🔍 리뷰어 검토 중...", color=0xF39C12, ) review_msg = await message.channel.send(embed=review_embed) - review = await pipeline.batch_review(tasks, code_outputs) + 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 '⚠️'} 리뷰 결과{attempt_label}" + 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) @@ -428,7 +459,7 @@ async def _handle_task(message: discord.Message, text: str, ws): break # 3. 총평 - summary = await pipeline.summarize(text, plan, code_outputs, review) + summary = await pipeline.summarize(text, plan, all_code_outputs, review) summary_embed = discord.Embed( title=f"📊 {summary.get('title', '작업 완료')}", diff --git a/core/task_pipeline.py b/core/task_pipeline.py index 5a03ca1..0e5ea23 100644 --- a/core/task_pipeline.py +++ b/core/task_pipeline.py @@ -114,31 +114,16 @@ class TaskPipeline: return processed # ────────────────────────────────────────── - # Batch Review + # 프로젝트 파일 읽기 (공용) # ────────────────────────────────────────── - async def batch_review(self, tasks: list[dict], code_outputs: list[str]) -> dict: - """에이전트가 생성/수정한 실제 파일을 리뷰. - - code_outputs(에이전트 보고)와 함께 실제 프로젝트 파일 내용을 읽어서 리뷰합니다. - """ + def _read_recent_files(self, cutoff_seconds: int = 600) -> list[str]: + """최근 변경된 프로젝트 파일 읽기.""" import os import time as _time - # 태스크 요약 - task_summaries = [] - for i, task in enumerate(tasks): - title = task.get("title", task.get("description", f"Task {i+1}")) - task_summaries.append(f"### Task {i+1}: {title}") - - # 에이전트 보고 요약 - agent_reports = [] - for i, output in enumerate(code_outputs): - agent_reports.append(f"--- Agent {i+1} 보고 ---\n{output}") - - # 최근 변경된 파일 읽기 (10분 이내) recent_files = [] - cutoff = _time.time() - 600 + cutoff = _time.time() - cutoff_seconds project_root = Path(self.project_path) skip_dirs = {".git", "__pycache__", "node_modules", ".venv", "venv"} @@ -156,11 +141,84 @@ class TaskPipeline: except (OSError, UnicodeDecodeError): continue + return recent_files + + # ────────────────────────────────────────── + # Planner 자가 검증 (오케스트레이션) + # ────────────────────────────────────────── + + async def planner_verify( + self, user_request: str, plan: dict, + code_outputs: list[str], + ) -> dict: + """Planner가 자기 계획의 달성 여부를 검증. + + 실제 파일을 읽어서 계획이 충족됐는지 판단합니다. + 미달이면 추가 태스크를 생성합니다. + + Returns: + { + "satisfied": bool, + "feedback": "미충족 사유", + "additional_tasks": [...] (satisfied=false일 때) + } + """ + recent_files = self._read_recent_files() + files_section = "\n\n".join(recent_files) if recent_files else "(파일 없음)" + + agent_reports = "\n".join( + f"--- Agent {i+1} ---\n{output}" + for i, output in enumerate(code_outputs) + ) + + prompt = ( + f"## 원래 사용자 요청\n{user_request}\n\n" + f"## 내가 세운 계획\n{json.dumps(plan, ensure_ascii=False, indent=2)}\n\n" + f"## 에이전트 보고\n{agent_reports}\n\n" + f"## 현재 프로젝트 파일\n{files_section}\n\n" + f"## 판단 요청\n" + f"위 계획이 충족되었는지 판단하세요.\n" + f"충족되었으면 satisfied=true.\n" + f"미충족이면 satisfied=false + 부족한 부분을 해결할 추가 태스크를 생성하세요.\n\n" + f"JSON 형식:\n" + f"```json\n" + f'{{\n' + f' "satisfied": true|false,\n' + f' "feedback": "판단 근거 (한국어)",\n' + f' "additional_tasks": [\n' + f' {{"id": 1, "title": "추가 태스크", "description": "구현 내용", "type": "modify"}}\n' + f' ]\n' + f'}}\n' + f"```" + ) + + response = await self.gemini.call("planner", prompt, timeout=180) + self._log("planner_verify", user_request, response) + + result = self._extract_json(response) + return result or {"satisfied": True, "feedback": response} + + # ────────────────────────────────────────── + # Batch Review + # ────────────────────────────────────────── + + async def batch_review(self, tasks: list[dict], code_outputs: list[str]) -> dict: + """에이전트가 생성/수정한 실제 파일을 리뷰.""" + task_summaries = [] + for i, task in enumerate(tasks): + title = task.get("title", task.get("description", f"Task {i+1}")) + task_summaries.append(f"### Task {i+1}: {title}") + + agent_reports = [] + for i, output in enumerate(code_outputs): + agent_reports.append(f"--- Agent {i+1} 보고 ---\n{output}") + + recent_files = self._read_recent_files() + if not recent_files: - # 변경 파일 없음 → 자동 통과 (삭제 작업 등) return { "passed": True, - "summary": "파일 변경 없음 또는 삭제 작업 — 자동 통과", + "summary": "파일 변경 없음 또는 삭제 작업 - 자동 통과", "issues": [], }