420 lines
14 KiB
Python
420 lines
14 KiB
Python
"""
|
|
대화형 운영 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}
|