zioinfo-mail/workspace/guardia-itsm/core/nl_command.py
DESKTOP-TKLFCPR\ython cfe2901a55 refactor(structure): consolidate all projects under workspace/
- itsm/    -> workspace/guardia-itsm/
- manager/ -> workspace/guardia-manager/
- app/     -> workspace/guardia-messenger/
- manual/  -> workspace/guardia-docs/

workspace/zioinfo-web/ unchanged.
git mv preserves full commit history.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 23:50:56 +09:00

394 lines
14 KiB
Python

"""
자연어 → 메신저 봇 명령어 파서 (NL Command Parser)
Ollama(로컬 LLM) 기반으로 자연어 입력을 봇 명령어로 변환.
Ollama 미연결 시 규칙 기반 폴백.
반환 형태:
{
"command": "!scrap",
"args": ["https://example.com"],
"full_command": "!scrap https://example.com",
"confidence": 0.92,
"explanation": "URL 스크랩 요청으로 판단"
}
"""
from __future__ import annotations
import json
import logging
import os
import re
from typing import Any, Dict, List, Optional, Tuple
import httpx
logger = logging.getLogger(__name__)
OLLAMA_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434") + "/api/generate"
NL_MODEL = os.getenv("NL_COMMAND_MODEL", "llama3")
TIMEOUT = 20 # Ollama 호출 타임아웃
MIN_CONFIDENCE = 0.55 # 이 이하면 "이해 못함"으로 처리
# ── Few-shot 프롬프트 ─────────────────────────────────────────────────────────
_SYSTEM_PROMPT = """\
너는 GUARDiA ITSM 메신저 봇의 자연어 명령 해석기다.
사용자의 자연어 입력을 분석해 아래 봇 명령어 중 하나로 변환하라.
[지원 명령어 목록]
!vibe <SR-ID> [project_id] - SR에 대한 바이브 코딩 세션 시작
!build <session_id> - 빌드 실행
!deploy <session_id> - 배포 실행
!status <SR-ID> - SR 상태 조회
!cancel <session_id> - 세션 취소
!health <server> - 서버 헬스체크
!log <server> [path] - 서버 로그 분석
!sm <server> <script> - SM 스크립트 실행
/sr <제목> - SR 빠른 접수
/status - 전체 시스템 현황
/assign <SR-ID> <담당자> - SR 담당자 배정
/approve <SR-ID> [의견] - SR 승인
/reject <SR-ID> [사유] - SR 반려
/incident <제목> [P1-P4] - 인시던트 등록
/rca <INC-ID> - AI RCA 분석
/escalate <SR-ID> - 에스컬레이션
/sla - SLA 위반 현황
/kb <검색어> - KB 문서 검색
/pms <프로젝트코드> - 프로젝트 현황
/report <코드> [daily|weekly] - 보고서 발송
/oncall - 당직자 조회
/scan - 보안 스캔
/perf [url] - 성능 테스트
!scrap <url> - URL 스크랩
!scrap list [n] - 스크랩 목록
!scrap publish <id> - 스크랩 게시
!scrap del <id> - 스크랩 삭제
!scrap restore <id> - 스크랩 원복
!scrap status <id> - 스크랩 상태 조회
/autoq - 자율 운영 대기 목록
!help - 도움말
[규칙]
1. 반드시 JSON만 반환. 자연어 설명 없음.
2. 입력에서 SR ID를 찾으면 그대로 사용 (SR-20260531-XXXX 형태 유지).
3. 확신이 없으면 confidence를 낮게 설정.
4. 명확히 매핑 불가능하면 command를 null로.
[예시 입력 → 출력]
입력: "SR-20260531-ABCD 배포해줘"
출력: {"command":"!deploy","args":["SR-20260531-ABCD"],"full_command":"!deploy SR-20260531-ABCD","confidence":0.95,"explanation":"배포 요청으로 판단"}
입력: "서버1 헬스체크 해줘"
출력: {"command":"!health","args":["서버1"],"full_command":"!health 서버1","confidence":0.92,"explanation":"헬스체크 요청"}
입력: "최근 스크랩 5개 보여줘"
출력: {"command":"!scrap","args":["list","5"],"full_command":"!scrap list 5","confidence":0.90,"explanation":"스크랩 목록 조회"}
입력: "https://example.com 스크랩해줘"
출력: {"command":"!scrap","args":["https://example.com"],"full_command":"!scrap https://example.com","confidence":0.95,"explanation":"URL 스크랩 요청"}
입력: "#3 게시해줘"
출력: {"command":"!scrap","args":["publish","3"],"full_command":"!scrap publish 3","confidence":0.88,"explanation":"스크랩 게시 요청"}
입력: "전체 시스템 현황 알려줘"
출력: {"command":"/status","args":[],"full_command":"/status","confidence":0.85,"explanation":"시스템 현황 조회"}
입력: "오늘 서버 배포 요청 접수해줘 - web01 Tomcat 재기동 필요"
출력: {"command":"/sr","args":["[배포] web01 Tomcat 재기동"],"full_command":"/sr [배포] web01 Tomcat 재기동","confidence":0.82,"explanation":"SR 접수 요청"}
입력: "P1 긴급 장애 발생 - 결제 시스템 전면 중단"
출력: {"command":"/incident","args":["결제 시스템 전면 중단","P1"],"full_command":"/incident 결제 시스템 전면 중단 P1","confidence":0.95,"explanation":"P1 인시던트 등록"}
입력: "홍길동에게 SR-20260531-XXXX 배정해줘"
출력: {"command":"/assign","args":["SR-20260531-XXXX","홍길동"],"full_command":"/assign SR-20260531-XXXX 홍길동","confidence":0.93,"explanation":"SR 담당자 배정"}
입력: "날씨 어때?"
출력: {"command":null,"args":[],"full_command":null,"confidence":0.1,"explanation":"ITSM과 무관한 질문"}
"""
# ── Ollama 호출 ───────────────────────────────────────────────────────────────
async def parse_nl_command(text: str) -> Dict[str, Any]:
"""
자연어 텍스트를 봇 명령어로 변환.
Ollama 실패 시 규칙 기반 폴백.
"""
# 이미 명령어 형식이면 그대로 반환
if _is_explicit_command(text):
cmd_parts = text.strip().split()
return {
"command": cmd_parts[0],
"args": cmd_parts[1:],
"full_command": text.strip(),
"confidence": 1.0,
"explanation": "명시적 명령어",
}
# Ollama 시도
try:
result = await _call_ollama(text)
if result and result.get("confidence", 0) >= MIN_CONFIDENCE:
return result
except Exception as e:
logger.warning("[NL Command] Ollama 실패, 규칙 기반 폴백: %s", e)
# 규칙 기반 폴백
return _rule_based_parse(text)
async def _call_ollama(text: str) -> Optional[Dict[str, Any]]:
"""Ollama에 자연어 명령어 파싱 요청."""
prompt = (
_SYSTEM_PROMPT
+ f'\n입력: "{text}"\n출력: '
)
payload = {
"model": NL_MODEL,
"prompt": prompt,
"stream": False,
"format": "json",
"options": {
"temperature": 0.1, # 결정론적 출력
"top_p": 0.9,
"num_predict": 200,
},
}
async with httpx.AsyncClient(timeout=TIMEOUT) as client:
resp = await client.post(OLLAMA_URL, json=payload)
resp.raise_for_status()
data = resp.json()
raw = data.get("response", "").strip()
# JSON 추출
json_match = re.search(r'\{.*\}', raw, re.DOTALL)
if not json_match:
return None
parsed = json.loads(json_match.group())
# 필수 필드 검증
if "command" not in parsed or "confidence" not in parsed:
return None
# full_command 자동 생성
if parsed.get("command") and not parsed.get("full_command"):
args_str = " ".join(str(a) for a in parsed.get("args", []))
parsed["full_command"] = (
f"{parsed['command']} {args_str}".strip()
if args_str else parsed["command"]
)
return parsed
# ── 규칙 기반 폴백 ────────────────────────────────────────────────────────────
_RULES: List[Tuple[List[str], str, callable]] = [
# (키워드목록, command, args_extractor)
(["배포해", "배포 해", "deploy", "릴리즈해"],
"!deploy", lambda t: [_extract_sr(t) or _extract_session(t) or ""]),
(["빌드해", "빌드 해", "build"],
"!build", lambda t: [_extract_session(t) or ""]),
(["바이브", "vibe", "코딩 세션"],
"!vibe", lambda t: [_extract_sr(t) or ""]),
(["헬스체크", "상태 확인", "health", "헬스 체크"],
"!health", lambda t: [_extract_server(t) or ""]),
(["로그 분석", "로그 확인", "로그 봐", "log 분석"],
"!log", lambda t: [_extract_server(t) or ""]),
(["스크랩 목록", "스크랩 리스트", "scrap list", "수집 목록",
"최근 스크랩", "스크랩 보여", "수집 결과", "스크랩 결과"],
"!scrap", lambda t: ["list", _extract_number(t) or "5"]),
(["스크랩해줘", "스크랩 해줘", "수집해줘", "크롤링", "스크래핑"],
"!scrap", lambda t: [_extract_url(t) or ""]),
(["게시해", "publish", "발행"],
"!scrap", lambda t: ["publish", _extract_id(t) or ""]),
(["삭제해", "지워", "delete"],
"!scrap", lambda t: ["del", _extract_id(t) or ""]),
(["원복", "복구", "restore"],
"!scrap", lambda t: ["restore", _extract_id(t) or ""]),
(["상태 조회", "현황 조회", "상태 알려", "어떻게 돼"],
"!status", lambda t: [_extract_sr(t) or ""]),
(["시스템 현황", "전체 현황", "현황 알려", "/status"],
"/status", lambda t: []),
(["SLA 현황", "SLA 위반", "sla"],
"/sla", lambda t: []),
(["당직자", "온콜", "oncall"],
"/oncall", lambda t: []),
(["인시던트", "장애 등록", "장애 신고", "incident",
"장애 발생", "장애 다운", "서비스 중단", "전면 장애",
"긴급 장애", "P1 장애", "P2 장애"],
"/incident", lambda t: [_extract_incident_title(t), _extract_priority(t)]),
(["승인해", "approve", "승인 처리"],
"/approve", lambda t: [_extract_sr(t) or ""]),
(["반려해", "거절", "reject"],
"/reject", lambda t: [_extract_sr(t) or ""]),
(["배정해", "assign", "담당자 지정"],
"/assign", lambda t: [_extract_sr(t) or "", _extract_person(t) or ""]),
(["SR 접수", "서비스 요청", "티켓 등록", "sr 올려"],
"/sr", lambda t: [_extract_title(t)]),
(["보안 스캔", "취약점 점검", "/scan"],
"/scan", lambda t: []),
(["성능 테스트", "부하 테스트", "/perf"],
"/perf", lambda t: [_extract_url(t) or ""]),
(["프로젝트 현황", "pms", "/pms"],
"/pms", lambda t: [_extract_code(t) or ""]),
(["자율 운영", "autoq", "승인 대기"],
"/autoq", lambda t: []),
(["도움말", "명령어", "help"],
"!help", lambda t: []),
]
def _rule_based_parse(text: str) -> Dict[str, Any]:
text_lower = text.lower()
best_cmd = None
best_args: List[str] = []
best_score = 0
for keywords, command, args_fn in _RULES:
score = sum(1 for kw in keywords if kw.lower() in text_lower)
if score > best_score:
best_score = score
best_cmd = command
try:
best_args = [a for a in args_fn(text) if a]
except Exception:
best_args = []
if not best_cmd or best_score == 0:
return {
"command": None,
"args": [],
"full_command": None,
"confidence": 0.0,
"explanation": "해당 요청을 이해하지 못했습니다. !help 로 명령어를 확인하세요.",
}
args_str = " ".join(best_args)
full = f"{best_cmd} {args_str}".strip() if args_str else best_cmd
confidence = min(0.5 + best_score * 0.1, 0.75)
return {
"command": best_cmd,
"args": best_args,
"full_command": full,
"confidence": confidence,
"explanation": f"규칙 기반 매핑 (키워드 {best_score}개 일치)",
}
# ── 엔티티 추출 헬퍼 ─────────────────────────────────────────────────────────
def _is_explicit_command(text: str) -> bool:
"""이미 명시적 명령어 형식인지 확인."""
t = text.strip()
return bool(re.match(r'^[!/]', t))
def _extract_sr(text: str) -> Optional[str]:
m = re.search(r'SR-\d{8}-[A-Z0-9]+', text, re.IGNORECASE)
return m.group().upper() if m else None
def _extract_session(text: str) -> Optional[str]:
m = re.search(r'(?:세션|session)[\s#]*(\d+)', text, re.IGNORECASE)
if m:
return m.group(1)
m = re.search(r'#(\d+)', text)
return m.group(1) if m else None
def _extract_id(text: str) -> Optional[str]:
m = re.search(r'#(\d+)', text)
if m:
return m.group(1)
m = re.search(r'\b(\d+)\b', text)
return m.group(1) if m else None
def _extract_url(text: str) -> Optional[str]:
m = re.search(r'https?://[^\s]+', text)
return m.group() if m else None
def _extract_server(text: str) -> Optional[str]:
# "서버1", "서버-prod", "서버 web01" 형태
m = re.search(r'서버\s*([A-Za-z0-9\-_가-힣]+)', text)
if m:
return '서버' + m.group(1) # "서버1" 전체 반환
m = re.search(r'server[\s:]*([A-Za-z0-9\-_]+)', text, re.IGNORECASE)
if m:
return m.group(1)
# web01, was-prod, app01 등 서버명 패턴
m = re.search(r'\b([A-Za-z가-힣][A-Za-z0-9\-_가-힣]*(?:web|was|db|app|srv|prod|dev)\w*)\b',
text, re.IGNORECASE)
return m.group(1) if m else None
def _extract_number(text: str) -> Optional[str]:
m = re.search(r'\b(\d+)\b', text)
return m.group(1) if m else None
def _extract_priority(text: str) -> str:
m = re.search(r'\b(P[1-4]|P1|P2|P3|P4|긴급|critical)\b', text, re.IGNORECASE)
if m:
v = m.group().upper()
if v in ("긴급", "CRITICAL"):
return "P1"
return v
return "P3"
def _extract_person(text: str) -> Optional[str]:
m = re.search(r'(?:에게|한테|담당자?\s*)([가-힣]{2,4}|[A-Za-z]+)', text)
return m.group(1) if m else None
def _extract_code(text: str) -> Optional[str]:
m = re.search(r'\b([A-Z][A-Z0-9\-]{1,10})\b', text)
return m.group(1) if m else None
def _extract_title(text: str) -> str:
for kw in ["SR 접수", "서비스 요청", "티켓 등록", "sr 올려", "접수해줘"]:
text = re.sub(kw, "", text, flags=re.IGNORECASE)
return text.strip() or "자연어 SR 접수"
def _extract_incident_title(text: str) -> str:
for kw in ["인시던트", "장애 등록", "incident", "P1", "P2", "P3", "P4",
"긴급", "등록해", "신고해"]:
text = re.sub(kw, "", text, flags=re.IGNORECASE)
return text.strip() or "자동 인시던트"