feat: Pipeline 전면 개선 — 병렬실행, Batch Review, 총평, 대화기억, 스마트라우팅
- GeminiCaller: cmd/c 제거, 인자 분리, Semaphore(4) 동시성 제어, GeminiCallError - TaskPipeline: asyncio.gather 병렬 코딩, batch_review 1회, summarize 총평 - FileApplier: Coder 출력 파싱 → 실제 파일 적용 (경로 보안 체크) - Discord Bot: on_message 자동채팅, 의도분류(chat/task/clarify), 대화기억(10메시지) - Prompts: router.md (의도분류), summarizer.md (총평) - Workflows: agent_chat 환경 경로 업데이트
This commit is contained in:
@@ -1,24 +1,24 @@
|
|||||||
---
|
---
|
||||||
description: 프로젝트 전체 작업 현황을 종합 체크하는 워크플로우 (Git + Vikunja + 로컬)
|
description: ?„로?<3F>트 ?„ì²´ ?‘ì—… ?„황??종합 ì²´í<C2B4>¬?˜ëŠ” ?Œí<C592>¬?Œë¡œ??(Git + Vikunja + 로컬)
|
||||||
---
|
---
|
||||||
|
|
||||||
# 프로젝트 현황 종합 체크
|
# ?„로?<3F>트 ?„황 종합 ì²´í<C2B4>¬
|
||||||
|
|
||||||
"작업상황 체크", "현황 확인", "status check" 등 요청 시 이 워크플로우를 실행합니다.
|
"?‘ì—…?<3F>황 ì²´í<C2B4>¬", "?„황 ?•ì<E280A2>¸", "status check" ???”ì² ?????Œí<C592>¬?Œë¡œ?°ë? ?¤í–‰?©ë‹ˆ??
|
||||||
|
|
||||||
// turbo-all
|
// turbo-all
|
||||||
|
|
||||||
## 절차
|
## ?ˆì°¨
|
||||||
|
|
||||||
1. Git 로컬 상태 확인:
|
1. Git 로컬 ?<3F>태 ?•ì<E280A2>¸:
|
||||||
```powershell
|
```powershell
|
||||||
git -C "c:\Users\CafeVariet-GL552VW\Desktop\source_diff\variet-agent" status --short
|
git -C "c:\Users\Certes\Desktop\variet-agent" status --short
|
||||||
```
|
```
|
||||||
```powershell
|
```powershell
|
||||||
git -C "c:\Users\CafeVariet-GL552VW\Desktop\source_diff\variet-agent" log --oneline -5
|
git -C "c:\Users\Certes\Desktop\variet-agent" log --oneline -5
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Vikunja 태스크 현황 조회:
|
2. Vikunja ?œìФ???„황 조회:
|
||||||
```powershell
|
```powershell
|
||||||
$h = @{Authorization="Bearer tk_070f8e0b715e818bb7178c3815ed5389040eddca"}
|
$h = @{Authorization="Bearer tk_070f8e0b715e818bb7178c3815ed5389040eddca"}
|
||||||
$tasks = Invoke-RestMethod -Uri "https://plan.variet.net/api/v1/projects/7/tasks?per_page=50" -Headers $h
|
$tasks = Invoke-RestMethod -Uri "https://plan.variet.net/api/v1/projects/7/tasks?per_page=50" -Headers $h
|
||||||
@@ -28,7 +28,7 @@ Write-Host "=== Vikunja: TODO $($todo.Count), DONE $($done.Count) ==="
|
|||||||
$todo | ForEach-Object { Write-Host " #$($_.id) $($_.title)" }
|
$todo | ForEach-Object { Write-Host " #$($_.id) $($_.title)" }
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Gitea 최근 커밋 확인 (리모트):
|
3. Gitea 최근 커밋 ?•ì<E280A2>¸ (리모??:
|
||||||
```powershell
|
```powershell
|
||||||
$h = @{Authorization="token 3a01b4b15a39921572e64c413353e870d4d2161b"}
|
$h = @{Authorization="token 3a01b4b15a39921572e64c413353e870d4d2161b"}
|
||||||
$commits = Invoke-RestMethod -Uri "https://git.variet.net/api/v1/repos/Variet/variet-agent/commits?limit=5&sha=main" -Headers $h
|
$commits = Invoke-RestMethod -Uri "https://git.variet.net/api/v1/repos/Variet/variet-agent/commits?limit=5&sha=main" -Headers $h
|
||||||
@@ -36,8 +36,8 @@ Write-Host "=== Gitea: Recent Commits ==="
|
|||||||
$commits | ForEach-Object { Write-Host " $($_.sha.Substring(0,7)) $($_.commit.message.Split("`n")[0])" }
|
$commits | ForEach-Object { Write-Host " $($_.sha.Substring(0,7)) $($_.commit.message.Split("`n")[0])" }
|
||||||
```
|
```
|
||||||
|
|
||||||
4. 결과를 종합하여 사용자에게 보고:
|
4. ê²°ê³¼ë¥?종합?˜ì—¬ ?¬ìš©?<3F>ì—<C3AC>ê²?ë³´ê³ :
|
||||||
- 로컬 uncommitted 변경 여부
|
- 로컬 uncommitted ë³€ê²??¬ë?
|
||||||
- 로컬 vs 리모트 커밋 차이
|
- 로컬 vs 리모??커밋 ì°¨ì<C2A8>´
|
||||||
- TODO 태스크 목록 + 우선순위
|
- TODO ?œìФ??목ë¡<C3AB> + ?°ì„ ?œìœ„
|
||||||
- 다음 작업 제안
|
- ?¤ì<C2A4>Œ ?‘ì—… ?œì•ˆ
|
||||||
|
|||||||
@@ -1,50 +1,50 @@
|
|||||||
---
|
---
|
||||||
description: Vikunja API로 Variet Agent 프로젝트 태스크 현황을 조회하는 워크플로우
|
description: Vikunja APIë¡?Variet Agent ?„로?<3F>트 ?œìФ???„황??조회?˜ëŠ” ?Œí<C592>¬?Œë¡œ??
|
||||||
---
|
---
|
||||||
|
|
||||||
# Vikunja 태스크 현황 조회
|
# Vikunja ?œìФ???„황 조회
|
||||||
|
|
||||||
서비스 정보는 `.agent/workflows/services.md` 참조.
|
?œë¹„???•ë³´??`.agent/workflows/services.md` 참조.
|
||||||
|
|
||||||
// turbo-all
|
// turbo-all
|
||||||
|
|
||||||
## 절차
|
## ?ˆì°¨
|
||||||
|
|
||||||
1. 간편 조회 (TODO/DONE 리스트):
|
1. 간편 조회 (TODO/DONE 리스??:
|
||||||
```powershell
|
```powershell
|
||||||
C:\ProgramData\miniforge3\envs\quant\python.exe .agent\workflows\vikunja_helper.py list
|
C:\ProgramData\miniforge3\envs\agent_chat\python.exe .agent\workflows\vikunja_helper.py list
|
||||||
```
|
```
|
||||||
|
|
||||||
2. TODO만 조회:
|
2. TODO�조회:
|
||||||
```powershell
|
```powershell
|
||||||
C:\ProgramData\miniforge3\envs\quant\python.exe .agent\workflows\vikunja_helper.py list todo
|
C:\ProgramData\miniforge3\envs\agent_chat\python.exe .agent\workflows\vikunja_helper.py list todo
|
||||||
```
|
```
|
||||||
|
|
||||||
3. DONE만 조회:
|
3. DONE�조회:
|
||||||
```powershell
|
```powershell
|
||||||
C:\ProgramData\miniforge3\envs\quant\python.exe .agent\workflows\vikunja_helper.py list done
|
C:\ProgramData\miniforge3\envs\agent_chat\python.exe .agent\workflows\vikunja_helper.py list done
|
||||||
```
|
```
|
||||||
|
|
||||||
4. 태스크 완료 처리 (**⚠️ 반드시 이 방법 사용 — 직접 API 호출 금지**):
|
4. ?œìФ???„료 처리 (**? ï¸<C3AF> 반드????방법 ?¬ìš© ??ì§<C3AC>ì ‘ API ?¸ì¶œ 금ì?**):
|
||||||
```powershell
|
```powershell
|
||||||
C:\ProgramData\miniforge3\envs\quant\python.exe .agent\workflows\vikunja_helper.py done {TASK_ID}
|
C:\ProgramData\miniforge3\envs\agent_chat\python.exe .agent\workflows\vikunja_helper.py done {TASK_ID}
|
||||||
```
|
```
|
||||||
여러 태스크 동시:
|
?¬ëŸ¬ ?œìФ???™ì‹œ:
|
||||||
```powershell
|
```powershell
|
||||||
C:\ProgramData\miniforge3\envs\quant\python.exe .agent\workflows\vikunja_helper.py done 71 77 78
|
C:\ProgramData\miniforge3\envs\agent_chat\python.exe .agent\workflows\vikunja_helper.py done 71 77 78
|
||||||
```
|
```
|
||||||
|
|
||||||
5. 태스크 코멘트 추가:
|
5. ?œìФ??코멘??ì¶”ê?:
|
||||||
```powershell
|
```powershell
|
||||||
C:\ProgramData\miniforge3\envs\quant\python.exe .agent\workflows\vikunja_helper.py comment {TASK_ID} "내용"
|
C:\ProgramData\miniforge3\envs\agent_chat\python.exe .agent\workflows\vikunja_helper.py comment {TASK_ID} "?´ìš©"
|
||||||
```
|
```
|
||||||
|
|
||||||
6. 새 태스크 생성:
|
6. ???œìФ???<3F>성:
|
||||||
```powershell
|
```powershell
|
||||||
C:\ProgramData\miniforge3\envs\quant\python.exe .agent\workflows\vikunja_helper.py create "제목" "설명"
|
C:\ProgramData\miniforge3\envs\agent_chat\python.exe .agent\workflows\vikunja_helper.py create "?œëª©" "?¤ëª…"
|
||||||
```
|
```
|
||||||
|
|
||||||
> [!CAUTION]
|
> [!CAUTION]
|
||||||
> **절대로** `Invoke-RestMethod -Method Post -Body '{"done": true}'` 같은 직접 API 호출을 사용하지 마세요.
|
> **?ˆë?ë¡?* `Invoke-RestMethod -Method Post -Body '{"done": true}'` ê°™ì? ì§<C3AC>ì ‘ API ?¸ì¶œ???¬ìš©?˜ì? 마세??
|
||||||
> Vikunja API는 POST 시 body에 포함되지 않은 필드를 빈값으로 덮어씁니다.
|
> Vikunja API??POST ??body???¬í•¨?˜ì? ?Šì? ?„드ë¥?빈값?¼ë¡œ ??–´?<3F>니??
|
||||||
> `vikunja_helper.py`는 항상 GET → 기존 필드 보존 → POST 패턴을 사용하여 title/description 보존을 보장합니다.
|
> `vikunja_helper.py`????ƒ<> GET ??기존 ?„드 ë³´ì¡´ ??POST ?¨í„´???¬ìš©?˜ì—¬ title/description ë³´ì¡´??보장?©ë‹ˆ??
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
---
|
---
|
||||||
description: 개발 서버 실행 방법
|
description: 개발 ?œë²„ ?¤í–‰ 방법
|
||||||
---
|
---
|
||||||
|
|
||||||
## 환경 설정
|
## ?˜ê²½ ?¤ì •
|
||||||
|
|
||||||
1. Python 환경 활성화
|
1. Python ?˜ê²½ ?œì„±??
|
||||||
// turbo
|
// turbo
|
||||||
```
|
```
|
||||||
C:\ProgramData\miniforge3\envs\quant\python.exe -m pip install -r requirements.txt
|
C:\ProgramData\miniforge3\envs\agent_chat\python.exe -m pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
2. API 서버 실행
|
2. API ?œë²„ ?¤í–‰
|
||||||
```
|
```
|
||||||
C:\ProgramData\miniforge3\envs\quant\python.exe -m uvicorn api.server:app --reload --host 0.0.0.0 --port 8100
|
C:\ProgramData\miniforge3\envs\agent_chat\python.exe -m uvicorn api.server:app --reload --host 0.0.0.0 --port 8100
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Discord Bot 실행 (별도 터미널)
|
3. Discord Bot ?¤í–‰ (별ë<E2809E>„ ?°ë???
|
||||||
```
|
```
|
||||||
C:\ProgramData\miniforge3\envs\quant\python.exe -m api.discord_bot
|
C:\ProgramData\miniforge3\envs\agent_chat\python.exe -m api.discord_bot
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
---
|
---
|
||||||
description: 테스트 실행 방법
|
description: ?ŒìФ???¤í–‰ 방법
|
||||||
---
|
---
|
||||||
|
|
||||||
## 단위 테스트
|
## ?¨ìœ„ ?ŒìФ??
|
||||||
// turbo
|
// turbo
|
||||||
```
|
```
|
||||||
C:\ProgramData\miniforge3\envs\quant\python.exe -m pytest tests/ -v
|
C:\ProgramData\miniforge3\envs\agent_chat\python.exe -m pytest tests/ -v
|
||||||
```
|
```
|
||||||
|
|
||||||
## Context Manager 효과 테스트
|
## Context Manager ?¨ê³¼ ?ŒìФ??
|
||||||
```
|
```
|
||||||
C:\ProgramData\miniforge3\envs\quant\python.exe -m tests.test_context_manager
|
C:\ProgramData\miniforge3\envs\agent_chat\python.exe -m tests.test_context_manager
|
||||||
```
|
```
|
||||||
|
|
||||||
## Task Pipeline E2E
|
## Task Pipeline E2E
|
||||||
```
|
```
|
||||||
C:\ProgramData\miniforge3\envs\quant\python.exe -m tests.test_pipeline_e2e
|
C:\ProgramData\miniforge3\envs\agent_chat\python.exe -m tests.test_pipeline_e2e
|
||||||
```
|
```
|
||||||
|
|
||||||
## Gemini CLI 연동 테스트
|
## Gemini CLI ?°ë<C2B0>™ ?ŒìФ??
|
||||||
```
|
```
|
||||||
gemini -p "Hello, respond with 'OK'" --approval-mode yolo -o json
|
gemini -p "Hello, respond with 'OK'" --approval-mode yolo -o json
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,21 +1,26 @@
|
|||||||
"""Discord Bot 어댑터.
|
"""Discord Bot 어댑터.
|
||||||
|
|
||||||
사용자 명령을 받아 FastAPI API를 호출하고 결과를 디스코드로 보고합니다.
|
사용자 명령을 받아 파이프라인 실행 또는 즉답.
|
||||||
|
지정 채널(봇 이름 채널)에서는 접두사 없이 자연스럽게 대화합니다.
|
||||||
|
대화 기억: 채널별 최근 메시지를 컨텍스트로 주입.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
|
|
||||||
import config
|
import config
|
||||||
|
from core.gemini_caller import GeminiCallError
|
||||||
|
|
||||||
logger = logging.getLogger("variet.discord")
|
logger = logging.getLogger("variet.discord")
|
||||||
|
|
||||||
# Bot 설정
|
# Bot 설정
|
||||||
intents = discord.Intents.default()
|
intents = discord.Intents.default()
|
||||||
intents.message_content = True # MESSAGE CONTENT INTENT 필요
|
intents.message_content = True
|
||||||
|
|
||||||
bot = commands.Bot(
|
bot = commands.Bot(
|
||||||
command_prefix=config.DISCORD_COMMAND_PREFIX,
|
command_prefix=config.DISCORD_COMMAND_PREFIX,
|
||||||
@@ -23,56 +28,217 @@ bot = commands.Bot(
|
|||||||
help_command=commands.DefaultHelpCommand(no_category="명령어"),
|
help_command=commands.DefaultHelpCommand(no_category="명령어"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# In-memory: Discord 채널 ↔ Task 매핑
|
# In-memory
|
||||||
_channel_tasks: dict[int, list[str]] = {}
|
_channel_tasks: dict[int, list[str]] = {}
|
||||||
|
_auto_chat_channel_ids: set[int] = set()
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# 대화 기억 (Conversation Memory)
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _get_channel_history(channel: discord.TextChannel, limit: int = 10) -> str:
|
||||||
|
"""채널의 최근 메시지를 대화 히스토리 문자열로 변환."""
|
||||||
|
messages = []
|
||||||
|
async for msg in channel.history(limit=limit + 1): # 현재 메시지 포함이므로 +1
|
||||||
|
if msg.author.bot:
|
||||||
|
role = "assistant"
|
||||||
|
else:
|
||||||
|
role = "user"
|
||||||
|
messages.append(f"[{role}] {msg.content[:300]}")
|
||||||
|
|
||||||
|
# 시간순 (오래된 것 먼저)
|
||||||
|
messages.reverse()
|
||||||
|
|
||||||
|
# 마지막(현재 메시지)은 제외 — 이미 context로 전달되니까
|
||||||
|
if messages:
|
||||||
|
messages = messages[:-1]
|
||||||
|
|
||||||
|
if not messages:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
return "=== CONVERSATION HISTORY ===\n" + "\n".join(messages) + "\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# 의도 분류 (Intent Router)
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _classify_intent(message: str) -> dict:
|
||||||
|
"""Gemini로 사용자 메시지의 의도를 분류."""
|
||||||
|
from core.gemini_caller import GeminiCaller
|
||||||
|
|
||||||
|
gemini = GeminiCaller(str(config.PROJECT_ROOT))
|
||||||
|
try:
|
||||||
|
raw = await gemini.call("router", message, timeout=30)
|
||||||
|
except GeminiCallError as e:
|
||||||
|
logger.warning(f"Intent 분류 실패: {e}")
|
||||||
|
return {"intent": "chat", "reason": "분류 실패"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
json_match = re.search(r'\{[^}]+\}', raw)
|
||||||
|
if json_match:
|
||||||
|
return json.loads(json_match.group())
|
||||||
|
except (json.JSONDecodeError, AttributeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.warning(f"Intent JSON 파싱 실패, chat으로 처리: {raw[:100]}")
|
||||||
|
return {"intent": "chat", "reason": "파싱 실패 — 기본 chat"}
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# 이벤트 핸들러
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
@bot.event
|
@bot.event
|
||||||
async def on_ready():
|
async def on_ready():
|
||||||
"""봇 접속 완료."""
|
"""봇 접속 완료 — 봇 이름 채널 자동 감지."""
|
||||||
logger.info(f"Discord Bot 접속 완료: {bot.user} (ID: {bot.user.id})")
|
logger.info(f"Discord Bot 접속 완료: {bot.user} (ID: {bot.user.id})")
|
||||||
logger.info(f"서버 {len(bot.guilds)}개 연결됨")
|
logger.info(f"서버 {len(bot.guilds)}개 연결됨")
|
||||||
|
|
||||||
|
bot_name = bot.user.name.lower().replace(" ", "-")
|
||||||
|
bot_name_underscore = bot_name.replace("-", "_")
|
||||||
|
bot_name_dash = bot_name.replace("_", "-")
|
||||||
|
|
||||||
|
for guild in bot.guilds:
|
||||||
|
for channel in guild.text_channels:
|
||||||
|
ch_name = channel.name.lower()
|
||||||
|
if ch_name in (bot_name, bot_name_underscore, bot_name_dash):
|
||||||
|
_auto_chat_channel_ids.add(channel.id)
|
||||||
|
logger.info(
|
||||||
|
f"자동 채팅 채널 감지: #{channel.name} (ID: {channel.id}) "
|
||||||
|
f"in {guild.name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _auto_chat_channel_ids:
|
||||||
|
logger.info(f"봇 이름({bot_name}) 채널 없음 — !명령어만 사용 가능")
|
||||||
|
|
||||||
await bot.change_presence(
|
await bot.change_presence(
|
||||||
activity=discord.Activity(
|
activity=discord.Activity(
|
||||||
type=discord.ActivityType.listening,
|
type=discord.ActivityType.listening,
|
||||||
name=f"{config.DISCORD_COMMAND_PREFIX}agent",
|
name="대화" if _auto_chat_channel_ids else f"{config.DISCORD_COMMAND_PREFIX}agent",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bot.command(name="agent", help="AI Agent에게 작업 요청\n예: !agent README에 설치 방법 추가해줘")
|
@bot.event
|
||||||
async def agent_command(ctx: commands.Context, *, request: str):
|
async def on_message(message: discord.Message):
|
||||||
"""작업 요청 → Pipeline 실행 → 결과 보고."""
|
"""모든 메시지 수신 — 봇 전용 채널이면 스마트 라우팅."""
|
||||||
|
if message.author == bot.user or message.author.bot:
|
||||||
|
return
|
||||||
|
|
||||||
# 1. 접수 메시지
|
# ! 명령어는 기존 핸들러로
|
||||||
|
if message.content.startswith(config.DISCORD_COMMAND_PREFIX):
|
||||||
|
await bot.process_commands(message)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 봇 전용 채널이 아니면 무시
|
||||||
|
if message.channel.id not in _auto_chat_channel_ids:
|
||||||
|
return
|
||||||
|
|
||||||
|
await _route_message(message)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# 스마트 라우팅
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _route_message(message: discord.Message):
|
||||||
|
"""메시지 의도를 분류하고 적절한 핸들러로 라우팅."""
|
||||||
|
user_text = message.content.strip()
|
||||||
|
if not user_text:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 짧은 메시지 → 분류 없이 바로 chat
|
||||||
|
if len(user_text) <= 15:
|
||||||
|
await _handle_chat(message, user_text)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 의도 분류
|
||||||
|
async with message.channel.typing():
|
||||||
|
intent_result = await _classify_intent(user_text)
|
||||||
|
|
||||||
|
intent = intent_result.get("intent", "chat")
|
||||||
|
reason = intent_result.get("reason", "")
|
||||||
|
logger.info(f"의도 분류: {intent} ({reason}) — \"{user_text[:50]}\"")
|
||||||
|
|
||||||
|
if intent == "task":
|
||||||
|
await _handle_task(message, user_text)
|
||||||
|
elif intent == "clarify":
|
||||||
|
await _handle_clarify(message, user_text, reason)
|
||||||
|
else:
|
||||||
|
await _handle_chat(message, user_text)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# 핸들러: Chat (즉답 + 대화 기억)
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _handle_chat(message: discord.Message, text: str):
|
||||||
|
"""즉답 — 대화 히스토리 포함하여 Gemini 직접 호출."""
|
||||||
|
async with message.channel.typing():
|
||||||
|
try:
|
||||||
|
from core.gemini_caller import GeminiCaller
|
||||||
|
|
||||||
|
# 대화 기억: 최근 10개 메시지
|
||||||
|
history = await _get_channel_history(message.channel, limit=10)
|
||||||
|
context = f"{history}{text}"
|
||||||
|
|
||||||
|
gemini = GeminiCaller(str(config.PROJECT_ROOT))
|
||||||
|
response = await gemini.call("default", context, timeout=60)
|
||||||
|
|
||||||
|
if not response:
|
||||||
|
await message.reply("⚠️ 응답을 생성하지 못했어요. 다시 시도해 주세요.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Discord 2000자 제한
|
||||||
|
if len(response) <= 2000:
|
||||||
|
await message.reply(response)
|
||||||
|
else:
|
||||||
|
for i in range(0, len(response), 4000):
|
||||||
|
chunk = response[i:i + 4000]
|
||||||
|
embed = discord.Embed(description=chunk, color=0x3498DB)
|
||||||
|
await message.channel.send(embed=embed)
|
||||||
|
|
||||||
|
except GeminiCallError as e:
|
||||||
|
logger.error(f"Chat Gemini 오류: {e}")
|
||||||
|
await message.reply(f"⚠️ AI 호출 오류: {str(e)[:200]}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Chat 오류: {e}", exc_info=True)
|
||||||
|
await message.reply(f"❌ 오류: {str(e)[:200]}")
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# 핸들러: Task (파이프라인 실행)
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _handle_task(message: discord.Message, text: str):
|
||||||
|
"""작업 요청 — 새 파이프라인 (병렬 Code + Batch Review + 총평)."""
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
task_id = uuid.uuid4().hex[:8]
|
||||||
|
|
||||||
|
# 접수 메시지
|
||||||
embed = discord.Embed(
|
embed = discord.Embed(
|
||||||
title="📋 작업 접수",
|
title="📋 작업 접수",
|
||||||
description=f"```{request[:200]}```",
|
description=f"```{text[:200]}```",
|
||||||
color=0x3498DB,
|
color=0x3498DB,
|
||||||
)
|
)
|
||||||
embed.set_footer(text="분석 중...")
|
embed.set_footer(text=f"ID: {task_id} — 분석 중...")
|
||||||
status_msg = await ctx.send(embed=embed)
|
status_msg = await message.channel.send(embed=embed)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 2. Pipeline 직접 실행 (같은 프로세스)
|
|
||||||
from core.task_pipeline import TaskPipeline
|
from core.task_pipeline import TaskPipeline
|
||||||
import uuid
|
|
||||||
|
|
||||||
task_id = uuid.uuid4().hex[:8]
|
pipeline = TaskPipeline(project_path=str(config.PROJECT_ROOT))
|
||||||
|
pipeline.setup()
|
||||||
|
|
||||||
# Planning
|
# ── Plan ──
|
||||||
embed.color = 0xF39C12
|
embed.color = 0xF39C12
|
||||||
embed.set_footer(text=f"🔍 작업 분해 중... (ID: {task_id})")
|
embed.set_footer(text=f"🔍 작업 분해 중... (ID: {task_id})")
|
||||||
await status_msg.edit(embed=embed)
|
await status_msg.edit(embed=embed)
|
||||||
|
|
||||||
pipeline = TaskPipeline(
|
plan = await pipeline.plan(text)
|
||||||
project_path=str(config.PROJECT_ROOT),
|
|
||||||
)
|
|
||||||
pipeline.setup()
|
|
||||||
|
|
||||||
# Plan 단계
|
|
||||||
plan = await pipeline.plan(request)
|
|
||||||
|
|
||||||
tasks = plan.get("tasks", [])
|
tasks = plan.get("tasks", [])
|
||||||
plan_text = plan.get("summary", str(plan))[:500]
|
plan_text = plan.get("summary", str(plan))[:500]
|
||||||
|
|
||||||
@@ -92,82 +258,178 @@ async def agent_command(ctx: commands.Context, *, request: str):
|
|||||||
inline=False,
|
inline=False,
|
||||||
)
|
)
|
||||||
plan_embed.set_footer(text=f"ID: {task_id}")
|
plan_embed.set_footer(text=f"ID: {task_id}")
|
||||||
await ctx.send(embed=plan_embed)
|
await message.channel.send(embed=plan_embed)
|
||||||
|
|
||||||
# Code + Review 단계
|
if not tasks:
|
||||||
if tasks:
|
await message.channel.send(
|
||||||
for i, task in enumerate(tasks, 1):
|
embed=discord.Embed(
|
||||||
progress_embed = discord.Embed(
|
title="⚠️ 실행할 태스크가 없습니다",
|
||||||
title=f"⚙️ 실행 중 ({i}/{len(tasks)})",
|
description="요청을 더 구체적으로 해주세요.",
|
||||||
description=task.get("title", task.get("description", ""))[:200],
|
color=0xF39C12,
|
||||||
color=0xE67E22,
|
|
||||||
)
|
)
|
||||||
await ctx.send(embed=progress_embed)
|
)
|
||||||
|
return
|
||||||
|
|
||||||
code_output = await pipeline.code(task)
|
# ── Code 병렬 실행 ──
|
||||||
review = await pipeline.review(task, code_output)
|
code_embed = discord.Embed(
|
||||||
|
title=f"⚙️ 코딩 중... ({len(tasks)}개 병렬 실행)",
|
||||||
passed = review.get("passed", True)
|
description="\n".join(
|
||||||
review_emoji = "✅" if passed else "⚠️"
|
f"• {t.get('title', t.get('description', '?'))[:60]}"
|
||||||
review_embed = discord.Embed(
|
for t in tasks
|
||||||
title=f"{review_emoji} 리뷰 결과 ({i}/{len(tasks)})",
|
),
|
||||||
description=review.get("summary", str(review))[:500],
|
color=0xE67E22,
|
||||||
color=0x2ECC71 if passed else 0xE74C3C,
|
|
||||||
)
|
|
||||||
await ctx.send(embed=review_embed)
|
|
||||||
|
|
||||||
# 완료
|
|
||||||
done_embed = discord.Embed(
|
|
||||||
title="✅ 작업 완료",
|
|
||||||
description=f"총 {len(tasks)}개 태스크 처리 완료",
|
|
||||||
color=0x2ECC71,
|
|
||||||
)
|
)
|
||||||
done_embed.set_footer(text=f"ID: {task_id}")
|
code_msg = await message.channel.send(embed=code_embed)
|
||||||
await ctx.send(embed=done_embed)
|
|
||||||
|
|
||||||
# 채널 태스크 기록
|
code_outputs = await pipeline.code_parallel(tasks)
|
||||||
_channel_tasks.setdefault(ctx.channel.id, []).append(task_id)
|
|
||||||
|
|
||||||
|
# 코딩 완료 표시
|
||||||
|
code_embed.title = f"✅ 코딩 완료 ({len(tasks)}개)"
|
||||||
|
code_embed.color = 0x2ECC71
|
||||||
|
await code_msg.edit(embed=code_embed)
|
||||||
|
|
||||||
|
# ── 파일 적용 ──
|
||||||
|
from core.file_applier import parse_code_output, apply_changes
|
||||||
|
|
||||||
|
all_applied = []
|
||||||
|
for output in code_outputs:
|
||||||
|
if not output.startswith("[ERROR]"):
|
||||||
|
changes = parse_code_output(output)
|
||||||
|
if changes:
|
||||||
|
applied = apply_changes(changes, str(config.PROJECT_ROOT))
|
||||||
|
all_applied.extend(applied)
|
||||||
|
|
||||||
|
if all_applied:
|
||||||
|
files_text = "\n".join(
|
||||||
|
f"• `{f['path']}` ({f['action']})"
|
||||||
|
for f in all_applied[:15]
|
||||||
|
)
|
||||||
|
await message.channel.send(
|
||||||
|
embed=discord.Embed(
|
||||||
|
title=f"📁 파일 적용 ({len(all_applied)}개)",
|
||||||
|
description=files_text,
|
||||||
|
color=0x3498DB,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Batch Review ──
|
||||||
|
review_msg = await message.channel.send(
|
||||||
|
embed=discord.Embed(
|
||||||
|
title="🔍 전체 리뷰 중...",
|
||||||
|
color=0xF39C12,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
review = await pipeline.batch_review(tasks, code_outputs)
|
||||||
|
passed = review.get("passed", True)
|
||||||
|
review_embed = discord.Embed(
|
||||||
|
title=f"{'✅' if passed else '⚠️'} 리뷰 결과",
|
||||||
|
description=review.get("summary", str(review))[:500],
|
||||||
|
color=0x2ECC71 if passed else 0xE74C3C,
|
||||||
|
)
|
||||||
|
await review_msg.edit(embed=review_embed)
|
||||||
|
|
||||||
|
# ── 총평 ──
|
||||||
|
summary = await pipeline.summarize(
|
||||||
|
text, plan, code_outputs, review, all_applied
|
||||||
|
)
|
||||||
|
|
||||||
|
summary_embed = discord.Embed(
|
||||||
|
title=f"📊 {summary.get('title', '작업 완료')}",
|
||||||
|
description=summary.get("summary", "작업이 완료되었습니다."),
|
||||||
|
color=0x9B59B6,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 변경 파일 요약
|
||||||
|
changes = summary.get("changes", [])
|
||||||
|
if changes:
|
||||||
|
changes_text = "\n".join(
|
||||||
|
f"• `{c.get('file', '?')}` — {c.get('description', '')}"
|
||||||
|
for c in changes[:10]
|
||||||
|
)
|
||||||
|
summary_embed.add_field(
|
||||||
|
name="변경 사항",
|
||||||
|
value=changes_text[:1000],
|
||||||
|
inline=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 주의사항
|
||||||
|
warnings = summary.get("warnings", [])
|
||||||
|
if warnings:
|
||||||
|
summary_embed.add_field(
|
||||||
|
name="⚠️ 주의",
|
||||||
|
value="\n".join(f"• {w}" for w in warnings),
|
||||||
|
inline=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 다음 단계 제안
|
||||||
|
next_steps = summary.get("next_steps", [])
|
||||||
|
if next_steps:
|
||||||
|
summary_embed.add_field(
|
||||||
|
name="🔜 다음 단계",
|
||||||
|
value="\n".join(f"• {s}" for s in next_steps),
|
||||||
|
inline=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
summary_embed.set_footer(text=f"ID: {task_id}")
|
||||||
|
await message.channel.send(embed=summary_embed)
|
||||||
|
|
||||||
|
_channel_tasks.setdefault(message.channel.id, []).append(task_id)
|
||||||
|
|
||||||
|
except GeminiCallError as e:
|
||||||
|
logger.error(f"파이프라인 Gemini 오류: {e}")
|
||||||
|
await message.channel.send(
|
||||||
|
embed=discord.Embed(
|
||||||
|
title="❌ AI 호출 오류",
|
||||||
|
description=f"```{str(e)[:500]}```",
|
||||||
|
color=0xE74C3C,
|
||||||
|
)
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"작업 실행 오류: {e}", exc_info=True)
|
logger.error(f"작업 실행 오류: {e}", exc_info=True)
|
||||||
error_embed = discord.Embed(
|
await message.channel.send(
|
||||||
title="❌ 오류 발생",
|
embed=discord.Embed(
|
||||||
description=f"```{str(e)[:500]}```",
|
title="❌ 오류 발생",
|
||||||
color=0xE74C3C,
|
description=f"```{str(e)[:500]}```",
|
||||||
|
color=0xE74C3C,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
await ctx.send(embed=error_embed)
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# 핸들러: Clarify (되묻기)
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _handle_clarify(message: discord.Message, text: str, reason: str):
|
||||||
|
"""의도 불명확 — 사용자에게 되묻기."""
|
||||||
|
embed = discord.Embed(
|
||||||
|
title="🤔 확인이 필요해요",
|
||||||
|
description=(
|
||||||
|
f"말씀하신 내용을 정확히 이해하고 싶어요.\n\n"
|
||||||
|
f"> {text[:200]}\n\n"
|
||||||
|
f"**💬 질문/대화**인가요, **🔧 작업 요청**인가요?\n"
|
||||||
|
f"`질문` 또는 `작업`으로 답해주세요."
|
||||||
|
),
|
||||||
|
color=0xF39C12,
|
||||||
|
)
|
||||||
|
embed.set_footer(text=f"사유: {reason}")
|
||||||
|
await message.reply(embed=embed)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# 기존 ! 명령어 (유지)
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@bot.command(name="agent", help="AI Agent에게 작업 요청\n예: !agent README에 설치 방법 추가해줘")
|
||||||
|
async def agent_command(ctx: commands.Context, *, request: str):
|
||||||
|
"""작업 요청 → Pipeline 실행."""
|
||||||
|
await _handle_task(ctx.message, request)
|
||||||
|
|
||||||
|
|
||||||
@bot.command(name="chat", help="Gemini에게 질문/대화\n예: !chat 네 소개를 해줘")
|
@bot.command(name="chat", help="Gemini에게 질문/대화\n예: !chat 네 소개를 해줘")
|
||||||
async def chat_command(ctx: commands.Context, *, message: str):
|
async def chat_command(ctx: commands.Context, *, message: str):
|
||||||
"""단순 대화 — Pipeline 없이 Gemini 직접 호출."""
|
"""단순 대화."""
|
||||||
async with ctx.typing():
|
await _handle_chat(ctx.message, message)
|
||||||
try:
|
|
||||||
from core.gemini_caller import GeminiCaller
|
|
||||||
|
|
||||||
gemini = GeminiCaller(str(config.PROJECT_ROOT))
|
|
||||||
response = await gemini.call(
|
|
||||||
role="default",
|
|
||||||
context=message,
|
|
||||||
timeout=60,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Discord 메시지 2000자 제한 처리
|
|
||||||
if len(response) <= 2000:
|
|
||||||
await ctx.send(response)
|
|
||||||
else:
|
|
||||||
# 긴 응답은 Embed로 분할
|
|
||||||
for i in range(0, len(response), 4000):
|
|
||||||
chunk = response[i:i + 4000]
|
|
||||||
embed = discord.Embed(
|
|
||||||
description=chunk,
|
|
||||||
color=0x3498DB,
|
|
||||||
)
|
|
||||||
await ctx.send(embed=embed)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Chat 오류: {e}", exc_info=True)
|
|
||||||
await ctx.send(f"❌ 오류: {str(e)[:200]}")
|
|
||||||
|
|
||||||
|
|
||||||
@bot.command(name="ping", help="봇 응답 테스트")
|
@bot.command(name="ping", help="봇 응답 테스트")
|
||||||
@@ -188,6 +450,16 @@ async def info_command(ctx: commands.Context):
|
|||||||
embed.add_field(name="프로젝트", value=str(config.PROJECT_ROOT), inline=False)
|
embed.add_field(name="프로젝트", value=str(config.PROJECT_ROOT), inline=False)
|
||||||
embed.add_field(name="명령어 접두사", value=config.DISCORD_COMMAND_PREFIX, inline=True)
|
embed.add_field(name="명령어 접두사", value=config.DISCORD_COMMAND_PREFIX, inline=True)
|
||||||
embed.add_field(name="서버 수", value=str(len(bot.guilds)), inline=True)
|
embed.add_field(name="서버 수", value=str(len(bot.guilds)), inline=True)
|
||||||
|
|
||||||
|
if _auto_chat_channel_ids:
|
||||||
|
channels = ", ".join(f"<#{ch_id}>" for ch_id in _auto_chat_channel_ids)
|
||||||
|
embed.add_field(name="자동 채팅 채널", value=channels, inline=False)
|
||||||
|
|
||||||
|
embed.add_field(
|
||||||
|
name="파이프라인",
|
||||||
|
value="Plan → Code(병렬) → Review(배치) → 총평",
|
||||||
|
inline=False,
|
||||||
|
)
|
||||||
await ctx.send(embed=embed)
|
await ctx.send(embed=embed)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
117
core/file_applier.py
Normal file
117
core/file_applier.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
"""File Applier — Coder 출력을 실제 파일에 적용.
|
||||||
|
|
||||||
|
Coder가 출력한 `=== FILE: path === ... === END FILE ===` 블록을
|
||||||
|
파싱하여 프로젝트 파일에 실제로 쓰기.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
logger = logging.getLogger("variet.applier")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FileChange:
|
||||||
|
"""파일 변경 단위."""
|
||||||
|
path: str # 상대 경로
|
||||||
|
content: str # 전체 파일 내용
|
||||||
|
is_new: bool # 신규 파일 여부
|
||||||
|
|
||||||
|
|
||||||
|
def parse_code_output(raw: str) -> list[FileChange]:
|
||||||
|
"""Coder 출력에서 파일 블록을 추출.
|
||||||
|
|
||||||
|
지원 형식:
|
||||||
|
=== FILE: path/to/file.py ===
|
||||||
|
(content)
|
||||||
|
=== END FILE ===
|
||||||
|
|
||||||
|
또는 마크다운 방식:
|
||||||
|
```python:path/to/file.py
|
||||||
|
(content)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
changes: list[FileChange] = []
|
||||||
|
|
||||||
|
# 패턴 1: === FILE: path === ... === END FILE ===
|
||||||
|
pattern1 = re.compile(
|
||||||
|
r'===\s*FILE:\s*(.+?)\s*===\s*\n(.*?)\n\s*===\s*END\s*FILE\s*===',
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
for match in pattern1.finditer(raw):
|
||||||
|
path = match.group(1).strip()
|
||||||
|
content = match.group(2)
|
||||||
|
changes.append(FileChange(path=path, content=content, is_new=False))
|
||||||
|
|
||||||
|
# 패턴 2: ```lang:path/to/file.py\n...\n```
|
||||||
|
if not changes:
|
||||||
|
pattern2 = re.compile(
|
||||||
|
r'```\w*:(.+?)\n(.*?)\n```',
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
for match in pattern2.finditer(raw):
|
||||||
|
path = match.group(1).strip()
|
||||||
|
content = match.group(2)
|
||||||
|
changes.append(FileChange(path=path, content=content, is_new=False))
|
||||||
|
|
||||||
|
return changes
|
||||||
|
|
||||||
|
|
||||||
|
def apply_changes(
|
||||||
|
changes: list[FileChange],
|
||||||
|
project_path: str | Path,
|
||||||
|
dry_run: bool = False,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""파일 변경사항을 프로젝트에 적용.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
changes: parse_code_output() 결과
|
||||||
|
project_path: 프로젝트 루트 경로
|
||||||
|
dry_run: True면 실제 파일 쓰기 없이 결과만 반환
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
적용 결과 리스트 [{"path": ..., "action": "created|modified|skipped", "lines": N}]
|
||||||
|
"""
|
||||||
|
root = Path(project_path).resolve()
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for change in changes:
|
||||||
|
# 경로 정규화 + 보안: 프로젝트 밖 경로 차단
|
||||||
|
target = (root / change.path).resolve()
|
||||||
|
if not str(target).startswith(str(root)):
|
||||||
|
logger.warning(f"경로 보안 위반 — 스킵: {change.path}")
|
||||||
|
results.append({
|
||||||
|
"path": change.path,
|
||||||
|
"action": "skipped",
|
||||||
|
"reason": "프로젝트 외부 경로",
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
is_new = not target.exists()
|
||||||
|
line_count = len(change.content.splitlines())
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
results.append({
|
||||||
|
"path": change.path,
|
||||||
|
"action": "would_create" if is_new else "would_modify",
|
||||||
|
"lines": line_count,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 디렉토리 생성
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 파일 쓰기
|
||||||
|
target.write_text(change.content, encoding="utf-8")
|
||||||
|
action = "created" if is_new else "modified"
|
||||||
|
logger.info(f"파일 {action}: {change.path} ({line_count}L)")
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"path": change.path,
|
||||||
|
"action": action,
|
||||||
|
"lines": line_count,
|
||||||
|
})
|
||||||
|
|
||||||
|
return results
|
||||||
@@ -1,16 +1,25 @@
|
|||||||
"""GeminiCaller — gemini headless 호출.
|
"""GeminiCaller — gemini headless 호출.
|
||||||
|
|
||||||
stdin으로 시스템 프롬프트 + 컨텍스트를 직접 전달합니다.
|
인자 분리, 세마포어 기반 동시성 제어, 에러 처리 개선.
|
||||||
cmd /c 래핑으로 PowerShell 실행 정책 우회.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger("variet.gemini")
|
||||||
|
|
||||||
ROLE_PROMPTS_DIR = Path(__file__).parent.parent / "prompts"
|
ROLE_PROMPTS_DIR = Path(__file__).parent.parent / "prompts"
|
||||||
|
|
||||||
|
# 동시 호출 제한 (Gemini AI Ultra 120RPM 고려)
|
||||||
|
_semaphore = asyncio.Semaphore(4)
|
||||||
|
|
||||||
|
|
||||||
|
class GeminiCallError(Exception):
|
||||||
|
"""Gemini CLI 호출 실패."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class GeminiCaller:
|
class GeminiCaller:
|
||||||
"""Gemini CLI headless 호출을 관리합니다."""
|
"""Gemini CLI headless 호출을 관리합니다."""
|
||||||
@@ -23,8 +32,14 @@ class GeminiCaller:
|
|||||||
async def call(self, role: str, context: str, timeout: int = 120) -> str:
|
async def call(self, role: str, context: str, timeout: int = 120) -> str:
|
||||||
"""역할별 프롬프트로 gemini 호출.
|
"""역할별 프롬프트로 gemini 호출.
|
||||||
|
|
||||||
시스템 프롬프트와 컨텍스트를 하나로 합쳐 stdin으로 전달.
|
세마포어로 동시 호출 수 제한.
|
||||||
|
인자를 분리하여 안정적 subprocess 실행.
|
||||||
"""
|
"""
|
||||||
|
async with _semaphore:
|
||||||
|
return await self._call_impl(role, context, timeout)
|
||||||
|
|
||||||
|
async def _call_impl(self, role: str, context: str, timeout: int) -> str:
|
||||||
|
"""실제 gemini 호출 구현."""
|
||||||
# 시스템 프롬프트 로드
|
# 시스템 프롬프트 로드
|
||||||
prompt_file = ROLE_PROMPTS_DIR / f"{role}.md"
|
prompt_file = ROLE_PROMPTS_DIR / f"{role}.md"
|
||||||
if prompt_file.exists():
|
if prompt_file.exists():
|
||||||
@@ -42,14 +57,14 @@ class GeminiCaller:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
"cmd", "/c", "gemini --approval-mode yolo",
|
"gemini", "--approval-mode", "yolo",
|
||||||
stdin=asyncio.subprocess.PIPE,
|
stdin=asyncio.subprocess.PIPE,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
)
|
)
|
||||||
stdout, stderr = await asyncio.wait_for(
|
stdout, stderr = await asyncio.wait_for(
|
||||||
proc.communicate(input=full_input.encode("utf-8")),
|
proc.communicate(input=full_input.encode("utf-8")),
|
||||||
timeout=timeout
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.call_count += 1
|
self.call_count += 1
|
||||||
@@ -57,23 +72,40 @@ class GeminiCaller:
|
|||||||
|
|
||||||
output = stdout.decode("utf-8", errors="replace").strip()
|
output = stdout.decode("utf-8", errors="replace").strip()
|
||||||
|
|
||||||
# YOLO 모드 메시지 제거
|
# 노이즈 라인 제거
|
||||||
lines = output.splitlines()
|
lines = output.splitlines()
|
||||||
cleaned = []
|
cleaned = []
|
||||||
for line in lines:
|
for line in lines:
|
||||||
if "YOLO mode" in line or "Loaded cached" in line:
|
if any(noise in line for noise in [
|
||||||
|
"YOLO mode", "Loaded cached", "Welcome to Gemini",
|
||||||
|
"Type /help", "Gemini CLI",
|
||||||
|
]):
|
||||||
continue
|
continue
|
||||||
cleaned.append(line)
|
cleaned.append(line)
|
||||||
|
|
||||||
return "\n".join(cleaned).strip()
|
result = "\n".join(cleaned).strip()
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
logger.warning(f"Gemini [{role}] 빈 응답")
|
||||||
|
if stderr:
|
||||||
|
err = stderr.decode("utf-8", errors="replace").strip()
|
||||||
|
logger.warning(f"Gemini stderr: {err[:200]}")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Gemini [{role}] 호출 #{self.call_count} "
|
||||||
|
f"— 입력 {len(full_input)}자 → 출력 {len(result)}자"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
return f"[ERROR] Gemini CLI timeout after {timeout}s"
|
raise GeminiCallError(f"Gemini CLI timeout ({timeout}s) — role={role}")
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise GeminiCallError(
|
||||||
|
"gemini CLI를 찾을 수 없습니다. PATH에 gemini가 있는지 확인하세요."
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"[ERROR] Gemini CLI call failed: {e}"
|
raise GeminiCallError(f"Gemini CLI 호출 실패: {e}")
|
||||||
|
|
||||||
async def call_simple(self, prompt: str, timeout: int = 60) -> str:
|
async def call_simple(self, prompt: str, timeout: int = 60) -> str:
|
||||||
"""시스템 프롬프트 없이 단순 호출."""
|
"""시스템 프롬프트 없이 단순 호출."""
|
||||||
return await self.call("default", prompt, timeout)
|
return await self.call("default", prompt, timeout)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Task Pipeline — Plan → Code → Review → Ship.
|
"""Task Pipeline — Plan → Code(병렬) → Batch Review → 총평.
|
||||||
|
|
||||||
E2E 파이프라인을 구성하고 실행합니다.
|
병렬 코드 실행, 단일 배치 리뷰, 파일 적용, 종합 총평을 수행합니다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -9,11 +9,12 @@ import re
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from core.project_indexer import ProjectIndex
|
from core.project_indexer import ProjectIndex
|
||||||
from core.context_manager import ContextManager
|
from core.context_manager import ContextManager
|
||||||
from core.gemini_caller import GeminiCaller
|
from core.gemini_caller import GeminiCaller, GeminiCallError
|
||||||
|
from core.file_applier import parse_code_output, apply_changes
|
||||||
|
|
||||||
|
|
||||||
class TaskPipeline:
|
class TaskPipeline:
|
||||||
"""작업 파이프라인: 사용자 요청을 분해하고 순차 실행합니다."""
|
"""작업 파이프라인: Plan → Code(병렬) → Review(배치) → Summary."""
|
||||||
|
|
||||||
def __init__(self, project_path: str, token_budget: int = 50_000):
|
def __init__(self, project_path: str, token_budget: int = 50_000):
|
||||||
self.project_path = project_path
|
self.project_path = project_path
|
||||||
@@ -27,6 +28,10 @@ class TaskPipeline:
|
|||||||
self.index.scan()
|
self.index.scan()
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────
|
||||||
|
# Plan
|
||||||
|
# ──────────────────────────────────────────
|
||||||
|
|
||||||
async def plan(self, user_request: str) -> dict:
|
async def plan(self, user_request: str) -> dict:
|
||||||
"""Planner로 작업 분해."""
|
"""Planner로 작업 분해."""
|
||||||
structure = self.index.get_structure_summary()
|
structure = self.index.get_structure_summary()
|
||||||
@@ -39,13 +44,15 @@ class TaskPipeline:
|
|||||||
response = await self.gemini.call("planner", prompt, timeout=180)
|
response = await self.gemini.call("planner", prompt, timeout=180)
|
||||||
self._log("plan", user_request, response)
|
self._log("plan", user_request, response)
|
||||||
|
|
||||||
# JSON 추출
|
|
||||||
plan = self._extract_json(response)
|
plan = self._extract_json(response)
|
||||||
return plan or {"summary": response, "tasks": [], "raw": response}
|
return plan or {"summary": response, "tasks": [], "raw": response}
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────
|
||||||
|
# Code (개별 태스크)
|
||||||
|
# ──────────────────────────────────────────
|
||||||
|
|
||||||
async def code(self, task: dict) -> str:
|
async def code(self, task: dict) -> str:
|
||||||
"""Coder로 코드 수정."""
|
"""Coder로 코드 수정 (단일 태스크)."""
|
||||||
# 관련 파일 컨텍스트 수집
|
|
||||||
context = self.ctx.gather(task.get("description", task.get("title", "")))
|
context = self.ctx.gather(task.get("description", task.get("title", "")))
|
||||||
|
|
||||||
prompt = (
|
prompt = (
|
||||||
@@ -58,27 +65,109 @@ class TaskPipeline:
|
|||||||
self._log("code", task.get("title", ""), response)
|
self._log("code", task.get("title", ""), response)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
async def review(self, task: dict, code_output: str) -> dict:
|
# ──────────────────────────────────────────
|
||||||
"""Reviewer로 코드 리뷰."""
|
# Code 병렬 실행
|
||||||
|
# ──────────────────────────────────────────
|
||||||
|
|
||||||
|
async def code_parallel(self, tasks: list[dict]) -> list[str]:
|
||||||
|
"""여러 태스크를 병렬로 코딩."""
|
||||||
|
results = await asyncio.gather(
|
||||||
|
*[self.code(task) for task in tasks],
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 예외를 문자열로 변환
|
||||||
|
processed = []
|
||||||
|
for i, result in enumerate(results):
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
error_msg = f"[ERROR] Task {i+1} 실패: {result}"
|
||||||
|
self._log("code_error", tasks[i].get("title", ""), error_msg)
|
||||||
|
processed.append(error_msg)
|
||||||
|
else:
|
||||||
|
processed.append(result)
|
||||||
|
|
||||||
|
return processed
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────
|
||||||
|
# Batch Review (전체 한 번)
|
||||||
|
# ──────────────────────────────────────────
|
||||||
|
|
||||||
|
async def batch_review(self, tasks: list[dict], code_outputs: list[str]) -> dict:
|
||||||
|
"""모든 코드 출력을 한 번에 리뷰."""
|
||||||
|
# 태스크별 코드 출력을 하나로 합침
|
||||||
|
combined = []
|
||||||
|
for i, (task, output) in enumerate(zip(tasks, code_outputs)):
|
||||||
|
title = task.get("title", task.get("description", f"Task {i+1}"))
|
||||||
|
combined.append(
|
||||||
|
f"### Task {i+1}: {title}\n"
|
||||||
|
f"{output[:2000]}\n" # 각 출력 2000자 제한
|
||||||
|
)
|
||||||
|
|
||||||
prompt = (
|
prompt = (
|
||||||
f"## Task\n{json.dumps(task, ensure_ascii=False, indent=2)}\n\n"
|
f"## All Code Changes\n\n"
|
||||||
f"## Code Output\n{code_output}\n\n"
|
f"{'---'.join(combined)}\n\n"
|
||||||
f"Review the code changes."
|
f"Review ALL changes above as a whole. "
|
||||||
|
f"Check for consistency, conflicts between tasks, and overall correctness."
|
||||||
)
|
)
|
||||||
|
|
||||||
response = await self.gemini.call("reviewer", prompt, timeout=180)
|
response = await self.gemini.call("reviewer", prompt, timeout=180)
|
||||||
self._log("review", task.get("title", ""), response)
|
self._log("batch_review", f"{len(tasks)} tasks", response)
|
||||||
|
|
||||||
review = self._extract_json(response)
|
review = self._extract_json(response)
|
||||||
return review or {"passed": True, "summary": response, "raw": response}
|
return review or {"passed": True, "summary": response, "raw": response}
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────
|
||||||
|
# 총평 (Summary)
|
||||||
|
# ──────────────────────────────────────────
|
||||||
|
|
||||||
|
async def summarize(
|
||||||
|
self,
|
||||||
|
user_request: str,
|
||||||
|
plan: dict,
|
||||||
|
code_outputs: list[str],
|
||||||
|
review: dict,
|
||||||
|
applied_files: list[dict],
|
||||||
|
) -> dict:
|
||||||
|
"""전체 작업 결과 종합 총평."""
|
||||||
|
file_changes = "\n".join(
|
||||||
|
f"- {f['path']} ({f['action']}, {f.get('lines', '?')}L)"
|
||||||
|
for f in applied_files
|
||||||
|
) if applied_files else "파일 변경 없음"
|
||||||
|
|
||||||
|
prompt = (
|
||||||
|
f"## 원래 요청\n{user_request}\n\n"
|
||||||
|
f"## 계획\n{plan.get('summary', str(plan))[:500]}\n\n"
|
||||||
|
f"## 태스크 수\n{len(plan.get('tasks', []))}개\n\n"
|
||||||
|
f"## 리뷰 결과\n{review.get('summary', str(review))[:500]}\n\n"
|
||||||
|
f"## 변경된 파일\n{file_changes}\n\n"
|
||||||
|
f"위 정보를 바탕으로 총평을 작성하세요."
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await self.gemini.call("summarizer", prompt, timeout=60)
|
||||||
|
self._log("summarize", user_request, response)
|
||||||
|
|
||||||
|
summary = self._extract_json(response)
|
||||||
|
return summary or {
|
||||||
|
"title": "작업 완료",
|
||||||
|
"summary": response,
|
||||||
|
"changes": [],
|
||||||
|
"warnings": [],
|
||||||
|
"next_steps": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────
|
||||||
|
# 전체 파이프라인 실행
|
||||||
|
# ──────────────────────────────────────────
|
||||||
|
|
||||||
async def execute(self, user_request: str) -> dict:
|
async def execute(self, user_request: str) -> dict:
|
||||||
"""전체 파이프라인 실행."""
|
"""전체 파이프라인: Plan → Code(병렬) → Review(배치) → 파일 적용 → 총평."""
|
||||||
result = {
|
result = {
|
||||||
"request": user_request,
|
"request": user_request,
|
||||||
"plan": None,
|
"plan": None,
|
||||||
"tasks_completed": [],
|
"code_outputs": [],
|
||||||
"reviews": [],
|
"review": None,
|
||||||
|
"applied_files": [],
|
||||||
|
"summary": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
# 1. Plan
|
# 1. Plan
|
||||||
@@ -87,32 +176,50 @@ class TaskPipeline:
|
|||||||
|
|
||||||
tasks = plan.get("tasks", [])
|
tasks = plan.get("tasks", [])
|
||||||
if not tasks:
|
if not tasks:
|
||||||
result["error"] = "Planner returned no tasks"
|
result["summary"] = {
|
||||||
|
"title": "태스크 없음",
|
||||||
|
"summary": "Planner가 실행할 태스크를 생성하지 못했습니다.",
|
||||||
|
"changes": [],
|
||||||
|
"warnings": ["요청을 더 구체적으로 해주세요."],
|
||||||
|
"next_steps": [],
|
||||||
|
}
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# 2. Code + Review for each task
|
# 2. Code 병렬 실행
|
||||||
for task in tasks:
|
code_outputs = await self.code_parallel(tasks)
|
||||||
code_output = await self.code(task)
|
result["code_outputs"] = [o[:500] for o in code_outputs]
|
||||||
|
|
||||||
review = await self.review(task, code_output)
|
# 3. 파일 적용 (Coder 출력 파싱)
|
||||||
|
all_applied = []
|
||||||
|
for output in code_outputs:
|
||||||
|
if output.startswith("[ERROR]"):
|
||||||
|
continue
|
||||||
|
changes = parse_code_output(output)
|
||||||
|
if changes:
|
||||||
|
applied = apply_changes(changes, self.project_path)
|
||||||
|
all_applied.extend(applied)
|
||||||
|
result["applied_files"] = all_applied
|
||||||
|
|
||||||
if not review.get("passed", True):
|
# 4. Batch Review (전체 1회)
|
||||||
# 리뷰 실패 시 한 번 재시도
|
review = await self.batch_review(tasks, code_outputs)
|
||||||
code_output = await self.code({
|
result["review"] = review
|
||||||
**task,
|
|
||||||
"description": task.get("description", "") +
|
|
||||||
f"\n\n## Review Feedback\n{json.dumps(review.get('issues', []), ensure_ascii=False)}"
|
|
||||||
})
|
|
||||||
review = await self.review(task, code_output)
|
|
||||||
|
|
||||||
result["tasks_completed"].append({
|
# 리뷰 실패 시 로그만 (재시도 없이 진행)
|
||||||
"task": task,
|
if not review.get("passed", True):
|
||||||
"output": code_output[:500], # 요약
|
self._log("review_warning", "batch", "리뷰 이슈 있음 — 총평에 반영")
|
||||||
"review": review,
|
|
||||||
})
|
# 5. 총평
|
||||||
|
summary = await self.summarize(
|
||||||
|
user_request, plan, code_outputs, review, all_applied
|
||||||
|
)
|
||||||
|
result["summary"] = summary
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────
|
||||||
|
# 유틸리티
|
||||||
|
# ──────────────────────────────────────────
|
||||||
|
|
||||||
def _extract_json(self, text: str) -> dict | None:
|
def _extract_json(self, text: str) -> dict | None:
|
||||||
"""텍스트에서 JSON 블록 추출."""
|
"""텍스트에서 JSON 블록 추출."""
|
||||||
# ```json ... ``` 패턴
|
# ```json ... ``` 패턴
|
||||||
@@ -123,13 +230,21 @@ class TaskPipeline:
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# { ... } 직접 찾기
|
# { ... } 직접 찾기 (중첩 지원)
|
||||||
match = re.search(r"\{.*\}", text, re.DOTALL)
|
brace_depth = 0
|
||||||
if match:
|
start = -1
|
||||||
try:
|
for i, ch in enumerate(text):
|
||||||
return json.loads(match.group(0))
|
if ch == '{':
|
||||||
except json.JSONDecodeError:
|
if brace_depth == 0:
|
||||||
pass
|
start = i
|
||||||
|
brace_depth += 1
|
||||||
|
elif ch == '}':
|
||||||
|
brace_depth -= 1
|
||||||
|
if brace_depth == 0 and start >= 0:
|
||||||
|
try:
|
||||||
|
return json.loads(text[start:i + 1])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
start = -1
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
24
prompts/router.md
Normal file
24
prompts/router.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Intent Router
|
||||||
|
|
||||||
|
사용자의 메시지를 분류하세요. 반드시 아래 JSON 형식만 출력하세요.
|
||||||
|
|
||||||
|
## 분류 기준
|
||||||
|
|
||||||
|
- **chat**: 즉답 가능한 질문, 인사, 잡담, 지식 질문, 의견 요청
|
||||||
|
- **task**: 코드 수정/생성/분석/리팩토링/파일 변경 등 구체적 작업 요청
|
||||||
|
- **clarify**: 맥락 없이 의도를 파악할 수 없는 경우 ("그거 해줘", "아까 그거")
|
||||||
|
|
||||||
|
## 출력 형식 (JSON만, 다른 텍스트 금지)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"intent": "chat", "reason": "인사 메시지"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 예시
|
||||||
|
|
||||||
|
- "안녕" → `{"intent": "chat", "reason": "인사"}`
|
||||||
|
- "파이썬 리스트 정렬 방법" → `{"intent": "chat", "reason": "지식 질문"}`
|
||||||
|
- "README에 설치 방법 추가해줘" → `{"intent": "task", "reason": "파일 수정 요청"}`
|
||||||
|
- "테스트 코드 작성해줘" → `{"intent": "task", "reason": "코드 생성 요청"}`
|
||||||
|
- "그거 고쳐줘" → `{"intent": "clarify", "reason": "대상 불명확"}`
|
||||||
|
- "이 프로젝트 구조 설명해줘" → `{"intent": "chat", "reason": "설명 요청"}`
|
||||||
33
prompts/summarizer.md
Normal file
33
prompts/summarizer.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Summarizer
|
||||||
|
|
||||||
|
당신은 AI Agent Team의 **총평 작성자**입니다.
|
||||||
|
작업 파이프라인이 완료된 후, 전체 결과를 사용자가 이해하기 쉽게 요약합니다.
|
||||||
|
|
||||||
|
## 입력
|
||||||
|
|
||||||
|
- 사용자의 원래 요청
|
||||||
|
- Plan 결과 (태스크 목록)
|
||||||
|
- Code 결과 (각 태스크별 출력)
|
||||||
|
- Review 결과 (전체 리뷰)
|
||||||
|
- 파일 변경 목록
|
||||||
|
|
||||||
|
## 출력 형식 (JSON)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "작업 완료 한줄 제목",
|
||||||
|
"changes": [
|
||||||
|
{"file": "path/to/file.py", "description": "변경 내용 설명"}
|
||||||
|
],
|
||||||
|
"warnings": ["주의사항이 있으면 여기에"],
|
||||||
|
"next_steps": ["사용자가 다음에 할 수 있는 작업 제안"],
|
||||||
|
"summary": "2-3문장 전체 요약"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 규칙
|
||||||
|
|
||||||
|
- 기술 용어는 최소화, 사용자 관점에서 서술
|
||||||
|
- 한국어로 답변
|
||||||
|
- 주의사항이 없으면 warnings를 빈 배열로
|
||||||
|
- next_steps는 1-2개만 제안
|
||||||
Reference in New Issue
Block a user