feat: 이드 도구 확장 — 캘린더/메일/문서 연동 (read-only + 캘린더 생성 확인)
- tools/calendar_tool.py: CalDAV search/today/create_draft/create_confirmed - tools/email_tool.py: IMAP search/read (전송 비활성화) - tools/document_tool.py: Document Server search/read (read-only) - tools/registry.py: 도구 디스패처 + WRITE_OPS 안전장치 + 에러 표준화 - 분류기: "tools" 액션 추가, 도구 목록/파라미터 스키마/규칙 명시 - Worker: tools 분기 + tool timeout 10초 + payload 2000자 제한 - conversation: pending_draft (TTL 5분) + create 확인 플로우 - 현재 시간을 분류기에 전달 (날짜 질문 대응) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,18 +11,37 @@ from services.model_adapter import ModelAdapter
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CLASSIFIER_PROMPT = """\
|
||||
너는 AI 라우터다. 사용자 메시지를 분석하여 JSON으로 응답하라.
|
||||
반드시 아래 3가지 action 중 하나를 선택하라:
|
||||
너는 AI 라우터다. 사용<EFBFBD><EFBFBD><EFBFBD> 메시지를 분석하여 JSON으로 응답하라.
|
||||
반드시 아래 4가<EFBFBD><EFBFBD> action 중 하나를 선택하라:
|
||||
|
||||
1. "direct" — 인사, 잡담, 간단한 질문, 자기소개 요청 등. 네가 직접 답변한다.
|
||||
2. "route" — 복잡한 질문, 분석, 설명, 코딩 등. 추론 모델에게 넘긴다. 이때 prompt 필드에 추론 모델이 이해하기 좋게 정리된 프롬프트를 작성하라.
|
||||
3. "clarify" — 질문이 모호하거나 정보가 부족할 때. 사용자에게 추가 질문한다.
|
||||
2. "route" — 복잡한 질문, 분석, 설명, 코딩 등. 추론 모델에게 넘긴다. prompt 필드에 추론 모델용 프롬프트를 작성하라.
|
||||
3. "clarify" — 질문이 모호하거나 정보가 부족할 때. 사용자에게 추가 질문<EFBFBD><EFBFBD><EFBFBD>다.
|
||||
4. "tools" — 캘린더, 이메일, 문서 조회/조작이 필요할 때. tool/operation/params를 지정하라.
|
||||
|
||||
반드시 아래 JSON 형식으로만 응답하라. JSON 외 텍스트는 절대 출력하지 마라:
|
||||
{"action": "direct|route|clarify", "response": "direct/clarify일 때 사용자에게 보낼 텍스트", "prompt": "route일 때 추론 모델에게 보낼 프롬프트"}
|
||||
사용 가능한 도구:
|
||||
- calendar.today() — 오늘 일정 조회
|
||||
- calendar.search(date_from, date_to) — 기간 일정 검색. 날짜는 YYYY-MM-DD
|
||||
- calendar.create_draft(title, date, time, description) — 일정 생성 초안. 시간은 HH:MM. 실제 생성 아님
|
||||
- calendar.create_confirmed() — pending_draft가 있을 때, 사용자가 "확인/예/yes" 한 경우만 사용
|
||||
- email.search(query, days) — 메일 검색. days 기본 7
|
||||
- email.read(uid) — 메일 본문 조회
|
||||
- document.search(query) — 문서 검색
|
||||
- document.read(doc_id) — 문서 조회
|
||||
|
||||
너의 이름은 '이드'이고, 상냥하고 친근하게 대화한다.
|
||||
너는 GPU 서버의 EXAONE 모델과 맥미니의 Gemma4 모델로 구성된 NanoClaude 파이프라인에서 돌아간다.
|
||||
메일 전송은 불가능하다. 메일 보내달라는 요청은 거부하라.
|
||||
|
||||
JSON 형식:
|
||||
- direct/route/clarify: {"action": "...", "response": "...", "prompt": "..."}
|
||||
- tools: {"action": "tools", "tool": "calendar|email|document", "operation": "...", "params": {...}}
|
||||
|
||||
규칙:
|
||||
- JSON 외 텍스트는 절대 출력하지 마라
|
||||
- 날짜는 YYYY-MM-DD, 시간은 HH:MM
|
||||
- pending_draft가 있다고 [대화 이력]에 표시되어 있을 때만 create_confirmed 사용
|
||||
- 오늘 날짜 정보가 [현재 시간]에 있으니 참고하라
|
||||
|
||||
<EFBFBD><EFBFBD><EFBFBD>의 이름은 '이드'. 상냥하고 친근하게 대화한다.
|
||||
대화 이력이 있으면 맥락을 고려하라.\
|
||||
"""
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from time import time
|
||||
|
||||
MAX_HISTORY = 10 # user당 최근 대화 수
|
||||
HISTORY_TTL = 3600.0 # 1시간 이후 대화 만료
|
||||
DRAFT_TTL = 300.0 # pending_draft 5분 만료
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -20,6 +21,7 @@ class Message:
|
||||
class ConversationStore:
|
||||
def __init__(self) -> None:
|
||||
self._history: dict[str, list[Message]] = defaultdict(list)
|
||||
self._pending_drafts: dict[str, tuple[dict, float]] = {} # user_id → (draft_data, created_at)
|
||||
|
||||
def add(self, user_id: str, role: str, content: str) -> None:
|
||||
msgs = self._history[user_id]
|
||||
@@ -40,13 +42,33 @@ class ConversationStore:
|
||||
def format_for_prompt(self, user_id: str) -> str:
|
||||
"""EXAONE에 전달할 대화 이력 포맷."""
|
||||
msgs = self.get(user_id)
|
||||
if not msgs:
|
||||
return ""
|
||||
lines = []
|
||||
for m in msgs[-6:]: # 최근 6개만
|
||||
prefix = "사용자" if m.role == "user" else "이드"
|
||||
lines.append(f"{prefix}: {m.content}")
|
||||
# pending_draft 표시
|
||||
draft = self.get_pending_draft(user_id)
|
||||
if draft:
|
||||
lines.append(f"[시스템: pending_draft 있음 — {draft.get('title', '일정')} {draft.get('date', '')} {draft.get('time', '')}]")
|
||||
if not lines:
|
||||
return ""
|
||||
return "\n".join(lines)
|
||||
|
||||
def set_pending_draft(self, user_id: str, draft_data: dict) -> None:
|
||||
self._pending_drafts[user_id] = (draft_data, time())
|
||||
|
||||
def get_pending_draft(self, user_id: str) -> dict | None:
|
||||
entry = self._pending_drafts.get(user_id)
|
||||
if not entry:
|
||||
return None
|
||||
data, created_at = entry
|
||||
if time() - created_at > DRAFT_TTL:
|
||||
del self._pending_drafts[user_id]
|
||||
return None
|
||||
return data
|
||||
|
||||
def clear_pending_draft(self, user_id: str) -> None:
|
||||
self._pending_drafts.pop(user_id, None)
|
||||
|
||||
|
||||
conversation_store = ConversationStore()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Worker — EXAONE 분류 → direct/route/clarify 분기 (cancel-safe + fallback)."""
|
||||
"""Worker — EXAONE 분류 → direct/route/clarify/tools 분기 (cancel-safe + fallback)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -15,6 +15,7 @@ from services.conversation import conversation_store
|
||||
from services.job_manager import Job, job_manager
|
||||
from services.state_stream import state_stream
|
||||
from services.synology_sender import send_to_synology
|
||||
from tools.registry import execute_tool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -22,6 +23,8 @@ HEARTBEAT_INTERVAL = 4.0
|
||||
CLASSIFY_HEARTBEAT = 2.0
|
||||
MAX_PROMPT_LENGTH = 1000
|
||||
SYNOLOGY_MAX_LEN = 4000
|
||||
MAX_TOOL_PAYLOAD = 2000
|
||||
TOOL_TIMEOUT = 10.0
|
||||
|
||||
|
||||
async def _complete_with_heartbeat(adapter, message: str, job_id: str, *, messages=None, beat_msg="처리 중...") -> str:
|
||||
@@ -114,11 +117,14 @@ async def run(job: Job) -> None:
|
||||
|
||||
classify_model = backend_registry.classifier.model
|
||||
|
||||
# --- 대화 이력 포함하여 분류 요청 ---
|
||||
# --- 대화 이력 + 현재 시간 포함하여 분류 요청 ---
|
||||
from datetime import datetime
|
||||
now_str = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
|
||||
history = conversation_store.format_for_prompt(user_id)
|
||||
classify_input = job.message
|
||||
classify_input = f"[현재 시간]\n{now_str}\n\n"
|
||||
if history:
|
||||
classify_input = f"[대화 이력]\n{history}\n\n[현재 메시지]\n{job.message}"
|
||||
classify_input += f"[대화 이력]\n{history}\n\n"
|
||||
classify_input += f"[현재 메시지]\n{job.message}"
|
||||
|
||||
await state_stream.push(job.id, "processing", {"message": "메시지를 분석하고 있습니다..."})
|
||||
classify_start = time()
|
||||
@@ -148,7 +154,76 @@ async def run(job: Job) -> None:
|
||||
if job.status == JobStatus.cancelled:
|
||||
return
|
||||
|
||||
if action == "clarify":
|
||||
if action == "tools":
|
||||
# === TOOLS: 도구 실행 ===
|
||||
tool_name = classification.get("tool", "")
|
||||
operation = classification.get("operation", "")
|
||||
params = classification.get("params", {})
|
||||
|
||||
logger.info("Job %s tool call: %s.%s(%s)", job.id, tool_name, operation, params)
|
||||
await state_stream.push(job.id, "processing", {"message": f"🔧 {tool_name} 도구를 사용하고 있습니다..."})
|
||||
|
||||
# create_confirmed → pending_draft에서 데이터 가져오기
|
||||
if operation == "create_confirmed":
|
||||
draft = conversation_store.get_pending_draft(user_id)
|
||||
if not draft:
|
||||
response = "확인할 일정이 없습니다. 다시 요청해주세요."
|
||||
collected.append(response)
|
||||
await state_stream.push(job.id, "result", {"content": response})
|
||||
conversation_store.add(user_id, "assistant", response)
|
||||
else:
|
||||
try:
|
||||
result = await asyncio.wait_for(execute_tool(tool_name, operation, draft), timeout=TOOL_TIMEOUT)
|
||||
except asyncio.TimeoutError:
|
||||
result = {"ok": False, "tool": tool_name, "operation": operation, "data": [], "summary": "", "error": "⚠️ 서비스 응답 시간이 초과되었습니다."}
|
||||
conversation_store.clear_pending_draft(user_id)
|
||||
response = result.get("summary", "") if result["ok"] else result.get("error", "⚠️ 오류")
|
||||
collected.append(response)
|
||||
await state_stream.push(job.id, "result", {"content": response})
|
||||
conversation_store.add(user_id, "assistant", response)
|
||||
else:
|
||||
# 일반 도구 실행
|
||||
try:
|
||||
result = await asyncio.wait_for(execute_tool(tool_name, operation, params), timeout=TOOL_TIMEOUT)
|
||||
except asyncio.TimeoutError:
|
||||
result = {"ok": False, "tool": tool_name, "operation": operation, "data": [], "summary": "", "error": "⚠️ 서비스 응답 시간이 초과되었습니다."}
|
||||
|
||||
if not result["ok"]:
|
||||
response = result.get("error", "⚠️ 서비스를 사용할 수 없습니다.")
|
||||
collected.append(response)
|
||||
await state_stream.push(job.id, "result", {"content": response})
|
||||
else:
|
||||
# create_draft → pending에 저장 + 확인 요청
|
||||
if operation == "create_draft":
|
||||
conversation_store.set_pending_draft(user_id, result["data"])
|
||||
response = result["summary"] + "\n\n'확인' 또는 '취소'로 답해주세요."
|
||||
collected.append(response)
|
||||
await state_stream.push(job.id, "result", {"content": response})
|
||||
else:
|
||||
# 결과를 EXAONE에 전달하여 자연어로 정리
|
||||
tool_json = json.dumps(result["data"], ensure_ascii=False)
|
||||
if len(tool_json) > MAX_TOOL_PAYLOAD:
|
||||
tool_json = tool_json[:MAX_TOOL_PAYLOAD] + "...(truncated)"
|
||||
format_input = f"[도구 결과]\n{tool_json}\n\n위 데이터를 바탕으로 사용자에게 자연스럽고 간결하게 답해."
|
||||
try:
|
||||
response = await _complete_with_heartbeat(
|
||||
backend_registry.classifier, format_input, job.id,
|
||||
beat_msg="결과를 정리하고 있습니다..."
|
||||
)
|
||||
# 포맷팅 응답이 JSON으로 올 수도 있으니 파싱 시도
|
||||
try:
|
||||
parsed = json.loads(response)
|
||||
response = parsed.get("response", response)
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
pass
|
||||
except Exception:
|
||||
response = result.get("summary", "결과를 조회했습니다.")
|
||||
collected.append(response)
|
||||
await state_stream.push(job.id, "result", {"content": response})
|
||||
|
||||
conversation_store.add(user_id, "assistant", "".join(collected))
|
||||
|
||||
elif action == "clarify":
|
||||
# === CLARIFY: 추가 질문 ===
|
||||
collected.append(response_text)
|
||||
await state_stream.push(job.id, "result", {"content": response_text})
|
||||
|
||||
Reference in New Issue
Block a user