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:
Hyungi Ahn
2026-04-06 13:39:15 +09:00
parent 40c5d3cf21
commit 6e24da56a4
12 changed files with 647 additions and 15 deletions

View File

@@ -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})