261 lines
8.8 KiB
Python
261 lines
8.8 KiB
Python
"""
|
|
스마트 알림 규칙 편집기 + 지능형 필터
|
|
|
|
노코드 방식으로 알림 규칙을 정의하고
|
|
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).where(SmartNotifyRule.tenant_id == user.tenant_id)
|
|
.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(
|
|
tenant_id=user.tenant_id,
|
|
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.tenant_id == user.tenant_id)
|
|
)
|
|
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.tenant_id == user.tenant_id)
|
|
)
|
|
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.tenant_id == user.tenant_id)
|
|
)
|
|
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
|
|
).where(SmartNotifyRule.tenant_id == user.tenant_id)
|
|
.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).where(SmartNotifyRule.tenant_id == user.tenant_id)
|
|
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}
|