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

@@ -63,6 +63,9 @@ DEVONTHINK_APP_NAME=DEVONthink
KARAKEEP_URL=http://192.168.1.227:3000 KARAKEEP_URL=http://192.168.1.227:3000
KARAKEEP_API_KEY=changeme KARAKEEP_API_KEY=changeme
# Intent Service — Claude API fallback 월간 예산 (USD)
API_MONTHLY_LIMIT=10.00
# Bridge Service URLs (n8n Docker → macOS 네이티브 서비스) # Bridge Service URLs (n8n Docker → macOS 네이티브 서비스)
HEIC_CONVERTER_URL=http://host.docker.internal:8090 HEIC_CONVERTER_URL=http://host.docker.internal:8090
CHAT_BRIDGE_URL=http://host.docker.internal:8091 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 DEVONTHINK_BRIDGE_URL=http://host.docker.internal:8093
MAIL_BRIDGE_URL=http://host.docker.internal:8094 MAIL_BRIDGE_URL=http://host.docker.internal:8094
KB_WRITER_URL=http://host.docker.internal:8095 KB_WRITER_URL=http://host.docker.internal:8095
NOTE_BRIDGE_URL=http://host.docker.internal:8096
INTENT_SERVICE_URL=http://host.docker.internal:8097

View File

@@ -47,9 +47,12 @@ bot-n8n (맥미니 Docker) — 51노드 파이프라인
네이티브 서비스 (맥미니): 네이티브 서비스 (맥미니):
heic_converter (:8090) — HEIC→JPEG 변환 (macOS sips) heic_converter (:8090) — HEIC→JPEG 변환 (macOS sips)
chat_bridge (:8091) — DSM Chat API 브릿지 (사진 폴링/다운로드) 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 래퍼 devonthink_bridge (:8093) — DEVONthink AppleScript 래퍼
mail_bridge (:8094) — IMAP 날짜 기반 메일 조회 (MailPlus) 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) inbox_processor (5분) — OmniFocus Inbox 폴링 (LaunchAgent)
news_digest (매일 07:00) — 뉴스 번역·요약 (LaunchAgent) news_digest (매일 07:00) — 뉴스 번역·요약 (LaunchAgent)
@@ -75,6 +78,9 @@ DEVONthink 4 (맥미니):
| caldav_bridge | 네이티브 (맥미니) | 8092 | CalDAV REST 래퍼 (Synology Calendar) | | caldav_bridge | 네이티브 (맥미니) | 8092 | CalDAV REST 래퍼 (Synology Calendar) |
| devonthink_bridge | 네이티브 (맥미니) | 8093 | DEVONthink AppleScript 래퍼 | | devonthink_bridge | 네이티브 (맥미니) | 8093 | DEVONthink AppleScript 래퍼 |
| mail_bridge | 네이티브 (맥미니) | 8094 | IMAP 날짜 기반 메일 조회 (MailPlus) | | 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분) | | inbox_processor | 네이티브 (맥미니) | — | OmniFocus Inbox 폴링 (LaunchAgent, 5분) |
| news_digest | 네이티브 (맥미니) | — | 뉴스 번역·요약 (LaunchAgent, 매일 07:00) | | news_digest | 네이티브 (맥미니) | — | 뉴스 번역·요약 (LaunchAgent, 매일 07:00) |
| Synology Chat | NAS (192.168.1.227) | — | 사용자 인터페이스 | | Synology Chat | NAS (192.168.1.227) | — | 사용자 인터페이스 |

View File

@@ -10,7 +10,7 @@ import httpx
from dotenv import load_dotenv from dotenv import load_dotenv
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from icalendar import Calendar, Event, vText from icalendar import Calendar, Event, Todo, vText
load_dotenv() load_dotenv()
@@ -42,6 +42,19 @@ def _make_ical(title: str, start: str, end: str | None, location: str | None,
cal = Calendar() cal = Calendar()
cal.add("prodid", "-//SynChatBot//CalDAV Bridge//KO") cal.add("prodid", "-//SynChatBot//CalDAV Bridge//KO")
cal.add("version", "2.0") 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 = Event()
evt.add("uid", uid) evt.add("uid", uid)
@@ -93,6 +106,44 @@ def _parse_events(ical_data: bytes) -> list[dict]:
return events 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") @app.post("/calendar/create")
async def create_event(request: Request): async def create_event(request: Request):
body = await request.json() body = await request.json()
@@ -108,6 +159,10 @@ async def create_event(request: Request):
uid = f"{uuid.uuid4()}@syn-chat-bot" uid = f"{uuid.uuid4()}@syn-chat-bot"
ical = _make_ical(title, start, end, location, description, uid) 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: async with _client() as client:
resp = await client.put( resp = await client.put(
f"{CALENDAR_URL}{uid}.ics", f"{CALENDAR_URL}{uid}.ics",
@@ -116,9 +171,46 @@ async def create_event(request: Request):
) )
if resp.status_code in (200, 201, 204): if resp.status_code in (200, 201, 204):
logger.info(f"Event created: {uid} '{title}'") 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]}") 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") @app.post("/calendar/query")

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.syn-chat-bot.intent-service</string>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/opt/python@3.14/bin/python3.14</string>
<string>-S</string>
<string>-c</string>
<string>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)</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/hyungi/Documents/code/syn-chat-bot</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/intent-service.log</string>
<key>StandardErrorPath</key>
<string>/tmp/intent-service.err</string>
</dict>
</plist>

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.syn-chat-bot.note-bridge</string>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/opt/python@3.14/bin/python3.14</string>
<string>-S</string>
<string>-c</string>
<string>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)</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/hyungi/Documents/code/syn-chat-bot</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/note-bridge.log</string>
<key>StandardErrorPath</key>
<string>/tmp/note-bridge.err</string>
</dict>
</plist>

519
intent_service.py Normal file
View File

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

184
note_bridge.py Normal file
View File

@@ -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"<p>{content}</p>"
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"<p>{content}</p>"
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)}