refactor(agent): MCP 기반 에이전트 아키텍처 재설계 — unified.md 분류기 제거, Gemini CLI + MCP 자율 도구 호출로 전환
This commit is contained in:
@@ -57,3 +57,9 @@
|
|||||||
- **원인**: 단일 검색 전략, suffix(ASW HEVC) 항상 부착
|
- **원인**: 단일 검색 전략, suffix(ASW HEVC) 항상 부착
|
||||||
- **해결**: 6단계 fallback 전략 (romaji±suffix → 원제±suffix → 한글±suffix)
|
- **해결**: 6단계 fallback 전략 (romaji±suffix → 원제±suffix → 한글±suffix)
|
||||||
- **주의**: 외부 API 검색 시 반드시 다중 전략 + suffix 토글 구현
|
- **주의**: 외부 API 검색 시 반드시 다중 전략 + suffix 토글 구현
|
||||||
|
|
||||||
|
### [2026-03-12] Gemini CLI MCP — settings.json 위치
|
||||||
|
- **증상**: MCP 도구가 인식되지 않음 (프로젝트 .gemini/settings.json에 설정했으나 실패)
|
||||||
|
- **원인**: Gemini CLI는 `cwd` 기준으로 `.gemini/settings.json`을 탐색. cwd가 다른 워크스페이스면 MCP 설정 못 찾음
|
||||||
|
- **해결**: `~/.gemini/settings.json` (홈 레벨)에 mcpServers 등록. `_set_thinking_budget`에서 자동 관리
|
||||||
|
- **주의**: MCP 서버 설정은 반드시 홈 레벨 settings.json에 등록. 프로젝트 레벨은 불충분
|
||||||
|
|||||||
16
.gemini/settings.json
Normal file
16
.gemini/settings.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"anime": {
|
||||||
|
"command": "C:\\ProgramData\\miniforge3\\envs\\variet-agent\\python.exe",
|
||||||
|
"args": ["mcp_servers/anime_server.py"],
|
||||||
|
"cwd": "C:\\Users\\Variet-Worker\\Desktop\\variet-agent",
|
||||||
|
"trust": true
|
||||||
|
},
|
||||||
|
"infra": {
|
||||||
|
"command": "C:\\ProgramData\\miniforge3\\envs\\variet-agent\\python.exe",
|
||||||
|
"args": ["mcp_servers/infra_server.py"],
|
||||||
|
"cwd": "C:\\Users\\Variet-Worker\\Desktop\\variet-agent",
|
||||||
|
"trust": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -124,52 +124,27 @@ async def safe_send_embed(channel, embed: discord.Embed):
|
|||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
# 통합 프롬프트 (1회 호출: 분류 + 응답/계획)
|
# 에이전트 호출 (MCP 도구 자동 사용)
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
async def _unified_call(text: str, history: str, project_path: str) -> dict:
|
async def _agent_call(text: str, history: str, project_path: str) -> str:
|
||||||
"""통합 프롬프트로 1회 호출 — chat/task/clarify 자동 분기."""
|
"""Gemini CLI 에이전트 모드로 호출 — MCP 도구를 자율적으로 사용.
|
||||||
gemini = GeminiCaller(project_path)
|
|
||||||
|
|
||||||
# docs 인덱스 주입
|
분류(chat/task/anime) 없이 에이전트가 직접 판단하여
|
||||||
from core.docs_manager import DocsManager
|
도구를 사용하거나 바로 답변합니다.
|
||||||
docs = DocsManager(project_path)
|
"""
|
||||||
docs_index = docs.get_docs_index()
|
gemini = GeminiCaller(project_path)
|
||||||
|
|
||||||
context = (
|
context = (
|
||||||
f"{history}"
|
f"{history}"
|
||||||
f"## Workspace\nPath: {project_path}\n\n"
|
f"## Workspace\nPath: {project_path}\n\n"
|
||||||
f"## Project Docs\n{docs_index}\n\n"
|
|
||||||
f"## User Message\n{text}"
|
f"## User Message\n{text}"
|
||||||
)
|
)
|
||||||
|
|
||||||
raw = await gemini.call("unified", context, timeout=120)
|
response = await gemini.call_agent(
|
||||||
|
"agent", context, cwd=project_path, timeout=300,
|
||||||
# JSON 추출
|
)
|
||||||
try:
|
return response
|
||||||
# ```json ... ``` 패턴
|
|
||||||
match = re.search(r'```json\s*\n(.*?)\n\s*```', raw, re.DOTALL)
|
|
||||||
if match:
|
|
||||||
return json.loads(match.group(1))
|
|
||||||
|
|
||||||
# 중첩 { } 찾기
|
|
||||||
brace_depth = 0
|
|
||||||
start = -1
|
|
||||||
for i, ch in enumerate(raw):
|
|
||||||
if ch == '{':
|
|
||||||
if brace_depth == 0:
|
|
||||||
start = i
|
|
||||||
brace_depth += 1
|
|
||||||
elif ch == '}':
|
|
||||||
brace_depth -= 1
|
|
||||||
if brace_depth == 0 and start >= 0:
|
|
||||||
return json.loads(raw[start:i + 1])
|
|
||||||
except (json.JSONDecodeError, AttributeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 파싱 실패 → chat으로 처리
|
|
||||||
logger.warning(f"통합 프롬프트 JSON 파싱 실패: {raw[:100]}")
|
|
||||||
return {"mode": "chat", "response": raw}
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
@@ -297,75 +272,68 @@ async def on_message(message: discord.Message):
|
|||||||
await message.reply("실행 중인 작업이 없습니다.")
|
await message.reply("실행 중인 작업이 없습니다.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# 통합 프롬프트 호출
|
# 에이전트 호출 (MCP 도구 자동 사용)
|
||||||
async with message.channel.typing():
|
channel_id = message.channel.id
|
||||||
|
if channel_id in _running_tasks and not _running_tasks[channel_id].done():
|
||||||
|
await message.reply("⚠️ 이미 작업이 실행 중입니다. `취소` 후 다시 요청하세요.")
|
||||||
|
return
|
||||||
|
|
||||||
|
async def _tracked_agent():
|
||||||
|
progress_msg = None
|
||||||
try:
|
try:
|
||||||
history = await _get_channel_history(message.channel, limit=10)
|
# 진행 표시
|
||||||
result = await _unified_call(user_text, history, ws.path)
|
progress_msg = await message.channel.send(
|
||||||
except GeminiCallError as e:
|
embed=discord.Embed(
|
||||||
await message.reply(f"⚠️ AI 호출 오류: {str(e)[:200]}")
|
title="🤖 에이전트 처리 중...",
|
||||||
return
|
description=f"```{user_text[:200]}```",
|
||||||
except Exception as e:
|
color=0xF39C12,
|
||||||
logger.error(f"통합 호출 오류: {e}", exc_info=True)
|
)
|
||||||
await message.reply(f"❌ 오류: {str(e)[:200]}")
|
|
||||||
return
|
|
||||||
|
|
||||||
mode = result.get("mode", "chat")
|
|
||||||
logger.info(f"통합 분류: {mode} - \"{user_text[:50]}\"")
|
|
||||||
|
|
||||||
if mode == "anime":
|
|
||||||
# 애니메이션 도구 자동 호출
|
|
||||||
async with message.channel.typing():
|
|
||||||
await _handle_anime(message, result)
|
|
||||||
elif mode == "task":
|
|
||||||
# Git/Vikunja 미설정 안내 (차단하지 않음)
|
|
||||||
if not ws.is_ready:
|
|
||||||
missing = ws.missing_configs
|
|
||||||
note = " / ".join(missing)
|
|
||||||
await message.channel.send(
|
|
||||||
f"ℹ️ {note} 미설정 상태입니다. 로컬 작업만 진행됩니다."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 작업을 추적 가능한 Task로 실행
|
async with message.channel.typing():
|
||||||
channel_id = message.channel.id
|
history = await _get_channel_history(message.channel, limit=10)
|
||||||
if channel_id in _running_tasks and not _running_tasks[channel_id].done():
|
response = await _agent_call(user_text, history, ws.path)
|
||||||
await message.reply("⚠️ 이미 작업이 실행 중입니다. `취소` 후 다시 요청하세요.")
|
|
||||||
return
|
|
||||||
|
|
||||||
async def _tracked_task():
|
logger.info(f"에이전트 응답: \"{user_text[:50]}\" -> {len(response)}자")
|
||||||
try:
|
|
||||||
await _handle_task(message, user_text, ws)
|
# 진행 메시지 삭제
|
||||||
except asyncio.CancelledError:
|
if progress_msg:
|
||||||
await message.channel.send(
|
try:
|
||||||
embed=discord.Embed(
|
await progress_msg.delete()
|
||||||
title="🛑 작업 취소됨",
|
except Exception:
|
||||||
description="작업이 사용자에 의해 취소되었습니다.",
|
pass
|
||||||
color=0xE74C3C,
|
|
||||||
)
|
if not response:
|
||||||
|
await message.reply("응답을 생성하지 못했습니다.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 응답 전송
|
||||||
|
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 asyncio.CancelledError:
|
||||||
|
await message.channel.send(
|
||||||
|
embed=discord.Embed(
|
||||||
|
title="🛑 작업 취소됨",
|
||||||
|
description="작업이 사용자에 의해 취소되었습니다.",
|
||||||
|
color=0xE74C3C,
|
||||||
)
|
)
|
||||||
finally:
|
)
|
||||||
_running_tasks.pop(channel_id, None)
|
except GeminiCallError as e:
|
||||||
|
await message.reply(f"⚠️ AI 호출 오류: {str(e)[:200]}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"에이전트 호출 오류: {e}", exc_info=True)
|
||||||
|
await message.reply(f"❌ 오류: {str(e)[:200]}")
|
||||||
|
finally:
|
||||||
|
_running_tasks.pop(channel_id, None)
|
||||||
|
|
||||||
task = asyncio.create_task(_tracked_task())
|
task = asyncio.create_task(_tracked_agent())
|
||||||
_running_tasks[channel_id] = task
|
_running_tasks[channel_id] = task
|
||||||
elif mode == "clarify":
|
|
||||||
question = result.get("question", "더 구체적으로 말씀해 주시겠어요?")
|
|
||||||
embed = discord.Embed(
|
|
||||||
title="🤔 확인이 필요해요",
|
|
||||||
description=question,
|
|
||||||
color=0xF39C12,
|
|
||||||
)
|
|
||||||
await message.reply(embed=embed)
|
|
||||||
else:
|
|
||||||
# chat — 즉답
|
|
||||||
response = result.get("response", "응답을 생성하지 못했습니다.")
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
@@ -1325,13 +1293,11 @@ async def _create_task_thread(
|
|||||||
try:
|
try:
|
||||||
async with thread.typing():
|
async with thread.typing():
|
||||||
history = ""
|
history = ""
|
||||||
result = await _unified_call(request_text, history, ws.path)
|
response = await _agent_call(request_text, history, ws.path)
|
||||||
|
|
||||||
mode = result.get("mode", "chat")
|
logger.info(f"[스레드] 에이전트 응답: \"{request_text[:50]}\" -> {len(response)}자")
|
||||||
logger.info(f"[스레드] 통합 분류: {mode} - \"{request_text[:50]}\"")
|
|
||||||
|
|
||||||
if mode == "chat":
|
if response:
|
||||||
response = result.get("response", "응답을 생성하지 못했습니다.")
|
|
||||||
if len(response) <= 2000:
|
if len(response) <= 2000:
|
||||||
await thread.send(response)
|
await thread.send(response)
|
||||||
else:
|
else:
|
||||||
@@ -1339,23 +1305,8 @@ async def _create_task_thread(
|
|||||||
chunk = response[i:i + 4000]
|
chunk = response[i:i + 4000]
|
||||||
embed = discord.Embed(description=chunk, color=0x3498DB)
|
embed = discord.Embed(description=chunk, color=0x3498DB)
|
||||||
await thread.send(embed=embed)
|
await thread.send(embed=embed)
|
||||||
elif mode == "clarify":
|
else:
|
||||||
question = result.get("question", "더 구체적으로 말씀해 주시겠어요?")
|
await thread.send("응답을 생성하지 못했습니다.")
|
||||||
embed = discord.Embed(
|
|
||||||
title="🤔 확인이 필요해요",
|
|
||||||
description=question,
|
|
||||||
color=0xF39C12,
|
|
||||||
)
|
|
||||||
await thread.send(embed=embed)
|
|
||||||
else: # task
|
|
||||||
# task 모드 — 스레드에서 파이프라인 안내
|
|
||||||
await thread.send(
|
|
||||||
embed=discord.Embed(
|
|
||||||
title="⚙️ 작업 모드 감지",
|
|
||||||
description="이 스레드에서 작업 요청을 다시 입력해주세요.\n스레드 내 메시지는 자동으로 처리됩니다.",
|
|
||||||
color=0xF39C12,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"스레드 초기 호출 오류: {e}", exc_info=True)
|
logger.error(f"스레드 초기 호출 오류: {e}", exc_info=True)
|
||||||
await thread.send(f"⚠️ 초기 호출 오류: {str(e)[:200]}")
|
await thread.send(f"⚠️ 초기 호출 오류: {str(e)[:200]}")
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ from pathlib import Path
|
|||||||
logger = logging.getLogger("variet.gemini")
|
logger = logging.getLogger("variet.gemini")
|
||||||
|
|
||||||
ROLE_PROMPTS_DIR = Path(__file__).parent.parent / "prompts"
|
ROLE_PROMPTS_DIR = Path(__file__).parent.parent / "prompts"
|
||||||
|
PROJECT_ROOT = Path(__file__).parent.parent
|
||||||
GEMINI_MODEL = "gemini-3-flash-preview"
|
GEMINI_MODEL = "gemini-3-flash-preview"
|
||||||
|
|
||||||
# 역할별 thinkingBudget (토큰 단위)
|
# 역할별 thinkingBudget (토큰 단위)
|
||||||
# 512=가벼운 분류/요약, 4096=계획/검수, 8192=구현/비평
|
# 512=가벼운 분류/요약, 4096=계획/검수, 8192=구현/비평
|
||||||
ROLE_THINKING: dict[str, int] = {
|
ROLE_THINKING: dict[str, int] = {
|
||||||
"unified": 512,
|
"unified": 512,
|
||||||
|
"agent": 4096,
|
||||||
"summarizer": 512,
|
"summarizer": 512,
|
||||||
"planner": 4096,
|
"planner": 4096,
|
||||||
"coder": 8192,
|
"coder": 8192,
|
||||||
@@ -57,28 +59,50 @@ class GeminiCaller:
|
|||||||
"--approval-mode", "yolo"]
|
"--approval-mode", "yolo"]
|
||||||
return ["gemini", "--model", GEMINI_MODEL, "--approval-mode", "yolo"]
|
return ["gemini", "--model", GEMINI_MODEL, "--approval-mode", "yolo"]
|
||||||
|
|
||||||
|
# MCP 서버 설정 (홈 레벨에 등록)
|
||||||
|
_MCP_SERVERS = {
|
||||||
|
"anime": {
|
||||||
|
"command": str(Path(sys.executable)),
|
||||||
|
"args": [str(PROJECT_ROOT / "mcp_servers" / "anime_server.py")],
|
||||||
|
"cwd": str(PROJECT_ROOT),
|
||||||
|
"trust": True,
|
||||||
|
},
|
||||||
|
"infra": {
|
||||||
|
"command": str(Path(sys.executable)),
|
||||||
|
"args": [str(PROJECT_ROOT / "mcp_servers" / "infra_server.py")],
|
||||||
|
"cwd": str(PROJECT_ROOT),
|
||||||
|
"trust": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
def _set_thinking_budget(self, role: str):
|
def _set_thinking_budget(self, role: str):
|
||||||
"""역할별 thinkingBudget을 settings.json에 반영."""
|
"""역할별 thinkingBudget + MCP 서버 설정을 settings.json에 반영."""
|
||||||
budget = ROLE_THINKING.get(role, DEFAULT_THINKING)
|
budget = ROLE_THINKING.get(role, DEFAULT_THINKING)
|
||||||
try:
|
try:
|
||||||
if _SETTINGS_PATH.exists():
|
if _SETTINGS_PATH.exists():
|
||||||
settings = json.loads(_SETTINGS_PATH.read_text(encoding="utf-8"))
|
settings = json.loads(_SETTINGS_PATH.read_text(encoding="utf-8"))
|
||||||
else:
|
else:
|
||||||
|
_SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
settings = {}
|
settings = {}
|
||||||
|
|
||||||
# modelConfigs.default.thinkingConfig.thinkingBudget 설정
|
# thinkingBudget
|
||||||
configs = settings.setdefault("modelConfigs", {})
|
configs = settings.setdefault("modelConfigs", {})
|
||||||
default = configs.setdefault("default", {})
|
default = configs.setdefault("default", {})
|
||||||
thinking = default.setdefault("thinkingConfig", {})
|
thinking = default.setdefault("thinkingConfig", {})
|
||||||
thinking["thinkingBudget"] = budget
|
thinking["thinkingBudget"] = budget
|
||||||
|
|
||||||
|
# MCP 서버 (홈 레벨에 등록 — cwd와 무관하게 항상 사용 가능)
|
||||||
|
mcp_servers = settings.setdefault("mcpServers", {})
|
||||||
|
for name, config in self._MCP_SERVERS.items():
|
||||||
|
mcp_servers[name] = config
|
||||||
|
|
||||||
_SETTINGS_PATH.write_text(
|
_SETTINGS_PATH.write_text(
|
||||||
json.dumps(settings, indent=2, ensure_ascii=False),
|
json.dumps(settings, indent=2, ensure_ascii=False),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
logger.debug(f"thinkingBudget={budget} for role={role}")
|
logger.debug(f"thinkingBudget={budget} for role={role}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"thinkingBudget 설정 실패 (role={role}): {e}")
|
logger.warning(f"settings.json 업데이트 실패 (role={role}): {e}")
|
||||||
|
|
||||||
def _clean_output(self, raw: str) -> str:
|
def _clean_output(self, raw: str) -> str:
|
||||||
"""Gemini 출력에서 노이즈 라인 제거."""
|
"""Gemini 출력에서 노이즈 라인 제거."""
|
||||||
@@ -190,16 +214,30 @@ class GeminiCaller:
|
|||||||
else:
|
else:
|
||||||
system_prompt = f"You are a {role}. Respond in Korean."
|
system_prompt = f"You are a {role}. Respond in Korean."
|
||||||
|
|
||||||
|
# 역할에 따라 다른 지시
|
||||||
|
if role == "agent":
|
||||||
|
# 범용 에이전트: 도구 사용 여부를 자율 판단
|
||||||
|
footer = (
|
||||||
|
f"프로젝트 루트: {cwd}\n"
|
||||||
|
f"모든 응답은 한국어로 작성하세요.\n"
|
||||||
|
f"파일 작업이 필요하면 직접 수행하고, 대화만 필요하면 바로 답변하세요."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# coder 등 파일 작업 전용 역할
|
||||||
|
footer = (
|
||||||
|
f"프로젝트 루트: {cwd}\n"
|
||||||
|
f"파일을 직접 생성/수정하세요. 코드블록으로 출력하지 말고, 실제 파일로 저장하세요.\n"
|
||||||
|
f"작업 완료 후 변경한 파일 목록을 간단히 출력하세요.\n"
|
||||||
|
f"모든 응답, 주석, 문서는 반드시 한국어로 작성하세요."
|
||||||
|
)
|
||||||
|
|
||||||
full_input = (
|
full_input = (
|
||||||
f"=== SYSTEM INSTRUCTIONS ===\n"
|
f"=== SYSTEM INSTRUCTIONS ===\n"
|
||||||
f"{system_prompt}\n\n"
|
f"{system_prompt}\n\n"
|
||||||
f"=== TASK ===\n"
|
f"=== TASK ===\n"
|
||||||
f"{context}\n\n"
|
f"{context}\n\n"
|
||||||
f"=== IMPORTANT ===\n"
|
f"=== IMPORTANT ===\n"
|
||||||
f"프로젝트 루트: {cwd}\n"
|
f"{footer}"
|
||||||
f"파일을 직접 생성/수정하세요. 코드블록으로 출력하지 말고, 실제 파일로 저장하세요.\n"
|
|
||||||
f"작업 완료 후 변경한 파일 목록을 간단히 출력하세요.\n"
|
|
||||||
f"모든 응답, 주석, 문서는 반드시 한국어로 작성하세요."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
5
docs/devlog/2026-03-12.md
Normal file
5
docs/devlog/2026-03-12.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# 2026-03-12 개발 로그
|
||||||
|
|
||||||
|
| # | 시간 | 작업 | 커밋 | 상태 |
|
||||||
|
|---|------|------|------|------|
|
||||||
|
| 001 | 16:45 | MCP 기반 에이전트 아키텍처 재설계 — unified.md 분류기 → Gemini CLI + MCP 자율 에이전트 전환 | `pending` | ✅ |
|
||||||
27
docs/devlog/entries/20260312-001.md
Normal file
27
docs/devlog/entries/20260312-001.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# MCP 기반 에이전트 아키텍처 재설계
|
||||||
|
|
||||||
|
- **시간**: 2026-03-12 15:00~16:45
|
||||||
|
- **Commit**: `pending`
|
||||||
|
- **Vikunja**: 신규 태스크
|
||||||
|
|
||||||
|
## 결정 사항
|
||||||
|
|
||||||
|
- Gemini CLI의 네이티브 MCP 지원을 활용하여 에이전트 루프를 직접 구현하지 않고 CLI에 위임
|
||||||
|
- unified.md 단발성 JSON 분류 → Gemini CLI 내부 추론 루프(ReAct 패턴)로 전환
|
||||||
|
- anime 하드코딩 핸들러 → MCP 도구로 표준화 (향후 모든 새 기능도 MCP 서버로 추가)
|
||||||
|
- MCP settings는 홈 레벨 `~/.gemini/settings.json`에 등록 (프로젝트 레벨은 cwd 문제)
|
||||||
|
|
||||||
|
## 변경 파일
|
||||||
|
|
||||||
|
- `mcp_servers/anime_server.py` — 5개 MCP 도구 (검색/다운/편성표/상태/NAS)
|
||||||
|
- `mcp_servers/infra_server.py` — 7개 MCP 도구 (Gitea + Vikunja)
|
||||||
|
- `.gemini/settings.json` — 프로젝트 레벨 MCP 설정 (백업용)
|
||||||
|
- `prompts/agent.md` — 범용 에이전트 프롬프트
|
||||||
|
- `core/gemini_caller.py` — MCP 설정 자동 등록 + 역할별 지시 분기
|
||||||
|
- `api/discord_bot.py` — _unified_call→_agent_call, 4-mode 분기 제거, 진행 표시 추가
|
||||||
|
|
||||||
|
## 미완료
|
||||||
|
|
||||||
|
- `_handle_anime`, `_handle_task` dead code 아직 남아있음 (실 테스트 후 제거)
|
||||||
|
- Discord 실행 + 실제 메시지 테스트 미수행
|
||||||
|
- Plan→Code→Review 품질 루프 대체 방안 검토 필요
|
||||||
1
mcp_servers/__init__.py
Normal file
1
mcp_servers/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# MCP 서버 패키지
|
||||||
224
mcp_servers/anime_server.py
Normal file
224
mcp_servers/anime_server.py
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
"""MCP 서버 — 애니메이션 도구.
|
||||||
|
|
||||||
|
Gemini CLI에서 MCP로 연결하여 애니 검색/다운로드/편성표 조회를 수행합니다.
|
||||||
|
stdio 트랜스포트를 사용합니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# 프로젝트 루트를 sys.path에 추가 (tools/, config 접근용)
|
||||||
|
PROJECT_ROOT = Path(__file__).parent.parent
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
import config # noqa: E402 — .env 로드
|
||||||
|
|
||||||
|
from mcp.server.fastmcp import FastMCP # noqa: E402
|
||||||
|
|
||||||
|
mcp = FastMCP("anime")
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────
|
||||||
|
# 애니 검색
|
||||||
|
# ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def anime_search(title: str) -> str:
|
||||||
|
"""애니메이션을 검색합니다.
|
||||||
|
|
||||||
|
제목으로 Anissia에서 애니를 검색하고, 자막 제작자 정보와
|
||||||
|
Nyaa 토렌트 검색 결과를 함께 반환합니다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: 검색할 애니 제목 (한글 또는 일본어)
|
||||||
|
"""
|
||||||
|
from tools.anime_pipeline import AnimePipeline
|
||||||
|
|
||||||
|
pipeline = AnimePipeline()
|
||||||
|
result = await pipeline.search(title)
|
||||||
|
|
||||||
|
if not result.success:
|
||||||
|
errors = "; ".join(result.errors) if result.errors else ""
|
||||||
|
return f"검색 실패: {result.message} {errors}"
|
||||||
|
|
||||||
|
anime = result.anime
|
||||||
|
lines = [
|
||||||
|
f"## {anime.subject} ({anime.original_subject})",
|
||||||
|
f"- 장르: {anime.genres}",
|
||||||
|
]
|
||||||
|
|
||||||
|
week_names = ['일', '월', '화', '수', '목', '금', '토', '기타']
|
||||||
|
if anime.week is not None:
|
||||||
|
lines.append(f"- 편성: {week_names[anime.week]}요일 {anime.time}")
|
||||||
|
lines.append(f"- NAS 폴더: {result.nas_folder}")
|
||||||
|
|
||||||
|
if result.captions:
|
||||||
|
lines.append(f"\n### 자막 ({len(result.captions)}명)")
|
||||||
|
for c in result.captions[:5]:
|
||||||
|
url = f" ({c.website})" if c.website else ""
|
||||||
|
lines.append(f" - {c.name} — {c.episode}화{url}")
|
||||||
|
|
||||||
|
if result.torrents:
|
||||||
|
lines.append(f"\n### 토렌트 ({len(result.torrents)}건)")
|
||||||
|
for t in result.torrents[:5]:
|
||||||
|
ep = f"{t.episode}화 " if t.episode else ""
|
||||||
|
lines.append(f" - [{t.group}] {ep}{t.size} (시드: {t.seeders})")
|
||||||
|
|
||||||
|
if result.errors:
|
||||||
|
lines.append(f"\n### 오류")
|
||||||
|
for e in result.errors:
|
||||||
|
lines.append(f" - {e}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────
|
||||||
|
# 애니 다운로드
|
||||||
|
# ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def anime_download(
|
||||||
|
title: str,
|
||||||
|
mode: str = "auto",
|
||||||
|
episode: int | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""애니메이션을 다운로드합니다 (자막+영상).
|
||||||
|
|
||||||
|
Anissia에서 검색 후 자막과 토렌트를 NAS에 다운로드합니다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: 다운로드할 애니 제목
|
||||||
|
mode: "auto" (자막+영상), "sub_only" (자막만), "video_only" (영상만)
|
||||||
|
episode: 특정 에피소드 번호 (None이면 최신)
|
||||||
|
"""
|
||||||
|
from tools.anime_pipeline import AnimePipeline
|
||||||
|
|
||||||
|
pipeline = AnimePipeline()
|
||||||
|
result = await pipeline.download(title, mode=mode, episode=episode)
|
||||||
|
return result.message
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────
|
||||||
|
# 편성표 조회
|
||||||
|
# ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def anime_schedule(weekday: int | None = None, sub_only: bool = False) -> str:
|
||||||
|
"""애니 편성표를 조회합니다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
weekday: 요일 번호 (0=일, 1=월, ..., 6=토). None이면 전체 방영 중 목록.
|
||||||
|
sub_only: True이면 자막 있는 애니만 표시
|
||||||
|
"""
|
||||||
|
from tools.anissia_client import AnissiaClient
|
||||||
|
|
||||||
|
client = AnissiaClient()
|
||||||
|
|
||||||
|
if weekday is not None:
|
||||||
|
schedule = await client.get_schedule(weekday)
|
||||||
|
week_names = ['일', '월', '화', '수', '목', '금', '토']
|
||||||
|
title = f"{week_names[weekday]}요일 편성표"
|
||||||
|
else:
|
||||||
|
schedule = await client.get_all_schedule()
|
||||||
|
schedule = [a for a in schedule if a.status == "ON"]
|
||||||
|
title = "현재 방영 중인 애니"
|
||||||
|
|
||||||
|
if sub_only:
|
||||||
|
schedule = [a for a in schedule if a.caption_count > 0]
|
||||||
|
title += " (자막 있음)"
|
||||||
|
|
||||||
|
lines = [f"## {title} ({len(schedule)}개)"]
|
||||||
|
for a in schedule[:30]:
|
||||||
|
sub = f" 📝{a.caption_count}" if a.caption_count > 0 else ""
|
||||||
|
lines.append(f"- {a.subject} — {a.time}{sub}")
|
||||||
|
|
||||||
|
if len(schedule) > 30:
|
||||||
|
lines.append(f"... 외 {len(schedule) - 30}개")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────
|
||||||
|
# 다운로드 상태
|
||||||
|
# ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def anime_download_status() -> str:
|
||||||
|
"""qBittorrent의 현재 다운로드 상태를 확인합니다."""
|
||||||
|
from tools.anime_pipeline import AnimePipeline
|
||||||
|
|
||||||
|
pipeline = AnimePipeline()
|
||||||
|
conn = await pipeline.qbit.test_connection()
|
||||||
|
|
||||||
|
if not conn.get("connected"):
|
||||||
|
return f"qBittorrent 연결 실패: {conn.get('error', '알 수 없는 오류')}"
|
||||||
|
|
||||||
|
torrents = await pipeline.get_status()
|
||||||
|
|
||||||
|
if not torrents:
|
||||||
|
return "다운로드 중인 항목이 없습니다."
|
||||||
|
|
||||||
|
lines = [f"## 다운로드 큐 ({len(torrents)}건)"]
|
||||||
|
for t in torrents[:15]:
|
||||||
|
icon = "✅" if t["progress"] == "100.0%" else "⏳"
|
||||||
|
lines.append(
|
||||||
|
f"- {icon} {t['name'][:50]} — {t['progress']} "
|
||||||
|
f"({t['speed']}, ETA: {t['eta']})"
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────
|
||||||
|
# NAS 애니 목록
|
||||||
|
# ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def anime_nas_list(current_quarter: bool = False, keyword: str = "") -> str:
|
||||||
|
"""NAS에 다운로드된 애니 목록을 조회합니다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
current_quarter: True이면 이번 분기 애니만 표시
|
||||||
|
keyword: 검색 키워드 (빈 문자열이면 전체)
|
||||||
|
"""
|
||||||
|
from tools.nas_scanner import NasScanner
|
||||||
|
|
||||||
|
scanner = NasScanner()
|
||||||
|
if not scanner.is_accessible():
|
||||||
|
return f"NAS 접근 불가: {scanner.base_path}"
|
||||||
|
|
||||||
|
if keyword:
|
||||||
|
folders = scanner.search(keyword)
|
||||||
|
elif current_quarter:
|
||||||
|
folders = scanner.get_current_quarter_anime()
|
||||||
|
else:
|
||||||
|
folders = scanner.list_anime_folders()
|
||||||
|
|
||||||
|
if not folders:
|
||||||
|
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 = [f"## NAS 애니 목록 ({len(folders)}개)"]
|
||||||
|
for f in folders[:25]:
|
||||||
|
sub = f" 📝{f.subtitle_count}" if f.subtitle_count > 0 else ""
|
||||||
|
lines.append(
|
||||||
|
f"- {f.title} — 🎬{f.video_count}화{sub} ({f.total_size_gb:.1f}GB)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(folders) > 25:
|
||||||
|
lines.append(f"... 외 {len(folders) - 25}개")
|
||||||
|
|
||||||
|
lines.append(f"\n총 {total_vids}개 영상 | {total_subs}개 자막 | {total_size:.1f}GB")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────
|
||||||
|
# 실행
|
||||||
|
# ──────────────────────────────────────────
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
mcp.run(transport="stdio")
|
||||||
161
mcp_servers/infra_server.py
Normal file
161
mcp_servers/infra_server.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
"""MCP 서버 — 인프라 도구 (Gitea + Vikunja).
|
||||||
|
|
||||||
|
Gemini CLI에서 MCP로 연결하여 Git 저장소 관리, 태스크 관리를 수행합니다.
|
||||||
|
stdio 트랜스포트를 사용합니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# 프로젝트 루트를 sys.path에 추가
|
||||||
|
PROJECT_ROOT = Path(__file__).parent.parent
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
import config # noqa: E402
|
||||||
|
|
||||||
|
from mcp.server.fastmcp import FastMCP # noqa: E402
|
||||||
|
|
||||||
|
mcp = FastMCP("infra")
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════
|
||||||
|
# Gitea 도구
|
||||||
|
# ══════════════════════════════════════════
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def gitea_commits(limit: int = 5, branch: str = "main") -> str:
|
||||||
|
"""Gitea 저장소의 최근 커밋 목록을 조회합니다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: 조회할 커밋 수 (기본 5)
|
||||||
|
branch: 브랜치 이름 (기본 main)
|
||||||
|
"""
|
||||||
|
from integrations.gitea_client import GiteaClient
|
||||||
|
|
||||||
|
client = GiteaClient()
|
||||||
|
commits = await client.get_commits(limit=limit, branch=branch)
|
||||||
|
|
||||||
|
if not commits:
|
||||||
|
return "커밋이 없습니다."
|
||||||
|
|
||||||
|
lines = [f"## 최근 커밋 ({branch}, {len(commits)}개)"]
|
||||||
|
for c in commits:
|
||||||
|
lines.append(f"- `{c['sha']}` {c['message']} — {c['author']}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def gitea_prs(state: str = "open") -> str:
|
||||||
|
"""Gitea 저장소의 PR 목록을 조회합니다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
state: "open" 또는 "closed" (기본 open)
|
||||||
|
"""
|
||||||
|
from integrations.gitea_client import GiteaClient
|
||||||
|
|
||||||
|
client = GiteaClient()
|
||||||
|
prs = await client.list_prs(state=state)
|
||||||
|
|
||||||
|
if not prs:
|
||||||
|
return f"{state} 상태의 PR이 없습니다."
|
||||||
|
|
||||||
|
lines = [f"## PR 목록 ({state}, {len(prs)}개)"]
|
||||||
|
for p in prs:
|
||||||
|
lines.append(f"- #{p['number']} {p['title']} ({p['state']}, {p['user']})")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def gitea_issues(state: str = "open") -> str:
|
||||||
|
"""Gitea 저장소의 이슈 목록을 조회합니다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
state: "open" 또는 "closed" (기본 open)
|
||||||
|
"""
|
||||||
|
from integrations.gitea_client import GiteaClient
|
||||||
|
|
||||||
|
client = GiteaClient()
|
||||||
|
issues = await client.list_issues(state=state)
|
||||||
|
|
||||||
|
if not issues:
|
||||||
|
return f"{state} 상태의 이슈가 없습니다."
|
||||||
|
|
||||||
|
lines = [f"## 이슈 목록 ({state}, {len(issues)}개)"]
|
||||||
|
for i in issues:
|
||||||
|
lines.append(f"- #{i['number']} {i['title']} ({i['state']})")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def gitea_branches() -> str:
|
||||||
|
"""Gitea 저장소의 브랜치 목록을 조회합니다."""
|
||||||
|
from integrations.gitea_client import GiteaClient
|
||||||
|
|
||||||
|
client = GiteaClient()
|
||||||
|
branches = await client.list_branches()
|
||||||
|
return "## 브랜치 목록\n" + "\n".join(f"- {b}" for b in branches)
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════
|
||||||
|
# Vikunja 도구
|
||||||
|
# ══════════════════════════════════════════
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def vikunja_tasks(filter: str = "todo") -> str:
|
||||||
|
"""Vikunja 프로젝트의 태스크 목록을 조회합니다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filter: "todo" (미완료), "done" (완료), "all" (전체)
|
||||||
|
"""
|
||||||
|
from integrations.vikunja_client import VikunjaClient
|
||||||
|
|
||||||
|
client = VikunjaClient()
|
||||||
|
tasks = await client.list_tasks(filter_=filter)
|
||||||
|
|
||||||
|
if not tasks:
|
||||||
|
return f"{filter} 상태의 태스크가 없습니다."
|
||||||
|
|
||||||
|
lines = [f"## 태스크 ({filter}, {len(tasks)}개)"]
|
||||||
|
for t in tasks:
|
||||||
|
icon = "✅" if t["done"] else "⬜"
|
||||||
|
labels = f" [{', '.join(t['labels'])}]" if t["labels"] else ""
|
||||||
|
desc = f" — {t['description']}" if t["description"] else ""
|
||||||
|
lines.append(f"- {icon} #{t['id']} {t['title']}{labels}{desc}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def vikunja_create_task(title: str, description: str = "") -> str:
|
||||||
|
"""Vikunja에 새 태스크를 생성합니다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: 태스크 제목
|
||||||
|
description: 태스크 설명 (선택)
|
||||||
|
"""
|
||||||
|
from integrations.vikunja_client import VikunjaClient
|
||||||
|
|
||||||
|
client = VikunjaClient()
|
||||||
|
result = await client.create_task(title=title, description=description)
|
||||||
|
return f"태스크 #{result['id']} 생성: {result['title']}"
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def vikunja_complete_task(task_id: int) -> str:
|
||||||
|
"""Vikunja 태스크를 완료 처리합니다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_id: 완료할 태스크 ID
|
||||||
|
"""
|
||||||
|
from integrations.vikunja_client import VikunjaClient
|
||||||
|
|
||||||
|
client = VikunjaClient()
|
||||||
|
result = await client.mark_done(task_id)
|
||||||
|
return f"태스크 #{task_id} 완료: {result['title']}"
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────
|
||||||
|
# 실행
|
||||||
|
# ──────────────────────────────────────────
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
mcp.run(transport="stdio")
|
||||||
35
prompts/agent.md
Normal file
35
prompts/agent.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Variet Agent
|
||||||
|
|
||||||
|
당신은 **Variet Agent** — 범용 AI 에이전트입니다.
|
||||||
|
사용자의 요청을 이해하고, 필요한 도구를 자율적으로 선택하여 작업을 완수합니다.
|
||||||
|
|
||||||
|
## 도구 사용 원칙
|
||||||
|
|
||||||
|
- 도구 없이 답변할 수 있으면 **바로 답변**하세요.
|
||||||
|
- 도구가 필요하면 호출하고, **결과를 확인한 뒤** 답변하세요.
|
||||||
|
- 여러 도구를 **순서대로** 사용해야 할 때도 있습니다.
|
||||||
|
- 도구 호출 결과가 불충분하면 **다른 도구를 시도**하거나 **다른 파라미터**로 재호출하세요.
|
||||||
|
|
||||||
|
## 사용 가능한 도구 영역
|
||||||
|
|
||||||
|
### 🎬 anime 서버
|
||||||
|
- `anime_search` — 애니 검색 (제목, 자막, 토렌트)
|
||||||
|
- `anime_download` — 애니 다운로드 (자막+영상)
|
||||||
|
- `anime_schedule` — 편성표 조회
|
||||||
|
- `anime_download_status` — qBittorrent 상태
|
||||||
|
- `anime_nas_list` — NAS 다운로드 목록
|
||||||
|
|
||||||
|
### 🔧 infra 서버
|
||||||
|
- `gitea_commits`, `gitea_prs`, `gitea_issues`, `gitea_branches` — Git 관리
|
||||||
|
- `vikunja_tasks`, `vikunja_create_task`, `vikunja_complete_task` — 태스크 관리
|
||||||
|
|
||||||
|
### 💻 내장 도구
|
||||||
|
- 프로젝트 파일 읽기/쓰기, 쉘 명령 실행 등 Gemini CLI 내장 도구 사용 가능
|
||||||
|
|
||||||
|
## 응답 규칙
|
||||||
|
|
||||||
|
- **한국어**로 응답하세요.
|
||||||
|
- 도구 실행 결과를 사용자에게 **알기 쉽게 정리**하세요.
|
||||||
|
- 파일 변경 시 **변경 요약**을 제공하세요.
|
||||||
|
- 에러 발생 시 **원인과 대안**을 안내하세요.
|
||||||
|
- 불필요하게 길지 않게, **핵심만** 전달하세요.
|
||||||
Reference in New Issue
Block a user