""" 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, }