""" 자율 비용 최적화 (AutonomousCostOps) 기능: 1. 비용 AI 분석 현황 조회 2. Ollama sLLM 기반 비용 분석 실행 → CostRecommendation 자동 생성 3. 비용 예측 (30/60/90일) — 선형 회귀 기반 + AI 보정 4. 최적화 권고 목록 조회 5. 권고 자동 적용 (승인 후) / 반려 6. 낭비 리소스 감지 (CPU < 10%, 메모리 < 20%, 30일 이상 SR 없는 서버) 7. 절감 실적 리포트 엔드포인트: GET /api/cost-ai/analysis POST /api/cost-ai/analyze GET /api/cost-ai/forecast/{days} GET /api/cost-ai/recommendations POST /api/cost-ai/recommendations/{id}/apply POST /api/cost-ai/recommendations/{id}/reject GET /api/cost-ai/waste GET /api/cost-ai/savings-report """ from __future__ import annotations import json import logging import math from datetime import datetime, timedelta from typing import List, Optional import httpx from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy import select, func, text from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user, require_admin_role from database import get_db from models import ( CostAIAnalysis, CostForecast, CostRecommendation, MetricSnapshot, SRRequest, Server, User, UserRole, ) logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/cost-ai", tags=["cost-ai"]) # ── 상수 ────────────────────────────────────────────────────────────────────── _OLLAMA_URL = "http://localhost:11434/api/generate" _OLLAMA_MODEL = "llama3" # 낭비 기준 _WASTE_CPU_THRESHOLD = 10.0 # CPU 7일 평균 (%) _WASTE_MEM_THRESHOLD = 20.0 # 메모리 사용률 (%) _WASTE_SR_DAYS = 30 # SR 미발생 일수 # 절감 단가 (만원/월) — 유형별 기본 추산 _SAVING_UNIT = { "server": 50, # 서버 1대 유휴 절감 추산 "license": 20, # 라이선스 1건 해지 "cloud": 30, # 클라우드 리소스 최적화 } # ── Pydantic 스키마 ─────────────────────────────────────────────────────────── class RecommendationOut(BaseModel): id: int category: str title: str description: Optional[str] = None estimated_saving: float risk_level: str auto_applicable: bool status: str created_at: datetime model_config = {"from_attributes": True} class AnalysisOut(BaseModel): id: int period: str total_cost: float ai_insights: Optional[str] = None waste_detected: Optional[str] = None created_at: datetime model_config = {"from_attributes": True} class ForecastOut(BaseModel): id: int forecast_date: datetime predicted_cost: float confidence: float factors: Optional[str] = None created_at: datetime model_config = {"from_attributes": True} # ── Ollama 호출 헬퍼 ────────────────────────────────────────────────────────── async def _call_ollama(prompt: str, timeout: float = 30.0) -> Optional[str]: """Ollama sLLM 호출. 실패 시 None 반환.""" try: async with httpx.AsyncClient(timeout=timeout) as client: resp = await client.post( _OLLAMA_URL, json={"model": _OLLAMA_MODEL, "prompt": prompt, "stream": False}, ) if resp.status_code == 200: return resp.json().get("response", "").strip() except Exception as exc: logger.warning("Ollama 호출 실패: %s", exc) return None # ── AI 비용 분석 핵심 로직 ──────────────────────────────────────────────────── async def _collect_cost_snapshot(db: AsyncSession) -> dict: """FinOps 비용 기반 현황 요약을 수집한다.""" now = datetime.utcnow() period = f"{now.year}-{now.month:02d}" # 서버 수 server_count = (await db.execute(select(func.count(Server.id)))).scalar() or 0 # 최근 MetricSnapshot 집계 (CPU, 메모리) # 7일치 스냅샷을 가져와 평균 계산 seven_days_ago = now - timedelta(days=7) snapshots = ( await db.execute( select(MetricSnapshot).where(MetricSnapshot.ts >= seven_days_ago) ) ).scalars().all() avg_cpu = 0.0 avg_mem = 0.0 if snapshots: avg_cpu = sum(s.cpu_pct for s in snapshots if s.cpu_pct is not None) / len(snapshots) avg_mem = sum(s.mem_pct for s in snapshots if s.mem_pct is not None) / len(snapshots) # 서버당 월 운영비 추산 (단순 계산: 서버 수 × 50만원) estimated_monthly = server_count * 50.0 # 만원 return { "period": period, "server_count": server_count, "avg_cpu_pct": round(avg_cpu, 1), "avg_mem_pct": round(avg_mem, 1), "estimated_monthly": estimated_monthly, "snapshot_count": len(snapshots), } async def _detect_waste(db: AsyncSession) -> List[dict]: """낭비 리소스 감지 — 3가지 기준.""" now = datetime.utcnow() seven_days_ago = now - timedelta(days=7) thirty_days_ago = now - timedelta(days=_WASTE_SR_DAYS) waste_items = [] # 모든 서버 조회 servers = (await db.execute(select(Server))).scalars().all() for srv in servers: reasons = [] # 1. CPU 7일 평균 < 10% cpu_snaps = ( await db.execute( select(MetricSnapshot).where( MetricSnapshot.server_id == srv.id, MetricSnapshot.ts >= seven_days_ago, ) ) ).scalars().all() if cpu_snaps: avg_cpu = sum(s.cpu_pct for s in cpu_snaps if s.cpu_pct is not None) / len(cpu_snaps) if avg_cpu < _WASTE_CPU_THRESHOLD: reasons.append(f"CPU 7일 평균 {avg_cpu:.1f}% (기준 {_WASTE_CPU_THRESHOLD}% 미만)") # 2. 메모리 사용률 < 20% avg_mem = sum(s.mem_pct for s in cpu_snaps if s.mem_pct is not None) / len(cpu_snaps) if avg_mem < _WASTE_MEM_THRESHOLD: reasons.append(f"메모리 사용률 {avg_mem:.1f}% (기준 {_WASTE_MEM_THRESHOLD}% 미만)") # 3. 30일 이상 SR 없는 서버 sr_count = ( await db.execute( select(func.count(SRRequest.id)).where( SRRequest.server_id == srv.id, SRRequest.created_at >= thirty_days_ago, ) ) ).scalar() or 0 if sr_count == 0: reasons.append(f"{_WASTE_SR_DAYS}일 이상 SR 발생 없음") if reasons: waste_items.append({ "server_id": srv.id, "server_name": srv.server_name, "server_role": srv.server_role, "reasons": reasons, "waste_score": len(reasons), # 많을수록 낭비 심각 "est_monthly_saving": _SAVING_UNIT["server"], }) waste_items.sort(key=lambda x: x["waste_score"], reverse=True) return waste_items async def _build_recommendations_from_ai( ai_text: str, db: AsyncSession ) -> List[CostRecommendation]: """Ollama 응답 텍스트를 파싱하여 CostRecommendation 레코드 생성.""" recs = [] # 번호 목록 패턴 파싱: "1. ...", "2. ..." 등 lines = [l.strip() for l in ai_text.split("\n") if l.strip()] current_title = "" current_desc_parts: List[str] = [] idx = 0 for line in lines: # "숫자. " 로 시작하는 행 = 새 권고 항목 if len(line) > 2 and line[0].isdigit() and line[1] in (".", ")"): # 이전 항목 저장 if current_title: rec = CostRecommendation( category="cloud", title=current_title[:300], description="\n".join(current_desc_parts) or None, estimated_saving=float(_SAVING_UNIT["cloud"]), risk_level="LOW", auto_applicable=False, status="pending", ) recs.append(rec) idx += 1 current_title = line[2:].strip() current_desc_parts = [] else: current_desc_parts.append(line) # 마지막 항목 저장 if current_title: rec = CostRecommendation( category="cloud", title=current_title[:300], description="\n".join(current_desc_parts) or None, estimated_saving=float(_SAVING_UNIT["cloud"]), risk_level="LOW", auto_applicable=False, status="pending", ) recs.append(rec) # 최대 5개 제한 return recs[:5] # ── 엔드포인트 ──────────────────────────────────────────────────────────────── @router.get("/analysis") async def get_analysis_status( limit: int = Query(10, ge=1, le=50), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """비용 AI 분석 현황 조회 — 최근 분석 이력을 반환한다.""" rows = ( await db.execute( select(CostAIAnalysis) .order_by(CostAIAnalysis.created_at.desc()) .limit(limit) ) ).scalars().all() pending_recs = ( await db.execute( select(func.count(CostRecommendation.id)).where( CostRecommendation.status == "pending" ) ) ).scalar() or 0 applied_recs = ( await db.execute( select(func.count(CostRecommendation.id)).where( CostRecommendation.status == "applied" ) ) ).scalar() or 0 total_saved = ( await db.execute( select(func.coalesce(func.sum(CostRecommendation.estimated_saving), 0.0)).where( CostRecommendation.status == "applied" ) ) ).scalar() or 0.0 return { "analysis_count": len(rows), "pending_recs": pending_recs, "applied_recs": applied_recs, "total_saved_manwon": round(total_saved, 1), "latest_analysis": [AnalysisOut.model_validate(r) for r in rows], } @router.post("/analyze", status_code=201) async def run_analysis( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """AI 비용 분석 실행 — Ollama 기반 절감 권고를 자동 생성한다. 분석 흐름: 1. 비용 현황 스냅샷 수집 2. 낭비 리소스 감지 3. Ollama sLLM에 분석 요청 4. 응답 파싱 → CostRecommendation 자동 생성 5. CostAIAnalysis 기록 저장 """ snapshot = await _collect_cost_snapshot(db) waste_items = await _detect_waste(db) # Ollama 프롬프트 조합 waste_summary = ( f"\n낭비 감지 서버 {len(waste_items)}대:\n" + "\n".join( f" - {w['server_name']}: {', '.join(w['reasons'])}" for w in waste_items[:5] ) if waste_items else "\n낭비 감지 서버 없음" ) prompt = ( "다음 IT 인프라 비용 현황을 분석하여 절감 기회 3가지를 한국어로 제안해줘:\n\n" f"분석 기간: {snapshot['period']}\n" f"서버 수: {snapshot['server_count']}대\n" f"7일 평균 CPU: {snapshot['avg_cpu_pct']}%\n" f"7일 평균 메모리: {snapshot['avg_mem_pct']}%\n" f"월 추산 운영비: {snapshot['estimated_monthly']:.0f}만원" f"{waste_summary}\n\n" "각 항목은 '번호. 제목' 형식으로 시작하고 2~3줄 설명을 덧붙여줘." ) ai_text = await _call_ollama(prompt, timeout=30.0) # 폴백: 규칙 기반 인사이트 if not ai_text: ai_text = ( "1. 유휴 서버 통합 가상화\n" " CPU/메모리 사용률이 낮은 서버를 가상화하여 물리 서버 수를 줄이세요.\n" "2. 미사용 라이선스 정기 감사\n" " 분기마다 소프트웨어 라이선스 사용 현황을 점검하고 불필요한 계약을 해지하세요.\n" "3. 네트워크 대역폭 최적화\n" " 실제 사용량 대비 과잉 할당된 회선을 축소하여 통신비를 절감하세요." ) # CostRecommendation 자동 생성 (낭비 서버 권고 포함) new_recs: List[CostRecommendation] = [] # 낭비 서버 권고 for w in waste_items[:3]: rec = CostRecommendation( category="server", title=f"[유휴 서버 절감] {w['server_name']} — {w['reasons'][0]}", description="서버 통합·가상화 또는 하드웨어 반납을 검토하세요.", estimated_saving=float(w["est_monthly_saving"]), risk_level="LOW" if w["waste_score"] == 1 else "MEDIUM", auto_applicable=False, status="pending", ) new_recs.append(rec) # AI 텍스트 파싱 권고 ai_recs = await _build_recommendations_from_ai(ai_text, db) new_recs.extend(ai_recs) for rec in new_recs: db.add(rec) # 분석 결과 저장 analysis = CostAIAnalysis( period=snapshot["period"], total_cost=snapshot["estimated_monthly"], breakdown=json.dumps(snapshot, ensure_ascii=False), ai_insights=ai_text, waste_detected=json.dumps(waste_items[:10], ensure_ascii=False), ) db.add(analysis) await db.commit() await db.refresh(analysis) logger.info("비용 AI 분석 완료: period=%s recs=%d", snapshot["period"], len(new_recs)) return { "analysis_id": analysis.id, "period": analysis.period, "total_cost_manwon": analysis.total_cost, "waste_count": len(waste_items), "recommendations_created": len(new_recs), "ai_insights": ai_text, "ollama_used": ai_text != "" and "유휴 서버 통합" not in ai_text, } @router.get("/forecast/{days}") async def get_forecast( days: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """비용 예측 — 30/60/90일 선형 추세 기반. 과거 분석 이력에서 monthly_cost 시계열을 추출하여 단순 선형 회귀로 미래 비용을 예측한다. """ if days not in (30, 60, 90): raise HTTPException(400, "days는 30, 60, 90 중 하나여야 합니다.") # 과거 분석 이력 수집 rows = ( await db.execute( select(CostAIAnalysis) .order_by(CostAIAnalysis.created_at.asc()) .limit(12) ) ).scalars().all() now = datetime.utcnow() # 데이터 부족 시 기본 추산 if len(rows) < 2: base_cost = rows[0].total_cost if rows else 500.0 # 만원 trend_rate = 0.02 # 월 2% 성장 가정 else: costs = [r.total_cost for r in rows] n = len(costs) x_mean = (n - 1) / 2.0 y_mean = sum(costs) / n numerator = sum((i - x_mean) * (costs[i] - y_mean) for i in range(n)) denominator = sum((i - x_mean) ** 2 for i in range(n)) slope = numerator / denominator if denominator > 0 else 0.0 base_cost = costs[-1] # 월 환산 추세율 trend_rate = slope / base_cost if base_cost > 0 else 0.02 # 예측 포인트 생성 (월 단위) months_ahead = days // 30 forecasts_saved = [] for m in range(1, months_ahead + 1): target_date = now + timedelta(days=m * 30) predicted = base_cost * ((1 + trend_rate) ** m) # 신뢰도: 데이터 적을수록, 예측 기간 길수록 낮아짐 confidence = max(0.3, min(0.95, 0.95 - 0.1 * m - (0.05 if len(rows) < 4 else 0))) factors_obj = { "trend_rate_pct": round(trend_rate * 100, 2), "base_cost": round(base_cost, 1), "month_offset": m, "history_points": len(rows), } fc = CostForecast( forecast_date=target_date, predicted_cost=round(predicted, 1), confidence=round(confidence, 2), factors=json.dumps(factors_obj, ensure_ascii=False), ) db.add(fc) forecasts_saved.append(fc) await db.commit() for fc in forecasts_saved: await db.refresh(fc) total_predicted = sum(fc.predicted_cost for fc in forecasts_saved) delta_pct = round((total_predicted / (base_cost * months_ahead) - 1) * 100, 1) if base_cost > 0 else 0.0 return { "days": days, "base_period_cost": round(base_cost, 1), "trend_rate_pct": round(trend_rate * 100, 2), "history_points": len(rows), "total_predicted": round(total_predicted, 1), "delta_vs_flat_pct": delta_pct, "forecasts": [ForecastOut.model_validate(fc) for fc in forecasts_saved], "disclaimer": "예측은 과거 추세 기반 참고값입니다. 실제와 다를 수 있습니다.", } @router.get("/recommendations") async def list_recommendations( status: Optional[str] = Query(None, description="pending|applied|rejected"), category: Optional[str] = Query(None, description="server|license|cloud"), limit: int = Query(20, ge=1, le=100), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """최적화 권고 목록 조회.""" q = select(CostRecommendation).order_by( CostRecommendation.estimated_saving.desc(), CostRecommendation.created_at.desc(), ) if status: q = q.where(CostRecommendation.status == status) if category: q = q.where(CostRecommendation.category == category) q = q.limit(limit) rows = (await db.execute(q)).scalars().all() total_saving = sum(r.estimated_saving for r in rows) return { "total": len(rows), "total_saving_manwon": round(total_saving, 1), "recommendations": [RecommendationOut.model_validate(r) for r in rows], } @router.post("/recommendations/{rec_id}/apply") async def apply_recommendation( rec_id: int, current_user: User = Depends(require_admin_role), db: AsyncSession = Depends(get_db), ): """권고 자동 적용 — ADMIN 승인 후 상태를 applied로 전환한다. 실제 자동화 액션(서버 셧다운 등)은 별도 SSH 실행 레이어가 담당한다. 여기서는 상태 전환 + 감사 기록만 처리한다. """ rec = ( await db.execute(select(CostRecommendation).where(CostRecommendation.id == rec_id)) ).scalar_one_or_none() if not rec: raise HTTPException(404, f"권고 ID {rec_id} 를 찾을 수 없습니다.") if rec.status != "pending": raise HTTPException(400, f"이미 처리된 권고입니다 (현재 상태: {rec.status}).") if not rec.auto_applicable: raise HTTPException( 400, "이 권고는 자동 적용이 불가합니다. 수동으로 조치 후 상태를 업데이트하세요.", ) rec.status = "applied" await db.commit() await db.refresh(rec) logger.info("비용 권고 적용: id=%d title=%s by=%s", rec.id, rec.title, current_user.username) return { "message": "권고가 적용되었습니다.", "recommendation": RecommendationOut.model_validate(rec), "applied_by": current_user.username, "applied_at": datetime.utcnow().isoformat(), } @router.post("/recommendations/{rec_id}/reject") async def reject_recommendation( rec_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """권고 반려 — 불필요한 권고를 rejected 상태로 전환한다.""" rec = ( await db.execute(select(CostRecommendation).where(CostRecommendation.id == rec_id)) ).scalar_one_or_none() if not rec: raise HTTPException(404, f"권고 ID {rec_id} 를 찾을 수 없습니다.") if rec.status != "pending": raise HTTPException(400, f"이미 처리된 권고입니다 (현재 상태: {rec.status}).") rec.status = "rejected" await db.commit() await db.refresh(rec) logger.info("비용 권고 반려: id=%d by=%s", rec.id, current_user.username) return { "message": "권고가 반려되었습니다.", "recommendation": RecommendationOut.model_validate(rec), } @router.get("/waste") async def detect_waste_resources( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """낭비 리소스 감지. 기준: - 서버 CPU 7일 평균 < 10% - 메모리 사용률 < 20% - 30일 이상 SR 없는 서버 """ waste_items = await _detect_waste(db) total_saving = sum(w["est_monthly_saving"] for w in waste_items) return { "waste_count": len(waste_items), "total_saving_manwon": total_saving, "cpu_threshold_pct": _WASTE_CPU_THRESHOLD, "mem_threshold_pct": _WASTE_MEM_THRESHOLD, "sr_inactive_days": _WASTE_SR_DAYS, "waste_resources": waste_items, "detection_at": datetime.utcnow().isoformat(), } @router.get("/savings-report") async def savings_report( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """절감 실적 리포트 — 적용된 권고 기반 누적 절감 효과를 리포트한다.""" # 상태별 집계 status_counts: dict = {} for status_val in ("pending", "applied", "rejected"): cnt = ( await db.execute( select(func.count(CostRecommendation.id)).where( CostRecommendation.status == status_val ) ) ).scalar() or 0 status_counts[status_val] = cnt # 카테고리별 절감액 (적용된 항목만) applied_rows = ( await db.execute( select(CostRecommendation).where(CostRecommendation.status == "applied") ) ).scalars().all() by_category: dict = {} for r in applied_rows: by_category.setdefault(r.category, {"count": 0, "saving": 0.0}) by_category[r.category]["count"] += 1 by_category[r.category]["saving"] += r.estimated_saving total_applied_saving = sum(r.estimated_saving for r in applied_rows) # 최근 분석 이력 latest_analysis = ( await db.execute( select(CostAIAnalysis) .order_by(CostAIAnalysis.created_at.desc()) .limit(1) ) ).scalar_one_or_none() # 12개월 누적 추산 (월 절감 × 12) annual_projected = total_applied_saving * 12 return { "report_date": datetime.utcnow().isoformat(), "recommendation_status": status_counts, "total_applied_saving_manwon": round(total_applied_saving, 1), "annual_projected_manwon": round(annual_projected, 1), "by_category": { k: {"count": v["count"], "saving_manwon": round(v["saving"], 1)} for k, v in by_category.items() }, "latest_analysis_period": latest_analysis.period if latest_analysis else None, "total_analyses": ( await db.execute(select(func.count(CostAIAnalysis.id))) ).scalar() or 0, "roi_note": ( f"현재까지 월 {total_applied_saving:.0f}만원 절감 권고 적용 완료. " f"연 환산 약 {annual_projected:.0f}만원 절감 예상." ), }