feat(messenger): natural language command parser (NL Command)

- core/nl_command.py: Ollama LLM + rule-based fallback
- POST /api/messenger/bot/nl: new NL endpoint
- /bot/command: NL fallback when no command matches
- 9/10 rule tests PASS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
DESKTOP-TKLFCPRython 2026-05-31 17:07:11 +09:00
parent 9514379a96
commit 60e619a132
2 changed files with 494 additions and 3 deletions

393
core/nl_command.py Normal file
View File

@ -0,0 +1,393 @@
"""
자연어 메신저 명령어 파서 (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 "자동 인시던트"

View File

@ -150,6 +150,48 @@ def _format_event_message(event: MessengerEvent) -> str:
# ── 봇 명령어 처리 (inbound) ──────────────────────────────────────────────────
@router.post("/bot/nl", response_model=BotReply)
async def handle_nl_command(
cmd: BotCommand,
bg: BackgroundTasks,
db: AsyncSession = Depends(get_db),
):
"""
자연어 명령 처리 전용 엔드포인트.
자연어 명령어 변환 실행. 명시적 명령어도 처리 가능.
"""
from core.nl_command import parse_nl_command
parsed = await parse_nl_command(cmd.command.strip())
if not parsed.get("full_command") or parsed.get("confidence", 0) < 0.45:
return BotReply(
room=cmd.room,
text=(
f"❓ 요청을 이해하지 못했습니다.\n"
f"자연어 예시: '서버1 헬스체크 해줘', 'SR-2026-XXXX 배포해줘'\n"
f"!help 로 명령어 목록을 확인하세요."
),
)
# 파싱된 명령어를 BotCommand로 재생성해서 기존 핸들러 호출
nl_cmd = BotCommand(
room=cmd.room,
user=cmd.user,
command=parsed["full_command"],
message=f"[자연어→{parsed['command']}] {cmd.command}",
)
reply = await handle_bot_command(nl_cmd, bg, db)
# 신뢰도 낮으면 안내 메시지 추가
if parsed.get("confidence", 1.0) < 0.75:
reply.text = (
f"💬 자연어 해석: {parsed.get('explanation', '')}\n"
f"명령어: {parsed['full_command']}\n\n"
+ reply.text
)
return reply
@router.post("/bot/command", response_model=BotReply)
async def handle_bot_command(
cmd: BotCommand,
@ -158,7 +200,7 @@ async def handle_bot_command(
):
"""
GUARDiA 메신저 봇에서 전달되는 명령어 처리.
메신저 봇이 사용자 명령을 엔드포인트로 POST 전달.
명시적 명령어(!vibe, /sr ) 자연어 모두 처리.
"""
text = cmd.command.strip()
parts = text.split()
@ -575,8 +617,9 @@ async def handle_bot_command(
return BotReply(room=cmd.room, text=_help_text())
else:
return BotReply(room=cmd.room,
text=f"알 수 없는 명령어: {keyword}\n!help 또는 /help 로 도움말 확인")
# ── 자연어 처리 폴백 ────────────────────────────────────────────────────
# 명시적 명령어가 아닌 경우 NL → 명령어 파싱 시도
return await _handle_natural_language(cmd, bg, db)
# ── 백그라운드 명령 실행 헬퍼 ────────────────────────────────────────────────
@ -1913,6 +1956,61 @@ esb, elasticsearch, solr, pinpoint, scouter
# ── 스크랩 봇 헬퍼 ────────────────────────────────────────────────────────────
async def _handle_natural_language(
cmd: BotCommand,
bg: BackgroundTasks,
db: AsyncSession,
) -> BotReply:
"""
명시적 명령어가 아닌 자연어 입력을 처리.
NL 파서 명령어 변환 기존 핸들러 재호출.
"""
from core.nl_command import parse_nl_command
text = cmd.command.strip()
parsed = await parse_nl_command(text)
confidence = parsed.get("confidence", 0)
full_cmd = parsed.get("full_command")
# 너무 낮은 신뢰도 → 안내
if not full_cmd or confidence < 0.45:
return BotReply(
room=cmd.room,
text=(
f"❓ 명령어를 인식하지 못했습니다.\n\n"
f"자연어로 입력 예시:\n"
f" • 서버1 헬스체크 해줘\n"
f" • SR-2026-XXXX 배포해줘\n"
f" • https://example.com 스크랩해줘\n"
f" • P1 긴급 장애 결제 시스템 다운\n\n"
f"!help 로 전체 명령어 목록 확인"
),
)
# 파싱된 명령어로 재호출
nl_cmd = BotCommand(
room=cmd.room,
user=cmd.user,
command=full_cmd,
)
reply = await handle_bot_command(nl_cmd, bg, db)
# 신뢰도 < 0.75면 해석 과정 투명하게 표시
if confidence < 0.75:
prefix = (
f"💬 자연어 해석 (신뢰도 {int(confidence*100)}%)\n"
f" 입력: {text}\n"
f" 명령: {full_cmd}\n\n"
)
reply.text = prefix + reply.text
else:
prefix = f"💬 → {full_cmd}\n"
reply.text = prefix + reply.text
return reply
async def _cmd_scrap_url(room: str, actor: str, url: str) -> None:
"""URL 즉시 스크랩 후 결과를 채널로 전송."""
from core.scraping_engine import scrape as _scrape