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