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

View File

@@ -0,0 +1,140 @@
"""Calendar 도구 — CalDAV를 통한 일정 조회/생성."""
from __future__ import annotations
import logging
import uuid
from datetime import datetime, timedelta
import caldav
from config import settings
logger = logging.getLogger(__name__)
TOOL_NAME = "calendar"
def _make_result(ok: bool, operation: str, data=None, summary: str = "", error: str | None = None) -> dict:
return {"ok": ok, "tool": TOOL_NAME, "operation": operation, "data": data or [], "summary": summary, "error": error}
def _get_client():
if not settings.caldav_url or not settings.caldav_user:
return None
return caldav.DAVClient(
url=settings.caldav_url,
username=settings.caldav_user,
password=settings.caldav_pass,
ssl_verify_cert=False,
)
def _parse_event(event) -> dict:
"""VEVENT → dict."""
try:
vevent = event.vobject_instance.vevent
summary = str(vevent.summary.value) if hasattr(vevent, "summary") else "(제목 없음)"
dtstart = vevent.dtstart.value
dtend = vevent.dtend.value if hasattr(vevent, "dtend") else None
start_str = dtstart.strftime("%Y-%m-%d %H:%M") if hasattr(dtstart, "strftime") else str(dtstart)
end_str = dtend.strftime("%Y-%m-%d %H:%M") if dtend and hasattr(dtend, "strftime") else ""
return {"summary": summary, "start": start_str, "end": end_str}
except Exception:
return {"summary": "(파싱 실패)", "start": "", "end": ""}
async def today() -> dict:
"""오늘 일정 조회."""
now = datetime.now()
start = now.replace(hour=0, minute=0, second=0)
end = start + timedelta(days=1)
return await search(start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d"))
async def search(date_from: str, date_to: str) -> dict:
"""기간 내 일정 검색."""
try:
client = _get_client()
if not client:
return _make_result(False, "search", error="CalDAV 설정이 없습니다.")
principal = client.principal()
calendars = principal.calendars()
if not calendars:
return _make_result(True, "search", data=[], summary="캘린더가 없습니다.")
start = datetime.strptime(date_from, "%Y-%m-%d")
end = datetime.strptime(date_to, "%Y-%m-%d") + timedelta(days=1)
events = []
for cal in calendars:
try:
results = cal.date_search(start=start, end=end, expand=True)
for ev in results:
events.append(_parse_event(ev))
except Exception:
continue
summary = f"{date_from}~{date_to} 기간에 {len(events)}개의 일정이 있습니다."
return _make_result(True, "search", data=events, summary=summary)
except Exception as e:
logger.exception("Calendar search failed")
return _make_result(False, "search", error=str(e))
async def create_draft(title: str, date: str, time: str, description: str = "") -> dict:
"""일정 생성 초안 (실제 생성 안 함)."""
title = title or "일정"
draft = {
"title": title,
"date": date,
"time": time,
"description": description,
"display": f"📅 제목: {title}\n날짜: {date} {time}\n설명: {description or '없음'}",
}
return _make_result(True, "create_draft", data=draft, summary=draft["display"])
async def create_confirmed(draft_data: dict) -> dict:
"""사용자 확인 후 실제 CalDAV 이벤트 생성."""
try:
client = _get_client()
if not client:
return _make_result(False, "create_confirmed", error="CalDAV 설정이 없습니다.")
title = draft_data.get("title", "일정")
date_str = draft_data.get("date", "")
time_str = draft_data.get("time", "00:00")
description = draft_data.get("description", "")
dt_start = datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M")
dt_end = dt_start + timedelta(hours=1)
uid = str(uuid.uuid4())
vcal = f"""BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//NanoClaude//이드//KO
BEGIN:VEVENT
UID:{uid}
DTSTART:{dt_start.strftime('%Y%m%dT%H%M%S')}
DTEND:{dt_end.strftime('%Y%m%dT%H%M%S')}
SUMMARY:{title}
DESCRIPTION:{description}
END:VEVENT
END:VCALENDAR"""
principal = client.principal()
calendars = principal.calendars()
if not calendars:
return _make_result(False, "create_confirmed", error="캘린더를 찾을 수 없습니다.")
calendars[0].save_event(vcal)
return _make_result(True, "create_confirmed", data={"uid": uid, "title": title}, summary=f"'{title}' 일정이 등록되었습니다!")
except Exception as e:
logger.exception("Calendar create failed")
return _make_result(False, "create_confirmed", error=str(e))

View File

@@ -0,0 +1,91 @@
"""Document 도구 — Document Server REST API (read-only)."""
from __future__ import annotations
import logging
import httpx
from config import settings
logger = logging.getLogger(__name__)
TOOL_NAME = "document"
MAX_RESULTS = 5
def _make_result(ok: bool, operation: str, data=None, summary: str = "", error: str | None = None) -> dict:
return {"ok": ok, "tool": TOOL_NAME, "operation": operation, "data": data or [], "summary": summary, "error": error}
def _headers() -> dict:
return {"Authorization": f"Bearer {settings.document_api_token}"} if settings.document_api_token else {}
async def search(query: str) -> dict:
"""문서 하이브리드 검색."""
if not settings.document_api_url:
return _make_result(False, "search", error="Document Server 설정이 없습니다.")
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(
f"{settings.document_api_url}/search/",
params={"q": query, "mode": "hybrid"},
headers=_headers(),
)
if resp.status_code != 200:
return _make_result(False, "search", error=f"API 응답 오류 ({resp.status_code})")
results = resp.json()
if isinstance(results, dict):
results = results.get("results", results.get("data", []))
# top-N 제한
results = results[:MAX_RESULTS]
items = []
for doc in results:
items.append({
"id": doc.get("id", ""),
"title": doc.get("title", "(제목 없음)"),
"domain": doc.get("domain", ""),
"preview": str(doc.get("content", doc.get("snippet", "")))[:200],
})
summary = f"'{query}' 검색 결과 {len(items)}"
return _make_result(True, "search", data=items, summary=summary)
except Exception as e:
logger.exception("Document search failed")
return _make_result(False, "search", error=str(e))
async def read(doc_id: str) -> dict:
"""문서 내용 조회."""
if not settings.document_api_url:
return _make_result(False, "read", error="Document Server 설정이 없습니다.")
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(
f"{settings.document_api_url}/documents/{doc_id}",
headers=_headers(),
)
if resp.status_code == 404:
return _make_result(False, "read", error=f"문서 {doc_id}를 찾을 수 없습니다.")
if resp.status_code != 200:
return _make_result(False, "read", error=f"API 응답 오류 ({resp.status_code})")
doc = resp.json()
data = {
"id": doc.get("id", ""),
"title": doc.get("title", ""),
"domain": doc.get("domain", ""),
"content": str(doc.get("content", doc.get("markdown_content", "")))[:2000],
}
return _make_result(True, "read", data=data, summary=f"문서: {data['title']}")
except Exception as e:
logger.exception("Document read failed")
return _make_result(False, "read", error=str(e))

View File

@@ -0,0 +1,151 @@
"""Email 도구 — IMAP을 통한 메일 조회 (read-only)."""
from __future__ import annotations
import email
import email.header
import imaplib
import logging
from datetime import datetime, timedelta
from config import settings
logger = logging.getLogger(__name__)
TOOL_NAME = "email"
MAX_RESULTS = 10
PREVIEW_LEN = 200
def _make_result(ok: bool, operation: str, data=None, summary: str = "", error: str | None = None) -> dict:
return {"ok": ok, "tool": TOOL_NAME, "operation": operation, "data": data or [], "summary": summary, "error": error}
def _decode_header(raw: str) -> str:
parts = email.header.decode_header(raw)
decoded = []
for part, charset in parts:
if isinstance(part, bytes):
decoded.append(part.decode(charset or "utf-8", errors="replace"))
else:
decoded.append(part)
return "".join(decoded)
def _get_body(msg) -> str:
"""메일 본문 추출."""
if msg.is_multipart():
for part in msg.walk():
ct = part.get_content_type()
if ct == "text/plain":
payload = part.get_payload(decode=True)
if payload:
charset = part.get_content_charset() or "utf-8"
return payload.decode(charset, errors="replace")
# fallback to html
for part in msg.walk():
if part.get_content_type() == "text/html":
payload = part.get_payload(decode=True)
if payload:
charset = part.get_content_charset() or "utf-8"
return payload.decode(charset, errors="replace")
else:
payload = msg.get_payload(decode=True)
if payload:
charset = msg.get_content_charset() or "utf-8"
return payload.decode(charset, errors="replace")
return ""
def _connect():
if not settings.mailplus_host or not settings.mailplus_user:
return None
conn = imaplib.IMAP4_SSL(settings.mailplus_host, settings.mailplus_port)
conn.login(settings.mailplus_user, settings.mailplus_pass)
return conn
async def search(query: str = "", days: int = 7) -> dict:
"""최근 메일 검색."""
try:
conn = _connect()
if not conn:
return _make_result(False, "search", error="메일 설정이 없습니다.")
conn.select("INBOX", readonly=True)
since = (datetime.now() - timedelta(days=days)).strftime("%d-%b-%Y")
if query:
criteria = f'(SINCE {since} SUBJECT "{query}")'
else:
criteria = f"(SINCE {since})"
_, data = conn.search(None, criteria)
uids = data[0].split()
# 최신 MAX_RESULTS개만
uids = uids[-MAX_RESULTS:]
uids.reverse()
results = []
for uid in uids:
_, msg_data = conn.fetch(uid, "(RFC822.HEADER)")
if not msg_data or not msg_data[0]:
continue
raw = msg_data[0][1]
msg = email.message_from_bytes(raw)
subject = _decode_header(msg.get("Subject", "(제목 없음)"))
from_addr = _decode_header(msg.get("From", ""))
date_str = msg.get("Date", "")
results.append({
"uid": uid.decode(),
"subject": subject,
"from": from_addr,
"date": date_str[:20],
})
conn.close()
conn.logout()
summary = f"최근 {days}일간 {len(results)}개의 메일이 있습니다."
return _make_result(True, "search", data=results, summary=summary)
except Exception as e:
logger.exception("Email search failed")
return _make_result(False, "search", error=str(e))
async def read(uid: str) -> dict:
"""특정 메일 본문 조회."""
try:
conn = _connect()
if not conn:
return _make_result(False, "read", error="메일 설정이 없습니다.")
conn.select("INBOX", readonly=True)
_, msg_data = conn.fetch(uid.encode(), "(RFC822)")
if not msg_data or not msg_data[0]:
conn.close()
conn.logout()
return _make_result(False, "read", error=f"UID {uid} 메일을 찾을 수 없습니다.")
raw = msg_data[0][1]
msg = email.message_from_bytes(raw)
subject = _decode_header(msg.get("Subject", ""))
from_addr = _decode_header(msg.get("From", ""))
date_str = msg.get("Date", "")
body = _get_body(msg)[:PREVIEW_LEN * 5] # read는 더 긴 본문
conn.close()
conn.logout()
data = {"uid": uid, "subject": subject, "from": from_addr, "date": date_str, "body": body}
return _make_result(True, "read", data=data, summary=f"메일: {subject}")
except Exception as e:
logger.exception("Email read failed")
return _make_result(False, "read", error=str(e))

View File

@@ -0,0 +1,100 @@
"""Tool Registry — 도구 실행 디스패처 + 안전장치."""
from __future__ import annotations
import logging
from tools import calendar_tool, document_tool, email_tool
logger = logging.getLogger(__name__)
# 에러 메시지 표준화 (내부 에러 노출 안 함)
ERROR_MESSAGES = {
"calendar": "⚠️ 캘린더 서비스를 사용할 수 없습니다. 잠시 후 다시 시도해주세요.",
"email": "⚠️ 메일 서비스를 사용할 수 없습니다. 잠시 후 다시 시도해주세요.",
"document": "⚠️ 문서 서비스를 사용할 수 없습니다. 잠시 후 다시 시도해주세요.",
}
# 허용된 operations
ALLOWED_OPS = {
"calendar": {"today", "search", "create_draft", "create_confirmed"},
"email": {"search", "read"},
"document": {"search", "read"},
}
# payload hard limit
MAX_TOOL_PAYLOAD = 2000
async def execute_tool(tool_name: str, operation: str, params: dict) -> dict:
"""도구 실행 디스패처."""
# 도구 존재 확인
if tool_name not in ALLOWED_OPS:
return _error(tool_name, operation, f"알 수 없는 도구: {tool_name}")
# operation 허용 확인
if operation not in ALLOWED_OPS[tool_name]:
return _error(tool_name, operation, f"허용되지 않은 작업: {tool_name}.{operation}")
try:
if tool_name == "calendar":
result = await _exec_calendar(operation, params)
elif tool_name == "email":
result = await _exec_email(operation, params)
elif tool_name == "document":
result = await _exec_document(operation, params)
else:
result = _error(tool_name, operation, "미구현")
if not result["ok"]:
logger.warning("Tool %s.%s failed: %s", tool_name, operation, result.get("error"))
result["error"] = ERROR_MESSAGES.get(tool_name, "⚠️ 서비스를 사용할 수 없습니다.")
return result
except Exception:
logger.exception("Tool %s.%s exception", tool_name, operation)
return _error(tool_name, operation, ERROR_MESSAGES.get(tool_name, "⚠️ 서비스 오류"))
async def _exec_calendar(operation: str, params: dict) -> dict:
if operation == "today":
return await calendar_tool.today()
elif operation == "search":
return await calendar_tool.search(
params.get("date_from", ""),
params.get("date_to", ""),
)
elif operation == "create_draft":
return await calendar_tool.create_draft(
params.get("title", ""),
params.get("date", ""),
params.get("time", "00:00"),
params.get("description", ""),
)
elif operation == "create_confirmed":
return await calendar_tool.create_confirmed(params)
return _error("calendar", operation, "미구현")
async def _exec_email(operation: str, params: dict) -> dict:
if operation == "search":
return await email_tool.search(
params.get("query", ""),
params.get("days", 7),
)
elif operation == "read":
return await email_tool.read(params.get("uid", ""))
return _error("email", operation, "미구현")
async def _exec_document(operation: str, params: dict) -> dict:
if operation == "search":
return await document_tool.search(params.get("query", ""))
elif operation == "read":
return await document_tool.read(params.get("doc_id", ""))
return _error("document", operation, "미구현")
def _error(tool: str, operation: str, msg: str) -> dict:
return {"ok": False, "tool": tool, "operation": operation, "data": [], "summary": "", "error": msg}