"""Intent Service — 의도 분류 + 날짜 파싱 + Claude API fallback (port 8099) 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, }