- 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>
415 lines
14 KiB
Python
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,
|
|
}
|