""" B-2: 자연어 SR 접수 챗봇 엔진 기능: - Ollama LLM 기반 자연어 의도 분류 + 엔티티 추출 - Ollama 미연결 시 규칙 기반 폴백 (키워드 매칭) - 다단계 대화: 정보 수집 → SR 자동 생성 - 대화 컨텍스트 누적 관리 """ from __future__ import annotations import json import logging import re import uuid from datetime import datetime from typing import Dict, List, Optional, Tuple import httpx logger = logging.getLogger(__name__) # ── 설정 ───────────────────────────────────────────────────────────────────── OLLAMA_URL = "http://localhost:11434/api/generate" DEFAULT_MODEL = "llama3" # ── 규칙 기반 폴백 ────────────────────────────────────────────────────────── # 인텐트 키워드 맵 _INTENT_KEYWORDS: Dict[str, List[str]] = { "SR_CREATE": [ "오류", "에러", "error", "장애", "느려", "안 돼", "안돼", "문제", "접속 안", "접속이 안", "서버", "다운", "중단", "실패", "요청", "불가", "이상", "이슈", "고장", "먹통", "응답 없", "timeout", "배포 요청", "업데이트", "설치 요청", ], "INCIDENT_REPORT": [ "긴급", "즉시", "critical", "전면", "전체 장애", "서비스 중단", "대규모", "모든 사용자", "운영 중단", ], "DEPLOY_REQUEST": [ "배포", "릴리즈", "deploy", "release", "빌드", "build", "소스 반영", "패치", "업그레이드", ], "SR_QUERY": [ "조회", "확인", "상태", "어떻게", "얼마나", "진행", "처리", "언제", "완료", "sr-", "SR-", ], "GENERAL_INQUIRY": [ "문의", "질문", "어떻게 하면", "도움", "help", "방법", "알려", ], } # 우선순위 키워드 _PRIORITY_KEYWORDS: Dict[str, List[str]] = { "CRITICAL": ["긴급", "즉시", "critical", "전면 장애", "모든 사용자", "운영 중단", "지금 당장"], "HIGH": ["빠르게", "빨리", "urgent", "high", "중요", "높음", "오늘 중"], "MEDIUM": ["medium", "보통", "일반", "중간"], "LOW": ["천천히", "여유", "low", "낮음", "나중에"], } # SR 유형 키워드 _SR_TYPE_KEYWORDS: Dict[str, List[str]] = { "DEPLOY": ["배포", "deploy", "릴리즈", "소스 반영", "패치"], "RESTART": ["재기동", "restart", "재시작", "기동"], "LOG": ["로그", "log", "로그 확인", "오류 로그"], "INCIDENT":["장애", "중단", "다운", "먹통"], } def classify_intent_rule(text: str) -> Tuple[str, float]: """규칙 기반 인텐트 분류. Returns (intent, confidence).""" text_lower = text.lower() scores: Dict[str, int] = {} for intent, keywords in _INTENT_KEYWORDS.items(): score = sum(1 for kw in keywords if kw.lower() in text_lower) if score > 0: scores[intent] = score if not scores: return "GENERAL_INQUIRY", 0.3 # INCIDENT_REPORT > SR_CREATE (인시던트는 더 구체적) best = max(scores, key=lambda k: scores[k]) confidence = min(0.9, 0.4 + scores[best] * 0.15) return best, confidence def extract_entities_rule(text: str) -> Dict: """규칙 기반 엔티티 추출.""" entities: Dict = {} text_lower = text.lower() # 우선순위 for prio, keywords in _PRIORITY_KEYWORDS.items(): if any(kw.lower() in text_lower for kw in keywords): entities["priority"] = prio break if "priority" not in entities: entities["priority"] = "MEDIUM" # SR 유형 for sr_type, keywords in _SR_TYPE_KEYWORDS.items(): if any(kw.lower() in text_lower for kw in keywords): entities["sr_type"] = sr_type break if "sr_type" not in entities: entities["sr_type"] = "OTHER" # 서버명 패턴: app-서버명, web01, was-prod 등 server_pattern = re.search( r'(?:서버|서비스|시스템|앱)[\s:]*([A-Za-z0-9\-_가-힣]+)', text ) if server_pattern: entities["server"] = server_pattern.group(1) # SR-xxxx 패턴 sr_ref = re.search(r'SR-\d{4,}', text, re.IGNORECASE) if sr_ref: entities["sr_ref"] = sr_ref.group().upper() # 설명 (원문 그대로) entities["description"] = text.strip() return entities # ── Ollama LLM 기반 NLU ─────────────────────────────────────────────────────── _NLU_PROMPT_TEMPLATE = """\ 너는 IT 서비스 관리(ITSM) 챗봇이다. 사용자 메시지를 분석하여 JSON만 반환하라. 사용자 메시지: "{message}" 이전 대화 컨텍스트: {context} 다음 JSON을 반환하라 (다른 텍스트 없이 순수 JSON만): {{ "intent": "SR_CREATE | INCIDENT_REPORT | DEPLOY_REQUEST | SR_QUERY | GENERAL_INQUIRY | CLARIFICATION", "confidence": 0.0~1.0, "entities": {{ "priority": "CRITICAL | HIGH | MEDIUM | LOW", "sr_type": "DEPLOY | RESTART | LOG | INCIDENT | OTHER", "description": "문제 설명 (원문 기준 요약)", "server": "서버명 또는 null", "application": "애플리케이션명 또는 null", "symptom": "증상 요약 또는 null" }}, "needs_clarification": true/false, "clarification_prompt": "추가 질문 또는 null", "reply": "사용자에게 보낼 친절한 한국어 응답 (1-3 문장)" }}""" async def analyze_with_llm( message: str, context: List[Dict], model: str = DEFAULT_MODEL, timeout: int = 30, ) -> Optional[Dict]: """Ollama LLM으로 메시지 분석. 실패 시 None 반환.""" context_str = "\n".join( f"{m['role']}: {m['content'][:100]}" for m in context[-4:] ) if context else "없음" prompt = _NLU_PROMPT_TEMPLATE.format( message=message, context=context_str, ) try: async with httpx.AsyncClient(timeout=timeout) as client: resp = await client.post( OLLAMA_URL, json={"model": model, "prompt": prompt, "stream": False}, ) if resp.status_code != 200: return None raw = resp.json().get("response", "") # JSON 추출 start = raw.find("{") end = raw.rfind("}") + 1 if start >= 0 and end > start: parsed = json.loads(raw[start:end]) return parsed except (httpx.ConnectError, httpx.TimeoutException): logger.debug("Ollama 연결 실패 — 규칙 기반 폴백 사용") except json.JSONDecodeError as e: logger.debug("LLM JSON 파싱 실패: %s", e) return None # ── 응답 템플릿 ────────────────────────────────────────────────────────────── _REPLY_TEMPLATES = { "SR_CREATE": { "need_info": ( "무슨 문제가 발생했는지 파악했습니다. " "SR 접수를 위해 몇 가지 정보가 더 필요합니다.\n\n" "{question}" ), "confirm": ( "다음과 같이 SR을 접수하겠습니다:\n" "- 제목: {title}\n" "- 우선순위: {priority}\n" "- 유형: {sr_type}\n\n" "접수를 진행할까요? (네/아니오)" ), "created": ( "✅ SR이 접수되었습니다!\n\n" "- SR ID: **{sr_id}**\n" "- 제목: {title}\n" "- 우선순위: {priority}\n\n" "담당자가 곧 연락드릴 예정입니다." ), }, "INCIDENT_REPORT": { "created": ( "🚨 긴급 인시던트로 접수했습니다!\n\n" "- SR ID: **{sr_id}**\n" "- 우선순위: CRITICAL\n\n" "온콜 엔지니어에게 즉시 알림을 발송했습니다." ), }, "GENERAL_INQUIRY": { "default": ( "안녕하세요! GUARDiA ITSM 챗봇입니다. 😊\n\n" "다음과 같은 도움을 드릴 수 있습니다:\n" "• IT 장애/오류 신고 → SR 자동 접수\n" "• 배포 요청\n" "• SR 상태 조회 (예: SR-0042 상태가 어떻게 됩니까?)\n\n" "어떤 문제가 발생했나요?" ), }, "SR_QUERY": { "default": "SR 조회는 'SR-{숫자}' 형식으로 말씀해 주세요. 예: SR-0042 상태 알려줘", }, } # 수집 필요 정보 순서 _REQUIRED_FIELDS = ["description", "priority", "sr_type"] _CLARIFICATION_QUESTIONS = { "description": "어떤 문제가 발생했나요? 증상을 구체적으로 설명해 주세요.", "priority": "긴급도가 어느 정도인가요? (긴급/높음/보통/낮음)", "sr_type": "어떤 유형의 요청인가요? (장애신고/배포요청/재기동/로그분석/기타)", } def build_sr_title(entities: Dict) -> str: """수집된 엔티티로 SR 제목 생성.""" desc = entities.get("description", "") sr_type = entities.get("sr_type", "OTHER") server = entities.get("server", "") type_prefix = { "DEPLOY": "[배포]", "RESTART": "[재기동]", "LOG": "[로그분석]", "INCIDENT": "[장애]", "OTHER": "[SR]", }.get(sr_type, "[SR]") title_parts = [type_prefix] if server: title_parts.append(server) # 설명 앞 30자 short_desc = desc[:40].strip() if short_desc: title_parts.append(short_desc) return " ".join(title_parts) # ── 대화 처리 메인 함수 ──────────────────────────────────────────────────────── async def process_message( message: str, session_context: Dict, use_llm: bool = True, model: str = DEFAULT_MODEL, ) -> Dict: """ 사용자 메시지 처리. session_context: { "history": [...], # 이전 메시지 목록 "collected": {...}, # 수집된 엔티티 "state": "GATHERING|CONFIRMING|DONE", "intent": str, } Returns: { "intent": str, "entities": dict, "reply": str, "needs_clarification": bool, "clarification_prompt": str | None, "action": "CREATE_SR | NONE", "sr_data": dict | None, "confidence": float, } """ history = session_context.get("history", []) collected = session_context.get("collected", {}) state = session_context.get("state", "GATHERING") prev_intent = session_context.get("intent", "") # ── LLM 분석 시도 ───────────────────────────────────────────────────────── llm_result = None if use_llm: llm_result = await analyze_with_llm(message, history, model=model) # LLM 결과 또는 규칙 기반 폴백 if llm_result: intent = llm_result.get("intent", "GENERAL_INQUIRY") confidence = llm_result.get("confidence", 0.5) entities = llm_result.get("entities", {}) llm_reply = llm_result.get("reply", "") needs_clarif = llm_result.get("needs_clarification", False) clarif_prompt = llm_result.get("clarification_prompt") else: # 규칙 기반 폴백 intent, confidence = classify_intent_rule(message) entities = extract_entities_rule(message) llm_reply = "" needs_clarif = False clarif_prompt = None # CLARIFICATION 상태: 이전 인텐트 유지 if intent == "CLARIFICATION" and prev_intent: intent = prev_intent # 수집된 엔티티 업데이트 (None이 아닌 값만) for k, v in entities.items(): if v and v != "null": collected[k] = v # 상태 머신 처리 result = { "intent": intent, "entities": collected.copy(), "reply": "", "needs_clarification": False, "clarification_prompt": None, "action": "NONE", "sr_data": None, "confidence": confidence, } if intent in ("SR_CREATE", "INCIDENT_REPORT", "DEPLOY_REQUEST"): # SR 관련 인텐트 처리 result = await _handle_sr_flow( message, intent, collected, state, llm_reply, needs_clarif, clarif_prompt, result ) elif intent == "SR_QUERY": result["reply"] = _handle_sr_query(message, collected) elif intent == "GENERAL_INQUIRY": result["reply"] = llm_reply or _REPLY_TEMPLATES["GENERAL_INQUIRY"]["default"] else: result["reply"] = llm_reply or "무슨 문제가 발생했나요? 자세히 말씀해 주세요." return result async def _handle_sr_flow( message: str, intent: str, collected: Dict, state: str, llm_reply: str, needs_clarif: bool, clarif_prompt: Optional[str], result: Dict, ) -> Dict: """SR 관련 대화 흐름 처리.""" # 긴급 인시던트는 즉시 접수 if intent == "INCIDENT_REPORT": if "priority" not in collected or collected.get("priority") != "CRITICAL": collected["priority"] = "CRITICAL" if "description" not in collected: collected["description"] = message result["action"] = "CREATE_SR" result["sr_data"] = _build_sr_data(collected, intent) result["reply"] = llm_reply or ( "🚨 긴급 인시던트로 접수합니다. " "담당자와 온콜 팀에 즉시 알림을 발송합니다." ) return result # 일반 SR — 필수 정보 수집 missing = [f for f in ["description", "priority"] if f not in collected or not collected.get(f)] if missing: # 첫 번째 미수집 필드에 대한 질문 next_q = missing[0] question = clarif_prompt or _CLARIFICATION_QUESTIONS.get(next_q, "추가 정보를 알려주세요.") result["reply"] = llm_reply or _REPLY_TEMPLATES["SR_CREATE"]["need_info"].format(question=question) result["needs_clarification"] = True result["clarification_prompt"] = question return result # 모든 정보 수집 완료 → SR 생성 준비 if state == "GATHERING": # 확인 요청 title = build_sr_title(collected) result["reply"] = ( llm_reply or _REPLY_TEMPLATES["SR_CREATE"]["confirm"].format( title=title, priority=collected.get("priority", "MEDIUM"), sr_type=collected.get("sr_type", "OTHER"), ) ) result["needs_clarification"] = True result["clarification_prompt"] = "접수를 진행할까요? (네/아니오)" return result # 확인 응답 처리 confirm_positive = any(w in message.lower() for w in ["네", "예", "yes", "맞아", "확인", "진행", "ok"]) confirm_negative = any(w in message.lower() for w in ["아니", "no", "취소", "수정", "다시"]) if confirm_positive or state == "CONFIRMING": result["action"] = "CREATE_SR" result["sr_data"] = _build_sr_data(collected, intent) result["reply"] = "" # 실제 SR ID는 라우터에서 채움 return result elif confirm_negative: result["reply"] = "알겠습니다. 어떤 내용을 수정하시겠나요?" result["needs_clarification"] = True result["clarification_prompt"] = "수정할 내용을 말씀해 주세요." return result result["reply"] = llm_reply or "접수를 진행할까요? (네/아니오)" result["needs_clarification"] = True return result def _build_sr_data(collected: Dict, intent: str) -> Dict: """수집된 엔티티로 SR 생성 데이터 빌드.""" priority = collected.get("priority", "MEDIUM") if intent == "INCIDENT_REPORT": priority = "CRITICAL" sr_type_map = { "DEPLOY": "DEPLOY", "RESTART": "RESTART", "LOG": "LOG", "INCIDENT": "OTHER", "OTHER": "OTHER", } sr_type = sr_type_map.get(collected.get("sr_type", "OTHER"), "OTHER") server = collected.get("server", "") desc = collected.get("description", "자연어 챗봇 접수") title = build_sr_title(collected) return { "title": title, "description": desc, "priority": priority, "sr_type": sr_type, "server_name": server, "source": "chatbot", } def _handle_sr_query(message: str, collected: Dict) -> str: """SR 조회 의도 처리.""" sr_ref = collected.get("sr_ref") if sr_ref: return f"SR '{sr_ref}' 조회를 진행합니다. 잠시만 기다려 주세요." return _REPLY_TEMPLATES["SR_QUERY"]["default"] def new_session_key() -> str: """새 세션 키 생성.""" return str(uuid.uuid4()).replace("-", "")[:24]