zioinfo-mail/workspace/guardia-itsm/routers/smart_notify.py
DESKTOP-TKLFCPR\ython 0ebac500f5
Some checks are pending
GUARDiA CI / Python Lint & Import Test (push) Waiting to run
GUARDiA CI / Validate Install Scripts (push) Waiting to run
GUARDiA CI / PR Validation Summary (push) Blocked by required conditions
feat(enhance-v4): APK QR 배포 / 배치SSH / 자산QR / 스마트알림 / 웹메일 주소록+서명
- ITSM: app_deploy.py (APK 업로드·QR 생성·랜딩 페이지)
- ITSM: batch_ssh.py (다중 서버 동시 SSH 실행)
- ITSM: asset_qr.py (자산 QR 태그·체크인·라벨 인쇄)
- ITSM: smart_notify.py (조건 기반 알림 규칙 엔진)
- ITSM: models.py (AppVersion/BatchSSHJob/AssetQRToken/SmartNotifyRule 등 7개 모델)
- ITSM: main.py (4개 신규 라우터 등록)
- ITSM: static/app.js (앱배포·배치SSH·자산QR·알림규칙 4개 뷰)
- ITSM: static/index.html (신규 사이드바 메뉴 4개)
- Manager: AppDistribution.tsx (APK 업로드 UI·QR 표시·버전 관리)
- Manager: NotificationRules.tsx (알림 규칙 편집기)
- Manager: App.tsx + Sidebar.tsx (신규 라우트 등록)
- Mail: contacts.py (주소록 CRUD·자동완성)
- Mail: signature.py (HTML 서명 관리)
- Mail: Contacts.tsx + SignatureEditor.tsx (프론트엔드 컴포넌트)
- Messenger: scan.tsx (자산 QR 스캔 탭)
- Messenger: _layout.tsx (QR 탭 추가)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 19:49:00 +09:00

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}