""" 스마트 알림 규칙 편집기 + 지능형 필터 노코드 방식으로 알림 규칙을 정의하고 AI 기반 스마트 필터로 알림 피로도를 관리한다. 엔드포인트: GET /api/smart-notify/rules — 규칙 목록 POST /api/smart-notify/rules — 규칙 생성 PUT /api/smart-notify/rules/{id} — 규칙 수정 DELETE /api/smart-notify/rules/{id}— 규칙 삭제 POST /api/smart-notify/test/{id} — 테스트 발송 GET /api/smart-notify/logs — 발송 이력 POST /api/smart-notify/silence — 무음 기간 설정 GET /api/smart-notify/digest — 일괄 요약 설정 """ from __future__ import annotations import json import logging from datetime import datetime from typing import List, Optional import httpx from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from pydantic import BaseModel, Field from sqlalchemy import select, desc from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user, require_admin_role from database import get_db from models import User, SmartNotifyRule, NotifyLog logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/smart-notify", tags=["스마트 알림"]) OLLAMA_URL = "http://localhost:11434" ITSM_NOTIFY = "http://127.0.0.1:9001/api/messenger/webhook" class NotifyRuleCreate(BaseModel): name: str = Field(..., max_length=200) trigger_type: str = Field(..., description="SR_CREATED|SR_UPDATED|INCIDENT|DRIFT|KPI_BREACH|CUSTOM") conditions: dict = Field(default_factory=dict) channels: List[str] = Field(default_factory=list, description="messenger|email|slack|kakao") priority_filter: str = Field("ALL", description="HIGH|MEDIUM|ALL") silence_hours: Optional[List[int]] = Field(None, description="무음 시간 목록 [22,23,0,1,...,7]") digest_mode: bool = Field(False, description="묶음 발송 모드") digest_interval_min: int = Field(60, description="묶음 발송 간격(분)") is_active: bool = True class SilenceRequest(BaseModel): rule_id: Optional[int] = None # None = 전체 hours: List[int] = Field(..., description="무음 시간 [22,23,0,1,...,7]") async def _send_to_messenger(message: str, room: str = "ops") -> bool: try: async with httpx.AsyncClient(timeout=5) as c: r = await c.post(ITSM_NOTIFY, json={ "event": "smart_notify", "room": room, "success": True, "result_summary": message }) return r.status_code == 200 except Exception as e: logger.warning(f"메신저 알림 실패: {e}") return False async def _ai_classify_importance(notification: dict) -> str: """Ollama로 알림 중요도 재평가 (HIGH/MEDIUM/LOW).""" try: async with httpx.AsyncClient(timeout=10) as c: r = await c.post(f"{OLLAMA_URL}/api/generate", json={ "model": "llama3", "system": "IT 운영 알림 중요도 분류. HIGH/MEDIUM/LOW 중 하나만 답변.", "prompt": f"알림: {json.dumps(notification, ensure_ascii=False)[:200]}", "stream": False, }) if r.status_code == 200: resp = r.json().get("response", "").strip().upper() if "HIGH" in resp: return "HIGH" if "LOW" in resp: return "LOW" except Exception: pass return "MEDIUM" def _check_conditions(rule: SmartNotifyRule, event: dict) -> bool: """규칙 조건 충족 여부 확인.""" conditions = rule.conditions or {} for key, expected in conditions.items(): actual = event.get(key) if isinstance(expected, list): if actual not in expected: return False elif actual != expected: return False return True def _is_silent_hour(rule: SmartNotifyRule) -> bool: """현재 시각이 무음 시간대인지 확인.""" silent = rule.silence_hours if not silent: return False current_hour = datetime.utcnow().hour + 9 # KST current_hour = current_hour % 24 return current_hour in silent @router.get("/rules") async def list_rules( db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): rows = await db.execute( select(SmartNotifyRule) .order_by(SmartNotifyRule.name) ) rules = rows.scalars().all() return [ { "id": r.id, "name": r.name, "trigger_type": r.trigger_type, "conditions": r.conditions, "channels": r.channels, "priority_filter": r.priority_filter, "silence_hours": r.silence_hours, "digest_mode": r.digest_mode, "is_active": r.is_active, "created_at": r.created_at, } for r in rules ] @router.post("/rules") async def create_rule( req: NotifyRuleCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role), ): rule = SmartNotifyRule( name=req.name, trigger_type=req.trigger_type, conditions=req.conditions, channels=req.channels, priority_filter=req.priority_filter, silence_hours=req.silence_hours or [], digest_mode=req.digest_mode, digest_interval_min=req.digest_interval_min, is_active=req.is_active, created_at=datetime.utcnow(), ) db.add(rule) await db.commit() await db.refresh(rule) return {"ok": True, "id": rule.id} @router.put("/rules/{rule_id}") async def update_rule( rule_id: int, req: NotifyRuleCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role), ): row = await db.execute( select(SmartNotifyRule).where(SmartNotifyRule.id == rule_id, SmartNotifyRule.id > 0) ) rule = row.scalar_one_or_none() if not rule: raise HTTPException(404) rule.name = req.name; rule.trigger_type = req.trigger_type rule.conditions = req.conditions; rule.channels = req.channels rule.priority_filter = req.priority_filter rule.silence_hours = req.silence_hours or [] rule.digest_mode = req.digest_mode; rule.is_active = req.is_active await db.commit() return {"ok": True} @router.delete("/rules/{rule_id}") async def delete_rule( rule_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role), ): row = await db.execute( select(SmartNotifyRule).where(SmartNotifyRule.id == rule_id, SmartNotifyRule.id > 0) ) rule = row.scalar_one_or_none() if not rule: raise HTTPException(404) await db.delete(rule); await db.commit() return {"ok": True} @router.post("/test/{rule_id}") async def test_rule( rule_id: int, background_tasks: BackgroundTasks, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """테스트 알림 발송.""" row = await db.execute( select(SmartNotifyRule).where(SmartNotifyRule.id == rule_id, SmartNotifyRule.id > 0) ) rule = row.scalar_one_or_none() if not rule: raise HTTPException(404) msg = f"[테스트] 알림 규칙 '{rule.name}' 테스트 발송" sent = False if "messenger" in (rule.channels or []): sent = await _send_to_messenger(msg) log = NotifyLog( rule_id=rule.id, channel="messenger", recipient="ops", message=msg, status="SENT" if sent else "FAILED", sent_at=datetime.utcnow(), ) db.add(log); await db.commit() return {"ok": sent, "message": msg} @router.get("/logs") async def notify_logs( limit: int = 50, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): rows = await db.execute( select(NotifyLog, SmartNotifyRule.name.label("rule_name")).join( SmartNotifyRule, NotifyLog.rule_id == SmartNotifyRule.id, isouter=True ) .order_by(desc(NotifyLog.sent_at)).limit(limit) ) return [ {"id": r.NotifyLog.id, "rule": r.rule_name, "channel": r.NotifyLog.channel, "status": r.NotifyLog.status, "message": r.NotifyLog.message[:100], "sent_at": r.NotifyLog.sent_at} for r in rows.all() ] @router.post("/silence") async def set_silence( req: SilenceRequest, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role), ): """무음 시간대 설정.""" q = select(SmartNotifyRule) if req.rule_id: q = q.where(SmartNotifyRule.id == req.rule_id) rows = await db.execute(q) rules = rows.scalars().all() for rule in rules: rule.silence_hours = req.hours await db.commit() return {"ok": True, "updated": len(rules), "silence_hours": req.hours}