""" 자연어 명령 라우터 — GUARDiA AI 어시스턴트. POST /api/nlcmd → intent 분류 → 엔티티 추출 → ITSM 액션 → 자연어 응답 외부 LLM 없음. 순수 온프레미스 규칙 기반. """ from datetime import datetime from typing import Optional from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user from core.nlu import parse from database import get_db from models import ( AuditLog, EngineerProfile, Institution, KBDocument, SRRequest, SRStatus, SRType, Priority, User, UserRole, compute_log_hash, ) router = APIRouter(prefix="/api/nlcmd", tags=["nlcmd"]) PRIORITY_LABEL = {"CRITICAL": "긴급", "HIGH": "높음", "MEDIUM": "보통", "LOW": "낮음"} STATUS_LABEL = { "RECEIVED": "접수", "PARSED": "파싱 완료", "PENDING_APPROVAL": "승인 대기", "APPROVED": "승인됨", "IN_PROGRESS": "진행 중", "PENDING_PM_VALIDATION": "PM 검증 대기", "COMPLETED": "완료", "FAILED_ROLLBACK": "롤백 실패", "REJECTED": "반려", } TYPE_LABEL = { "DEPLOY": "배포", "RESTART": "재기동", "LOG": "로그 분석", "INQUIRY": "문의", "OTHER": "기타", } # 활성 상태 (워크로드 계산) _ACTIVE = [s.value for s in [ SRStatus.RECEIVED, SRStatus.PARSED, SRStatus.PENDING_APPROVAL, SRStatus.APPROVED, SRStatus.IN_PROGRESS, SRStatus.PENDING_PM_VALIDATION, ]] # ── Schemas ─────────────────────────────────────────────────────────────────── class NLCmdRequest(BaseModel): text: str class NLCmdResponse(BaseModel): intent: str response: str action_taken: bool = False data: Optional[list] = None suggestions: Optional[list[str]] = None # ── Helper ───────────────────────────────────────────────────────────────────── def _sr_summary(sr: SRRequest) -> str: p = PRIORITY_LABEL.get(sr.priority, sr.priority) s = STATUS_LABEL.get(sr.status, sr.status) t = TYPE_LABEL.get(sr.sr_type, sr.sr_type) assigned = sr.assigned_to or "미배정" return f"• **{sr.sr_id}** [{p}] {t} — {sr.title[:40]} | 상태: {s} | 담당: {assigned}" async def _write_audit(db, sr_id, actor, action, detail): res = await db.execute( select(AuditLog).where(AuditLog.sr_id == sr_id) .order_by(AuditLog.id.desc()).limit(1) ) last = res.scalars().first() prev = last.log_hash if last else None ts = datetime.now().isoformat() db.add(AuditLog( sr_id=sr_id, actor=actor, action=action, detail=detail, prev_hash=prev, log_hash=compute_log_hash(prev, actor, action, detail, ts), )) # ── Handler dispatch ─────────────────────────────────────────────────────────── async def _handle_query_sr_list(p, db, current_user) -> NLCmdResponse: q = select(SRRequest).order_by(SRRequest.created_at.desc()) filters = [] if p.sr_type: q = q.where(SRRequest.sr_type == p.sr_type) filters.append(TYPE_LABEL.get(p.sr_type, p.sr_type)) if p.inst_code: r2 = await db.execute(select(Institution).where(Institution.inst_code == p.inst_code)) inst = r2.scalars().first() if inst: q = q.where(SRRequest.inst_id == inst.id) filters.append(p.inst_code) if p.priority: q = q.where(SRRequest.priority == p.priority) filters.append(PRIORITY_LABEL.get(p.priority, p.priority)) if p.status: q = q.where(SRRequest.status == p.status) filters.append(STATUS_LABEL.get(p.status, p.status)) # 상태 키워드 없이 "승인 대기" 의도 감지 elif "승인 대기" in p.raw_text or "대기" in p.raw_text: q = q.where(SRRequest.status == SRStatus.PENDING_APPROVAL) filters.append("승인 대기") elif "진행" in p.raw_text or "진행 중" in p.raw_text: q = q.where(SRRequest.status == SRStatus.IN_PROGRESS) filters.append("진행 중") elif "긴급" in p.raw_text and not p.priority: q = q.where(SRRequest.priority == Priority.CRITICAL) filters.append("긴급") q = q.limit(10) res = await db.execute(q) srs = res.scalars().all() tag = " · ".join(filters) if filters else "전체" if not srs: return NLCmdResponse( intent=p.intent, response=f"**{tag}** 조건에 해당하는 SR이 없습니다.", ) lines = [f"**{tag}** SR 목록 ({len(srs)}건):"] lines += [_sr_summary(sr) for sr in srs] data = [{"sr_id": sr.sr_id, "title": sr.title, "status": sr.status, "priority": sr.priority, "assigned_to": sr.assigned_to} for sr in srs] return NLCmdResponse(intent=p.intent, response="\n".join(lines), data=data) async def _handle_query_sr_detail(p, db) -> NLCmdResponse: if not p.sr_id: return NLCmdResponse( intent=p.intent, response="SR ID를 명시해 주세요. 예: `SR-20260525-XXXXXX 상태 알려줘`", ) res = await db.execute(select(SRRequest).where(SRRequest.sr_id == p.sr_id)) sr = res.scalars().first() if not sr: return NLCmdResponse(intent=p.intent, response=f"**{p.sr_id}** SR을 찾을 수 없습니다.") lines = [ f"**{sr.sr_id}** 상세 정보", f"제목: {sr.title}", f"유형: {TYPE_LABEL.get(sr.sr_type, sr.sr_type)}", f"상태: {STATUS_LABEL.get(sr.status, sr.status)}", f"우선순위: {PRIORITY_LABEL.get(sr.priority, sr.priority)}", f"요청자: {sr.requested_by or '—'}", f"담당자: {sr.assigned_to or '미배정'}", f"대상 서버: {sr.target_server or '—'}", f"생성일: {sr.created_at.strftime('%Y-%m-%d %H:%M') if sr.created_at else '—'}", ] if sr.description: lines.append(f"설명: {sr.description[:100]}{'...' if len(sr.description or '') > 100 else ''}") return NLCmdResponse( intent=p.intent, response="\n".join(lines), data=[{"sr_id": sr.sr_id, "status": sr.status}], ) async def _handle_assign(p, db, current_user) -> NLCmdResponse: if not p.sr_id: return NLCmdResponse( intent=p.intent, response="배정할 SR ID를 명시해 주세요. 예: `SR-20260525-XXXXXX 자동 배정해줘`", ) res = await db.execute(select(SRRequest).where(SRRequest.sr_id == p.sr_id)) sr = res.scalars().first() if not sr: return NLCmdResponse(intent=p.intent, response=f"**{p.sr_id}** SR을 찾을 수 없습니다.") if p.engineer: ue = await db.execute(select(User).where(User.username == p.engineer)) u = ue.scalars().first() if not u or u.role != UserRole.ENGINEER: return NLCmdResponse( intent=p.intent, response=f"**{p.engineer}** 은(는) 등록된 엔지니어가 아닙니다.", ) assigned = p.engineer mode = "수동" else: from routers.assign import auto_assign_engine assigned = await auto_assign_engine(db, sr) if not assigned: return NLCmdResponse( intent=p.intent, response="현재 배정 가능한 엔지니어가 없습니다. (모두 최대 워크로드)", ) mode = "자동" prev = sr.assigned_to or "미배정" sr.assigned_to = assigned sr.updated_at = datetime.now() actor = current_user.display_name or current_user.username await _write_audit(db, p.sr_id, actor, "ENGINEER_ASSIGNED", f"{mode} 배정: {prev} → {assigned}") await db.commit() # 표시 이름 조회 ep = await db.execute(select(EngineerProfile).where(EngineerProfile.username == assigned)) ep_obj = ep.scalars().first() disp = (ep_obj.display_name if ep_obj else None) or assigned return NLCmdResponse( intent=p.intent, response=f"**{p.sr_id}** 담당 엔지니어를 **{disp}**({assigned})으로 {mode} 배정했습니다.\n이전: {prev}", action_taken=True, ) async def _handle_approve_reject(p, db, current_user, result_val) -> NLCmdResponse: from models import ApprovalFlow, ApprovalResult if not p.sr_id: act_kr = "승인" if result_val == "APPROVED" else "반려" return NLCmdResponse( intent=p.intent, response=f"{act_kr}할 SR ID를 명시해 주세요. 예: `SR-20260525-XXXXXX 승인해줘`", ) if current_user.role not in (UserRole.ADMIN, UserRole.PM): return NLCmdResponse( intent=p.intent, response="승인/반려 권한은 PM 또는 관리자만 있습니다.", ) res = await db.execute(select(SRRequest).where(SRRequest.sr_id == p.sr_id)) sr = res.scalars().first() if not sr: return NLCmdResponse(intent=p.intent, response=f"**{p.sr_id}** SR을 찾을 수 없습니다.") if sr.status != SRStatus.PENDING_APPROVAL: return NLCmdResponse( intent=p.intent, response=f"**{p.sr_id}** 은(는) 현재 '승인 대기' 상태가 아닙니다. (현재: {STATUS_LABEL.get(sr.status, sr.status)})", ) actor = current_user.display_name or current_user.username new_status = SRStatus.APPROVED if result_val == "APPROVED" else SRStatus.REJECTED apv = ApprovalFlow( sr_id=p.sr_id, approver=actor, result=result_val, comment="AI 명령으로 처리", decided_at=datetime.now(), ) db.add(apv) sr.status = new_status sr.updated_at = datetime.now() action_str = "SR_APPROVED" if result_val == "APPROVED" else "SR_REJECTED" await _write_audit(db, p.sr_id, actor, action_str, f"자연어 명령: {p.raw_text[:80]}") await db.commit() act_kr = "승인" if result_val == "APPROVED" else "반려" return NLCmdResponse( intent=p.intent, response=f"**{p.sr_id}** — {sr.title[:40]} 을(를) **{act_kr}** 처리했습니다.", action_taken=True, ) async def _handle_simulate(p, db, current_user) -> NLCmdResponse: if not p.sr_id: return NLCmdResponse( intent=p.intent, response="시뮬레이션할 SR ID를 명시해 주세요. 예: `SR-20260525-XXXXXX 작업 시뮬레이션 실행해줘`", ) res = await db.execute(select(SRRequest).where(SRRequest.sr_id == p.sr_id)) sr = res.scalars().first() if not sr: return NLCmdResponse(intent=p.intent, response=f"**{p.sr_id}** SR을 찾을 수 없습니다.") if sr.status in (SRStatus.COMPLETED, SRStatus.FAILED_ROLLBACK, SRStatus.REJECTED): return NLCmdResponse( intent=p.intent, response=f"**{p.sr_id}** 은(는) 이미 종료된 SR입니다. (상태: {STATUS_LABEL.get(sr.status, sr.status)})", ) # work simulate 로직 재사용 from routers.work import _notify_messenger import asyncio from models import WorkLog, WorkActionType _SSH_SIM = { "RESTART": [ {"action": WorkActionType.SSH_CONNECT, "content": "SSH 접속 (opsagent@{server}:22)", "result": "Connected to {server}"}, {"action": WorkActionType.SSH_EXEC, "content": "systemctl stop {svc} && sleep 2 && systemctl start {svc}", "result": "● {svc}.service: active (running)"}, {"action": WorkActionType.HEALTH_CHECK, "content": "curl -sf http://localhost:8080/health", "result": "200 OK"}, ], "DEPLOY": [ {"action": WorkActionType.SSH_CONNECT, "content": "SFTP 접속 (opsagent@{server}:22)", "result": "Connected"}, {"action": WorkActionType.SOURCE_MOD, "content": "파일 전송 (SFTP)", "result": "전송 완료"}, {"action": WorkActionType.SSH_EXEC, "content": "systemctl reload tomcat9", "result": "Reload OK"}, {"action": WorkActionType.HEALTH_CHECK, "content": "헬스체크", "result": "200 OK"}, ], } steps = _SSH_SIM.get(sr.sr_type, [ {"action": WorkActionType.SSH_EXEC, "content": "작업 수행", "result": "완료"}, ]) server = sr.target_server or "UNKNOWN-SRV" ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") actor = current_user.display_name or current_user.username sr.status = SRStatus.IN_PROGRESS sr.updated_at = datetime.now() await _write_audit(db, p.sr_id, actor, "SR_STARTED", f"자연어 명령: 작업 시작") for step in steps: wlog = WorkLog( sr_id=p.sr_id, engineer=actor, action_type=step["action"], content=step["content"].format(server=server, svc="tomcat9", ts=ts), result=step["result"].format(server=server, svc="tomcat9", ts=ts), is_success=True, ) db.add(wlog) final = f"{sr.title[:50]} 처리 완료" db.add(WorkLog( sr_id=p.sr_id, engineer=actor, action_type=WorkActionType.RESULT, content="작업 결과 기록", result=final, is_success=True, )) sr.status = SRStatus.COMPLETED sr.updated_at = datetime.now() await _write_audit(db, p.sr_id, actor, "SR_COMPLETED", final) await db.commit() asyncio.create_task(_notify_messenger(sr, final)) return NLCmdResponse( intent=p.intent, response=f"**{p.sr_id}** 작업 시뮬레이션을 완료했습니다.\n{final}", action_taken=True, ) async def _handle_search_kb(p, db) -> NLCmdResponse: from routers.kb import _search_docs q = p.keyword or p.raw_text hits = await _search_docs(db, q, sr_type_hint=p.sr_type, limit=3) if not hits: return NLCmdResponse( intent=p.intent, response=f"**'{q}'** 관련 기술 문서를 찾지 못했습니다.", ) lines = [f"**'{q}'** 관련 기술 문서 {len(hits)}건:"] data = [] for h in hits: d = h["doc"] lines.append( f"• [{d.category}] **{d.doc_id}** {d.title} " f"(관련도 {int(h['score']*100)}%)" ) data.append({"doc_id": d.doc_id, "title": d.title, "score": h["score"]}) lines.append("\n💡 *상세 내용은 KB 뷰에서 확인하세요.*") return NLCmdResponse( intent=p.intent, response="\n".join(lines), data=data, ) async def _handle_workload(p, db) -> NLCmdResponse: res = await db.execute(select(EngineerProfile)) profiles = res.scalars().all() lines = ["**엔지니어 워크로드 현황:**"] data = [] for ep in profiles: cnt = (await db.execute( select(func.count(SRRequest.id)).where( SRRequest.assigned_to == ep.username, SRRequest.status.in_(_ACTIVE), ) )).scalar() or 0 done = (await db.execute( select(func.count(SRRequest.id)).where( SRRequest.assigned_to == ep.username, SRRequest.status == SRStatus.COMPLETED.value, ) )).scalar() or 0 bar = "█" * cnt + "░" * (ep.max_workload - cnt) disp = ep.display_name or ep.username lines.append(f"• **{disp}** [{bar}] {cnt}/{ep.max_workload}건 완료: {done}건") data.append({"username": ep.username, "active": cnt, "max": ep.max_workload}) if not profiles: return NLCmdResponse(intent=p.intent, response="등록된 엔지니어 프로필이 없습니다.") return NLCmdResponse(intent=p.intent, response="\n".join(lines), data=data) async def _handle_stats(p, db) -> NLCmdResponse: total = (await db.execute(select(func.count(SRRequest.id)))).scalar() or 0 by_status: dict[str, int] = {} for s in SRStatus: cnt = (await db.execute( select(func.count(SRRequest.id)).where(SRRequest.status == s) )).scalar() or 0 if cnt: by_status[s.value] = cnt lines = [f"**ITSM 현황 요약** (전체 {total}건)"] for k, v in sorted(by_status.items(), key=lambda x: -x[1]): label = STATUS_LABEL.get(k, k) bar = "▪" * min(v, 10) lines.append(f"• {label}: **{v}건** {bar}") return NLCmdResponse(intent=p.intent, response="\n".join(lines)) # ── Main endpoint ────────────────────────────────────────────────────────────── @router.post("", response_model=NLCmdResponse) async def nl_command( req: NLCmdRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """자연어 명령을 받아 ITSM 작업을 수행하고 자연어로 응답합니다.""" if not req.text.strip(): return NLCmdResponse(intent="UNKNOWN", response="명령어를 입력해 주세요.") p = parse(req.text.strip()) suggestions = [ "승인 대기 SR 목록 보여줘", "엔지니어 워크로드 현황", "KB에서 OOM 검색해줘", ] try: if p.intent == "QUERY_SR_LIST": return await _handle_query_sr_list(p, db, current_user) elif p.intent == "QUERY_SR_DETAIL": return await _handle_query_sr_detail(p, db) elif p.intent == "ASSIGN_ENGINEER": return await _handle_assign(p, db, current_user) elif p.intent == "APPROVE_SR": return await _handle_approve_reject(p, db, current_user, "APPROVED") elif p.intent == "REJECT_SR": return await _handle_approve_reject(p, db, current_user, "REJECTED") elif p.intent == "SIMULATE": return await _handle_simulate(p, db, current_user) elif p.intent == "SEARCH_KB": return await _handle_search_kb(p, db) elif p.intent == "QUERY_WORKLOAD": return await _handle_workload(p, db) elif p.intent == "QUERY_STATS": return await _handle_stats(p, db) else: return NLCmdResponse( intent="UNKNOWN", response=( "죄송합니다, 명령을 이해하지 못했습니다.\n" "다음과 같이 입력해 보세요:" ), suggestions=suggestions, ) except Exception as e: return NLCmdResponse( intent=p.intent, response=f"처리 중 오류가 발생했습니다: {str(e)[:100]}", )