feat: 이드 기능 완성 — note_bridge, intent_service, CalDAV Todo

- note_bridge.py: port 8096, log-conversation 제거, message 필드 추가
- intent_service.py: 의도 분류(Ollama→Claude fallback) + 한국어 날짜 파싱 + API 사용량 추적
- caldav_bridge.py: VTODO 생성 (/calendar/create-todo) + 응답 message 필드
- LaunchAgent plist: note-bridge (8096), intent-service (8097)
- .env.example: API_MONTHLY_LIMIT, NOTE_BRIDGE_URL, INTENT_SERVICE_URL

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-19 13:48:10 +09:00
parent 1137754964
commit dc08d29509
7 changed files with 860 additions and 4 deletions

View File

@@ -10,7 +10,7 @@ import httpx
from dotenv import load_dotenv
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from icalendar import Calendar, Event, vText
from icalendar import Calendar, Event, Todo, vText
load_dotenv()
@@ -42,6 +42,19 @@ def _make_ical(title: str, start: str, end: str | None, location: str | None,
cal = Calendar()
cal.add("prodid", "-//SynChatBot//CalDAV Bridge//KO")
cal.add("version", "2.0")
cal.add("calscale", "GREGORIAN")
# VTIMEZONE for Asia/Seoul (required by Synology Calendar)
from icalendar import Timezone, TimezoneStandard
tz = Timezone()
tz.add("tzid", "Asia/Seoul")
tz_std = TimezoneStandard()
tz_std.add("tzname", "KST")
tz_std.add("tzoffsetfrom", timedelta(hours=9))
tz_std.add("tzoffsetto", timedelta(hours=9))
tz_std.add("dtstart", datetime(1970, 1, 1))
tz.add_component(tz_std)
cal.add_component(tz)
evt = Event()
evt.add("uid", uid)
@@ -93,6 +106,44 @@ def _parse_events(ical_data: bytes) -> list[dict]:
return events
def _make_vtodo(title: str, due: str | None, description: str | None, uid: str) -> bytes:
"""iCalendar VTODO 생성."""
cal = Calendar()
cal.add("prodid", "-//SynChatBot//CalDAV Bridge//KO")
cal.add("version", "2.0")
cal.add("calscale", "GREGORIAN")
from icalendar import Timezone, TimezoneStandard
tz = Timezone()
tz.add("tzid", "Asia/Seoul")
tz_std = TimezoneStandard()
tz_std.add("tzname", "KST")
tz_std.add("tzoffsetfrom", timedelta(hours=9))
tz_std.add("tzoffsetto", timedelta(hours=9))
tz_std.add("dtstart", datetime(1970, 1, 1))
tz.add_component(tz_std)
cal.add_component(tz)
todo = Todo()
todo.add("uid", uid)
todo.add("dtstamp", datetime.now(KST))
todo.add("summary", title)
todo.add("created", datetime.now(KST))
todo.add("status", "NEEDS-ACTION")
if due:
dt_due = datetime.fromisoformat(due)
if dt_due.tzinfo is None:
dt_due = dt_due.replace(tzinfo=KST)
todo.add("due", dt_due)
if description:
todo["description"] = vText(description)
cal.add_component(todo)
return cal.to_ical()
@app.post("/calendar/create")
async def create_event(request: Request):
body = await request.json()
@@ -108,6 +159,10 @@ async def create_event(request: Request):
uid = f"{uuid.uuid4()}@syn-chat-bot"
ical = _make_ical(title, start, end, location, description, uid)
# 표시용 시간 포맷
dt = datetime.fromisoformat(start)
display_time = dt.strftime("%-m/%d %H:%M")
async with _client() as client:
resp = await client.put(
f"{CALENDAR_URL}{uid}.ics",
@@ -116,9 +171,46 @@ async def create_event(request: Request):
)
if resp.status_code in (200, 201, 204):
logger.info(f"Event created: {uid} '{title}'")
return JSONResponse({"success": True, "uid": uid})
return JSONResponse({"success": True, "uid": uid,
"message": f"일정 등록 완료: {display_time} {title}"})
logger.error(f"CalDAV PUT failed: {resp.status_code} {resp.text[:200]}")
return JSONResponse({"success": False, "error": f"CalDAV PUT {resp.status_code}"}, status_code=502)
return JSONResponse({"success": False, "error": f"CalDAV PUT {resp.status_code}",
"message": f"일정 등록 실패: {title}"}, status_code=502)
@app.post("/calendar/create-todo")
async def create_todo(request: Request):
"""VTODO 생성. body: {title, due?, description?}"""
body = await request.json()
title = body.get("title", "")
due = body.get("due")
description = body.get("description")
if not title:
return JSONResponse({"success": False, "error": "title required"}, status_code=400)
uid = f"{uuid.uuid4()}@syn-chat-bot"
ical = _make_vtodo(title, due, description, uid)
# 표시용 기한
display_due = ""
if due:
dt = datetime.fromisoformat(due)
display_due = f"~{dt.strftime('%-m/%d')} "
async with _client() as client:
resp = await client.put(
f"{CALENDAR_URL}{uid}.ics",
content=ical,
headers={"Content-Type": "text/calendar; charset=utf-8"},
)
if resp.status_code in (200, 201, 204):
logger.info(f"Todo created: {uid} '{title}'")
return JSONResponse({"success": True, "uid": uid,
"message": f"작업 등록 완료: {display_due}{title}"})
logger.error(f"CalDAV PUT (todo) failed: {resp.status_code} {resp.text[:200]}")
return JSONResponse({"success": False, "error": f"CalDAV PUT {resp.status_code}",
"message": f"작업 등록 실패: {title}"}, status_code=502)
@app.post("/calendar/query")