guardia-itsm/routers/chatbot.py
DESKTOP-TKLFCPRython 64c27c3509 feat(itsm): G-1~G-12 확장 기능 + 하네스/봇/설치스크립트 구현
G-1: 메신저 Webhook Relay + _send_to_room 실제 httpx 호출 구현
G-2: POST /api/tasks/bulk SR 대량작업 엔드포인트 (최대 100건)
G-3: 라이선스 만료 알림 스케줄러 (매일 09:00 KST)
G-4: 체험판 upgrade_banner 필드 + license.py 배너 로직
G-5: core/auto_rca.py + incidents/problem auto-rca 엔드포인트
G-6: core/deploy_impact.py + vibe impact-analysis 엔드포인트
G-7: core/ticket_classifier.py + SR 생성 시 AI 분류 + ai-suggestion API
G-8: VulnPatchRecord 모델 + vuln_scan 패치추적 4개 엔드포인트
G-9: core/jira_sync.py + gateway Jira/Confluence 연동 엔드포인트
G-10: core/push_notify.py + routers/push.py + PushSubscription 모델
G-11: approvals 다중승인 (위임/서명/기한초과/마감연장)
G-12: alembic.ini + migrations/ + cicd/migrate_to_postgres.sh

하네스: guardia-orchestrator 확장기능 Phase 반영
봇명령어: /sr /status /license /bulk 슬래시 명령어 추가
설치스크립트: setup/ (Ubuntu, CentOS, RHEL, Windows) --test 옵션 포함

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:18:52 +09:00

415 lines
14 KiB
Python

"""
B-2: 자연어 SR 접수 챗봇 API 라우터
엔드포인트:
POST /api/chatbot/message — 메시지 전송 (대화 처리)
GET /api/chatbot/sessions — 세션 목록
GET /api/chatbot/sessions/{key} — 세션 상세 (대화 기록 포함)
DELETE /api/chatbot/sessions/{key} — 세션 종료
GET /api/chatbot/history/{key} — 대화 기록만
POST /api/chatbot/sessions/{key}/reset — 세션 초기화
"""
from __future__ import annotations
import json
import logging
from datetime import datetime
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import desc, select, and_
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db
from models import (
ChatSession, ChatSessionOut,
ChatMessage, ChatMessageOut,
ChatMessageRequest, ChatResponse,
SRRequest, SRStatus, SRType, Priority,
AuditLog, compute_log_hash,
)
from core.chatbot import process_message, new_session_key
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/chatbot", tags=["chatbot"])
# ── 헬퍼 ─────────────────────────────────────────────────────────────────────
async def _get_or_create_session(
session_key: Optional[str],
username: Optional[str],
db: AsyncSession,
) -> ChatSession:
"""세션 키로 세션 조회 또는 신규 생성."""
if session_key:
sess = (await db.execute(
select(ChatSession).where(ChatSession.session_key == session_key)
)).scalars().first()
if sess:
return sess
# 새 세션 생성
key = session_key or new_session_key()
sess = ChatSession(
session_key = key,
username = username or "anonymous",
status = "ACTIVE",
context_json = json.dumps({"history": [], "collected": {}, "state": "GATHERING"}, ensure_ascii=False),
)
db.add(sess)
await db.flush()
return sess
def _parse_context(sess: ChatSession) -> dict:
"""세션 컨텍스트 파싱."""
try:
if sess.context_json:
return json.loads(sess.context_json)
except Exception:
pass
return {"history": [], "collected": {}, "state": "GATHERING", "intent": ""}
def _save_context(sess: ChatSession, context: dict) -> None:
"""세션 컨텍스트 저장."""
sess.context_json = json.dumps(context, ensure_ascii=False)
sess.updated_at = datetime.utcnow()
# ── SR 자동 생성 ──────────────────────────────────────────────────────────────
async def _auto_create_sr(
sr_data: dict,
session_key: str,
username: str,
db: AsyncSession,
) -> Optional[SRRequest]:
"""챗봇에서 수집한 정보로 SR 자동 생성."""
from core.srid import next_sr_id
sr_id_val = await next_sr_id(db)
priority_map = {
"CRITICAL": Priority.CRITICAL,
"HIGH": Priority.HIGH,
"MEDIUM": Priority.MEDIUM,
"LOW": Priority.LOW,
}
type_map = {
"DEPLOY": SRType.DEPLOY,
"RESTART": SRType.RESTART,
"LOG": SRType.LOG,
"OTHER": SRType.OTHER,
}
sr = SRRequest(
sr_id = sr_id_val,
raw_text = f"[챗봇] {sr_data.get('description', '')}",
title = sr_data.get("title", "챗봇 접수 SR"),
description = sr_data.get("description", ""),
priority = priority_map.get(sr_data.get("priority", "MEDIUM"), Priority.MEDIUM),
sr_type = type_map.get(sr_data.get("sr_type", "OTHER"), SRType.OTHER),
status = SRStatus.RECEIVED,
requested_by = username or "chatbot",
source = "chatbot",
created_at = datetime.utcnow(),
)
db.add(sr)
await db.flush()
# 감사 로그
last_log = (await db.execute(
select(AuditLog).order_by(desc(AuditLog.id)).limit(1)
)).scalars().first()
prev_hash = last_log.log_hash if last_log else None
ts = datetime.utcnow().isoformat()
log_hash = compute_log_hash(prev_hash, username or "chatbot", "SR_CREATE_CHATBOT",
f"SR {sr_id_val} 챗봇 자동 생성", ts)
db.add(AuditLog(
sr_id = sr_id_val,
actor = username or "chatbot",
action = "SR_CREATE_CHATBOT",
detail = f"챗봇 세션 {session_key}에서 SR 자동 접수",
log_hash = log_hash,
))
await db.commit()
await db.refresh(sr)
return sr
# ── 메시지 처리 ───────────────────────────────────────────────────────────────
@router.post("/message", response_model=ChatResponse)
async def send_message(
body: ChatMessageRequest,
use_llm: bool = Query(True, description="Ollama LLM 사용 여부 (False=규칙 기반)"),
db: AsyncSession = Depends(get_db),
):
"""
챗봇 메시지 전송 및 처리.
- 신규 대화 시 session_key 미전송 → 새 세션 자동 생성
- 기존 대화 계속 시 session_key 전송
"""
if not body.message.strip():
raise HTTPException(400, "메시지가 비어 있습니다.")
# 세션 조회/생성
sess = await _get_or_create_session(
body.session_key, body.username, db
)
context = _parse_context(sess)
# 사용자 메시지 저장
user_msg = ChatMessage(
session_id = sess.id,
role = "user",
content = body.message,
)
db.add(user_msg)
await db.flush()
# 대화 처리
try:
proc_result = await process_message(
message = body.message,
session_context = context,
use_llm = use_llm,
)
except Exception as e:
logger.error("[B-2] 메시지 처리 오류: %s", e)
proc_result = {
"intent": "UNKNOWN",
"entities": {},
"reply": "죄송합니다. 처리 중 오류가 발생했습니다. 다시 시도해 주세요.",
"needs_clarification": False,
"clarification_prompt": None,
"action": "NONE",
"sr_data": None,
"confidence": 0.0,
}
intent = proc_result["intent"]
entities = proc_result["entities"]
reply = proc_result["reply"]
action = proc_result.get("action", "NONE")
sr_data = proc_result.get("sr_data")
# SR 자동 생성
created_sr = None
if action == "CREATE_SR" and sr_data:
try:
created_sr = await _auto_create_sr(
sr_data = sr_data,
session_key = sess.session_key,
username = body.username or sess.username or "chatbot",
db = db,
)
sess.created_sr_id = created_sr.sr_id
sess.status = "RESOLVED"
type_ko = {
"CRITICAL": "긴급",
"HIGH": "높음",
"MEDIUM": "보통",
"LOW": "낮음",
}.get(sr_data.get("priority", "MEDIUM"), "보통")
reply = (
f"✅ SR이 접수되었습니다!\n\n"
f"- **SR ID**: {created_sr.sr_id}\n"
f"- **제목**: {created_sr.title}\n"
f"- **우선순위**: {type_ko}\n\n"
f"담당자가 곧 연락드릴 예정입니다. 감사합니다."
)
except Exception as e:
logger.error("[B-2] SR 자동 생성 오류: %s", e)
reply = f"죄송합니다. SR 생성 중 오류가 발생했습니다: {str(e)[:50]}"
# 어시스턴트 메시지 저장
bot_msg = ChatMessage(
session_id = sess.id,
role = "assistant",
content = reply,
intent = intent,
entities_json = json.dumps(entities, ensure_ascii=False),
confidence = proc_result.get("confidence", 0.5),
)
db.add(bot_msg)
# 컨텍스트 업데이트
context["history"].append({"role": "user", "content": body.message})
context["history"].append({"role": "assistant", "content": reply})
# 히스토리 최근 20개만 유지
context["history"] = context["history"][-20:]
context["collected"] = entities
context["intent"] = intent
# 상태 전이
if action == "CREATE_SR":
context["state"] = "DONE"
elif proc_result.get("needs_clarification"):
context["state"] = "CONFIRMING" if context.get("state") == "GATHERING" else context.get("state", "GATHERING")
else:
context["state"] = "GATHERING"
_save_context(sess, context)
await db.commit()
return ChatResponse(
session_key = sess.session_key,
reply = reply,
intent = intent,
entities = entities,
action_taken = action == "CREATE_SR",
created_sr_id = created_sr.sr_id if created_sr else None,
needs_clarification = proc_result.get("needs_clarification", False),
clarification_prompt = proc_result.get("clarification_prompt"),
suggestions = _get_suggestions(intent),
)
def _get_suggestions(intent: str) -> List[str]:
"""상황별 추천 발화."""
if intent in ("SR_CREATE", "INCIDENT_REPORT"):
return [
"다른 서버도 문제가 있나요?",
"현재 사용자에게 영향이 있나요?",
"SR 목록 보여줘",
]
return [
"서버 장애 신고",
"배포 요청",
"SR-0001 상태 확인",
]
# ── 세션 관리 ─────────────────────────────────────────────────────────────────
@router.get("/sessions", response_model=List[ChatSessionOut])
async def list_sessions(
status: Optional[str] = Query(None),
username: Optional[str] = Query(None),
limit: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
"""챗봇 세션 목록 조회."""
q = select(ChatSession).order_by(desc(ChatSession.created_at)).limit(limit)
if status:
q = q.where(ChatSession.status == status.upper())
if username:
q = q.where(ChatSession.username == username)
rows = (await db.execute(q)).scalars().all()
return rows
@router.get("/sessions/{session_key}", response_model=ChatSessionOut)
async def get_session(
session_key: str,
db: AsyncSession = Depends(get_db),
):
"""세션 상세 (대화 기록 포함)."""
from sqlalchemy.orm import selectinload
q = (
select(ChatSession)
.options(selectinload(ChatSession.messages))
.where(ChatSession.session_key == session_key)
)
sess = (await db.execute(q)).scalars().first()
if not sess:
raise HTTPException(404, f"세션 '{session_key}'를 찾을 수 없습니다.")
return sess
@router.delete("/sessions/{session_key}", status_code=204)
async def end_session(
session_key: str,
db: AsyncSession = Depends(get_db),
):
"""세션 종료."""
sess = (await db.execute(
select(ChatSession).where(ChatSession.session_key == session_key)
)).scalars().first()
if not sess:
raise HTTPException(404, f"세션 '{session_key}'를 찾을 수 없습니다.")
sess.status = "ABANDONED"
await db.commit()
@router.post("/sessions/{session_key}/reset", status_code=200)
async def reset_session(
session_key: str,
db: AsyncSession = Depends(get_db),
):
"""세션 초기화 (대화 기록 유지, 컨텍스트 리셋)."""
sess = (await db.execute(
select(ChatSession).where(ChatSession.session_key == session_key)
)).scalars().first()
if not sess:
raise HTTPException(404, f"세션 '{session_key}'를 찾을 수 없습니다.")
sess.context_json = json.dumps(
{"history": [], "collected": {}, "state": "GATHERING"}, ensure_ascii=False
)
sess.status = "ACTIVE"
sess.created_sr_id = None
sess.updated_at = datetime.utcnow()
await db.commit()
return {"session_key": session_key, "status": "reset"}
@router.get("/history/{session_key}", response_model=List[ChatMessageOut])
async def get_history(
session_key: str,
limit: int = Query(50, ge=1, le=200),
db: AsyncSession = Depends(get_db),
):
"""대화 기록 조회."""
sess = (await db.execute(
select(ChatSession).where(ChatSession.session_key == session_key)
)).scalars().first()
if not sess:
raise HTTPException(404, f"세션 '{session_key}'를 찾을 수 없습니다.")
msgs = (await db.execute(
select(ChatMessage)
.where(ChatMessage.session_id == sess.id)
.order_by(ChatMessage.created_at)
.limit(limit)
)).scalars().all()
return msgs
@router.get("/stats")
async def get_chatbot_stats(
db: AsyncSession = Depends(get_db),
):
"""챗봇 사용 통계."""
from sqlalchemy import func as sqlfunc
total_sessions = (await db.execute(
select(sqlfunc.count()).select_from(ChatSession)
)).scalar() or 0
active_sessions = (await db.execute(
select(sqlfunc.count()).select_from(ChatSession).where(ChatSession.status == "ACTIVE")
)).scalar() or 0
sr_created = (await db.execute(
select(sqlfunc.count()).select_from(ChatSession)
.where(ChatSession.created_sr_id.isnot(None))
)).scalar() or 0
total_messages = (await db.execute(
select(sqlfunc.count()).select_from(ChatMessage)
)).scalar() or 0
return {
"total_sessions": total_sessions,
"active_sessions": active_sessions,
"sr_auto_created": sr_created,
"total_messages": total_messages,
"sr_conversion_rate": round(sr_created / total_sessions * 100, 1) if total_sessions > 0 else 0,
}