""" 대화형 운영 AI — 자연어 명령으로 ITSM 운영 작업 실행. 엔드포인트: POST /api/conv-ops/execute — 자연어 명령 실행 GET /api/conv-ops/history — 실행 이력 GET /api/conv-ops/intents — 지원 인텐트 목록 POST /api/conv-ops/feedback — 피드백 핵심 흐름: 1. 사용자 자연어 입력 수신 2. Ollama(localhost:11434)로 intent + params 파싱 (JSON 전용) 3. intent에 따라 내부 httpx로 기존 API 순차 호출 4. 각 단계 결과를 steps에 기록, 전체 요약 생성 5. tb_conv_ops_session에 저장 """ from __future__ import annotations import json import logging from datetime import datetime from typing import Any, Dict, List, Optional import httpx from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy import desc, select from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user from database import get_db, SessionLocal from models import ConvOpsSession, User logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/conv-ops", tags=["conversational-ops"]) # ── 지원 인텐트 ─────────────────────────────────────────────────────────────── INTENTS: Dict[str, str] = { "sr_notify": "SR 알림 — 특정 SR 상태 조회 및 담당자에게 알림 발송", "server_check": "서버 점검 — CMDB 서버 목록 조회 및 상태 확인", "deploy": "배포 — 지정 서버에 배포 작업 SR 생성 및 실행", "report": "보고서 — 일간/주간/월간 운영 보고서 생성", "bulk_action": "일괄 처리 — 여러 SR을 한 번에 상태 변경·배정", } # ── Pydantic 스키마 ─────────────────────────────────────────────────────────── class ExecuteRequest(BaseModel): user_input: str dry_run: bool = False # True이면 실행 없이 파싱 결과만 반환 class FeedbackRequest(BaseModel): session_id: int helpful: bool comment: Optional[str] = None class StepResult(BaseModel): action: str result: Any status: str # success | failed | skipped class ExecuteResponse(BaseModel): session_id: int parsed_intent: Optional[str] parsed_params: Optional[Dict[str, Any]] steps: List[StepResult] summary: str success: bool class IntentInfo(BaseModel): intent: str description: str example: str # ── Ollama 파싱 헬퍼 ────────────────────────────────────────────────────────── _OLLAMA_URL = "http://localhost:11434/api/generate" _PARSE_PROMPT_TMPL = """당신은 ITSM 운영 명령 파서입니다. 다음 자연어 입력을 JSON으로 변환하세요. 지원 인텐트: {intents} 출력 JSON 형식 (이것만 출력, 설명 없음): {{ "intent": "<인텐트 키>", "params": {{ "target": "<대상 서버/SR ID/기관명>", "action": "<세부 작업>", "filters": {{}} }}, "confidence": 0.0 }} 인텐트를 알 수 없으면 "intent": "unknown" 으로 응답하세요. 입력: {user_input} """ async def _parse_intent(user_input: str) -> Dict[str, Any]: """Ollama로 자연어 → intent+params 파싱. 실패 시 unknown 반환.""" intent_list = ", ".join(INTENTS.keys()) prompt = _PARSE_PROMPT_TMPL.format( intents=intent_list, user_input=user_input, ) try: async with httpx.AsyncClient(timeout=30.0) as client: resp = await client.post( _OLLAMA_URL, json={"model": "llama3", "prompt": prompt, "stream": False}, ) if resp.status_code == 200: raw = resp.json().get("response", "") # JSON 블록 추출 start = raw.find("{") end = raw.rfind("}") + 1 if start >= 0 and end > start: parsed = json.loads(raw[start:end]) return parsed except Exception as exc: logger.warning("Ollama 파싱 실패: %s", exc) return {"intent": "unknown", "params": {}, "confidence": 0.0} # ── 내부 API 호출 헬퍼 ──────────────────────────────────────────────────────── _BASE = "http://127.0.0.1:9001" async def _call_internal( method: str, path: str, token: Optional[str] = None, **kwargs, ) -> Dict[str, Any]: """내부 ITSM API 호출. 에러 시 상태 포함 dict 반환.""" headers = {} if token: headers["Authorization"] = f"Bearer {token}" try: async with httpx.AsyncClient(timeout=20.0, headers=headers) as client: fn = getattr(client, method.lower()) resp = await fn(f"{_BASE}{path}", **kwargs) return {"status_code": resp.status_code, "body": resp.json()} except Exception as exc: return {"status_code": 500, "body": {"detail": str(exc)}} # ── 인텐트 실행기 ───────────────────────────────────────────────────────────── async def _execute_sr_notify( params: Dict[str, Any], token: Optional[str], ) -> List[StepResult]: steps = [] target = params.get("target", "") # 1. SR 조회 result = await _call_internal("GET", f"/api/tasks?search={target}", token=token) ok = result["status_code"] == 200 steps.append(StepResult( action=f"SR 조회 (검색어: {target})", result=result["body"], status="success" if ok else "failed", )) return steps async def _execute_server_check( params: Dict[str, Any], token: Optional[str], ) -> List[StepResult]: steps = [] # 1. CMDB 서버 목록 조회 result = await _call_internal("GET", "/api/cmdb/servers?limit=20", token=token) ok = result["status_code"] == 200 steps.append(StepResult( action="CMDB 서버 목록 조회", result=result["body"], status="success" if ok else "failed", )) return steps async def _execute_deploy( params: Dict[str, Any], token: Optional[str], ) -> List[StepResult]: steps = [] target = params.get("target", "") # 1. 서버 조회 result = await _call_internal("GET", f"/api/cmdb/servers?search={target}", token=token) ok = result["status_code"] == 200 steps.append(StepResult( action=f"배포 대상 서버 조회 (target={target})", result=result["body"], status="success" if ok else "failed", )) return steps async def _execute_report( params: Dict[str, Any], token: Optional[str], ) -> List[StepResult]: steps = [] action = params.get("action", "daily") # 1. 보고서 목록 조회 result = await _call_internal("GET", f"/api/report/list?type={action}", token=token) ok = result["status_code"] == 200 steps.append(StepResult( action=f"보고서 조회 (유형={action})", result=result["body"], status="success" if ok else "failed", )) return steps async def _execute_bulk_action( params: Dict[str, Any], token: Optional[str], ) -> List[StepResult]: steps = [] filters = params.get("filters", {}) # 1. SR 목록 조회 result = await _call_internal("GET", "/api/tasks?status=RECEIVED&limit=50", token=token) ok = result["status_code"] == 200 steps.append(StepResult( action="일괄 처리 대상 SR 조회", result=result["body"], status="success" if ok else "failed", )) return steps _EXECUTOR_MAP = { "sr_notify": _execute_sr_notify, "server_check": _execute_server_check, "deploy": _execute_deploy, "report": _execute_report, "bulk_action": _execute_bulk_action, } # ── 요약 생성 헬퍼 ──────────────────────────────────────────────────────────── def _build_summary(intent: str, steps: List[StepResult], success: bool) -> str: ok_count = sum(1 for s in steps if s.status == "success") fail_count = sum(1 for s in steps if s.status == "failed") intent_label = INTENTS.get(intent, intent) if success: return f"[{intent_label}] 완료 — {ok_count}단계 성공" return f"[{intent_label}] 부분 완료 — 성공 {ok_count}건, 실패 {fail_count}건" # ── 엔드포인트 ──────────────────────────────────────────────────────────────── @router.post("/execute", response_model=ExecuteResponse, summary="자연어 명령 실행") async def execute_command( req: ExecuteRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """자연어 명령을 Ollama로 파싱하고 해당 인텐트를 실행한다.""" # 1. intent 파싱 parsed = await _parse_intent(req.user_input) intent = parsed.get("intent", "unknown") params = parsed.get("params", {}) steps: List[StepResult] = [] if req.dry_run: # dry_run: 실행 없이 파싱 결과만 반환 steps.append(StepResult( action="dry_run — 파싱 결과 확인", result=parsed, status="success", )) summary = f"[DRY RUN] 인텐트: {intent}" success = True elif intent == "unknown" or intent not in _EXECUTOR_MAP: steps.append(StepResult( action="인텐트 매핑 실패", result={"parsed": parsed}, status="failed", )) summary = f"지원하지 않는 명령입니다. 지원 인텐트: {', '.join(INTENTS.keys())}" success = False else: # 토큰 추출 (request의 Authorization 헤더에서 가져올 수 없으므로 None 전달) # 실제 운영에서는 current_user로 내부 서비스 토큰 발급 가능 executor = _EXECUTOR_MAP[intent] steps = await executor(params, token=None) success = all(s.status == "success" for s in steps) summary = _build_summary(intent, steps, success) # 2. 세션 저장 session = ConvOpsSession( user_input=req.user_input, parsed_intent=json.dumps(parsed, ensure_ascii=False), steps=json.dumps([s.model_dump() for s in steps], ensure_ascii=False), summary=summary, success=success, created_by=current_user.id, ) db.add(session) await db.commit() await db.refresh(session) return ExecuteResponse( session_id=session.id, parsed_intent=intent, parsed_params=params, steps=steps, summary=summary, success=success, ) @router.get("/history", summary="실행 이력 조회") async def get_history( skip: int = Query(0, ge=0), limit: int = Query(20, ge=1, le=100), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """현재 사용자의 대화형 운영 명령 실행 이력을 반환한다.""" stmt = ( select(ConvOpsSession) .where(ConvOpsSession.created_by == current_user.id) .order_by(desc(ConvOpsSession.created_at)) .offset(skip) .limit(limit) ) rows = (await db.execute(stmt)).scalars().all() result = [] for row in rows: parsed_intent_data = {} if row.parsed_intent: try: parsed_intent_data = json.loads(row.parsed_intent) except Exception: parsed_intent_data = {} steps_data = [] if row.steps: try: steps_data = json.loads(row.steps) except Exception: steps_data = [] result.append({ "id": row.id, "user_input": row.user_input, "parsed_intent": parsed_intent_data, "steps": steps_data, "summary": row.summary, "success": row.success, "created_at": row.created_at.isoformat() if row.created_at else None, }) return {"items": result, "total": len(result)} @router.get("/intents", summary="지원 인텐트 목록") async def list_intents( current_user: User = Depends(get_current_user), ): """지원하는 자연어 명령 인텐트 목록과 설명 및 예시를 반환한다.""" examples = { "sr_notify": "SR-20260101 상태 알려줘", "server_check": "서버 목록 조회해줘", "deploy": "web01 서버에 배포해줘", "report": "이번 주 운영 보고서 만들어줘", "bulk_action": "대기중인 SR 전부 처리해줘", } return { "intents": [ { "intent": k, "description": v, "example": examples.get(k, ""), } for k, v in INTENTS.items() ] } @router.post("/feedback", summary="실행 결과 피드백") async def submit_feedback( req: FeedbackRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """실행 결과에 대한 도움 여부 피드백을 기록한다.""" stmt = select(ConvOpsSession).where(ConvOpsSession.id == req.session_id) session = (await db.execute(stmt)).scalars().first() if not session: raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다") if session.created_by != current_user.id: raise HTTPException(status_code=403, detail="본인 세션에만 피드백 가능합니다") # 피드백을 summary에 메타데이터로 추가 (별도 컬럼 없이 간소 처리) feedback_note = f" [피드백: {'도움됨' if req.helpful else '도움안됨'}]" if req.comment: feedback_note += f" — {req.comment}" if session.summary and "[피드백:" not in session.summary: session.summary = (session.summary or "") + feedback_note await db.commit() return {"ok": True, "session_id": req.session_id}