feat(expansion): GUARDiA v3 P3 완성 — 13 routers + 14 DB tables
라우터 (667개 엔드포인트, P3 신규 69개):
- multimodal.py: llava 이미지 분석 + 에러 자동 분류
- learning_loop.py: Ollama 파인튜닝 + 품질 지표
- ai_insights.py: 주간 인사이트 + 반복 패턴 + 개선 권고
- container_alerts.py: Docker 이상 감지 → SR 자동 생성
- ncloud.py: NCloud API (서버/LB/스토리지/비용)
- billing.py: 구독 플랜 + 사용량 측정 + 청구서
- servicenow.py: ServiceNow CMDB/Incident 양방향 연동
- erp_connector.py: 그룹웨어/HR ERP 연동 + 결재 웹훅
- kakao_notify.py: 카카오 알림톡 + 대량 발송
- auto_report.py: Excel/PDF 보고서 자동 생성·다운로드
- benchmark.py: 기관 간 익명 벤치마킹 (완전 익명화)
- cohort_analysis.py: 도입 코호트 + 리텐션 + 기능 도입률
DB 모델 (14개 신규 테이블):
tb_learning_run, tb_container_alert_{rule,log},
tb_ncloud_config, tb_subscription, tb_invoice,
tb_servicenow_{config,mapping}, tb_erp_config,
tb_kakao_{config,notify_log}, tb_report_{record,schedule},
tb_benchmark_contrib
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
09bab3c2ff
commit
fc0ba65e05
@ -324,6 +324,25 @@ app.include_router(predictive_ops.router) # 예측 운영 분석 (SLA/SR
|
|||||||
app.include_router(slack_connector.router) # Slack 연동 (알림/명령어)
|
app.include_router(slack_connector.router) # Slack 연동 (알림/명령어)
|
||||||
app.include_router(white_label.router) # 화이트라벨 브랜딩
|
app.include_router(white_label.router) # 화이트라벨 브랜딩
|
||||||
|
|
||||||
|
# ── GUARDiA 확장 v3 P3 (2026-06-02) ──────────────────────────────────────────
|
||||||
|
from routers import (
|
||||||
|
multimodal, learning_loop, ai_insights, container_alerts, ncloud,
|
||||||
|
billing, servicenow, erp_connector, kakao_notify,
|
||||||
|
auto_report, benchmark, cohort_analysis,
|
||||||
|
)
|
||||||
|
app.include_router(multimodal.router) # 멀티모달 AI (이미지/로그 분석)
|
||||||
|
app.include_router(learning_loop.router) # Self-Improving Learning Loop
|
||||||
|
app.include_router(ai_insights.router) # AI 운영 인사이트 + 주간 리포트
|
||||||
|
app.include_router(container_alerts.router) # 컨테이너 이상 감지 → SR 자동 생성
|
||||||
|
app.include_router(ncloud.router) # NCloud 서버/LB/스토리지 관리
|
||||||
|
app.include_router(billing.router) # 구독·과금·청구서
|
||||||
|
app.include_router(servicenow.router) # ServiceNow CMDB/Incident 연동
|
||||||
|
app.include_router(erp_connector.router) # ERP/그룹웨어 연동
|
||||||
|
app.include_router(kakao_notify.router) # 카카오 알림톡
|
||||||
|
app.include_router(auto_report.router) # 자동 보고서 생성·다운로드
|
||||||
|
app.include_router(benchmark.router) # 기관 간 익명 벤치마킹
|
||||||
|
app.include_router(cohort_analysis.router) # 코호트 분석
|
||||||
|
|
||||||
|
|
||||||
# ── 개방망 보안 헤더 미들웨어 ────────────────────────────────────────────────
|
# ── 개방망 보안 헤더 미들웨어 ────────────────────────────────────────────────
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
|
|||||||
@ -4910,3 +4910,197 @@ class TenantBranding(Base):
|
|||||||
email_footer_html = Column(Text, nullable=True)
|
email_footer_html = Column(Text, nullable=True)
|
||||||
created_at = Column(DateTime, default=func.now())
|
created_at = Column(DateTime, default=func.now())
|
||||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# ── GUARDiA 확장 v3 P3 — Learning / Container / NCloud / Billing / ServiceNow
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class LearningRun(Base):
|
||||||
|
"""AI 학습 실행 이력."""
|
||||||
|
__tablename__ = "tb_learning_run"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
triggered_by = Column(Integer, nullable=True)
|
||||||
|
sample_count = Column(Integer, default=0)
|
||||||
|
samples_used = Column(Integer, default=0)
|
||||||
|
model_name = Column(String(200), nullable=True)
|
||||||
|
status = Column(String(20), default="PENDING") # PENDING|RUNNING|SUCCESS|FAILED
|
||||||
|
error_message = Column(Text, nullable=True)
|
||||||
|
started_at = Column(DateTime, default=func.now())
|
||||||
|
finished_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ContainerAlertRule(Base):
|
||||||
|
"""컨테이너 알림 규칙."""
|
||||||
|
__tablename__ = "tb_container_alert_rule"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
tenant_id = Column(Integer, nullable=False, index=True)
|
||||||
|
name = Column(String(200), nullable=False)
|
||||||
|
server_id = Column(Integer, ForeignKey("tb_server_info.id"), nullable=False)
|
||||||
|
container_name = Column(String(200), nullable=True)
|
||||||
|
alert_on_stopped = Column(Boolean, default=True)
|
||||||
|
alert_on_high_cpu = Column(Boolean, default=True)
|
||||||
|
cpu_threshold = Column(Float, default=90.0)
|
||||||
|
alert_on_high_mem = Column(Boolean, default=True)
|
||||||
|
mem_threshold = Column(Float, default=90.0)
|
||||||
|
auto_sr = Column(Boolean, default=True)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class ContainerAlertLog(Base):
|
||||||
|
"""컨테이너 알림 이력."""
|
||||||
|
__tablename__ = "tb_container_alert_log"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
rule_id = Column(Integer, ForeignKey("tb_container_alert_rule.id"), nullable=False)
|
||||||
|
alert_type = Column(String(50), nullable=False)
|
||||||
|
container_name = Column(String(200), nullable=True)
|
||||||
|
severity = Column(String(20), nullable=False)
|
||||||
|
message = Column(Text, nullable=True)
|
||||||
|
detected_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class NCloudConfig(Base):
|
||||||
|
"""NCloud API 설정."""
|
||||||
|
__tablename__ = "tb_ncloud_config"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
tenant_id = Column(Integer, nullable=False, unique=True, index=True)
|
||||||
|
access_key = Column(String(200), nullable=False)
|
||||||
|
secret_key_enc = Column(Text, nullable=False)
|
||||||
|
region = Column(String(20), default="KR")
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class Subscription(Base):
|
||||||
|
"""테넌트 구독 정보."""
|
||||||
|
__tablename__ = "tb_subscription"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
tenant_id = Column(Integer, nullable=False, unique=True, index=True)
|
||||||
|
plan = Column(String(50), nullable=False, default="COMMUNITY")
|
||||||
|
billing_cycle = Column(String(20), default="MONTHLY")
|
||||||
|
status = Column(String(20), default="ACTIVE")
|
||||||
|
is_trial = Column(Boolean, default=False)
|
||||||
|
start_date = Column(DateTime, nullable=True)
|
||||||
|
next_billing_date = Column(DateTime, nullable=True)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class Invoice(Base):
|
||||||
|
"""청구서."""
|
||||||
|
__tablename__ = "tb_invoice"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
tenant_id = Column(Integer, nullable=False, index=True)
|
||||||
|
plan = Column(String(50), nullable=True)
|
||||||
|
period = Column(String(10), nullable=False) # YYYY-MM
|
||||||
|
amount = Column(Integer, default=0)
|
||||||
|
servers_used = Column(Integer, default=0)
|
||||||
|
users_used = Column(Integer, default=0)
|
||||||
|
sr_count = Column(Integer, default=0)
|
||||||
|
status = Column(String(20), default="DRAFT") # DRAFT|ISSUED|PAID
|
||||||
|
generated_by = Column(Integer, nullable=True)
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceNowConfig(Base):
|
||||||
|
"""ServiceNow 연동 설정."""
|
||||||
|
__tablename__ = "tb_servicenow_config"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
tenant_id = Column(Integer, nullable=False, unique=True, index=True)
|
||||||
|
instance_url = Column(String(500), nullable=False)
|
||||||
|
username = Column(String(200), nullable=False)
|
||||||
|
password_enc = Column(Text, nullable=False)
|
||||||
|
assignment_group = Column(String(200), nullable=True)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceNowMapping(Base):
|
||||||
|
"""SR ↔ ServiceNow Incident 매핑."""
|
||||||
|
__tablename__ = "tb_servicenow_mapping"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
sr_id = Column(Integer, ForeignKey("tb_sr_request.id"), nullable=False)
|
||||||
|
snow_number = Column(String(50), nullable=False)
|
||||||
|
config_id = Column(Integer, ForeignKey("tb_servicenow_config.id"), nullable=False)
|
||||||
|
synced_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class ERPConfig(Base):
|
||||||
|
"""ERP / 그룹웨어 연동 설정."""
|
||||||
|
__tablename__ = "tb_erp_config"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
tenant_id = Column(Integer, nullable=False, index=True)
|
||||||
|
name = Column(String(100), nullable=False)
|
||||||
|
base_url = Column(String(500), nullable=False)
|
||||||
|
erp_type = Column(String(50), default="generic")
|
||||||
|
api_key_enc = Column(Text, nullable=True)
|
||||||
|
username = Column(String(200), nullable=True)
|
||||||
|
password_enc = Column(Text, nullable=True)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class KakaoConfig(Base):
|
||||||
|
"""카카오 알림톡 설정."""
|
||||||
|
__tablename__ = "tb_kakao_config"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
tenant_id = Column(Integer, nullable=False, unique=True, index=True)
|
||||||
|
apikey = Column(String(200), nullable=False)
|
||||||
|
userid = Column(String(100), nullable=False)
|
||||||
|
senderkey_enc = Column(Text, nullable=False)
|
||||||
|
sender = Column(String(20), nullable=False)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class KakaoNotifyLog(Base):
|
||||||
|
"""카카오 발송 이력."""
|
||||||
|
__tablename__ = "tb_kakao_notify_log"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
tenant_id = Column(Integer, nullable=False, index=True)
|
||||||
|
template_code = Column(String(100), nullable=False)
|
||||||
|
receiver_count = Column(Integer, default=0)
|
||||||
|
success = Column(Boolean, default=False)
|
||||||
|
result_json = Column(Text, nullable=True)
|
||||||
|
sent_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class ReportRecord(Base):
|
||||||
|
"""생성된 보고서 이력."""
|
||||||
|
__tablename__ = "tb_report_record"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
tenant_id = Column(Integer, nullable=False, index=True)
|
||||||
|
template = Column(String(50), nullable=False)
|
||||||
|
period_start = Column(DateTime, nullable=True)
|
||||||
|
period_end = Column(DateTime, nullable=True)
|
||||||
|
format = Column(String(10), default="excel")
|
||||||
|
file_size = Column(Integer, default=0)
|
||||||
|
status = Column(String(20), default="DONE")
|
||||||
|
generated_by = Column(Integer, nullable=True)
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class BenchmarkContrib(Base):
|
||||||
|
"""익명 벤치마킹 기여 데이터."""
|
||||||
|
__tablename__ = "tb_benchmark_contrib"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
completion_rate = Column(Float, nullable=True)
|
||||||
|
mttr_hours = Column(Float, nullable=True)
|
||||||
|
sla_compliance = Column(Float, nullable=True)
|
||||||
|
sr_volume_band = Column(String(20), nullable=True) # LOW|MEDIUM|HIGH
|
||||||
|
contributed_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class ReportSchedule(Base):
|
||||||
|
"""자동 보고서 발송 스케줄."""
|
||||||
|
__tablename__ = "tb_report_schedule"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
tenant_id = Column(Integer, nullable=False, index=True)
|
||||||
|
template = Column(String(50), nullable=False)
|
||||||
|
cron = Column(String(100), nullable=False)
|
||||||
|
email = Column(String(200), nullable=False)
|
||||||
|
format = Column(String(10), default="excel")
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
|||||||
230
workspace/guardia-itsm/routers/ai_insights.py
Normal file
230
workspace/guardia-itsm/routers/ai_insights.py
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
"""
|
||||||
|
AI 인사이트 — SR 패턴 분석 + 반복 장애 예측 + 주간 운영 리포트
|
||||||
|
|
||||||
|
엔드포인트:
|
||||||
|
GET /api/insights/weekly — 주간 AI 인사이트 리포트
|
||||||
|
GET /api/insights/patterns — 반복 SR 패턴 분석
|
||||||
|
GET /api/insights/anomalies — 이상 패턴 감지
|
||||||
|
GET /api/insights/recommendations — AI 운영 개선 권고
|
||||||
|
POST /api/insights/ask — 운영 데이터 자연어 질의
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy import select, func, desc
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from core.auth import get_current_user
|
||||||
|
from database import get_db
|
||||||
|
from models import User, SRRequest, SRStatus
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/insights", tags=["AI Insights"])
|
||||||
|
|
||||||
|
OLLAMA_URL = "http://localhost:11434"
|
||||||
|
MODEL = "llama3"
|
||||||
|
|
||||||
|
|
||||||
|
async def _llm(prompt: str, system: str = "") -> str:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30) as c:
|
||||||
|
r = await c.post(f"{OLLAMA_URL}/api/generate", json={
|
||||||
|
"model": MODEL, "prompt": prompt,
|
||||||
|
"system": system or "GUARDiA ITSM 전문 분석가. 한국어로 핵심만 간결하게.",
|
||||||
|
"stream": False,
|
||||||
|
})
|
||||||
|
return r.json().get("response", "").strip() if r.status_code == 200 else ""
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"LLM 호출 실패: {e}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/weekly")
|
||||||
|
async def weekly_insights(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""주간 AI 인사이트 리포트."""
|
||||||
|
today = date.today()
|
||||||
|
week_start = today - timedelta(days=7)
|
||||||
|
|
||||||
|
# 이번 주 SR 통계
|
||||||
|
total_r = await db.execute(
|
||||||
|
select(func.count(SRRequest.id)).where(SRRequest.created_at >= week_start)
|
||||||
|
)
|
||||||
|
done_r = await db.execute(
|
||||||
|
select(func.count(SRRequest.id)).where(
|
||||||
|
SRRequest.status == SRStatus.DONE, SRRequest.updated_at >= week_start
|
||||||
|
)
|
||||||
|
)
|
||||||
|
open_r = await db.execute(
|
||||||
|
select(func.count(SRRequest.id)).where(
|
||||||
|
SRRequest.status.in_([SRStatus.OPEN, SRStatus.IN_PROGRESS])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
total = total_r.scalar() or 0
|
||||||
|
done = done_r.scalar() or 0
|
||||||
|
open_count = open_r.scalar() or 0
|
||||||
|
|
||||||
|
# 카테고리별 분포
|
||||||
|
cat_rows = await db.execute(
|
||||||
|
select(SRRequest.category, func.count(SRRequest.id).label("cnt"))
|
||||||
|
.where(SRRequest.created_at >= week_start)
|
||||||
|
.group_by(SRRequest.category).order_by(desc("cnt")).limit(5)
|
||||||
|
)
|
||||||
|
top_categories = [(r.category or "기타", r.cnt) for r in cat_rows.all()]
|
||||||
|
|
||||||
|
# Ollama 인사이트 생성
|
||||||
|
stats_summary = (
|
||||||
|
f"이번 주 신규 SR {total}건, 완료 {done}건, 미처리 {open_count}건. "
|
||||||
|
f"상위 카테고리: {', '.join(f'{c}({n}건)' for c, n in top_categories[:3])}"
|
||||||
|
)
|
||||||
|
insight = await _llm(
|
||||||
|
f"운영 현황: {stats_summary}\n운영팀을 위한 핵심 인사이트 3가지를 번호 매겨 제시하세요."
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"period": {"start": week_start.isoformat(), "end": today.isoformat()},
|
||||||
|
"stats": {"total": total, "done": done, "open": open_count,
|
||||||
|
"completion_rate": round(done / total * 100, 1) if total else 0},
|
||||||
|
"top_categories": [{"category": c, "count": n} for c, n in top_categories],
|
||||||
|
"ai_insight": insight,
|
||||||
|
"generated_at": datetime.utcnow(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/patterns")
|
||||||
|
async def sr_patterns(
|
||||||
|
days: int = Query(30, ge=7, le=90),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""반복 SR 패턴 — 같은 카테고리/서버에서 반복 발생하는 SR."""
|
||||||
|
since = date.today() - timedelta(days=days)
|
||||||
|
|
||||||
|
# 카테고리별 반복 패턴
|
||||||
|
cat_rows = await db.execute(
|
||||||
|
select(
|
||||||
|
SRRequest.category, SRRequest.priority,
|
||||||
|
func.count(SRRequest.id).label("cnt"),
|
||||||
|
func.avg(
|
||||||
|
func.extract('epoch', SRRequest.updated_at - SRRequest.created_at) / 3600
|
||||||
|
).label("avg_hours")
|
||||||
|
).where(SRRequest.created_at >= since)
|
||||||
|
.group_by(SRRequest.category, SRRequest.priority)
|
||||||
|
.order_by(desc("cnt")).limit(10)
|
||||||
|
)
|
||||||
|
patterns = [
|
||||||
|
{
|
||||||
|
"category": r.category or "기타",
|
||||||
|
"priority": r.priority or "MEDIUM",
|
||||||
|
"count": r.cnt,
|
||||||
|
"avg_resolution_hours": round(r.avg_hours or 0, 1),
|
||||||
|
"is_recurring": r.cnt >= 3,
|
||||||
|
}
|
||||||
|
for r in cat_rows.all()
|
||||||
|
]
|
||||||
|
|
||||||
|
recurring = [p for p in patterns if p["is_recurring"]]
|
||||||
|
insight = ""
|
||||||
|
if recurring:
|
||||||
|
summary = ", ".join(f"{p['category']}({p['count']}건)" for p in recurring[:3])
|
||||||
|
insight = await _llm(
|
||||||
|
f"반복 발생 카테고리: {summary}. 근본 원인과 재발 방지 방안을 제시하세요."
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"period_days": days, "patterns": patterns, "recurring_count": len(recurring), "insight": insight}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/anomalies")
|
||||||
|
async def detect_anomalies(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""이상 패턴 감지 — 오늘 SR이 7일 평균보다 2배 이상이거나 미처리가 급증."""
|
||||||
|
today = date.today()
|
||||||
|
today_count_r = await db.execute(
|
||||||
|
select(func.count(SRRequest.id)).where(func.date(SRRequest.created_at) == today)
|
||||||
|
)
|
||||||
|
today_count = today_count_r.scalar() or 0
|
||||||
|
|
||||||
|
# 7일 평균
|
||||||
|
daily_counts = []
|
||||||
|
for i in range(1, 8):
|
||||||
|
d = today - timedelta(days=i)
|
||||||
|
r = await db.execute(select(func.count(SRRequest.id)).where(func.date(SRRequest.created_at) == d))
|
||||||
|
daily_counts.append(r.scalar() or 0)
|
||||||
|
avg_7d = sum(daily_counts) / len(daily_counts) if daily_counts else 0
|
||||||
|
|
||||||
|
anomalies = []
|
||||||
|
if avg_7d > 0 and today_count >= avg_7d * 2:
|
||||||
|
anomalies.append({
|
||||||
|
"type": "SR_SURGE", "severity": "HIGH",
|
||||||
|
"message": f"오늘 SR {today_count}건 — 7일 평균({avg_7d:.0f}건) 대비 {today_count/avg_7d:.1f}배",
|
||||||
|
})
|
||||||
|
|
||||||
|
# 미처리 SR 급증
|
||||||
|
open_r = await db.execute(
|
||||||
|
select(func.count(SRRequest.id)).where(SRRequest.status.in_([SRStatus.OPEN, SRStatus.IN_PROGRESS]))
|
||||||
|
)
|
||||||
|
open_count = open_r.scalar() or 0
|
||||||
|
if open_count > 20:
|
||||||
|
anomalies.append({
|
||||||
|
"type": "BACKLOG_HIGH", "severity": "MEDIUM",
|
||||||
|
"message": f"미처리 SR {open_count}건 — 임계값(20건) 초과",
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"anomalies": anomalies, "today_sr": today_count, "avg_7d": round(avg_7d, 1), "open_sr": open_count}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/recommendations")
|
||||||
|
async def ai_recommendations(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""AI 운영 개선 권고사항."""
|
||||||
|
weekly = await weekly_insights(db, user)
|
||||||
|
patterns = await sr_patterns(30, db, user)
|
||||||
|
anomalies = await detect_anomalies(db, user)
|
||||||
|
|
||||||
|
context = (
|
||||||
|
f"완료율 {weekly['stats']['completion_rate']}%, "
|
||||||
|
f"미처리 {weekly['stats']['open']}건, "
|
||||||
|
f"반복 카테고리 {patterns['recurring_count']}개, "
|
||||||
|
f"이상 감지 {len(anomalies['anomalies'])}건"
|
||||||
|
)
|
||||||
|
recommendations = await _llm(
|
||||||
|
f"운영 현황: {context}\n개선 권고사항 5가지를 우선순위 순으로 제시하세요.",
|
||||||
|
"GUARDiA ITSM 운영 컨설턴트. 구체적이고 실행 가능한 권고사항을 제시."
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"summary": context,
|
||||||
|
"recommendations": recommendations,
|
||||||
|
"generated_at": datetime.utcnow(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AskRequest(__import__('pydantic', fromlist=['BaseModel']).BaseModel):
|
||||||
|
question: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ask")
|
||||||
|
async def ask_operations(
|
||||||
|
req: AskRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""운영 데이터 자연어 질의."""
|
||||||
|
weekly = await weekly_insights(db, user)
|
||||||
|
context = (
|
||||||
|
f"이번 주 SR 현황: 신규 {weekly['stats']['total']}건, "
|
||||||
|
f"완료 {weekly['stats']['done']}건, 미처리 {weekly['stats']['open']}건"
|
||||||
|
)
|
||||||
|
answer = await _llm(f"운영 현황: {context}\n\n질문: {req.question}")
|
||||||
|
return {"question": req.question, "answer": answer}
|
||||||
216
workspace/guardia-itsm/routers/auto_report.py
Normal file
216
workspace/guardia-itsm/routers/auto_report.py
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
"""
|
||||||
|
자동 보고서 생성 — 주간/월간/분기 운영 보고서 자동 발송
|
||||||
|
|
||||||
|
기존 report.py를 확장하여 스케줄 기반 자동 생성 + 이메일 발송.
|
||||||
|
|
||||||
|
엔드포인트:
|
||||||
|
GET /api/auto-report/templates — 보고서 템플릿 목록
|
||||||
|
POST /api/auto-report/generate — 보고서 즉시 생성
|
||||||
|
GET /api/auto-report/list — 생성된 보고서 목록
|
||||||
|
GET /api/auto-report/{id}/download — 보고서 다운로드
|
||||||
|
POST /api/auto-report/schedule — 자동 발송 스케줄 설정
|
||||||
|
GET /api/auto-report/schedule — 스케줄 목록
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
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, SRRequest, SRStatus, ReportRecord, ReportSchedule # 신규
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/auto-report", tags=["Auto Report"])
|
||||||
|
|
||||||
|
TEMPLATES = {
|
||||||
|
"weekly_ops": {"name": "주간 운영 보고서", "period": "WEEKLY", "format": ["excel", "pdf"]},
|
||||||
|
"monthly_sla": {"name": "월간 SLA 보고서", "period": "MONTHLY", "format": ["excel", "pdf"]},
|
||||||
|
"incident_rca": {"name": "인시던트 분석", "period": "MONTHLY", "format": ["pdf"]},
|
||||||
|
"capacity_plan": {"name": "용량 계획 보고서", "period": "QUARTERLY","format": ["excel"]},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateRequest(BaseModel):
|
||||||
|
template: str = Field(..., description="weekly_ops | monthly_sla | incident_rca | capacity_plan")
|
||||||
|
period_start: Optional[str] = None # YYYY-MM-DD
|
||||||
|
period_end: Optional[str] = None
|
||||||
|
format: str = Field("excel", pattern="^(excel|pdf)$")
|
||||||
|
send_email: bool = False
|
||||||
|
email: Optional[str] = None
|
||||||
|
|
||||||
|
class ScheduleCreate(BaseModel):
|
||||||
|
template: str
|
||||||
|
cron: str = Field(..., description="cron 표현식 (예: 0 9 * * 1 = 매주 월요일 9시)")
|
||||||
|
email: str
|
||||||
|
format: str = "excel"
|
||||||
|
|
||||||
|
|
||||||
|
async def _collect_report_data(template: str, start: date, end: date, db: AsyncSession) -> dict:
|
||||||
|
"""보고서 데이터 수집."""
|
||||||
|
total_r = await db.execute(
|
||||||
|
select(func.count(SRRequest.id)).where(
|
||||||
|
SRRequest.created_at >= start, SRRequest.created_at <= end
|
||||||
|
)
|
||||||
|
)
|
||||||
|
done_r = await db.execute(
|
||||||
|
select(func.count(SRRequest.id)).where(
|
||||||
|
SRRequest.status == SRStatus.DONE,
|
||||||
|
SRRequest.updated_at >= start, SRRequest.updated_at <= end,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
open_r = await db.execute(
|
||||||
|
select(func.count(SRRequest.id)).where(SRRequest.status.in_([SRStatus.OPEN, SRStatus.IN_PROGRESS]))
|
||||||
|
)
|
||||||
|
mttr_r = await db.execute(
|
||||||
|
select(func.avg(
|
||||||
|
func.extract('epoch', SRRequest.updated_at - SRRequest.created_at) / 3600
|
||||||
|
)).where(SRRequest.status == SRStatus.DONE, SRRequest.updated_at >= start, SRRequest.updated_at <= end)
|
||||||
|
)
|
||||||
|
|
||||||
|
total = total_r.scalar() or 0
|
||||||
|
done = done_r.scalar() or 0
|
||||||
|
return {
|
||||||
|
"period": {"start": start.isoformat(), "end": end.isoformat()},
|
||||||
|
"sr_total": total, "sr_done": done, "sr_open": open_r.scalar() or 0,
|
||||||
|
"completion_rate": round(done / total * 100, 1) if total else 0,
|
||||||
|
"mttr_hours": round(mttr_r.scalar() or 0, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_excel(data: dict, template: str) -> bytes:
|
||||||
|
"""Excel 보고서 생성 (openpyxl)."""
|
||||||
|
try:
|
||||||
|
import openpyxl
|
||||||
|
from openpyxl.styles import Font, PatternFill, Alignment
|
||||||
|
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = TEMPLATES.get(template, {}).get("name", "보고서")
|
||||||
|
|
||||||
|
# 헤더
|
||||||
|
ws["A1"] = TEMPLATES.get(template, {}).get("name", "운영 보고서")
|
||||||
|
ws["A1"].font = Font(bold=True, size=14)
|
||||||
|
ws["A2"] = f"기간: {data['period']['start']} ~ {data['period']['end']}"
|
||||||
|
|
||||||
|
ws["A4"] = "지표"; ws["B4"] = "값"
|
||||||
|
ws["A4"].font = Font(bold=True)
|
||||||
|
ws["B4"].font = Font(bold=True)
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
("신규 SR", data["sr_total"]),
|
||||||
|
("완료 SR", data["sr_done"]),
|
||||||
|
("미처리 SR", data["sr_open"]),
|
||||||
|
("완료율 (%)", data["completion_rate"]),
|
||||||
|
("평균 처리 시간 (시간)", data["mttr_hours"]),
|
||||||
|
]
|
||||||
|
for i, (label, value) in enumerate(rows, start=5):
|
||||||
|
ws[f"A{i}"] = label
|
||||||
|
ws[f"B{i}"] = value
|
||||||
|
|
||||||
|
ws["A4"].fill = PatternFill(start_color="003366", end_color="003366", fill_type="solid")
|
||||||
|
ws["A4"].font = Font(bold=True, color="FFFFFF")
|
||||||
|
ws["B4"].fill = PatternFill(start_color="003366", end_color="003366", fill_type="solid")
|
||||||
|
ws["B4"].font = Font(bold=True, color="FFFFFF")
|
||||||
|
ws.column_dimensions["A"].width = 25
|
||||||
|
ws.column_dimensions["B"].width = 15
|
||||||
|
|
||||||
|
output = io.BytesIO()
|
||||||
|
wb.save(output)
|
||||||
|
return output.getvalue()
|
||||||
|
except ImportError:
|
||||||
|
# openpyxl 없으면 CSV 대체
|
||||||
|
lines = [f"{k},{v}" for k, v in [("지표", "값")] + [(str(k), str(v)) for k, v in [
|
||||||
|
("신규 SR", data["sr_total"]), ("완료율", data["completion_rate"])
|
||||||
|
]]]
|
||||||
|
return "\n".join(lines).encode('utf-8-sig')
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/templates")
|
||||||
|
async def list_templates():
|
||||||
|
return [{"code": k, **v} for k, v in TEMPLATES.items()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/generate")
|
||||||
|
async def generate_report(
|
||||||
|
req: GenerateRequest, db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
if req.template not in TEMPLATES:
|
||||||
|
raise HTTPException(400, f"알 수 없는 템플릿: {req.template}")
|
||||||
|
|
||||||
|
today = date.today()
|
||||||
|
if req.period_start and req.period_end:
|
||||||
|
start = date.fromisoformat(req.period_start)
|
||||||
|
end = date.fromisoformat(req.period_end)
|
||||||
|
else:
|
||||||
|
period = TEMPLATES[req.template]["period"]
|
||||||
|
if period == "WEEKLY":
|
||||||
|
start = today - timedelta(days=7); end = today
|
||||||
|
elif period == "QUARTERLY":
|
||||||
|
q_start = date(today.year, ((today.month - 1) // 3) * 3 + 1, 1)
|
||||||
|
start = q_start; end = today
|
||||||
|
else: # MONTHLY
|
||||||
|
start = today.replace(day=1); end = today
|
||||||
|
|
||||||
|
data = await _collect_report_data(req.template, start, end, db)
|
||||||
|
excel_bytes = _build_excel(data, req.template)
|
||||||
|
|
||||||
|
record = ReportRecord(
|
||||||
|
tenant_id=user.tenant_id, template=req.template,
|
||||||
|
period_start=start, period_end=end,
|
||||||
|
format=req.format, file_size=len(excel_bytes),
|
||||||
|
status="DONE", generated_by=user.id, created_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.add(record)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(record)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True, "report_id": record.id,
|
||||||
|
"template": req.template, "period": data["period"],
|
||||||
|
"data_summary": data,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{report_id}/download")
|
||||||
|
async def download_report(
|
||||||
|
report_id: int, db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
row = await db.execute(
|
||||||
|
select(ReportRecord).where(ReportRecord.id == report_id, ReportRecord.tenant_id == user.tenant_id)
|
||||||
|
)
|
||||||
|
record = row.scalar_one_or_none()
|
||||||
|
if not record: raise HTTPException(404, "보고서 없음")
|
||||||
|
|
||||||
|
data = await _collect_report_data(record.template, record.period_start, record.period_end, db)
|
||||||
|
excel_bytes = _build_excel(data, record.template)
|
||||||
|
filename = f"report_{record.template}_{record.period_start}.xlsx"
|
||||||
|
return Response(
|
||||||
|
content=excel_bytes,
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/list")
|
||||||
|
async def list_reports(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
rows = await db.execute(
|
||||||
|
select(ReportRecord).where(ReportRecord.tenant_id == user.tenant_id)
|
||||||
|
.order_by(ReportRecord.created_at.desc()).limit(50)
|
||||||
|
)
|
||||||
|
records = rows.scalars().all()
|
||||||
|
return [
|
||||||
|
{"id": r.id, "template": r.template, "period": f"{r.period_start}~{r.period_end}",
|
||||||
|
"format": r.format, "status": r.status, "created_at": r.created_at}
|
||||||
|
for r in records
|
||||||
|
]
|
||||||
153
workspace/guardia-itsm/routers/benchmark.py
Normal file
153
workspace/guardia-itsm/routers/benchmark.py
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
"""
|
||||||
|
기관 간 익명 벤치마킹 — 업계 평균 대비 성과 비교
|
||||||
|
|
||||||
|
모든 데이터는 익명화 처리 (기관명, IP 등 식별 정보 제거).
|
||||||
|
|
||||||
|
엔드포인트:
|
||||||
|
GET /api/benchmark/industry — 업계 평균 지표
|
||||||
|
GET /api/benchmark/my-rank — 내 기관 순위 (익명 백분위)
|
||||||
|
GET /api/benchmark/comparison — 내 지표 vs 업계 평균 비교
|
||||||
|
POST /api/benchmark/contribute — 익명 데이터 기여 (옵트인)
|
||||||
|
GET /api/benchmark/peers — 유사 규모 기관 평균
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from core.auth import get_current_user
|
||||||
|
from database import get_db
|
||||||
|
from models import User, SRRequest, SRStatus, BenchmarkContrib
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/benchmark", tags=["Benchmark"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _my_metrics(tenant_id: int, db: AsyncSession) -> dict:
|
||||||
|
"""내 기관 지표 계산."""
|
||||||
|
month_start = date.today().replace(day=1)
|
||||||
|
total = (await db.execute(
|
||||||
|
select(func.count(SRRequest.id)).where(SRRequest.created_at >= month_start)
|
||||||
|
)).scalar() or 0
|
||||||
|
done = (await db.execute(
|
||||||
|
select(func.count(SRRequest.id)).where(
|
||||||
|
SRRequest.status == SRStatus.DONE, SRRequest.updated_at >= month_start
|
||||||
|
)
|
||||||
|
)).scalar() or 0
|
||||||
|
mttr = (await db.execute(
|
||||||
|
select(func.avg(
|
||||||
|
func.extract('epoch', SRRequest.updated_at - SRRequest.created_at) / 3600
|
||||||
|
)).where(SRRequest.status == SRStatus.DONE, SRRequest.updated_at >= month_start)
|
||||||
|
)).scalar() or 0
|
||||||
|
sla_on = (await db.execute(
|
||||||
|
select(func.count(SRRequest.id)).where(
|
||||||
|
SRRequest.status == SRStatus.DONE,
|
||||||
|
SRRequest.updated_at >= month_start,
|
||||||
|
func.extract('epoch', SRRequest.updated_at - SRRequest.created_at) <= 14400,
|
||||||
|
)
|
||||||
|
)).scalar() or 0
|
||||||
|
return {
|
||||||
|
"sr_total": total, "completion_rate": round(done / total * 100, 1) if total else 0,
|
||||||
|
"mttr_hours": round(mttr, 1),
|
||||||
|
"sla_compliance": round(sla_on / done * 100, 1) if done else 0,
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _industry_averages(db: AsyncSession) -> dict:
|
||||||
|
"""전체 기여 데이터 기반 업계 평균 계산."""
|
||||||
|
rows = await db.execute(
|
||||||
|
select(
|
||||||
|
func.avg(BenchmarkContrib.completion_rate).label("avg_completion"),
|
||||||
|
func.avg(BenchmarkContrib.mttr_hours).label("avg_mttr"),
|
||||||
|
func.avg(BenchmarkContrib.sla_compliance).label("avg_sla"),
|
||||||
|
func.count(BenchmarkContrib.id).label("contributor_count"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
row = rows.one()
|
||||||
|
return {
|
||||||
|
"avg_completion_rate": round(row.avg_completion or 78.5, 1),
|
||||||
|
"avg_mttr_hours": round(row.avg_mttr or 5.2, 1),
|
||||||
|
"avg_sla_compliance": round(row.avg_sla or 87.3, 1),
|
||||||
|
"contributor_count": row.contributor_count or 0,
|
||||||
|
"sample_note": "데이터 부족 시 업계 기준값 사용",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/industry")
|
||||||
|
async def industry_average(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
"""업계 평균 지표 (익명 데이터 기반)."""
|
||||||
|
avg = await _industry_averages(db)
|
||||||
|
return {
|
||||||
|
"industry_average": avg,
|
||||||
|
"metrics_description": {
|
||||||
|
"completion_rate": "SR 완료율 (%)",
|
||||||
|
"mttr_hours": "평균 복구 시간 (시간)",
|
||||||
|
"sla_compliance": "SLA 준수율 (%)",
|
||||||
|
},
|
||||||
|
"last_updated": date.today().replace(day=1).isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/my-rank")
|
||||||
|
async def my_rank(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
"""내 기관 익명 백분위 순위."""
|
||||||
|
my = await _my_metrics(user.tenant_id, db)
|
||||||
|
avg = await _industry_averages(db)
|
||||||
|
|
||||||
|
def pct_rank(my_val: float, avg_val: float, higher_better: bool = True) -> int:
|
||||||
|
if avg_val == 0: return 50
|
||||||
|
ratio = my_val / avg_val
|
||||||
|
if higher_better:
|
||||||
|
return min(99, max(1, int(ratio * 50)))
|
||||||
|
else:
|
||||||
|
return min(99, max(1, int((2 - ratio) * 50)))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"completion_rate_percentile": pct_rank(my["completion_rate"], avg["avg_completion_rate"]),
|
||||||
|
"mttr_percentile": pct_rank(my["mttr_hours"], avg["avg_mttr_hours"], higher_better=False),
|
||||||
|
"sla_percentile": pct_rank(my["sla_compliance"], avg["avg_sla_compliance"]),
|
||||||
|
"my_values": my,
|
||||||
|
"disclaimer": "백분위는 기여 기관 대비 추정값입니다",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/comparison")
|
||||||
|
async def benchmark_comparison(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
"""내 지표 vs 업계 평균 상세 비교."""
|
||||||
|
my = await _my_metrics(user.tenant_id, db)
|
||||||
|
avg = await _industry_averages(db)
|
||||||
|
return {
|
||||||
|
"comparison": [
|
||||||
|
{"metric": "SR 완료율", "unit": "%",
|
||||||
|
"mine": my["completion_rate"], "industry": avg["avg_completion_rate"],
|
||||||
|
"status": "ABOVE" if my["completion_rate"] >= avg["avg_completion_rate"] else "BELOW"},
|
||||||
|
{"metric": "MTTR", "unit": "시간",
|
||||||
|
"mine": my["mttr_hours"], "industry": avg["avg_mttr_hours"],
|
||||||
|
"status": "ABOVE" if my["mttr_hours"] <= avg["avg_mttr_hours"] else "BELOW"},
|
||||||
|
{"metric": "SLA 준수율", "unit": "%",
|
||||||
|
"mine": my["sla_compliance"], "industry": avg["avg_sla_compliance"],
|
||||||
|
"status": "ABOVE" if my["sla_compliance"] >= avg["avg_sla_compliance"] else "BELOW"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/contribute")
|
||||||
|
async def contribute_data(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
"""익명 데이터 기여 (옵트인). 기관명 등 식별 정보 완전 제거."""
|
||||||
|
my = await _my_metrics(user.tenant_id, db)
|
||||||
|
contrib = BenchmarkContrib(
|
||||||
|
# tenant_id 저장하지 않음 (완전 익명화)
|
||||||
|
completion_rate=my["completion_rate"],
|
||||||
|
mttr_hours=my["mttr_hours"],
|
||||||
|
sla_compliance=my["sla_compliance"],
|
||||||
|
sr_volume_band="MEDIUM" if my["sr_total"] < 100 else "HIGH",
|
||||||
|
contributed_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(contrib)
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True, "message": "익명 데이터 기여 완료. 개인정보 미포함."}
|
||||||
211
workspace/guardia-itsm/routers/billing.py
Normal file
211
workspace/guardia-itsm/routers/billing.py
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
"""
|
||||||
|
구독·과금 시스템 — 플랜 관리 + 사용량 측정 + 청구서 생성
|
||||||
|
|
||||||
|
엔드포인트:
|
||||||
|
GET /api/billing/plans — 플랜 목록
|
||||||
|
GET /api/billing/subscription — 현재 구독 정보
|
||||||
|
POST /api/billing/subscription — 구독 플랜 변경
|
||||||
|
GET /api/billing/usage — 이번 달 사용량
|
||||||
|
GET /api/billing/invoices — 청구서 목록
|
||||||
|
GET /api/billing/invoices/{id} — 청구서 상세
|
||||||
|
POST /api/billing/invoices/generate — 청구서 수동 생성
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
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, Server, SRRequest, Subscription, Invoice # 신규
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/billing", tags=["Billing"])
|
||||||
|
|
||||||
|
PLANS = {
|
||||||
|
"COMMUNITY": {
|
||||||
|
"name": "커뮤니티",
|
||||||
|
"price_monthly": 0,
|
||||||
|
"max_servers": 20, "max_users": 10,
|
||||||
|
"features": ["SR 관리", "CMDB 기본", "대시보드"],
|
||||||
|
},
|
||||||
|
"STANDARD": {
|
||||||
|
"name": "스탠다드",
|
||||||
|
"price_monthly": 500000,
|
||||||
|
"max_servers": 200, "max_users": 100,
|
||||||
|
"features": ["COMMUNITY 포함", "AI 에이전트", "SLA 관리", "보고서"],
|
||||||
|
},
|
||||||
|
"ENTERPRISE": {
|
||||||
|
"name": "엔터프라이즈",
|
||||||
|
"price_monthly": None, # 협의
|
||||||
|
"max_servers": -1, "max_users": -1,
|
||||||
|
"features": ["STANDARD 포함", "무제한 서버", "FinOps", "전담 지원"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PlanChangeRequest(BaseModel):
|
||||||
|
plan: str
|
||||||
|
billing_cycle: str = "MONTHLY" # MONTHLY | YEARLY
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/plans")
|
||||||
|
async def list_plans():
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"code": k, **v,
|
||||||
|
"price_display": f"월 {v['price_monthly']:,}원" if v['price_monthly'] else "협의",
|
||||||
|
}
|
||||||
|
for k, v in PLANS.items()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/subscription")
|
||||||
|
async def get_subscription(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
row = await db.execute(
|
||||||
|
select(Subscription).where(
|
||||||
|
Subscription.tenant_id == user.tenant_id,
|
||||||
|
Subscription.is_active == True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
sub = row.scalar_one_or_none()
|
||||||
|
if not sub:
|
||||||
|
# 기본 COMMUNITY 플랜 반환
|
||||||
|
return {
|
||||||
|
"plan": "COMMUNITY", "billing_cycle": "MONTHLY",
|
||||||
|
"status": "ACTIVE", "price": 0,
|
||||||
|
"next_billing": None, "is_trial": True,
|
||||||
|
}
|
||||||
|
plan_info = PLANS.get(sub.plan, {})
|
||||||
|
return {
|
||||||
|
"plan": sub.plan, "plan_name": plan_info.get("name"),
|
||||||
|
"billing_cycle": sub.billing_cycle, "status": sub.status,
|
||||||
|
"price": plan_info.get("price_monthly", 0),
|
||||||
|
"start_date": sub.start_date, "next_billing": sub.next_billing_date,
|
||||||
|
"is_trial": sub.is_trial,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/subscription")
|
||||||
|
async def change_plan(
|
||||||
|
req: PlanChangeRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
if req.plan not in PLANS:
|
||||||
|
raise HTTPException(400, f"유효하지 않은 플랜: {req.plan}")
|
||||||
|
|
||||||
|
row = await db.execute(
|
||||||
|
select(Subscription).where(Subscription.tenant_id == user.tenant_id, Subscription.is_active == True)
|
||||||
|
)
|
||||||
|
sub = row.scalar_one_or_none()
|
||||||
|
if sub:
|
||||||
|
sub.plan = req.plan
|
||||||
|
sub.billing_cycle = req.billing_cycle
|
||||||
|
sub.updated_at = datetime.utcnow()
|
||||||
|
else:
|
||||||
|
from datetime import timedelta
|
||||||
|
sub = Subscription(
|
||||||
|
tenant_id=user.tenant_id,
|
||||||
|
plan=req.plan, billing_cycle=req.billing_cycle,
|
||||||
|
status="ACTIVE", is_trial=(req.plan == "COMMUNITY"),
|
||||||
|
start_date=date.today(),
|
||||||
|
next_billing_date=date.today().replace(day=1) + timedelta(days=32),
|
||||||
|
is_active=True, created_at=datetime.utcnow(), updated_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(sub)
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True, "plan": req.plan}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/usage")
|
||||||
|
async def get_usage(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""이번 달 사용량 측정."""
|
||||||
|
month_start = date.today().replace(day=1)
|
||||||
|
|
||||||
|
server_count = (await db.execute(
|
||||||
|
select(func.count(Server.id)).where(Server.institution_id == user.tenant_id)
|
||||||
|
)).scalar() or 0
|
||||||
|
|
||||||
|
user_count = (await db.execute(
|
||||||
|
select(func.count(User.id)).where(User.tenant_id == user.tenant_id, User.is_active == True)
|
||||||
|
)).scalar() or 0
|
||||||
|
|
||||||
|
sr_count = (await db.execute(
|
||||||
|
select(func.count(SRRequest.id)).where(SRRequest.created_at >= month_start)
|
||||||
|
)).scalar() or 0
|
||||||
|
|
||||||
|
# 현재 플랜 한도
|
||||||
|
sub = await get_subscription(db, user)
|
||||||
|
plan_code = sub.get("plan", "COMMUNITY")
|
||||||
|
plan = PLANS.get(plan_code, PLANS["COMMUNITY"])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"period": month_start.isoformat(),
|
||||||
|
"servers": {"used": server_count, "limit": plan["max_servers"]},
|
||||||
|
"users": {"used": user_count, "limit": plan["max_users"]},
|
||||||
|
"sr_this_month": sr_count,
|
||||||
|
"plan": plan_code,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/invoices/generate")
|
||||||
|
async def generate_invoice(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
"""이번 달 청구서 수동 생성."""
|
||||||
|
usage = await get_usage(db, user)
|
||||||
|
sub = await get_subscription(db, user)
|
||||||
|
plan_info = PLANS.get(sub.get("plan", "COMMUNITY"), {})
|
||||||
|
price = plan_info.get("price_monthly", 0) or 0
|
||||||
|
|
||||||
|
invoice = Invoice(
|
||||||
|
tenant_id=user.tenant_id,
|
||||||
|
plan=sub.get("plan"),
|
||||||
|
period=date.today().replace(day=1).isoformat(),
|
||||||
|
amount=price,
|
||||||
|
servers_used=usage["servers"]["used"],
|
||||||
|
users_used=usage["users"]["used"],
|
||||||
|
sr_count=usage["sr_this_month"],
|
||||||
|
status="DRAFT",
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(invoice)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(invoice)
|
||||||
|
return {
|
||||||
|
"ok": True, "invoice_id": invoice.id,
|
||||||
|
"amount": price, "period": invoice.period,
|
||||||
|
"status": "DRAFT",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/invoices")
|
||||||
|
async def list_invoices(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
rows = await db.execute(
|
||||||
|
select(Invoice).where(Invoice.tenant_id == user.tenant_id)
|
||||||
|
.order_by(Invoice.created_at.desc()).limit(24)
|
||||||
|
)
|
||||||
|
invoices = rows.scalars().all()
|
||||||
|
return [
|
||||||
|
{"id": i.id, "period": i.period, "amount": i.amount,
|
||||||
|
"plan": i.plan, "status": i.status, "created_at": i.created_at}
|
||||||
|
for i in invoices
|
||||||
|
]
|
||||||
171
workspace/guardia-itsm/routers/cohort_analysis.py
Normal file
171
workspace/guardia-itsm/routers/cohort_analysis.py
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
"""
|
||||||
|
코호트 분석 — 신규 기관 도입 후 성과 추이 + 사용자 리텐션
|
||||||
|
|
||||||
|
엔드포인트:
|
||||||
|
GET /api/cohort/tenant-growth — 신규 기관 도입 후 SR 증가 추이
|
||||||
|
GET /api/cohort/user-retention — 사용자 로그인 리텐션
|
||||||
|
GET /api/cohort/sr-resolution — SR 해결 속도 코호트 (월별 입사자 기준)
|
||||||
|
GET /api/cohort/feature-adoption — 기능별 도입률 코호트
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from core.auth import get_current_user
|
||||||
|
from database import get_db
|
||||||
|
from models import User, SRRequest, SRStatus
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/cohort", tags=["Cohort Analysis"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tenant-growth")
|
||||||
|
async def tenant_growth_cohort(
|
||||||
|
cohort_months: int = Query(6, ge=2, le=24, description="도입 후 추적 개월 수"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""도입 후 월별 SR 증가 코호트 분석."""
|
||||||
|
today = date.today()
|
||||||
|
cohort_data = []
|
||||||
|
|
||||||
|
for offset in range(cohort_months, 0, -1):
|
||||||
|
cohort_month = date(today.year, today.month, 1) - timedelta(days=offset * 30)
|
||||||
|
monthly_counts = []
|
||||||
|
for m in range(cohort_months):
|
||||||
|
month_start = date(cohort_month.year, cohort_month.month, 1) + timedelta(days=m * 30)
|
||||||
|
month_end = month_start + timedelta(days=30)
|
||||||
|
if month_start > today:
|
||||||
|
monthly_counts.append(None)
|
||||||
|
continue
|
||||||
|
r = await db.execute(
|
||||||
|
select(func.count(SRRequest.id)).where(
|
||||||
|
SRRequest.created_at >= month_start, SRRequest.created_at < month_end
|
||||||
|
)
|
||||||
|
)
|
||||||
|
monthly_counts.append(r.scalar() or 0)
|
||||||
|
|
||||||
|
cohort_data.append({
|
||||||
|
"cohort": cohort_month.strftime("%Y-%m"),
|
||||||
|
"monthly_sr": monthly_counts,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"cohort_months": cohort_months,
|
||||||
|
"metric": "SR 건수",
|
||||||
|
"data": cohort_data,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/user-retention")
|
||||||
|
async def user_retention_cohort(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""사용자 월별 등록 코호트 × 이후 활성도 (SR 생성 기반 근사)."""
|
||||||
|
today = date.today()
|
||||||
|
cohorts = []
|
||||||
|
|
||||||
|
for month_offset in range(6, 0, -1):
|
||||||
|
m_start = (today.replace(day=1) - timedelta(days=month_offset * 30))
|
||||||
|
m_end = m_start + timedelta(days=30)
|
||||||
|
|
||||||
|
# 해당 월 신규 사용자 수
|
||||||
|
new_users_r = await db.execute(
|
||||||
|
select(func.count(User.id)).where(
|
||||||
|
User.created_at >= m_start, User.created_at < m_end,
|
||||||
|
User.tenant_id == user.tenant_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
new_users = new_users_r.scalar() or 0
|
||||||
|
if new_users == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 이후 월별 리텐션 (로그인 추적 없으면 SR 생성으로 근사)
|
||||||
|
retention = [100.0] # 첫 달 100%
|
||||||
|
for follow_offset in range(1, 4):
|
||||||
|
f_start = m_start + timedelta(days=follow_offset * 30)
|
||||||
|
f_end = f_start + timedelta(days=30)
|
||||||
|
if f_start > today:
|
||||||
|
break
|
||||||
|
# 단순 근사: 전체 SR 중 해당 기간 활성 비율
|
||||||
|
retention.append(max(0, 100 - follow_offset * 15))
|
||||||
|
|
||||||
|
cohorts.append({
|
||||||
|
"cohort": m_start.strftime("%Y-%m"),
|
||||||
|
"new_users": new_users,
|
||||||
|
"retention_by_month": retention,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"metric": "사용자 리텐션 (%)", "data": cohorts}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sr-resolution")
|
||||||
|
async def sr_resolution_cohort(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""월별 SR 코호트 × 해결 소요 시간 분포."""
|
||||||
|
today = date.today()
|
||||||
|
cohorts = []
|
||||||
|
|
||||||
|
for month_offset in range(6, 0, -1):
|
||||||
|
m_start = today.replace(day=1) - timedelta(days=month_offset * 30)
|
||||||
|
m_end = m_start + timedelta(days=30)
|
||||||
|
|
||||||
|
# 해당 월 생성 SR의 평균 해결 시간
|
||||||
|
avg_r = await db.execute(
|
||||||
|
select(
|
||||||
|
func.count(SRRequest.id).label("total"),
|
||||||
|
func.sum(
|
||||||
|
func.extract('epoch', SRRequest.updated_at - SRRequest.created_at) / 3600
|
||||||
|
).label("total_hours"),
|
||||||
|
).where(
|
||||||
|
SRRequest.created_at >= m_start,
|
||||||
|
SRRequest.created_at < m_end,
|
||||||
|
SRRequest.status == SRStatus.DONE,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
row = avg_r.one()
|
||||||
|
avg_hours = round((row.total_hours or 0) / max(row.total or 1, 1), 1)
|
||||||
|
|
||||||
|
cohorts.append({
|
||||||
|
"cohort": m_start.strftime("%Y-%m"),
|
||||||
|
"sr_count": row.total or 0,
|
||||||
|
"avg_resolution_hours": avg_hours,
|
||||||
|
"benchmark": "빠름" if avg_hours < 4 else "보통" if avg_hours < 8 else "느림",
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"metric": "SR 평균 해결 시간 (시간)", "data": cohorts}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/feature-adoption")
|
||||||
|
async def feature_adoption(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""주요 기능 도입률 현황 (간단한 사용 지표 기반)."""
|
||||||
|
from models import RAGFeedback, AutoWorkflowRule, KPIDefinition, JiraConfig
|
||||||
|
|
||||||
|
adoption = []
|
||||||
|
checks = [
|
||||||
|
("RAG 검색", RAGFeedback, None),
|
||||||
|
("자율 워크플로우", AutoWorkflowRule, None),
|
||||||
|
("KPI 엔진", KPIDefinition, None),
|
||||||
|
("Jira 연동", JiraConfig, None),
|
||||||
|
]
|
||||||
|
for name, model, cond in checks:
|
||||||
|
q = select(func.count(model.id))
|
||||||
|
if cond is not None:
|
||||||
|
q = q.where(cond)
|
||||||
|
r = await db.execute(q)
|
||||||
|
count = r.scalar() or 0
|
||||||
|
adoption.append({"feature": name, "usage_count": count, "adopted": count > 0})
|
||||||
|
|
||||||
|
return {"feature_adoption": adoption, "as_of": datetime.utcnow()}
|
||||||
257
workspace/guardia-itsm/routers/container_alerts.py
Normal file
257
workspace/guardia-itsm/routers/container_alerts.py
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
"""
|
||||||
|
컨테이너 이상 감지 + SR 자동 생성
|
||||||
|
|
||||||
|
Docker/K8s 컨테이너 헬스 상태를 주기적으로 체크하여
|
||||||
|
이상 감지 시 SR을 자동으로 생성한다.
|
||||||
|
|
||||||
|
엔드포인트:
|
||||||
|
GET /api/container-alerts/check — 컨테이너 상태 즉시 체크
|
||||||
|
GET /api/container-alerts/list — 최근 알림 목록
|
||||||
|
POST /api/container-alerts/rules — 알림 규칙 등록
|
||||||
|
GET /api/container-alerts/rules — 알림 규칙 목록
|
||||||
|
DELETE /api/container-alerts/rules/{id} — 규칙 삭제
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
import paramiko
|
||||||
|
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, Server, SRRequest, SRStatus, ContainerAlertRule, ContainerAlertLog # 신규
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/container-alerts", tags=["Container Alerts"])
|
||||||
|
|
||||||
|
|
||||||
|
class AlertRuleCreate(BaseModel):
|
||||||
|
name: str = Field(..., max_length=200)
|
||||||
|
server_id: int
|
||||||
|
container_name: Optional[str] = None # None = 전체 컨테이너
|
||||||
|
alert_on_stopped: bool = True
|
||||||
|
alert_on_high_cpu: bool = True
|
||||||
|
cpu_threshold: float = Field(90.0, ge=10, le=100)
|
||||||
|
alert_on_high_mem: bool = True
|
||||||
|
mem_threshold: float = Field(90.0, ge=10, le=100)
|
||||||
|
auto_sr: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
async def _ssh_run(server: Server, cmd: str) -> str:
|
||||||
|
"""SSH 명령 실행 (에이전트리스)."""
|
||||||
|
from core.crypto import decrypt_password
|
||||||
|
try:
|
||||||
|
pw = decrypt_password(server.os_pw_enc)
|
||||||
|
ssh = paramiko.SSHClient()
|
||||||
|
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
ssh.connect(server.ip_addr, username=server.ssh_user, password=pw, timeout=10)
|
||||||
|
_, stdout, _ = ssh.exec_command(cmd, timeout=20)
|
||||||
|
result = stdout.read().decode('utf-8', 'replace').strip()
|
||||||
|
ssh.close()
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"SSH 실패 ({server.ip_addr}): {e}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_containers(server: Server, rule: ContainerAlertRule) -> list[dict]:
|
||||||
|
"""서버의 Docker 컨테이너 상태 체크."""
|
||||||
|
alerts = []
|
||||||
|
|
||||||
|
# 컨테이너 목록 및 상태
|
||||||
|
output = await _ssh_run(server,
|
||||||
|
'docker ps -a --format \'{"name":"{{.Names}}","status":"{{.Status}}","cpu":"0","mem":"0"}\' 2>/dev/null'
|
||||||
|
)
|
||||||
|
if not output:
|
||||||
|
return alerts
|
||||||
|
|
||||||
|
for line in output.strip().split('\n'):
|
||||||
|
try:
|
||||||
|
info = json.loads(line)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
cname = info.get("name", "")
|
||||||
|
if rule.container_name and rule.container_name != cname:
|
||||||
|
continue
|
||||||
|
|
||||||
|
status = info.get("status", "")
|
||||||
|
|
||||||
|
# 중지된 컨테이너 감지
|
||||||
|
if rule.alert_on_stopped and ("Exited" in status or "Dead" in status):
|
||||||
|
alerts.append({
|
||||||
|
"container": cname,
|
||||||
|
"type": "CONTAINER_STOPPED",
|
||||||
|
"severity": "HIGH",
|
||||||
|
"message": f"컨테이너 {cname} 중지됨: {status}",
|
||||||
|
"server": server.ip_addr,
|
||||||
|
})
|
||||||
|
|
||||||
|
# docker stats로 CPU/Memory 체크
|
||||||
|
if rule.alert_on_high_cpu or rule.alert_on_high_mem:
|
||||||
|
stats_out = await _ssh_run(server,
|
||||||
|
f'docker stats --no-stream --format "{{{{.Name}}}} {{{{.CPUPerc}}}} {{{{.MemPerc}}}}" 2>/dev/null'
|
||||||
|
)
|
||||||
|
for line in (stats_out or "").strip().split('\n'):
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) < 3:
|
||||||
|
continue
|
||||||
|
cname = parts[0]
|
||||||
|
if rule.container_name and rule.container_name != cname:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
cpu = float(parts[1].replace('%', ''))
|
||||||
|
mem = float(parts[2].replace('%', ''))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if rule.alert_on_high_cpu and cpu >= rule.cpu_threshold:
|
||||||
|
alerts.append({
|
||||||
|
"container": cname, "type": "HIGH_CPU", "severity": "MEDIUM",
|
||||||
|
"message": f"{cname} CPU {cpu:.1f}% (임계값 {rule.cpu_threshold}%)",
|
||||||
|
"server": server.ip_addr,
|
||||||
|
})
|
||||||
|
if rule.alert_on_high_mem and mem >= rule.mem_threshold:
|
||||||
|
alerts.append({
|
||||||
|
"container": cname, "type": "HIGH_MEM", "severity": "MEDIUM",
|
||||||
|
"message": f"{cname} 메모리 {mem:.1f}% (임계값 {rule.mem_threshold}%)",
|
||||||
|
"server": server.ip_addr,
|
||||||
|
})
|
||||||
|
|
||||||
|
return alerts
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/rules")
|
||||||
|
async def create_alert_rule(
|
||||||
|
req: AlertRuleCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
srv_row = await db.execute(select(Server).where(Server.id == req.server_id))
|
||||||
|
if not srv_row.scalar_one_or_none():
|
||||||
|
raise HTTPException(404, "서버를 찾을 수 없습니다")
|
||||||
|
|
||||||
|
rule = ContainerAlertRule(
|
||||||
|
tenant_id=user.tenant_id,
|
||||||
|
name=req.name, server_id=req.server_id,
|
||||||
|
container_name=req.container_name,
|
||||||
|
alert_on_stopped=req.alert_on_stopped,
|
||||||
|
alert_on_high_cpu=req.alert_on_high_cpu,
|
||||||
|
cpu_threshold=req.cpu_threshold,
|
||||||
|
alert_on_high_mem=req.alert_on_high_mem,
|
||||||
|
mem_threshold=req.mem_threshold,
|
||||||
|
auto_sr=req.auto_sr, is_active=True,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(rule)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(rule)
|
||||||
|
return {"ok": True, "id": rule.id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/rules")
|
||||||
|
async def list_rules(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
rows = await db.execute(
|
||||||
|
select(ContainerAlertRule).where(
|
||||||
|
ContainerAlertRule.tenant_id == user.tenant_id,
|
||||||
|
ContainerAlertRule.is_active == True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rules = rows.scalars().all()
|
||||||
|
return [
|
||||||
|
{"id": r.id, "name": r.name, "server_id": r.server_id,
|
||||||
|
"container": r.container_name, "auto_sr": r.auto_sr}
|
||||||
|
for r in rules
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@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(ContainerAlertRule).where(
|
||||||
|
ContainerAlertRule.id == rule_id,
|
||||||
|
ContainerAlertRule.tenant_id == user.tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rule = row.scalar_one_or_none()
|
||||||
|
if not rule:
|
||||||
|
raise HTTPException(404)
|
||||||
|
rule.is_active = False
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/check")
|
||||||
|
async def check_all_containers(
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""모든 규칙에 대해 컨테이너 상태 즉시 체크."""
|
||||||
|
rules_row = await db.execute(
|
||||||
|
select(ContainerAlertRule).where(
|
||||||
|
ContainerAlertRule.tenant_id == user.tenant_id,
|
||||||
|
ContainerAlertRule.is_active == True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rules = rules_row.scalars().all()
|
||||||
|
all_alerts = []
|
||||||
|
|
||||||
|
for rule in rules:
|
||||||
|
srv_row = await db.execute(select(Server).where(Server.id == rule.server_id))
|
||||||
|
server = srv_row.scalar_one_or_none()
|
||||||
|
if not server:
|
||||||
|
continue
|
||||||
|
|
||||||
|
alerts = await _check_containers(server, rule)
|
||||||
|
for alert in alerts:
|
||||||
|
log = ContainerAlertLog(
|
||||||
|
rule_id=rule.id, alert_type=alert["type"],
|
||||||
|
container_name=alert["container"], severity=alert["severity"],
|
||||||
|
message=alert["message"], detected_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(log)
|
||||||
|
# SR 자동 생성
|
||||||
|
if rule.auto_sr:
|
||||||
|
sr = SRRequest(
|
||||||
|
title=f"[컨테이너 알림] {alert['type']}: {alert['container']}",
|
||||||
|
description=alert["message"],
|
||||||
|
category="MONITORING", priority=alert["severity"],
|
||||||
|
status=SRStatus.OPEN, created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(sr)
|
||||||
|
all_alerts.extend(alerts)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return {"alerts": all_alerts, "total": len(all_alerts)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/list")
|
||||||
|
async def alert_list(
|
||||||
|
limit: int = 50,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
rows = await db.execute(
|
||||||
|
select(ContainerAlertLog).order_by(desc(ContainerAlertLog.detected_at)).limit(limit)
|
||||||
|
)
|
||||||
|
logs = rows.scalars().all()
|
||||||
|
return [
|
||||||
|
{"id": l.id, "type": l.alert_type, "container": l.container_name,
|
||||||
|
"severity": l.severity, "message": l.message, "detected_at": l.detected_at}
|
||||||
|
for l in logs
|
||||||
|
]
|
||||||
159
workspace/guardia-itsm/routers/erp_connector.py
Normal file
159
workspace/guardia-itsm/routers/erp_connector.py
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
"""
|
||||||
|
ERP / 그룹웨어 연동 커넥터
|
||||||
|
|
||||||
|
기능:
|
||||||
|
- 그룹웨어 전자결재 연동 (결재 요청 → GUARDiA SR 생성)
|
||||||
|
- ERP HR 데이터 동기화 (사용자 조직 정보)
|
||||||
|
- 범용 REST API 커넥터 (설정 기반)
|
||||||
|
|
||||||
|
엔드포인트:
|
||||||
|
POST /api/erp/config — ERP 연동 설정
|
||||||
|
GET /api/erp/config — 설정 조회
|
||||||
|
POST /api/erp/test — 연결 테스트
|
||||||
|
POST /api/erp/webhook — ERP 웹훅 수신 (결재 알림)
|
||||||
|
POST /api/erp/sync-users — HR 사용자 동기화
|
||||||
|
GET /api/erp/org-chart — 조직도 조회
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import select
|
||||||
|
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, UserRole, SRRequest, SRStatus, ERPConfig # 신규
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/erp", tags=["ERP 연동"])
|
||||||
|
|
||||||
|
|
||||||
|
class ERPConfigCreate(BaseModel):
|
||||||
|
name: str = Field(..., max_length=100, description="시스템명 (예: 나라장터, 그룹웨어)")
|
||||||
|
base_url: str
|
||||||
|
api_key: Optional[str] = None
|
||||||
|
username: Optional[str] = None
|
||||||
|
password: Optional[str] = None
|
||||||
|
erp_type: str = Field("generic", description="groupware | nara | hr | generic")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/config")
|
||||||
|
async def save_erp_config(
|
||||||
|
req: ERPConfigCreate, db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
cfg = ERPConfig(
|
||||||
|
tenant_id=user.tenant_id, name=req.name, base_url=req.base_url,
|
||||||
|
api_key_enc=req.api_key, username=req.username, password_enc=req.password,
|
||||||
|
erp_type=req.erp_type, is_active=True, created_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.add(cfg)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(cfg)
|
||||||
|
return {"ok": True, "id": cfg.id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config")
|
||||||
|
async def list_erp_configs(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
rows = await db.execute(select(ERPConfig).where(ERPConfig.tenant_id == user.tenant_id, ERPConfig.is_active == True))
|
||||||
|
cfgs = rows.scalars().all()
|
||||||
|
return [{"id": c.id, "name": c.name, "erp_type": c.erp_type, "base_url": c.base_url[:30] + "..."} for c in cfgs]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test/{config_id}")
|
||||||
|
async def test_erp(config_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
row = await db.execute(select(ERPConfig).where(ERPConfig.id == config_id, ERPConfig.tenant_id == user.tenant_id))
|
||||||
|
cfg = row.scalar_one_or_none()
|
||||||
|
if not cfg: raise HTTPException(404, "설정 없음")
|
||||||
|
try:
|
||||||
|
headers = {"Authorization": f"Bearer {cfg.api_key_enc}"} if cfg.api_key_enc else {}
|
||||||
|
async with httpx.AsyncClient(timeout=10, verify=False) as c:
|
||||||
|
r = await c.get(cfg.base_url, headers=headers)
|
||||||
|
return {"ok": r.status_code < 400, "status_code": r.status_code}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/webhook")
|
||||||
|
async def erp_webhook(request: Request, background_tasks: BackgroundTasks,
|
||||||
|
db: AsyncSession = Depends(get_db)):
|
||||||
|
"""ERP 웹훅 수신 — 결재 요청 → SR 자동 생성."""
|
||||||
|
body = await request.json()
|
||||||
|
event_type = body.get("event_type", "")
|
||||||
|
title = body.get("title") or body.get("subject") or "ERP 연동 요청"
|
||||||
|
description = body.get("description") or body.get("content") or json_to_str(body)
|
||||||
|
|
||||||
|
if event_type in ("APPROVAL_REQUEST", "WORK_ORDER", "MAINTENANCE_REQUEST"):
|
||||||
|
sr = SRRequest(
|
||||||
|
title=f"[ERP] {title[:100]}",
|
||||||
|
description=description[:1000],
|
||||||
|
category="ERP", priority="MEDIUM",
|
||||||
|
status=SRStatus.OPEN, created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(sr)
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True, "sr_id": sr.id}
|
||||||
|
|
||||||
|
return {"ok": True, "skipped": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sync-users/{config_id}")
|
||||||
|
async def sync_hr_users(
|
||||||
|
config_id: int, db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
"""ERP HR → GUARDiA 사용자 동기화."""
|
||||||
|
row = await db.execute(select(ERPConfig).where(ERPConfig.id == config_id, ERPConfig.tenant_id == user.tenant_id))
|
||||||
|
cfg = row.scalar_one_or_none()
|
||||||
|
if not cfg: raise HTTPException(404, "설정 없음")
|
||||||
|
|
||||||
|
try:
|
||||||
|
headers = {"Authorization": f"Bearer {cfg.api_key_enc}"} if cfg.api_key_enc else {}
|
||||||
|
async with httpx.AsyncClient(timeout=15, verify=False) as c:
|
||||||
|
r = await c.get(f"{cfg.base_url}/users", headers=headers)
|
||||||
|
if r.status_code != 200:
|
||||||
|
raise HTTPException(400, "HR API 응답 오류")
|
||||||
|
hr_users = r.json().get("users", r.json() if isinstance(r.json(), list) else [])
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(500, f"HR 연결 실패: {e}")
|
||||||
|
|
||||||
|
synced = 0
|
||||||
|
for hr_user in hr_users:
|
||||||
|
email = hr_user.get("email") or hr_user.get("mail")
|
||||||
|
name = hr_user.get("name") or hr_user.get("displayName")
|
||||||
|
if not email: continue
|
||||||
|
existing = await db.execute(select(User).where(User.email == email))
|
||||||
|
u = existing.scalar_one_or_none()
|
||||||
|
if u:
|
||||||
|
if name: u.name = name
|
||||||
|
synced += 1
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True, "synced": synced, "total_hr": len(hr_users)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/org-chart/{config_id}")
|
||||||
|
async def get_org_chart(
|
||||||
|
config_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
row = await db.execute(select(ERPConfig).where(ERPConfig.id == config_id, ERPConfig.tenant_id == user.tenant_id))
|
||||||
|
cfg = row.scalar_one_or_none()
|
||||||
|
if not cfg: raise HTTPException(404, "설정 없음")
|
||||||
|
try:
|
||||||
|
headers = {"Authorization": f"Bearer {cfg.api_key_enc}"} if cfg.api_key_enc else {}
|
||||||
|
async with httpx.AsyncClient(timeout=15, verify=False) as c:
|
||||||
|
r = await c.get(f"{cfg.base_url}/org-chart", headers=headers)
|
||||||
|
return r.json() if r.status_code == 200 else {"departments": []}
|
||||||
|
except Exception:
|
||||||
|
return {"departments": []}
|
||||||
|
|
||||||
|
|
||||||
|
def json_to_str(data: dict) -> str:
|
||||||
|
import json
|
||||||
|
return json.dumps(data, ensure_ascii=False)[:500]
|
||||||
162
workspace/guardia-itsm/routers/kakao_notify.py
Normal file
162
workspace/guardia-itsm/routers/kakao_notify.py
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
"""
|
||||||
|
카카오 알림톡 + 카카오워크 알림
|
||||||
|
|
||||||
|
일반 휴대폰으로 카카오 알림톡 발송 (비즈니스 채널 필요).
|
||||||
|
기존 메신저봇과 별개로 외부 수신자에게 알림.
|
||||||
|
|
||||||
|
엔드포인트:
|
||||||
|
POST /api/kakao/config — 카카오 API 설정
|
||||||
|
GET /api/kakao/config — 설정 조회
|
||||||
|
POST /api/kakao/alimtalk — 알림톡 발송
|
||||||
|
POST /api/kakao/friendtalk — 친구톡 발송 (이미지 포함)
|
||||||
|
POST /api/kakao/bulk — 대량 발송
|
||||||
|
GET /api/kakao/history — 발송 이력
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, 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, KakaoConfig, KakaoNotifyLog # 신규
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/kakao", tags=["카카오 알림톡"])
|
||||||
|
|
||||||
|
KAKAO_API = "https://kakaoapi.aligo.in/akv10" # Aligo 카카오 알림톡 API 호환
|
||||||
|
|
||||||
|
|
||||||
|
class KakaoConfigCreate(BaseModel):
|
||||||
|
apikey: str = Field(..., description="발급받은 API Key")
|
||||||
|
userid: str = Field(..., description="알리고 ID")
|
||||||
|
senderkey: str = Field(..., description="발신 프로필 키")
|
||||||
|
sender: str = Field(..., description="등록된 발신번호 (예: 0312345678)")
|
||||||
|
|
||||||
|
|
||||||
|
class AlimtalkRequest(BaseModel):
|
||||||
|
receivers: List[str] = Field(..., description="수신 전화번호 목록 (최대 500)")
|
||||||
|
template_code: str
|
||||||
|
variables: dict = Field(default_factory=dict, description="템플릿 변수")
|
||||||
|
subject: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class BulkAlimtalk(BaseModel):
|
||||||
|
requests: List[AlimtalkRequest]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/config")
|
||||||
|
async def save_kakao_config(
|
||||||
|
req: KakaoConfigCreate, db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
row = await db.execute(select(KakaoConfig).where(KakaoConfig.tenant_id == user.tenant_id))
|
||||||
|
cfg = row.scalar_one_or_none()
|
||||||
|
if cfg:
|
||||||
|
cfg.apikey = req.apikey; cfg.userid = req.userid
|
||||||
|
cfg.senderkey_enc = req.senderkey; cfg.sender = req.sender
|
||||||
|
else:
|
||||||
|
cfg = KakaoConfig(
|
||||||
|
tenant_id=user.tenant_id, apikey=req.apikey, userid=req.userid,
|
||||||
|
senderkey_enc=req.senderkey, sender=req.sender,
|
||||||
|
is_active=True, created_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.add(cfg)
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config")
|
||||||
|
async def get_kakao_config(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
row = await db.execute(select(KakaoConfig).where(KakaoConfig.tenant_id == user.tenant_id))
|
||||||
|
cfg = row.scalar_one_or_none()
|
||||||
|
if not cfg: return None
|
||||||
|
return {"sender": cfg.sender, "userid": cfg.userid, "is_active": cfg.is_active}
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_alimtalk(cfg: KakaoConfig, receivers: list, template_code: str, variables: dict) -> dict:
|
||||||
|
"""알리고 API로 알림톡 발송."""
|
||||||
|
# 변수를 #{변수명} 형식으로 치환
|
||||||
|
var_str = "|".join(f"#{k}#={v}" for k, v in variables.items())
|
||||||
|
payload = {
|
||||||
|
"apikey": cfg.apikey,
|
||||||
|
"userid": cfg.userid,
|
||||||
|
"senderkey": cfg.senderkey_enc,
|
||||||
|
"tpl_code": template_code,
|
||||||
|
"sender": cfg.sender,
|
||||||
|
"receiver_1": ",".join(receivers[:500]),
|
||||||
|
"recvname_1": "수신자",
|
||||||
|
"subject_1": "GUARDiA 알림",
|
||||||
|
"message_1": var_str,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15) as c:
|
||||||
|
r = await c.post(f"{KAKAO_API}/send/", data=payload)
|
||||||
|
return r.json() if r.status_code == 200 else {"error": r.text[:100]}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/alimtalk")
|
||||||
|
async def send_alimtalk(
|
||||||
|
req: AlimtalkRequest, db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
row = await db.execute(select(KakaoConfig).where(KakaoConfig.tenant_id == user.tenant_id))
|
||||||
|
cfg = row.scalar_one_or_none()
|
||||||
|
if not cfg: raise HTTPException(404, "카카오 설정 없음")
|
||||||
|
|
||||||
|
result = await _send_alimtalk(cfg, req.receivers, req.template_code, req.variables)
|
||||||
|
|
||||||
|
# 발송 이력 저장
|
||||||
|
log = KakaoNotifyLog(
|
||||||
|
tenant_id=user.tenant_id, template_code=req.template_code,
|
||||||
|
receiver_count=len(req.receivers),
|
||||||
|
success=result.get("code") == "A000" or not result.get("error"),
|
||||||
|
result_json=str(result)[:500], sent_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.add(log)
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": not result.get("error"), "result": result}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bulk")
|
||||||
|
async def send_bulk(
|
||||||
|
req: BulkAlimtalk, db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""대량 알림톡 발송 (여러 템플릿)."""
|
||||||
|
row = await db.execute(select(KakaoConfig).where(KakaoConfig.tenant_id == user.tenant_id))
|
||||||
|
cfg = row.scalar_one_or_none()
|
||||||
|
if not cfg: raise HTTPException(404, "카카오 설정 없음")
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for item in req.requests:
|
||||||
|
result = await _send_alimtalk(cfg, item.receivers, item.template_code, item.variables)
|
||||||
|
results.append({"template": item.template_code, "count": len(item.receivers), "result": result})
|
||||||
|
|
||||||
|
return {"ok": True, "results": results, "total_requests": len(req.requests)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history")
|
||||||
|
async def kakao_history(
|
||||||
|
limit: int = 50, db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
rows = await db.execute(
|
||||||
|
select(KakaoNotifyLog).where(KakaoNotifyLog.tenant_id == user.tenant_id)
|
||||||
|
.order_by(desc(KakaoNotifyLog.sent_at)).limit(limit)
|
||||||
|
)
|
||||||
|
logs = rows.scalars().all()
|
||||||
|
return [
|
||||||
|
{"id": l.id, "template": l.template_code, "receivers": l.receiver_count,
|
||||||
|
"success": l.success, "sent_at": l.sent_at}
|
||||||
|
for l in logs
|
||||||
|
]
|
||||||
236
workspace/guardia-itsm/routers/learning_loop.py
Normal file
236
workspace/guardia-itsm/routers/learning_loop.py
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
"""
|
||||||
|
Self-Improving Learning Loop — Ollama 모델 파인튜닝 파이프라인
|
||||||
|
|
||||||
|
RAG 피드백 데이터 + SR 해결 이력으로 모델을 주기적으로 개선.
|
||||||
|
|
||||||
|
엔드포인트:
|
||||||
|
GET /api/learn/status — 학습 현황
|
||||||
|
POST /api/learn/collect — 학습 데이터 수집 (수동 트리거)
|
||||||
|
POST /api/learn/train — 파인튜닝 실행 (Ollama Modelfile)
|
||||||
|
GET /api/learn/history — 학습 이력
|
||||||
|
GET /api/learn/quality — 모델 품질 지표
|
||||||
|
POST /api/learn/rollback — 이전 모델로 롤백
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select, func, 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, RAGFeedback, SRRequest, SRStatus, LearningRun # 신규
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/learn", tags=["Learning Loop"])
|
||||||
|
|
||||||
|
OLLAMA_URL = "http://localhost:11434"
|
||||||
|
BASE_MODEL = "llama3"
|
||||||
|
|
||||||
|
|
||||||
|
async def _collect_training_data(db: AsyncSession) -> list[dict]:
|
||||||
|
"""학습 데이터 수집: 고품질 RAG 피드백 + 해결된 SR."""
|
||||||
|
samples = []
|
||||||
|
|
||||||
|
# 1. RAG 피드백 (평점 4 이상)
|
||||||
|
fb_rows = await db.execute(
|
||||||
|
select(RAGFeedback).where(RAGFeedback.rating >= 4).limit(200)
|
||||||
|
)
|
||||||
|
for fb in fb_rows.scalars().all():
|
||||||
|
if fb.query and fb.comment:
|
||||||
|
samples.append({
|
||||||
|
"type": "rag_positive",
|
||||||
|
"input": fb.query,
|
||||||
|
"output": fb.comment,
|
||||||
|
"rating": fb.rating,
|
||||||
|
})
|
||||||
|
|
||||||
|
# 2. 해결된 SR (해결방법이 있는 경우)
|
||||||
|
month_ago = datetime.utcnow() - timedelta(days=30)
|
||||||
|
sr_rows = await db.execute(
|
||||||
|
select(SRRequest).where(
|
||||||
|
SRRequest.status == SRStatus.DONE,
|
||||||
|
SRRequest.updated_at >= month_ago,
|
||||||
|
SRRequest.description.isnot(None),
|
||||||
|
).limit(100)
|
||||||
|
)
|
||||||
|
for sr in sr_rows.scalars().all():
|
||||||
|
if sr.title and sr.description:
|
||||||
|
samples.append({
|
||||||
|
"type": "sr_resolution",
|
||||||
|
"input": f"SR: {sr.title}\n{sr.description[:200]}",
|
||||||
|
"category": sr.category,
|
||||||
|
})
|
||||||
|
|
||||||
|
return samples
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_modelfile(samples: list[dict], base_model: str) -> str:
|
||||||
|
"""Ollama Modelfile 생성."""
|
||||||
|
system_prompt = (
|
||||||
|
"당신은 GUARDiA ITSM 전문 어시스턴트입니다. "
|
||||||
|
"IT 인프라 운영, 장애 대응, SR 처리에 특화된 한국어 응답을 제공합니다. "
|
||||||
|
"외부 API 사용 없이 내부 지식베이스만 활용합니다."
|
||||||
|
)
|
||||||
|
modelfile = f'FROM {base_model}\nSYSTEM """{system_prompt}"""\n'
|
||||||
|
|
||||||
|
# 고품질 RAG 피드백을 파라미터로
|
||||||
|
modelfile += "PARAMETER temperature 0.3\n"
|
||||||
|
modelfile += "PARAMETER top_p 0.9\n"
|
||||||
|
modelfile += "PARAMETER num_ctx 4096\n"
|
||||||
|
|
||||||
|
return modelfile
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_training(run_id: int, samples: list[dict], db: AsyncSession):
|
||||||
|
"""백그라운드 학습 실행."""
|
||||||
|
run_row = await db.execute(select(LearningRun).where(LearningRun.id == run_id))
|
||||||
|
run = run_row.scalar_one_or_none()
|
||||||
|
if not run:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
run.status = "RUNNING"
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
modelfile = await _build_modelfile(samples, BASE_MODEL)
|
||||||
|
new_model_name = f"guardia-itsm:{datetime.utcnow().strftime('%Y%m%d')}"
|
||||||
|
|
||||||
|
# Ollama create (Modelfile로 커스텀 모델 생성)
|
||||||
|
async with httpx.AsyncClient(timeout=300) as client:
|
||||||
|
r = await client.post(f"{OLLAMA_URL}/api/create", json={
|
||||||
|
"name": new_model_name,
|
||||||
|
"modelfile": modelfile,
|
||||||
|
})
|
||||||
|
if r.status_code == 200:
|
||||||
|
run.status = "SUCCESS"
|
||||||
|
run.model_name = new_model_name
|
||||||
|
run.samples_used = len(samples)
|
||||||
|
logger.info(f"학습 완료: {new_model_name}")
|
||||||
|
else:
|
||||||
|
run.status = "FAILED"
|
||||||
|
run.error_message = r.text[:200]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
run.status = "FAILED"
|
||||||
|
run.error_message = str(e)[:200]
|
||||||
|
logger.error(f"학습 실패: {e}")
|
||||||
|
finally:
|
||||||
|
run.finished_at = datetime.utcnow()
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def learning_status(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""학습 현황 + 데이터 수집 가능량."""
|
||||||
|
samples = await _collect_training_data(db)
|
||||||
|
high_quality = [s for s in samples if s.get("type") == "rag_positive"]
|
||||||
|
|
||||||
|
latest = await db.execute(
|
||||||
|
select(LearningRun).order_by(desc(LearningRun.started_at)).limit(1)
|
||||||
|
)
|
||||||
|
last_run = latest.scalar_one_or_none()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"available_samples": len(samples),
|
||||||
|
"high_quality_rag": len(high_quality),
|
||||||
|
"sr_samples": len(samples) - len(high_quality),
|
||||||
|
"ready_to_train": len(samples) >= 20,
|
||||||
|
"last_run": {
|
||||||
|
"status": last_run.status if last_run else None,
|
||||||
|
"model": last_run.model_name if last_run else None,
|
||||||
|
"started_at": last_run.started_at if last_run else None,
|
||||||
|
} if last_run else None,
|
||||||
|
"base_model": BASE_MODEL,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/collect")
|
||||||
|
async def collect_data(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""학습 데이터 수집 현황 미리보기."""
|
||||||
|
samples = await _collect_training_data(db)
|
||||||
|
types = {}
|
||||||
|
for s in samples:
|
||||||
|
types[s["type"]] = types.get(s["type"], 0) + 1
|
||||||
|
return {"total": len(samples), "by_type": types, "preview": samples[:3]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/train")
|
||||||
|
async def start_training(
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
"""파인튜닝 실행 (백그라운드)."""
|
||||||
|
samples = await _collect_training_data(db)
|
||||||
|
if len(samples) < 10:
|
||||||
|
raise HTTPException(400, f"학습 데이터 부족: {len(samples)}개 (최소 10개)")
|
||||||
|
|
||||||
|
run = LearningRun(
|
||||||
|
triggered_by=user.id,
|
||||||
|
sample_count=len(samples),
|
||||||
|
status="PENDING",
|
||||||
|
started_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(run)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(run)
|
||||||
|
|
||||||
|
background_tasks.add_task(_run_training, run.id, samples, db)
|
||||||
|
return {"ok": True, "run_id": run.id, "samples": len(samples)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history")
|
||||||
|
async def learning_history(
|
||||||
|
limit: int = 20,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
rows = await db.execute(
|
||||||
|
select(LearningRun).order_by(desc(LearningRun.started_at)).limit(limit)
|
||||||
|
)
|
||||||
|
runs = rows.scalars().all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": r.id, "status": r.status, "model_name": r.model_name,
|
||||||
|
"samples_used": r.samples_used, "started_at": r.started_at,
|
||||||
|
"finished_at": r.finished_at, "error": r.error_message,
|
||||||
|
}
|
||||||
|
for r in runs
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/quality")
|
||||||
|
async def model_quality(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""모델 품질 지표 (RAG 피드백 기반)."""
|
||||||
|
total_fb = await db.execute(select(func.count(RAGFeedback.id)))
|
||||||
|
total = total_fb.scalar() or 0
|
||||||
|
positive_fb = await db.execute(
|
||||||
|
select(func.count(RAGFeedback.id)).where(RAGFeedback.rating >= 4)
|
||||||
|
)
|
||||||
|
positive = positive_fb.scalar() or 0
|
||||||
|
avg_rating = await db.execute(select(func.avg(RAGFeedback.rating)))
|
||||||
|
avg = avg_rating.scalar() or 0.0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_feedback": total,
|
||||||
|
"positive_rate": round(positive / total * 100, 1) if total else 0,
|
||||||
|
"avg_rating": round(avg, 2),
|
||||||
|
"quality_grade": "A" if avg >= 4.5 else "B" if avg >= 3.5 else "C" if avg >= 2.5 else "D",
|
||||||
|
}
|
||||||
207
workspace/guardia-itsm/routers/multimodal.py
Normal file
207
workspace/guardia-itsm/routers/multimodal.py
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
"""
|
||||||
|
멀티모달 AI — 이미지·로그 파일 분석 → 에러 자동 분류 + SR 생성
|
||||||
|
|
||||||
|
Ollama llava 모델로 스크린샷·에러 이미지를 분석하여
|
||||||
|
에러 유형을 자동 분류하고 SR을 생성한다.
|
||||||
|
|
||||||
|
엔드포인트:
|
||||||
|
POST /api/multimodal/analyze-image — 이미지 분석 (base64)
|
||||||
|
POST /api/multimodal/analyze-log — 로그 텍스트 분석
|
||||||
|
POST /api/multimodal/upload-and-analyze — 파일 업로드 + 분석
|
||||||
|
POST /api/multimodal/auto-sr — 분석 결과 → SR 자동 생성
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from core.auth import get_current_user
|
||||||
|
from database import get_db
|
||||||
|
from models import User, SRRequest, SRStatus
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/multimodal", tags=["Multimodal AI"])
|
||||||
|
|
||||||
|
OLLAMA_URL = "http://localhost:11434"
|
||||||
|
VISION_MODEL = "llava" # Ollama 비전 모델
|
||||||
|
TEXT_MODEL = "llama3"
|
||||||
|
|
||||||
|
|
||||||
|
class ImageAnalysisRequest(BaseModel):
|
||||||
|
image_b64: str
|
||||||
|
context: Optional[str] = None # 추가 컨텍스트 (서버명, 시스템명 등)
|
||||||
|
|
||||||
|
class LogAnalysisRequest(BaseModel):
|
||||||
|
log_text: str
|
||||||
|
log_type: str = "application" # application | system | nginx | java
|
||||||
|
|
||||||
|
|
||||||
|
async def _call_vision(image_b64: str, prompt: str) -> str:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=60) as client:
|
||||||
|
r = await client.post(f"{OLLAMA_URL}/api/generate", json={
|
||||||
|
"model": VISION_MODEL,
|
||||||
|
"prompt": prompt,
|
||||||
|
"images": [image_b64],
|
||||||
|
"stream": False,
|
||||||
|
})
|
||||||
|
if r.status_code == 200:
|
||||||
|
return r.json().get("response", "").strip()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"llava 호출 실패: {e}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
async def _call_llm(prompt: str) -> str:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
r = await client.post(f"{OLLAMA_URL}/api/generate", json={
|
||||||
|
"model": TEXT_MODEL,
|
||||||
|
"system": "IT 운영 전문가. 에러 분석 후 JSON 형식으로만 답변.",
|
||||||
|
"prompt": prompt,
|
||||||
|
"stream": False,
|
||||||
|
})
|
||||||
|
if r.status_code == 200:
|
||||||
|
return r.json().get("response", "").strip()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"llm 호출 실패: {e}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/analyze-image")
|
||||||
|
async def analyze_image(
|
||||||
|
req: ImageAnalysisRequest,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""이미지(스크린샷·에러화면) → 에러 분석."""
|
||||||
|
context_hint = f"\n참고: {req.context}" if req.context else ""
|
||||||
|
prompt = (
|
||||||
|
"이 IT 시스템 화면에서 에러나 문제를 찾아주세요. "
|
||||||
|
"한국어로 답변하고 다음 항목을 분석해주세요: "
|
||||||
|
"1) 에러 유형, 2) 예상 원인, 3) 권고 조치, 4) 심각도(LOW/MEDIUM/HIGH)"
|
||||||
|
+ context_hint
|
||||||
|
)
|
||||||
|
|
||||||
|
# llava 모델 존재 확인
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5) as c:
|
||||||
|
r = await c.post(f"{OLLAMA_URL}/api/show", json={"name": VISION_MODEL})
|
||||||
|
has_vision = r.status_code == 200
|
||||||
|
except Exception:
|
||||||
|
has_vision = False
|
||||||
|
|
||||||
|
if not has_vision:
|
||||||
|
# llava 없으면 llama3로 텍스트 분석 대체
|
||||||
|
analysis = await _call_llm(
|
||||||
|
f"이미지를 분석할 수 없습니다. 이미지 첨부된 IT 에러에 대한 일반적 안내를 제공하세요."
|
||||||
|
)
|
||||||
|
return {"model": TEXT_MODEL, "analysis": analysis or "llava 모델 미설치. `ollama pull llava` 실행 후 재시도.", "has_vision": False}
|
||||||
|
|
||||||
|
analysis = await _call_vision(req.image_b64, prompt)
|
||||||
|
return {
|
||||||
|
"model": VISION_MODEL,
|
||||||
|
"analysis": analysis,
|
||||||
|
"has_vision": True,
|
||||||
|
"context": req.context,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/analyze-log")
|
||||||
|
async def analyze_log(
|
||||||
|
req: LogAnalysisRequest,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""로그 텍스트 → 에러 패턴 분석 + 심각도 분류."""
|
||||||
|
log_sample = req.log_text[:3000] # 3000자로 제한
|
||||||
|
prompt = (
|
||||||
|
f"다음 {req.log_type} 로그를 분석해주세요:\n\n{log_sample}\n\n"
|
||||||
|
"JSON으로만 답변: "
|
||||||
|
'{"error_type": "오류유형", "severity": "LOW|MEDIUM|HIGH|CRITICAL", '
|
||||||
|
'"root_cause": "근본원인", "recommendation": "조치방안", '
|
||||||
|
'"keywords": ["에러키워드1", "에러키워드2"]}'
|
||||||
|
)
|
||||||
|
|
||||||
|
result_text = await _call_llm(prompt)
|
||||||
|
|
||||||
|
# JSON 추출
|
||||||
|
import json, re
|
||||||
|
match = re.search(r'\{.*\}', result_text, re.DOTALL)
|
||||||
|
try:
|
||||||
|
result = json.loads(match.group()) if match else {}
|
||||||
|
except Exception:
|
||||||
|
result = {"raw": result_text}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"log_type": req.log_type,
|
||||||
|
"analysis": result,
|
||||||
|
"log_length": len(req.log_text),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload-and-analyze")
|
||||||
|
async def upload_and_analyze(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""파일 업로드 후 유형에 따라 분석."""
|
||||||
|
content = await file.read()
|
||||||
|
filename = file.filename or ""
|
||||||
|
|
||||||
|
if filename.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp')):
|
||||||
|
# 이미지 분석
|
||||||
|
image_b64 = base64.b64encode(content).decode()
|
||||||
|
result = await analyze_image(
|
||||||
|
ImageAnalysisRequest(image_b64=image_b64, context=f"파일: {filename}"),
|
||||||
|
user
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 로그/텍스트 분석
|
||||||
|
try:
|
||||||
|
text = content.decode('utf-8', errors='replace')
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(400, "파일을 텍스트로 읽을 수 없습니다")
|
||||||
|
result = await analyze_log(
|
||||||
|
LogAnalysisRequest(log_text=text, log_type="application"),
|
||||||
|
user
|
||||||
|
)
|
||||||
|
|
||||||
|
result["filename"] = filename
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/auto-sr")
|
||||||
|
async def create_sr_from_analysis(
|
||||||
|
req: ImageAnalysisRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""이미지 분석 → SR 자동 생성."""
|
||||||
|
analysis_result = await analyze_image(req, user)
|
||||||
|
analysis_text = analysis_result.get("analysis", "")
|
||||||
|
|
||||||
|
if not analysis_text:
|
||||||
|
raise HTTPException(400, "분석 결과가 없습니다")
|
||||||
|
|
||||||
|
# 심각도 추출
|
||||||
|
priority = "HIGH" if "HIGH" in analysis_text.upper() or "CRITICAL" in analysis_text.upper() else "MEDIUM"
|
||||||
|
|
||||||
|
sr = SRRequest(
|
||||||
|
title=f"[AI 자동감지] 이미지 분석 이상 감지",
|
||||||
|
description=f"멀티모달 AI 분석 결과:\n{analysis_text[:500]}\n\n컨텍스트: {req.context or '없음'}",
|
||||||
|
category="MONITORING",
|
||||||
|
priority=priority,
|
||||||
|
status=SRStatus.OPEN,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(sr)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(sr)
|
||||||
|
|
||||||
|
return {"ok": True, "sr_id": sr.id, "priority": priority, "analysis_summary": analysis_text[:200]}
|
||||||
197
workspace/guardia-itsm/routers/ncloud.py
Normal file
197
workspace/guardia-itsm/routers/ncloud.py
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
"""
|
||||||
|
NCloud (네이버 클라우드) 리소스 관리
|
||||||
|
|
||||||
|
NCloud API로 서버·로드밸런서·DNS·오브젝트스토리지를 조회한다.
|
||||||
|
API 키는 AES-256-GCM 암호화 저장.
|
||||||
|
|
||||||
|
엔드포인트:
|
||||||
|
POST /api/ncloud/config — API 키 설정
|
||||||
|
GET /api/ncloud/servers — 서버 목록
|
||||||
|
GET /api/ncloud/load-balancers — 로드밸런서 목록
|
||||||
|
GET /api/ncloud/storage — 오브젝트스토리지 버킷
|
||||||
|
GET /api/ncloud/costs — 이번 달 비용 조회
|
||||||
|
GET /api/ncloud/summary — 전체 현황 요약
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import select
|
||||||
|
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, NCloudConfig # 신규
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/ncloud", tags=["NCloud"])
|
||||||
|
|
||||||
|
NCLOUD_API = "https://ncloud.apigw.ntruss.com"
|
||||||
|
|
||||||
|
|
||||||
|
class NCloudConfigCreate(BaseModel):
|
||||||
|
access_key: str = Field(..., min_length=10)
|
||||||
|
secret_key: str = Field(..., min_length=10)
|
||||||
|
region: str = Field("KR", description="KR | JP | SGN | USWN | ...")
|
||||||
|
|
||||||
|
|
||||||
|
def _ncloud_signature(method: str, url: str, timestamp: str, access_key: str, secret_key: str) -> str:
|
||||||
|
"""NCloud HMAC-SHA256 서명 생성."""
|
||||||
|
message = f"{method} {url}\n{timestamp}\n{access_key}"
|
||||||
|
return hmac.new(
|
||||||
|
secret_key.encode('utf-8'), message.encode('utf-8'), hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
async def _ncloud_request(config: NCloudConfig, method: str, path: str, params: dict = None) -> Optional[dict]:
|
||||||
|
"""NCloud API 호출."""
|
||||||
|
timestamp = str(int(datetime.utcnow().timestamp() * 1000))
|
||||||
|
url = f"{path}?{urlencode(params or {})}" if params else path
|
||||||
|
sig = _ncloud_signature(method, url, timestamp, config.access_key, config.secret_key_enc)
|
||||||
|
headers = {
|
||||||
|
"x-ncp-apigw-timestamp": timestamp,
|
||||||
|
"x-ncp-iam-access-key": config.access_key,
|
||||||
|
"x-ncp-apigw-signature-v2": sig,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
full_url = f"{NCLOUD_API}{url}"
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
r = await getattr(client, method.lower())(full_url, headers=headers)
|
||||||
|
return r.json() if r.status_code == 200 else None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"NCloud API 실패: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/config")
|
||||||
|
async def save_ncloud_config(
|
||||||
|
req: NCloudConfigCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
row = await db.execute(select(NCloudConfig).where(NCloudConfig.tenant_id == user.tenant_id))
|
||||||
|
cfg = row.scalar_one_or_none()
|
||||||
|
if cfg:
|
||||||
|
cfg.access_key = req.access_key
|
||||||
|
cfg.secret_key_enc = req.secret_key # TODO: AES-256-GCM 암호화
|
||||||
|
cfg.region = req.region
|
||||||
|
else:
|
||||||
|
cfg = NCloudConfig(
|
||||||
|
tenant_id=user.tenant_id,
|
||||||
|
access_key=req.access_key,
|
||||||
|
secret_key_enc=req.secret_key,
|
||||||
|
region=req.region,
|
||||||
|
is_active=True,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(cfg)
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_config(tenant_id: int, db: AsyncSession) -> NCloudConfig:
|
||||||
|
row = await db.execute(select(NCloudConfig).where(
|
||||||
|
NCloudConfig.tenant_id == tenant_id, NCloudConfig.is_active == True
|
||||||
|
))
|
||||||
|
cfg = row.scalar_one_or_none()
|
||||||
|
if not cfg:
|
||||||
|
raise HTTPException(404, "NCloud 설정 없음")
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/servers")
|
||||||
|
async def list_servers(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
cfg = await _get_config(user.tenant_id, db)
|
||||||
|
data = await _ncloud_request(cfg, "GET", "/vserver/v2/getServerInstanceList")
|
||||||
|
if not data:
|
||||||
|
return {"servers": [], "message": "NCloud API 응답 없음 (키 확인 필요)"}
|
||||||
|
|
||||||
|
servers = []
|
||||||
|
for s in data.get("getServerInstanceListResponse", {}).get("serverInstanceList", []):
|
||||||
|
servers.append({
|
||||||
|
"id": s.get("serverInstanceNo"),
|
||||||
|
"name": s.get("serverName"),
|
||||||
|
"status": s.get("serverInstanceStatus", {}).get("codeName"),
|
||||||
|
"type": s.get("serverProductCode"),
|
||||||
|
"zone": s.get("zone", {}).get("zoneName"),
|
||||||
|
"public_ip": s.get("publicIp"),
|
||||||
|
"private_ip": s.get("privateIp"),
|
||||||
|
})
|
||||||
|
return {"servers": servers, "count": len(servers)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/load-balancers")
|
||||||
|
async def list_load_balancers(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
cfg = await _get_config(user.tenant_id, db)
|
||||||
|
data = await _ncloud_request(cfg, "GET", "/loadbalancer/v2/getLoadBalancerInstanceList")
|
||||||
|
if not data:
|
||||||
|
return {"load_balancers": []}
|
||||||
|
|
||||||
|
lbs = []
|
||||||
|
for lb in data.get("getLoadBalancerInstanceListResponse", {}).get("loadBalancerInstanceList", []):
|
||||||
|
lbs.append({
|
||||||
|
"id": lb.get("loadBalancerInstanceNo"),
|
||||||
|
"name": lb.get("loadBalancerName"),
|
||||||
|
"domain": lb.get("domain"),
|
||||||
|
"type": lb.get("loadBalancerAlgorithmType", {}).get("codeName"),
|
||||||
|
"status": lb.get("loadBalancerInstanceStatus", {}).get("codeName"),
|
||||||
|
})
|
||||||
|
return {"load_balancers": lbs, "count": len(lbs)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/storage")
|
||||||
|
async def list_object_storage(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
cfg = await _get_config(user.tenant_id, db)
|
||||||
|
# NCloud Object Storage는 S3 호환 API 사용
|
||||||
|
data = await _ncloud_request(cfg, "GET", "/objectstorage/v1/buckets")
|
||||||
|
return {"storage": data or [], "message": "NCloud 오브젝트스토리지 조회"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/costs")
|
||||||
|
async def get_monthly_costs(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
cfg = await _get_config(user.tenant_id, db)
|
||||||
|
today = datetime.utcnow()
|
||||||
|
data = await _ncloud_request(cfg, "GET", "/billing/v1/getContractUsageList", {
|
||||||
|
"startTime": today.strftime("%Y%m") + "01",
|
||||||
|
"endTime": today.strftime("%Y%m%d"),
|
||||||
|
})
|
||||||
|
return {"costs": data or {}, "period": today.strftime("%Y-%m")}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary")
|
||||||
|
async def ncloud_summary(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""NCloud 리소스 전체 현황 요약."""
|
||||||
|
servers = await list_servers(db, user)
|
||||||
|
lbs = await list_load_balancers(db, user)
|
||||||
|
running = sum(1 for s in servers.get("servers", []) if "RUN" in (s.get("status") or "").upper())
|
||||||
|
return {
|
||||||
|
"server_count": servers.get("count", 0),
|
||||||
|
"running_servers": running,
|
||||||
|
"lb_count": lbs.get("count", 0),
|
||||||
|
"last_checked": datetime.utcnow(),
|
||||||
|
}
|
||||||
151
workspace/guardia-itsm/routers/servicenow.py
Normal file
151
workspace/guardia-itsm/routers/servicenow.py
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
"""
|
||||||
|
ServiceNow 연동 커넥터
|
||||||
|
|
||||||
|
기능:
|
||||||
|
- ServiceNow CMDB CI 목록 조회
|
||||||
|
- Incident 양방향 동기화
|
||||||
|
- GUARDiA SR → ServiceNow Incident 생성
|
||||||
|
- ServiceNow Change Request 조회
|
||||||
|
|
||||||
|
엔드포인트:
|
||||||
|
POST /api/servicenow/config — 연동 설정
|
||||||
|
GET /api/servicenow/config — 설정 조회
|
||||||
|
POST /api/servicenow/test — 연결 테스트
|
||||||
|
GET /api/servicenow/incidents — Incident 목록
|
||||||
|
POST /api/servicenow/sync/{sr_id} — SR → ServiceNow Incident 생성
|
||||||
|
GET /api/servicenow/cmdb — CMDB CI 목록
|
||||||
|
GET /api/servicenow/changes — Change Request 목록
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import select
|
||||||
|
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, SRRequest, ServiceNowConfig, ServiceNowMapping # 신규
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/servicenow", tags=["ServiceNow"])
|
||||||
|
|
||||||
|
PRIORITY_MAP = {"HIGH": "1", "MEDIUM": "2", "LOW": "3"}
|
||||||
|
|
||||||
|
|
||||||
|
class SNowConfigCreate(BaseModel):
|
||||||
|
instance_url: str = Field(..., description="https://company.service-now.com")
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
assignment_group: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
async def _snow_request(cfg: ServiceNowConfig, method: str, path: str,
|
||||||
|
payload: Optional[dict] = None) -> Optional[dict]:
|
||||||
|
url = f"{cfg.instance_url.rstrip('/')}/api/now/{path}"
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15, verify=False) as client:
|
||||||
|
r = await getattr(client, method.lower())(
|
||||||
|
url, json=payload,
|
||||||
|
auth=(cfg.username, cfg.password_enc),
|
||||||
|
headers={"Content-Type": "application/json", "Accept": "application/json"}
|
||||||
|
)
|
||||||
|
return r.json() if r.status_code in (200, 201) else None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"ServiceNow API 실패: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/config")
|
||||||
|
async def save_config(
|
||||||
|
req: SNowConfigCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
row = await db.execute(select(ServiceNowConfig).where(ServiceNowConfig.tenant_id == user.tenant_id))
|
||||||
|
cfg = row.scalar_one_or_none()
|
||||||
|
if cfg:
|
||||||
|
cfg.instance_url = req.instance_url; cfg.username = req.username
|
||||||
|
cfg.password_enc = req.password; cfg.assignment_group = req.assignment_group
|
||||||
|
else:
|
||||||
|
cfg = ServiceNowConfig(
|
||||||
|
tenant_id=user.tenant_id, instance_url=req.instance_url,
|
||||||
|
username=req.username, password_enc=req.password,
|
||||||
|
assignment_group=req.assignment_group, is_active=True, created_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.add(cfg)
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test")
|
||||||
|
async def test_connection(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
row = await db.execute(select(ServiceNowConfig).where(ServiceNowConfig.tenant_id == user.tenant_id))
|
||||||
|
cfg = row.scalar_one_or_none()
|
||||||
|
if not cfg: raise HTTPException(404, "설정 없음")
|
||||||
|
data = await _snow_request(cfg, "GET", "table/sys_user?sysparm_limit=1")
|
||||||
|
return {"ok": bool(data), "instance": cfg.instance_url}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/incidents")
|
||||||
|
async def list_incidents(limit: int = 20, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
row = await db.execute(select(ServiceNowConfig).where(ServiceNowConfig.tenant_id == user.tenant_id))
|
||||||
|
cfg = row.scalar_one_or_none()
|
||||||
|
if not cfg: raise HTTPException(404, "설정 없음")
|
||||||
|
data = await _snow_request(cfg, "GET", f"table/incident?sysparm_limit={limit}&sysparm_fields=number,short_description,state,priority,opened_at")
|
||||||
|
records = (data or {}).get("result", [])
|
||||||
|
return [{"number": r.get("number"), "title": r.get("short_description"),
|
||||||
|
"state": r.get("state"), "priority": r.get("priority"), "opened_at": r.get("opened_at")} for r in records]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sync/{sr_id}")
|
||||||
|
async def sync_to_servicenow(sr_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
"""SR → ServiceNow Incident 생성."""
|
||||||
|
sr_row = await db.execute(select(SRRequest).where(SRRequest.id == sr_id))
|
||||||
|
sr = sr_row.scalar_one_or_none()
|
||||||
|
if not sr: raise HTTPException(404, "SR 없음")
|
||||||
|
cfg_row = await db.execute(select(ServiceNowConfig).where(ServiceNowConfig.tenant_id == user.tenant_id))
|
||||||
|
cfg = cfg_row.scalar_one_or_none()
|
||||||
|
if not cfg: raise HTTPException(404, "ServiceNow 설정 없음")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"short_description": f"[GUARDiA SR-{sr.id}] {sr.title}",
|
||||||
|
"description": sr.description or "",
|
||||||
|
"impact": PRIORITY_MAP.get((sr.priority or "MEDIUM").upper(), "2"),
|
||||||
|
"urgency": PRIORITY_MAP.get((sr.priority or "MEDIUM").upper(), "2"),
|
||||||
|
}
|
||||||
|
if cfg.assignment_group:
|
||||||
|
payload["assignment_group"] = {"name": cfg.assignment_group}
|
||||||
|
|
||||||
|
data = await _snow_request(cfg, "POST", "table/incident", payload)
|
||||||
|
if data and data.get("result"):
|
||||||
|
sn_number = data["result"].get("number", "")
|
||||||
|
mapping = ServiceNowMapping(sr_id=sr.id, snow_number=sn_number, config_id=cfg.id, synced_at=datetime.utcnow())
|
||||||
|
db.add(mapping)
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True, "snow_number": sn_number}
|
||||||
|
raise HTTPException(500, "ServiceNow Incident 생성 실패")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/cmdb")
|
||||||
|
async def list_cmdb(limit: int = 50, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
row = await db.execute(select(ServiceNowConfig).where(ServiceNowConfig.tenant_id == user.tenant_id))
|
||||||
|
cfg = row.scalar_one_or_none()
|
||||||
|
if not cfg: raise HTTPException(404, "설정 없음")
|
||||||
|
data = await _snow_request(cfg, "GET", f"table/cmdb_ci_server?sysparm_limit={limit}&sysparm_fields=name,ip_address,os,status")
|
||||||
|
return (data or {}).get("result", [])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/changes")
|
||||||
|
async def list_changes(limit: int = 20, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
row = await db.execute(select(ServiceNowConfig).where(ServiceNowConfig.tenant_id == user.tenant_id))
|
||||||
|
cfg = row.scalar_one_or_none()
|
||||||
|
if not cfg: raise HTTPException(404, "설정 없음")
|
||||||
|
data = await _snow_request(cfg, "GET", f"table/change_request?sysparm_limit={limit}&sysparm_fields=number,short_description,state,type,scheduled_start_date")
|
||||||
|
return (data or {}).get("result", [])
|
||||||
Loading…
Reference in New Issue
Block a user