diff --git a/.env.example b/.env.example
index 568887e..452acf5 100644
--- a/.env.example
+++ b/.env.example
@@ -63,6 +63,9 @@ DEVONTHINK_APP_NAME=DEVONthink
KARAKEEP_URL=http://192.168.1.227:3000
KARAKEEP_API_KEY=changeme
+# Intent Service — Claude API fallback 월간 예산 (USD)
+API_MONTHLY_LIMIT=10.00
+
# Bridge Service URLs (n8n Docker → macOS 네이티브 서비스)
HEIC_CONVERTER_URL=http://host.docker.internal:8090
CHAT_BRIDGE_URL=http://host.docker.internal:8091
@@ -70,3 +73,5 @@ CALDAV_BRIDGE_URL=http://host.docker.internal:8092
DEVONTHINK_BRIDGE_URL=http://host.docker.internal:8093
MAIL_BRIDGE_URL=http://host.docker.internal:8094
KB_WRITER_URL=http://host.docker.internal:8095
+NOTE_BRIDGE_URL=http://host.docker.internal:8096
+INTENT_SERVICE_URL=http://host.docker.internal:8097
diff --git a/CLAUDE.md b/CLAUDE.md
index d61e400..1d08a9e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -47,9 +47,12 @@ bot-n8n (맥미니 Docker) — 51노드 파이프라인
네이티브 서비스 (맥미니):
heic_converter (:8090) — HEIC→JPEG 변환 (macOS sips)
chat_bridge (:8091) — DSM Chat API 브릿지 (사진 폴링/다운로드)
- caldav_bridge (:8092) — CalDAV REST 래퍼 (Synology Calendar)
+ caldav_bridge (:8092) — CalDAV REST 래퍼 (Synology Calendar, VEVENT+VTODO)
devonthink_bridge (:8093) — DEVONthink AppleScript 래퍼
mail_bridge (:8094) — IMAP 날짜 기반 메일 조회 (MailPlus)
+ kb_writer (:8095) — 마크다운 KB 저장
+ note_bridge (:8096) — Note Station REST 래퍼 (메모 생성/추가)
+ intent_service (:8097) — 의도 분류 + 날짜 파싱 + Claude fallback
inbox_processor (5분) — OmniFocus Inbox 폴링 (LaunchAgent)
news_digest (매일 07:00) — 뉴스 번역·요약 (LaunchAgent)
@@ -75,6 +78,9 @@ DEVONthink 4 (맥미니):
| caldav_bridge | 네이티브 (맥미니) | 8092 | CalDAV REST 래퍼 (Synology Calendar) |
| devonthink_bridge | 네이티브 (맥미니) | 8093 | DEVONthink AppleScript 래퍼 |
| mail_bridge | 네이티브 (맥미니) | 8094 | IMAP 날짜 기반 메일 조회 (MailPlus) |
+| kb_writer | 네이티브 (맥미니) | 8095 | 마크다운 KB 저장 |
+| note_bridge | 네이티브 (맥미니) | 8096 | Note Station REST 래퍼 (메모 생성/추가) |
+| intent_service | 네이티브 (맥미니) | 8097 | 의도 분류 + 날짜 파싱 + Claude fallback |
| inbox_processor | 네이티브 (맥미니) | — | OmniFocus Inbox 폴링 (LaunchAgent, 5분) |
| news_digest | 네이티브 (맥미니) | — | 뉴스 번역·요약 (LaunchAgent, 매일 07:00) |
| Synology Chat | NAS (192.168.1.227) | — | 사용자 인터페이스 |
diff --git a/caldav_bridge.py b/caldav_bridge.py
index 774e756..e58998b 100644
--- a/caldav_bridge.py
+++ b/caldav_bridge.py
@@ -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")
diff --git a/com.syn-chat-bot.intent-service.plist b/com.syn-chat-bot.intent-service.plist
new file mode 100644
index 0000000..788e033
--- /dev/null
+++ b/com.syn-chat-bot.intent-service.plist
@@ -0,0 +1,25 @@
+
+
+
+
+ Label
+ com.syn-chat-bot.intent-service
+ ProgramArguments
+
+ /opt/homebrew/opt/python@3.14/bin/python3.14
+ -S
+ -c
+ import sys; sys.path.insert(0,'/Users/hyungi/Documents/code/syn-chat-bot/.venv/lib/python3.14/site-packages'); sys.path.insert(0,'/Users/hyungi/Documents/code/syn-chat-bot'); import uvicorn; uvicorn.run('intent_service:app',host='127.0.0.1',port=8097)
+
+ WorkingDirectory
+ /Users/hyungi/Documents/code/syn-chat-bot
+ RunAtLoad
+
+ KeepAlive
+
+ StandardOutPath
+ /tmp/intent-service.log
+ StandardErrorPath
+ /tmp/intent-service.err
+
+
diff --git a/com.syn-chat-bot.note-bridge.plist b/com.syn-chat-bot.note-bridge.plist
new file mode 100644
index 0000000..b8dd113
--- /dev/null
+++ b/com.syn-chat-bot.note-bridge.plist
@@ -0,0 +1,25 @@
+
+
+
+
+ Label
+ com.syn-chat-bot.note-bridge
+ ProgramArguments
+
+ /opt/homebrew/opt/python@3.14/bin/python3.14
+ -S
+ -c
+ import sys; sys.path.insert(0,'/Users/hyungi/Documents/code/syn-chat-bot/.venv/lib/python3.14/site-packages'); sys.path.insert(0,'/Users/hyungi/Documents/code/syn-chat-bot'); import uvicorn; uvicorn.run('note_bridge:app',host='127.0.0.1',port=8096)
+
+ WorkingDirectory
+ /Users/hyungi/Documents/code/syn-chat-bot
+ RunAtLoad
+
+ KeepAlive
+
+ StandardOutPath
+ /tmp/note-bridge.log
+ StandardErrorPath
+ /tmp/note-bridge.err
+
+
diff --git a/intent_service.py b/intent_service.py
new file mode 100644
index 0000000..1734340
--- /dev/null
+++ b/intent_service.py
@@ -0,0 +1,519 @@
+"""Intent Service — 의도 분류 + 날짜 파싱 + Claude API fallback (port 8097)
+
+n8n에서 호출하는 통합 서비스.
+- POST /classify — 의도 분류 (Ollama → Claude fallback)
+- POST /parse-date — 한국어 날짜/시간 파싱
+- POST /chat — 자유 대화 (Ollama → Claude fallback)
+- GET /api-usage — API 사용량 조회
+- GET /health
+"""
+
+import json
+import logging
+import os
+import re
+from datetime import datetime, timedelta, date
+from pathlib import Path
+from zoneinfo import ZoneInfo
+
+import httpx
+from dotenv import load_dotenv
+from fastapi import FastAPI, Request
+from fastapi.responses import JSONResponse
+
+load_dotenv()
+
+logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
+logger = logging.getLogger("intent_service")
+
+KST = ZoneInfo("Asia/Seoul")
+
+# Ollama (GPU 서버)
+GPU_OLLAMA_URL = os.getenv("GPU_OLLAMA_URL", "http://192.168.1.186:11434")
+OLLAMA_CLASSIFY_MODEL = os.getenv("OLLAMA_CLASSIFY_MODEL", "id-9b:latest")
+OLLAMA_CHAT_MODEL = os.getenv("OLLAMA_CHAT_MODEL", "id-9b:latest")
+OLLAMA_TIMEOUT = int(os.getenv("OLLAMA_TIMEOUT", "15"))
+
+# Claude API
+ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
+CLAUDE_MODEL = os.getenv("CLAUDE_MODEL", "claude-haiku-4-5-20251001")
+API_MONTHLY_LIMIT = float(os.getenv("API_MONTHLY_LIMIT", "10.0")) # USD
+
+# API 사용량 파일
+USAGE_DIR = Path.home() / ".syn-chat-bot"
+USAGE_FILE = USAGE_DIR / "api_usage.json"
+
+# 이드 시스템 프롬프트 (자유 대화용)
+ID_SYSTEM_PROMPT = """너는 '이드'라는 이름의 AI 비서야. 한국어로 대화해.
+간결하고 실용적으로 답변하되, 친근한 톤을 유지해.
+불필요한 인사나 꾸밈말은 생략하고 핵심만 전달해."""
+
+# 의도 분류 프롬프트
+CLASSIFY_PROMPT = """사용자 메시지를 분석하여 JSON으로 응답하라.
+
+분류 기준:
+- calendar: 일정/약속/회의 등 시간이 정해진 이벤트
+- todo: 작업/할일/과제 등 기한이 있는 태스크
+- note: 메모/기록/저장 요청
+- chat: 일반 대화, 질문, 인사
+
+반드시 아래 JSON 형식만 출력:
+{"intent": "calendar|todo|note|chat", "confidence": 0.0~1.0, "title": "추출된 제목", "raw_datetime": "원문의 날짜/시간 표현"}
+
+예시:
+- "내일 3시 회의" → {"intent": "calendar", "confidence": 0.95, "title": "회의", "raw_datetime": "내일 3시"}
+- "이번주까지 보고서 작성" → {"intent": "todo", "confidence": 0.9, "title": "보고서 작성", "raw_datetime": "이번주까지"}
+- "메모해둬: 부품 발주 필요" → {"intent": "note", "confidence": 0.95, "title": "부품 발주 필요", "raw_datetime": ""}
+- "안녕" → {"intent": "chat", "confidence": 0.99, "title": "", "raw_datetime": ""}
+- "내일 자료 정리" → {"intent": "todo", "confidence": 0.6, "title": "자료 정리", "raw_datetime": "내일"}
+
+사용자 메시지: """
+
+app = FastAPI(title="Intent Service")
+
+
+# ==================== 날짜 파싱 ====================
+
+# 요일 매핑
+WEEKDAY_MAP = {
+ "월요일": 0, "화요일": 1, "수요일": 2, "목요일": 3,
+ "금요일": 4, "토요일": 5, "일요일": 6,
+ "월": 0, "화": 1, "수": 2, "목": 3, "금": 4, "토": 5, "일": 6,
+}
+
+# 상대 날짜 매핑
+RELATIVE_DATE_MAP = {
+ "오늘": 0, "금일": 0,
+ "내일": 1, "명일": 1,
+ "모레": 2, "내일모레": 2,
+ "글피": 3,
+}
+
+
+def _next_weekday(base: date, weekday: int) -> date:
+ """base 이후 가장 가까운 특정 요일 날짜."""
+ days_ahead = weekday - base.weekday()
+ if days_ahead <= 0:
+ days_ahead += 7
+ return base + timedelta(days=days_ahead)
+
+
+def _this_weekday(base: date, weekday: int) -> date:
+ """이번주의 특정 요일 (지났더라도 이번주)."""
+ days_diff = weekday - base.weekday()
+ return base + timedelta(days=days_diff)
+
+
+def parse_korean_datetime(text: str, now: datetime | None = None) -> dict:
+ """한국어 날짜/시간 표현을 파싱.
+
+ Returns:
+ {
+ "success": True/False,
+ "datetime": "2026-03-20T15:00:00+09:00" (ISO format),
+ "date_only": True/False (시간 없이 날짜만 파싱된 경우),
+ "original": "내일 3시"
+ }
+ """
+ if not text or not text.strip():
+ return {"success": False, "error": "empty input", "original": text}
+
+ if now is None:
+ now = datetime.now(KST)
+ today = now.date()
+
+ text = text.strip()
+ parsed_date = None
+ parsed_time = None
+ date_only = True
+
+ # 1. 절대 날짜: YYYY-MM-DD, YYYY/MM/DD
+ m = re.search(r'(\d{4})[/-](\d{1,2})[/-](\d{1,2})', text)
+ if m:
+ try:
+ parsed_date = date(int(m.group(1)), int(m.group(2)), int(m.group(3)))
+ except ValueError:
+ pass
+
+ # 2. M/D 또는 M월D일
+ if parsed_date is None:
+ m = re.search(r'(\d{1,2})[/월]\s*(\d{1,2})[일]?', text)
+ if m:
+ month, day = int(m.group(1)), int(m.group(2))
+ if 1 <= month <= 12 and 1 <= day <= 31:
+ try:
+ candidate = date(today.year, month, day)
+ # 이미 지난 날짜면 내년
+ if candidate < today:
+ candidate = date(today.year + 1, month, day)
+ parsed_date = candidate
+ except ValueError:
+ pass
+
+ # 3. 상대 날짜: 오늘, 내일, 모레, 글피
+ if parsed_date is None:
+ for keyword, delta in RELATIVE_DATE_MAP.items():
+ if keyword in text:
+ parsed_date = today + timedelta(days=delta)
+ break
+
+ # 4. 다음주/이번주 + 요일
+ if parsed_date is None:
+ m = re.search(r'(다음\s*주|차주|다다음\s*주)\s*(월|화|수|목|금|토|일)(?:요일)?', text)
+ if m:
+ prefix = m.group(1).replace(" ", "")
+ weekday = WEEKDAY_MAP[m.group(2)]
+ next_monday = _next_weekday(today, 0) # 다음주 월요일
+ if "다다음" in prefix:
+ next_monday += timedelta(weeks=1)
+ parsed_date = next_monday + timedelta(days=weekday)
+ else:
+ m = re.search(r'이번\s*주\s*(월|화|수|목|금|토|일)(?:요일)?', text)
+ if m:
+ weekday = WEEKDAY_MAP[m.group(1)]
+ parsed_date = _this_weekday(today, weekday)
+
+ # 5. "X요일" (단독) — 이번주에 아직 안 지났으면 이번주, 지났으면 다음주
+ if parsed_date is None:
+ m = re.search(r'(월|화|수|목|금|토|일)요일', text)
+ if m:
+ weekday = WEEKDAY_MAP[m.group(1)]
+ candidate = _this_weekday(today, weekday)
+ if candidate < today:
+ candidate = _next_weekday(today, weekday)
+ parsed_date = candidate
+
+ # 6. 기한 표현: "이번주까지", "이번 주 내로"
+ if parsed_date is None:
+ if re.search(r'이번\s*주\s*(까지|내로|안에|중)', text):
+ # 이번주 금요일
+ parsed_date = _this_weekday(today, 4) # 금요일
+ if parsed_date < today:
+ parsed_date = today
+
+ # 시간 파싱
+ # 오후/오전 X시 (Y분)
+ m = re.search(r'(오전|오후|아침|저녁|밤)?\s*(\d{1,2})\s*시\s*(?:(\d{1,2})\s*분)?', text)
+ if m:
+ period = m.group(1) or ""
+ hour = int(m.group(2))
+ minute = int(m.group(3)) if m.group(3) else 0
+
+ if period in ("오후", "저녁", "밤") and hour < 12:
+ hour += 12
+ elif period == "오전" and hour == 12:
+ hour = 0
+ elif not period and hour < 8:
+ # 시간대 미지정 + 8시 미만 → 오후로 추정
+ hour += 12
+
+ parsed_time = (hour, minute)
+ date_only = False
+
+ # HH:MM 패턴
+ if parsed_time is None:
+ m = re.search(r'(\d{1,2}):(\d{2})', text)
+ if m:
+ hour, minute = int(m.group(1)), int(m.group(2))
+ if 0 <= hour <= 23 and 0 <= minute <= 59:
+ parsed_time = (hour, minute)
+ date_only = False
+
+ # 날짜도 시간도 없으면 실패
+ if parsed_date is None and parsed_time is None:
+ return {"success": False, "error": "날짜/시간을 인식할 수 없습니다", "original": text}
+
+ # 날짜 없이 시간만 → 오늘 (이미 지났으면 내일)
+ if parsed_date is None and parsed_time is not None:
+ parsed_date = today
+ check_dt = datetime(today.year, today.month, today.day,
+ parsed_time[0], parsed_time[1], tzinfo=KST)
+ if check_dt <= now:
+ parsed_date = today + timedelta(days=1)
+
+ # 시간 없이 날짜만 → 날짜만 반환
+ if parsed_time is None:
+ result_dt = datetime(parsed_date.year, parsed_date.month, parsed_date.day,
+ 0, 0, 0, tzinfo=KST)
+ else:
+ result_dt = datetime(parsed_date.year, parsed_date.month, parsed_date.day,
+ parsed_time[0], parsed_time[1], 0, tzinfo=KST)
+
+ return {
+ "success": True,
+ "datetime": result_dt.isoformat(),
+ "date_only": date_only,
+ "original": text,
+ }
+
+
+# ==================== API 사용량 추적 ====================
+
+def _load_usage() -> dict:
+ """API 사용량 JSON 로드."""
+ if USAGE_FILE.exists():
+ try:
+ return json.loads(USAGE_FILE.read_text())
+ except (json.JSONDecodeError, OSError):
+ pass
+ return {}
+
+
+def _save_usage(data: dict):
+ """API 사용량 JSON 저장."""
+ USAGE_DIR.mkdir(parents=True, exist_ok=True)
+ USAGE_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False))
+
+
+def _record_api_call(input_tokens: int = 0, output_tokens: int = 0):
+ """Claude API 호출 기록 (월별 누적)."""
+ month_key = datetime.now(KST).strftime("%Y-%m")
+ usage = _load_usage()
+
+ if month_key not in usage:
+ usage[month_key] = {"calls": 0, "input_tokens": 0, "output_tokens": 0, "estimated_cost_usd": 0.0}
+
+ entry = usage[month_key]
+ entry["calls"] += 1
+ entry["input_tokens"] += input_tokens
+ entry["output_tokens"] += output_tokens
+
+ # Haiku 4.5 가격: input $0.80/MTok, output $4.00/MTok
+ cost = (input_tokens * 0.8 + output_tokens * 4.0) / 1_000_000
+ entry["estimated_cost_usd"] = round(entry["estimated_cost_usd"] + cost, 4)
+
+ _save_usage(usage)
+
+
+def _get_monthly_cost() -> float:
+ """이번 달 API 비용."""
+ month_key = datetime.now(KST).strftime("%Y-%m")
+ usage = _load_usage()
+ return usage.get(month_key, {}).get("estimated_cost_usd", 0.0)
+
+
+# ==================== Ollama / Claude 호출 ====================
+
+async def _call_ollama(prompt: str, system: str = "/no_think",
+ model: str | None = None, timeout: int | None = None) -> str | None:
+ """GPU Ollama 호출. 실패 시 None 반환."""
+ model = model or OLLAMA_CLASSIFY_MODEL
+ timeout = timeout or OLLAMA_TIMEOUT
+ try:
+ async with httpx.AsyncClient(timeout=timeout) as client:
+ resp = await client.post(f"{GPU_OLLAMA_URL}/api/generate", json={
+ "model": model,
+ "prompt": prompt,
+ "system": system,
+ "stream": False,
+ "options": {"num_ctx": 4096, "temperature": 0.1},
+ })
+ data = resp.json()
+ return data.get("response", "").strip()
+ except Exception as e:
+ logger.warning(f"Ollama call failed: {e}")
+ return None
+
+
+async def _call_claude(prompt: str, system: str | None = None,
+ max_tokens: int = 1024) -> tuple[str | None, int, int]:
+ """Claude API Haiku 호출. Returns (response, input_tokens, output_tokens)."""
+ if not ANTHROPIC_API_KEY or ANTHROPIC_API_KEY.startswith("sk-ant-xxxxx"):
+ logger.warning("Claude API key not configured")
+ return None, 0, 0
+
+ # 예산 체크
+ if _get_monthly_cost() >= API_MONTHLY_LIMIT:
+ logger.warning(f"API monthly limit reached: ${_get_monthly_cost():.2f} >= ${API_MONTHLY_LIMIT}")
+ return None, 0, 0
+
+ messages = [{"role": "user", "content": prompt}]
+ body = {
+ "model": CLAUDE_MODEL,
+ "max_tokens": max_tokens,
+ "messages": messages,
+ }
+ if system:
+ body["system"] = system
+
+ try:
+ async with httpx.AsyncClient(timeout=30) as client:
+ resp = await client.post(
+ "https://api.anthropic.com/v1/messages",
+ json=body,
+ headers={
+ "x-api-key": ANTHROPIC_API_KEY,
+ "anthropic-version": "2023-06-01",
+ "content-type": "application/json",
+ },
+ )
+ data = resp.json()
+ if resp.status_code != 200:
+ logger.error(f"Claude API error: {resp.status_code} {data}")
+ return None, 0, 0
+
+ text = ""
+ for block in data.get("content", []):
+ if block.get("type") == "text":
+ text += block["text"]
+
+ in_tok = data.get("usage", {}).get("input_tokens", 0)
+ out_tok = data.get("usage", {}).get("output_tokens", 0)
+ _record_api_call(in_tok, out_tok)
+
+ return text.strip(), in_tok, out_tok
+ except Exception as e:
+ logger.error(f"Claude API call failed: {e}")
+ return None, 0, 0
+
+
+# ==================== 엔드포인트 ====================
+
+@app.post("/classify")
+async def classify_intent(request: Request):
+ """의도 분류. body: {message: str}
+
+ Returns: {intent, confidence, title, raw_datetime, source: "ollama"|"claude"}
+ """
+ body = await request.json()
+ message = body.get("message", "").strip()
+ if not message:
+ return JSONResponse({"success": False, "error": "message required"}, status_code=400)
+
+ prompt = CLASSIFY_PROMPT + message
+
+ # 1차: Ollama
+ result_text = await _call_ollama(prompt, system="/no_think")
+ source = "ollama"
+
+ # 2차: Claude fallback
+ if result_text is None:
+ logger.info("Classification fallback to Claude API")
+ result_text, _, _ = await _call_claude(prompt, system="JSON만 출력하라. 다른 텍스트 없이.")
+ source = "claude"
+
+ # 완전 실패
+ if not result_text:
+ return JSONResponse({"success": False,
+ "error": "AI 서비스 일시 중단. 잠시 후 다시 시도해주세요."})
+
+ # JSON 파싱
+ try:
+ # Ollama가 JSON 외 텍스트를 붙일 수 있으므로 추출
+ json_match = re.search(r'\{[^}]+\}', result_text)
+ if json_match:
+ parsed = json.loads(json_match.group())
+ else:
+ parsed = json.loads(result_text)
+ except json.JSONDecodeError:
+ logger.warning(f"JSON parse failed: {result_text[:200]}")
+ # 파싱 실패 → chat으로 폴백
+ parsed = {"intent": "chat", "confidence": 0.5, "title": "", "raw_datetime": ""}
+
+ intent = parsed.get("intent", "chat")
+ confidence = float(parsed.get("confidence", 0.5))
+ title = parsed.get("title", "")
+ raw_datetime = parsed.get("raw_datetime", "")
+
+ # confidence 낮으면 재질문 신호
+ needs_clarification = confidence < 0.7
+
+ return {
+ "success": True,
+ "intent": intent,
+ "confidence": confidence,
+ "title": title,
+ "raw_datetime": raw_datetime,
+ "needs_clarification": needs_clarification,
+ "source": source,
+ }
+
+
+@app.post("/parse-date")
+async def parse_date(request: Request):
+ """한국어 날짜/시간 파싱. body: {text: str}"""
+ body = await request.json()
+ text = body.get("text", "")
+ return parse_korean_datetime(text)
+
+
+@app.post("/chat")
+async def chat(request: Request):
+ """자유 대화. body: {message: str, system?: str}
+
+ 1차 Ollama → 실패 시 Claude API (응답에 source 표시).
+ """
+ body = await request.json()
+ message = body.get("message", "").strip()
+ system = body.get("system", ID_SYSTEM_PROMPT)
+
+ if not message:
+ return JSONResponse({"success": False, "error": "message required"}, status_code=400)
+
+ # 1차: Ollama (id-9b, 대화 모델)
+ response = await _call_ollama(message, system=system, model=OLLAMA_CHAT_MODEL, timeout=30)
+ source = "ollama"
+
+ # 2차: Claude fallback
+ if response is None:
+ logger.info("Chat fallback to Claude API")
+ response, _, _ = await _call_claude(message, system=system)
+ source = "claude"
+
+ # 완전 실패
+ if not response:
+ return {
+ "success": False,
+ "response": "AI 서비스 일시 중단. 잠시 후 다시 시도해주세요.",
+ "source": "error",
+ }
+
+ # Claude API 응답이면 표시 추가
+ if source == "claude":
+ response = response.rstrip() + " ☁️"
+
+ return {
+ "success": True,
+ "response": response,
+ "source": source,
+ }
+
+
+@app.get("/api-usage")
+async def api_usage():
+ """API 사용량 조회."""
+ usage = _load_usage()
+ month_key = datetime.now(KST).strftime("%Y-%m")
+ current = usage.get(month_key, {"calls": 0, "input_tokens": 0, "output_tokens": 0, "estimated_cost_usd": 0.0})
+ return {
+ "current_month": month_key,
+ "calls": current["calls"],
+ "input_tokens": current["input_tokens"],
+ "output_tokens": current["output_tokens"],
+ "estimated_cost_usd": current["estimated_cost_usd"],
+ "monthly_limit_usd": API_MONTHLY_LIMIT,
+ "remaining_usd": round(API_MONTHLY_LIMIT - current["estimated_cost_usd"], 4),
+ "all_months": usage,
+ }
+
+
+@app.get("/health")
+async def health():
+ """헬스체크."""
+ gpu_ok = False
+ try:
+ async with httpx.AsyncClient(timeout=5) as client:
+ resp = await client.get(f"{GPU_OLLAMA_URL}/api/tags")
+ gpu_ok = resp.status_code == 200
+ except Exception:
+ pass
+
+ claude_configured = bool(ANTHROPIC_API_KEY and not ANTHROPIC_API_KEY.startswith("sk-ant-xxxxx"))
+
+ return {
+ "status": "ok",
+ "gpu_ollama": "ok" if gpu_ok else "unreachable",
+ "claude_api": "configured" if claude_configured else "not_configured",
+ "monthly_cost_usd": _get_monthly_cost(),
+ "monthly_limit_usd": API_MONTHLY_LIMIT,
+ }
diff --git a/note_bridge.py b/note_bridge.py
new file mode 100644
index 0000000..1810d7b
--- /dev/null
+++ b/note_bridge.py
@@ -0,0 +1,184 @@
+"""Note Bridge — Synology Note Station REST API 래퍼 (port 8096)"""
+
+import logging
+import os
+
+import httpx
+from dotenv import load_dotenv
+from fastapi import FastAPI, Request
+from fastapi.responses import JSONResponse
+
+load_dotenv()
+
+logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
+logger = logging.getLogger("note_bridge")
+
+# Note Station 앱 포털 포트 (9350)
+NOTE_STATION_URL = os.getenv("NOTE_STATION_URL", "http://192.168.1.227:9350")
+NOTE_ACCOUNT = os.getenv("NOTE_ACCOUNT", "chatbot-api")
+NOTE_PASSWORD = os.getenv("NOTE_PASSWORD", "U1Ob2L3a")
+
+# 메모장 ID
+NOTEBOOK_MEMO = os.getenv("NOTEBOOK_MEMO", "1030_3CLANHDEQH4T9DRONETDNOG2U4") # chatbot (메모용)
+
+# 세션
+_sid: str = ""
+
+
+async def _login() -> str:
+ """Note Station 앱 포털에 로그인하여 SID를 반환."""
+ global _sid
+ async with httpx.AsyncClient(verify=False, timeout=15) as c:
+ resp = await c.post(f"{NOTE_STATION_URL}/webapi/entry.cgi", data={
+ "api": "SYNO.API.Auth",
+ "version": "7",
+ "method": "login",
+ "account": NOTE_ACCOUNT,
+ "passwd": NOTE_PASSWORD,
+ })
+ data = resp.json()
+ if data.get("success"):
+ _sid = data["data"]["sid"]
+ logger.info("Note Station login successful")
+ return _sid
+ else:
+ logger.error(f"Note Station login failed: {data}")
+ raise Exception(f"Login failed: {data}")
+
+
+async def _ensure_sid() -> str:
+ """SID가 유효한지 확인하고, 필요하면 재로그인."""
+ global _sid
+ if not _sid:
+ return await _login()
+ # SID 유효성 간단 체크
+ async with httpx.AsyncClient(verify=False, timeout=10) as c:
+ resp = await c.get(f"{NOTE_STATION_URL}/webapi/entry.cgi", params={
+ "api": "SYNO.NoteStation.Info",
+ "version": "1",
+ "method": "get",
+ "_sid": _sid,
+ })
+ data = resp.json()
+ if data.get("success"):
+ return _sid
+ return await _login()
+
+
+async def _api_call(api: str, method: str, version: int = 3, **kwargs) -> dict:
+ """Note Station API 호출 래퍼."""
+ sid = await _ensure_sid()
+ params = {
+ "api": api,
+ "version": str(version),
+ "method": method,
+ "_sid": sid,
+ **kwargs,
+ }
+ async with httpx.AsyncClient(verify=False, timeout=30) as c:
+ resp = await c.post(f"{NOTE_STATION_URL}/webapi/entry.cgi", data=params)
+ data = resp.json()
+ if not data.get("success"):
+ # SID 만료일 수 있으므로 재로그인 후 재시도
+ sid = await _login()
+ params["_sid"] = sid
+ resp = await c.post(f"{NOTE_STATION_URL}/webapi/entry.cgi", data=params)
+ data = resp.json()
+ return data
+
+
+# ==================== FastAPI App ====================
+
+app = FastAPI(title="Note Bridge")
+
+
+@app.post("/note/create")
+async def create_note(request: Request):
+ """노트 생성. body: {title, content}"""
+ body = await request.json()
+ title = body.get("title", "제목 없음")
+ content = body.get("content", "")
+
+ # HTML 래핑 (이미 HTML이 아닌 경우)
+ if content and not content.strip().startswith("<"):
+ content = f"
{content}
"
+
+ result = await _api_call(
+ "SYNO.NoteStation.Note",
+ "create",
+ parent_id=NOTEBOOK_MEMO,
+ title=title,
+ commit_msg='{"device":"api","listable":false}',
+ encrypt="false",
+ )
+
+ if result.get("success"):
+ note_id = result["data"].get("object_id", "")
+ # 내용 업데이트 (create는 빈 노트만 생성)
+ if content:
+ await _api_call(
+ "SYNO.NoteStation.Note",
+ "update",
+ object_id=note_id,
+ content=content,
+ commit_msg='{"device":"api","listable":false}',
+ )
+ logger.info(f"Note created: {note_id} '{title}'")
+ return {"success": True, "note_id": note_id, "title": title,
+ "message": f"메모 저장 완료: {title}"}
+ else:
+ logger.error(f"Note creation failed: {result}")
+ return JSONResponse(status_code=500, content={
+ "success": False, "error": str(result),
+ "message": f"메모 저장 실패: {title}"})
+
+
+@app.post("/note/append")
+async def append_to_note(request: Request):
+ """기존 노트에 내용 추가. body: {note_id, content}"""
+ body = await request.json()
+
+ note_id = body.get("note_id")
+ content = body.get("content", "")
+
+ if not content:
+ return JSONResponse(status_code=400, content={"success": False, "error": "content required"})
+ if not note_id:
+ return JSONResponse(status_code=400, content={"success": False, "error": "note_id required"})
+
+ # 기존 내용 가져오기
+ existing = await _api_call("SYNO.NoteStation.Note", "get", object_id=note_id)
+ if not existing.get("success"):
+ return JSONResponse(status_code=404, content={"success": False, "error": "Note not found"})
+
+ old_content = existing["data"].get("content", "")
+
+ # HTML 래핑
+ if not content.strip().startswith("<"):
+ content = f"{content}
"
+
+ updated_content = old_content + content
+
+ result = await _api_call(
+ "SYNO.NoteStation.Note",
+ "update",
+ object_id=note_id,
+ content=updated_content,
+ commit_msg='{"device":"api","listable":false}',
+ )
+
+ if result.get("success"):
+ logger.info(f"Note appended: {note_id}")
+ return {"success": True, "note_id": note_id, "message": f"메모 추가 완료"}
+ else:
+ return JSONResponse(status_code=500, content={"success": False, "error": str(result)})
+
+
+@app.get("/health")
+async def health():
+ """헬스체크."""
+ try:
+ sid = await _ensure_sid()
+ return {"status": "ok", "sid_active": bool(sid)}
+ except Exception as e:
+ return {"status": "error", "error": str(e)}