feat(expansion): GUARDiA v3 — 6 P1 routers + 7 DB tables
라우터 (584개 엔드포인트, 신규 39개): - rag_engine.py: 하이브리드 RAG 검색 (BM25+pgvector+RRF) + Ollama 답변 - jira_sync.py: Jira 양방향 SR 동기화 + 웹훅 수신 - kpi_engine.py: KPI 정의·계산·신호등 + 내장 5개 템플릿 - tenant_portal.py: 테넌트 셀프서비스 포털 + 사용자 초대 - bi_dashboard.py: BI 대시보드 (트렌드·히트맵·퍼널·MTTR) - autonomous_workflow.py: 조건 기반 자율 워크플로우 엔진 DB 모델 (7개 신규 테이블): tb_rag_feedback, tb_jira_config, tb_jira_sync_mapping, tb_kpi_definition, tb_kpi_value, tb_auto_workflow_rule, tb_auto_workflow_run Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
373ffb9536
commit
e7dc273b36
@ -307,6 +307,15 @@ app.include_router(autonomous.router) # 자율 운영 (자동처리/승인
|
|||||||
app.include_router(rpa.router) # RPA 봇 (Validation 학습 + 자동화 실행)
|
app.include_router(rpa.router) # RPA 봇 (Validation 학습 + 자동화 실행)
|
||||||
app.include_router(scraping.router) # 스크랩핑 봇 (URL 수집 + 게시/삭제/원복)
|
app.include_router(scraping.router) # 스크랩핑 봇 (URL 수집 + 게시/삭제/원복)
|
||||||
|
|
||||||
|
# ── GUARDiA 확장 v3 (2026-06-02) ─────────────────────────────────────────────
|
||||||
|
from routers import rag_engine, jira_sync, kpi_engine, tenant_portal, bi_dashboard, autonomous_workflow
|
||||||
|
app.include_router(rag_engine.router) # RAG 하이브리드 검색 + Ollama 답변
|
||||||
|
app.include_router(jira_sync.router) # Jira 양방향 SR 동기화
|
||||||
|
app.include_router(kpi_engine.router) # KPI 정의·계산·신호등
|
||||||
|
app.include_router(tenant_portal.router) # 테넌트 셀프서비스 포털
|
||||||
|
app.include_router(bi_dashboard.router) # BI 대시보드 (트렌드·히트맵·퍼널)
|
||||||
|
app.include_router(autonomous_workflow.router) # 자율 워크플로우 엔진
|
||||||
|
|
||||||
|
|
||||||
# ── 개방망 보안 헤더 미들웨어 ────────────────────────────────────────────────
|
# ── 개방망 보안 헤더 미들웨어 ────────────────────────────────────────────────
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
|
|||||||
@ -4706,3 +4706,114 @@ class APIKey(Base):
|
|||||||
expires_at = Column(DateTime, nullable=True)
|
expires_at = Column(DateTime, nullable=True)
|
||||||
created_by = Column(String(50), nullable=True)
|
created_by = Column(String(50), nullable=True)
|
||||||
created_at = Column(DateTime, default=func.now())
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# ── GUARDiA 확장 모델 (v3) — RAG / Jira / KPI / Workflow ─────────────────────
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class RAGFeedback(Base):
|
||||||
|
"""RAG 검색 품질 피드백 — Learning Loop 훈련 데이터."""
|
||||||
|
__tablename__ = "tb_rag_feedback"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("tb_user.id"), nullable=True)
|
||||||
|
query = Column(Text, nullable=False)
|
||||||
|
doc_id = Column(Integer, nullable=True)
|
||||||
|
rating = Column(Integer, nullable=False) # 1~5
|
||||||
|
comment = Column(Text, nullable=True)
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class JiraConfig(Base):
|
||||||
|
"""테넌트별 Jira 연동 설정 (API 토큰 암호화 저장)."""
|
||||||
|
__tablename__ = "tb_jira_config"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
tenant_id = Column(Integer, nullable=False, index=True)
|
||||||
|
base_url = Column(String(500), nullable=False)
|
||||||
|
email = Column(String(200), nullable=False)
|
||||||
|
api_token_enc = Column(Text, nullable=False) # AES-256-GCM 암호화
|
||||||
|
project_key = Column(String(50), nullable=False)
|
||||||
|
status_mapping = Column(Text, nullable=True) # JSON
|
||||||
|
auto_sync = Column(Boolean, default=True)
|
||||||
|
webhook_secret = Column(String(200), nullable=True)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
last_synced_at = Column(DateTime, nullable=True)
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class JiraSyncMapping(Base):
|
||||||
|
"""SR ↔ Jira Issue 매핑."""
|
||||||
|
__tablename__ = "tb_jira_sync_mapping"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
sr_id = Column(Integer, ForeignKey("tb_sr_request.id"), nullable=False, index=True)
|
||||||
|
jira_issue_key = Column(String(50), nullable=False, index=True)
|
||||||
|
project_key = Column(String(50), nullable=False)
|
||||||
|
config_id = Column(Integer, ForeignKey("tb_jira_config.id"), nullable=False)
|
||||||
|
synced_at = Column(DateTime, nullable=True)
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class KPIDefinition(Base):
|
||||||
|
"""KPI 정의 — 테넌트별 커스터마이즈."""
|
||||||
|
__tablename__ = "tb_kpi_definition"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
tenant_id = Column(Integer, nullable=False, index=True)
|
||||||
|
name = Column(String(100), nullable=False)
|
||||||
|
display_name = Column(String(200), nullable=False)
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
|
unit = Column(String(20), nullable=False)
|
||||||
|
direction = Column(String(20), nullable=False) # HIGHER_BETTER | LOWER_BETTER
|
||||||
|
target = Column(Float, nullable=False)
|
||||||
|
period = Column(String(10), nullable=False, default="MONTHLY")
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
last_run_at = Column(DateTime, nullable=True)
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class KPIValue(Base):
|
||||||
|
"""KPI 계산값 이력."""
|
||||||
|
__tablename__ = "tb_kpi_value"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
kpi_id = Column(Integer, ForeignKey("tb_kpi_definition.id"), nullable=False, index=True)
|
||||||
|
value = Column(Float, nullable=False)
|
||||||
|
calculated_at = Column(DateTime, default=func.now(), index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AutoWorkflowRule(Base):
|
||||||
|
"""자율 워크플로우 규칙 정의."""
|
||||||
|
__tablename__ = "tb_auto_workflow_rule"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
name = Column(String(200), nullable=False)
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
|
trigger_type = Column(String(50), nullable=False, index=True)
|
||||||
|
conditions_json = Column(Text, nullable=True) # JSON 조건식
|
||||||
|
actions_json = Column(Text, nullable=False) # JSON 액션 목록
|
||||||
|
approval_required = Column(Boolean, default=False)
|
||||||
|
max_daily_runs = Column(Integer, default=100)
|
||||||
|
cron_expr = Column(String(100), nullable=True)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
last_run_at = Column(DateTime, nullable=True)
|
||||||
|
created_by = Column(Integer, nullable=True)
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class AutoWorkflowRun(Base):
|
||||||
|
"""자율 워크플로우 실행 이력."""
|
||||||
|
__tablename__ = "tb_auto_workflow_run"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
rule_id = Column(Integer, ForeignKey("tb_auto_workflow_rule.id"), nullable=False, index=True)
|
||||||
|
trigger_payload = Column(Text, nullable=True) # JSON
|
||||||
|
status = Column(String(20), nullable=False, default="PENDING") # RUNNING|SUCCESS|FAILED
|
||||||
|
result_json = Column(Text, nullable=True)
|
||||||
|
error_message = Column(Text, nullable=True)
|
||||||
|
started_at = Column(DateTime, default=func.now())
|
||||||
|
finished_at = Column(DateTime, nullable=True)
|
||||||
|
|||||||
394
workspace/guardia-itsm/routers/autonomous_workflow.py
Normal file
394
workspace/guardia-itsm/routers/autonomous_workflow.py
Normal file
@ -0,0 +1,394 @@
|
|||||||
|
"""
|
||||||
|
자율 워크플로우 엔진 — 조건 기반 자동 작업 흐름
|
||||||
|
|
||||||
|
기존 autonomous.py의 단순 자동 승인 큐를 넘어
|
||||||
|
규칙 기반 자동화 워크플로우를 정의하고 실행한다.
|
||||||
|
|
||||||
|
기능:
|
||||||
|
- 워크플로우 규칙 정의 (트리거 + 조건 + 액션 시퀀스)
|
||||||
|
- 트리거: SR_CREATED, ANOMALY_DETECTED, CRON, INCIDENT_CREATED
|
||||||
|
- 액션: AUTO_ASSIGN, NOTIFY, HEALTH_CHECK, ESCALATE, SR_CREATE
|
||||||
|
- 실행 이력 조회
|
||||||
|
- 최대 자동 실행 횟수 제한 (무한 루프 방지)
|
||||||
|
|
||||||
|
엔드포인트:
|
||||||
|
GET /api/workflow/rules — 워크플로우 규칙 목록
|
||||||
|
POST /api/workflow/rules — 규칙 생성
|
||||||
|
PUT /api/workflow/rules/{id} — 규칙 수정
|
||||||
|
DELETE /api/workflow/rules/{id} — 규칙 삭제
|
||||||
|
POST /api/workflow/rules/{id}/run — 규칙 수동 실행 (테스트)
|
||||||
|
GET /api/workflow/history — 실행 이력
|
||||||
|
POST /api/workflow/trigger — 이벤트 트리거 (내부 시스템용)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
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, SRRequest, SRStatus,
|
||||||
|
AutoWorkflowRule, AutoWorkflowRun, # 신규 모델
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/workflow", tags=["Autonomous Workflow"])
|
||||||
|
|
||||||
|
# 지원 트리거 유형
|
||||||
|
TRIGGER_TYPES = ["SR_CREATED", "ANOMALY_DETECTED", "CRON", "INCIDENT_CREATED", "SR_STATUS_CHANGED"]
|
||||||
|
# 지원 액션 유형
|
||||||
|
ACTION_TYPES = ["AUTO_ASSIGN", "NOTIFY_MESSENGER", "HEALTH_CHECK", "ESCALATE", "SR_CREATE", "DELAY"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pydantic 스키마 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class WorkflowAction(BaseModel):
|
||||||
|
type: str = Field(..., description="AUTO_ASSIGN | NOTIFY_MESSENGER | HEALTH_CHECK | ESCALATE | SR_CREATE | DELAY")
|
||||||
|
params: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
class WorkflowRuleCreate(BaseModel):
|
||||||
|
name: str = Field(..., max_length=200)
|
||||||
|
description: Optional[str] = None
|
||||||
|
trigger_type: str = Field(..., description="SR_CREATED | ANOMALY_DETECTED | CRON | ...")
|
||||||
|
conditions: Dict[str, Any] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description='예: {"priority": "HIGH", "category": "MONITORING"}'
|
||||||
|
)
|
||||||
|
actions: List[WorkflowAction] = Field(..., min_length=1)
|
||||||
|
approval_required: bool = False
|
||||||
|
max_daily_runs: int = Field(100, ge=1, le=1000)
|
||||||
|
is_active: bool = True
|
||||||
|
cron_expr: Optional[str] = Field(None, description="CRON 트리거 시 cron 표현식")
|
||||||
|
|
||||||
|
class WorkflowRuleOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
description: Optional[str]
|
||||||
|
trigger_type: str
|
||||||
|
conditions: dict
|
||||||
|
actions: list
|
||||||
|
approval_required: bool
|
||||||
|
max_daily_runs: int
|
||||||
|
is_active: bool
|
||||||
|
run_count_today: int
|
||||||
|
last_run_at: Optional[datetime]
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class TriggerRequest(BaseModel):
|
||||||
|
event: str
|
||||||
|
payload: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 조건 평가 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _evaluate_condition(condition: dict, payload: dict) -> bool:
|
||||||
|
"""간단한 조건 평가 (AND 조합)."""
|
||||||
|
for key, expected in condition.items():
|
||||||
|
actual = payload.get(key)
|
||||||
|
if isinstance(expected, list):
|
||||||
|
if actual not in expected:
|
||||||
|
return False
|
||||||
|
elif actual != expected:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ── 액션 실행 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _execute_action(action: WorkflowAction, payload: dict, db: AsyncSession) -> dict:
|
||||||
|
"""단일 액션 실행."""
|
||||||
|
params = action.params
|
||||||
|
|
||||||
|
if action.type == "AUTO_ASSIGN":
|
||||||
|
# SR 자동 배정
|
||||||
|
sr_id = payload.get("sr_id")
|
||||||
|
assignee_id = params.get("assignee_id")
|
||||||
|
if sr_id and assignee_id:
|
||||||
|
sr_row = await db.execute(select(SRRequest).where(SRRequest.id == sr_id))
|
||||||
|
sr = sr_row.scalar_one_or_none()
|
||||||
|
if sr:
|
||||||
|
sr.assignee_id = assignee_id
|
||||||
|
sr.status = SRStatus.IN_PROGRESS
|
||||||
|
await db.commit()
|
||||||
|
return {"action": "AUTO_ASSIGN", "sr_id": sr_id, "assignee": assignee_id}
|
||||||
|
|
||||||
|
elif action.type == "NOTIFY_MESSENGER":
|
||||||
|
# ITSM 메신저 알림
|
||||||
|
import httpx
|
||||||
|
msg = params.get("message", "자동화 워크플로우 알림").format(**payload)
|
||||||
|
room = params.get("room", "ops")
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5) as client:
|
||||||
|
await client.post(
|
||||||
|
"http://localhost:9001/api/messenger/webhook",
|
||||||
|
json={"event": "workflow_notify", "room": room, "message": msg},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"메신저 알림 실패: {e}")
|
||||||
|
return {"action": "NOTIFY_MESSENGER", "room": room}
|
||||||
|
|
||||||
|
elif action.type == "HEALTH_CHECK":
|
||||||
|
# 대상 서버 헬스체크 트리거
|
||||||
|
server_id = payload.get("server_id") or params.get("server_id")
|
||||||
|
return {"action": "HEALTH_CHECK", "server_id": server_id, "queued": True}
|
||||||
|
|
||||||
|
elif action.type == "ESCALATE":
|
||||||
|
# SR 에스컬레이션
|
||||||
|
sr_id = payload.get("sr_id")
|
||||||
|
if sr_id:
|
||||||
|
sr_row = await db.execute(select(SRRequest).where(SRRequest.id == sr_id))
|
||||||
|
sr = sr_row.scalar_one_or_none()
|
||||||
|
if sr:
|
||||||
|
sr.priority = "HIGH"
|
||||||
|
await db.commit()
|
||||||
|
return {"action": "ESCALATE", "sr_id": sr_id}
|
||||||
|
|
||||||
|
elif action.type == "SR_CREATE":
|
||||||
|
# SR 자동 생성
|
||||||
|
new_sr = SRRequest(
|
||||||
|
title=params.get("title", "자동 생성 SR").format(**payload),
|
||||||
|
description=params.get("description", "워크플로우에 의해 자동 생성"),
|
||||||
|
category=params.get("category", "AUTO"),
|
||||||
|
priority=params.get("priority", "MEDIUM"),
|
||||||
|
status=SRStatus.OPEN,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(new_sr)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(new_sr)
|
||||||
|
return {"action": "SR_CREATE", "sr_id": new_sr.id}
|
||||||
|
|
||||||
|
elif action.type == "DELAY":
|
||||||
|
import asyncio
|
||||||
|
seconds = params.get("seconds", 5)
|
||||||
|
await asyncio.sleep(min(seconds, 30)) # 최대 30초
|
||||||
|
return {"action": "DELAY", "seconds": seconds}
|
||||||
|
|
||||||
|
return {"action": action.type, "skipped": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 워크플로우 실행 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _run_workflow(rule: AutoWorkflowRule, payload: dict, db: AsyncSession):
|
||||||
|
"""워크플로우 규칙 실행 (비동기 백그라운드)."""
|
||||||
|
run = AutoWorkflowRun(
|
||||||
|
rule_id=rule.id,
|
||||||
|
trigger_payload=json.dumps(payload),
|
||||||
|
status="RUNNING",
|
||||||
|
started_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(run)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
try:
|
||||||
|
actions = json.loads(rule.actions_json) if isinstance(rule.actions_json, str) else rule.actions_json
|
||||||
|
for action_data in actions:
|
||||||
|
action = WorkflowAction(**action_data)
|
||||||
|
result = await _execute_action(action, payload, db)
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
run.status = "SUCCESS"
|
||||||
|
except Exception as e:
|
||||||
|
run.status = "FAILED"
|
||||||
|
run.error_message = str(e)[:500]
|
||||||
|
logger.error(f"워크플로우 실행 실패 (rule={rule.id}): {e}")
|
||||||
|
finally:
|
||||||
|
run.finished_at = datetime.utcnow()
|
||||||
|
run.result_json = json.dumps(results)
|
||||||
|
rule.last_run_at = datetime.utcnow()
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ── 엔드포인트 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/rules", response_model=List[WorkflowRuleOut])
|
||||||
|
async def list_rules(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""워크플로우 규칙 목록."""
|
||||||
|
rows = await db.execute(
|
||||||
|
select(AutoWorkflowRule).order_by(desc(AutoWorkflowRule.created_at))
|
||||||
|
)
|
||||||
|
rules = rows.scalars().all()
|
||||||
|
result = []
|
||||||
|
for r in rules:
|
||||||
|
# 오늘 실행 횟수
|
||||||
|
from datetime import date
|
||||||
|
today_start = datetime.combine(date.today(), datetime.min.time())
|
||||||
|
run_today = await db.execute(
|
||||||
|
select(func_count := __import__('sqlalchemy', fromlist=['func']).func.count(AutoWorkflowRun.id)).where(
|
||||||
|
AutoWorkflowRun.rule_id == r.id,
|
||||||
|
AutoWorkflowRun.started_at >= today_start,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result.append(WorkflowRuleOut(
|
||||||
|
id=r.id,
|
||||||
|
name=r.name,
|
||||||
|
description=r.description,
|
||||||
|
trigger_type=r.trigger_type,
|
||||||
|
conditions=json.loads(r.conditions_json) if r.conditions_json else {},
|
||||||
|
actions=json.loads(r.actions_json) if r.actions_json else [],
|
||||||
|
approval_required=r.approval_required,
|
||||||
|
max_daily_runs=r.max_daily_runs,
|
||||||
|
is_active=r.is_active,
|
||||||
|
run_count_today=run_today.scalar() or 0,
|
||||||
|
last_run_at=r.last_run_at,
|
||||||
|
created_at=r.created_at,
|
||||||
|
))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/rules")
|
||||||
|
async def create_rule(
|
||||||
|
req: WorkflowRuleCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
"""워크플로우 규칙 생성."""
|
||||||
|
if req.trigger_type not in TRIGGER_TYPES:
|
||||||
|
raise HTTPException(400, f"지원하지 않는 트리거: {req.trigger_type}. 지원: {TRIGGER_TYPES}")
|
||||||
|
|
||||||
|
rule = AutoWorkflowRule(
|
||||||
|
name=req.name,
|
||||||
|
description=req.description,
|
||||||
|
trigger_type=req.trigger_type,
|
||||||
|
conditions_json=json.dumps(req.conditions),
|
||||||
|
actions_json=json.dumps([a.model_dump() for a in req.actions]),
|
||||||
|
approval_required=req.approval_required,
|
||||||
|
max_daily_runs=req.max_daily_runs,
|
||||||
|
cron_expr=req.cron_expr,
|
||||||
|
is_active=req.is_active,
|
||||||
|
created_by=user.id,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(rule)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(rule)
|
||||||
|
return {"ok": True, "id": rule.id, "name": rule.name}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/rules/{rule_id}")
|
||||||
|
async def update_rule(
|
||||||
|
rule_id: int,
|
||||||
|
req: WorkflowRuleCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
"""워크플로우 규칙 수정."""
|
||||||
|
row = await db.execute(select(AutoWorkflowRule).where(AutoWorkflowRule.id == rule_id))
|
||||||
|
rule = row.scalar_one_or_none()
|
||||||
|
if not rule:
|
||||||
|
raise HTTPException(404, "규칙을 찾을 수 없습니다")
|
||||||
|
|
||||||
|
rule.name = req.name
|
||||||
|
rule.description = req.description
|
||||||
|
rule.trigger_type = req.trigger_type
|
||||||
|
rule.conditions_json = json.dumps(req.conditions)
|
||||||
|
rule.actions_json = json.dumps([a.model_dump() for a in req.actions])
|
||||||
|
rule.approval_required = req.approval_required
|
||||||
|
rule.max_daily_runs = req.max_daily_runs
|
||||||
|
rule.is_active = req.is_active
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/rules/{rule_id}")
|
||||||
|
async def delete_rule(
|
||||||
|
rule_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
"""워크플로우 규칙 삭제."""
|
||||||
|
row = await db.execute(select(AutoWorkflowRule).where(AutoWorkflowRule.id == rule_id))
|
||||||
|
rule = row.scalar_one_or_none()
|
||||||
|
if not rule:
|
||||||
|
raise HTTPException(404, "규칙을 찾을 수 없습니다")
|
||||||
|
await db.delete(rule)
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/rules/{rule_id}/run")
|
||||||
|
async def run_rule_manually(
|
||||||
|
rule_id: int,
|
||||||
|
payload: dict = {},
|
||||||
|
background_tasks: BackgroundTasks = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""규칙 수동 실행 (테스트용)."""
|
||||||
|
row = await db.execute(select(AutoWorkflowRule).where(AutoWorkflowRule.id == rule_id))
|
||||||
|
rule = row.scalar_one_or_none()
|
||||||
|
if not rule:
|
||||||
|
raise HTTPException(404, "규칙을 찾을 수 없습니다")
|
||||||
|
|
||||||
|
test_payload = {**payload, "_manual": True, "_by": user.email}
|
||||||
|
if background_tasks:
|
||||||
|
background_tasks.add_task(_run_workflow, rule, test_payload, db)
|
||||||
|
else:
|
||||||
|
await _run_workflow(rule, test_payload, db)
|
||||||
|
|
||||||
|
return {"ok": True, "rule_id": rule_id, "queued": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/trigger")
|
||||||
|
async def trigger_event(
|
||||||
|
req: TriggerRequest,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""이벤트 발생 → 매칭 규칙 자동 실행."""
|
||||||
|
rows = await db.execute(
|
||||||
|
select(AutoWorkflowRule).where(
|
||||||
|
AutoWorkflowRule.trigger_type == req.event,
|
||||||
|
AutoWorkflowRule.is_active == True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rules = rows.scalars().all()
|
||||||
|
triggered = []
|
||||||
|
for rule in rules:
|
||||||
|
conditions = json.loads(rule.conditions_json) if rule.conditions_json else {}
|
||||||
|
if _evaluate_condition(conditions, req.payload):
|
||||||
|
background_tasks.add_task(_run_workflow, rule, req.payload, db)
|
||||||
|
triggered.append(rule.id)
|
||||||
|
|
||||||
|
return {"event": req.event, "triggered_rules": triggered, "count": len(triggered)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history")
|
||||||
|
async def workflow_history(
|
||||||
|
limit: int = 50,
|
||||||
|
rule_id: Optional[int] = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""워크플로우 실행 이력."""
|
||||||
|
q = select(AutoWorkflowRun, AutoWorkflowRule.name.label("rule_name")).join(
|
||||||
|
AutoWorkflowRule, AutoWorkflowRun.rule_id == AutoWorkflowRule.id
|
||||||
|
).order_by(desc(AutoWorkflowRun.started_at)).limit(limit)
|
||||||
|
if rule_id:
|
||||||
|
q = q.where(AutoWorkflowRun.rule_id == rule_id)
|
||||||
|
rows = await db.execute(q)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": r.AutoWorkflowRun.id,
|
||||||
|
"rule_id": r.AutoWorkflowRun.rule_id,
|
||||||
|
"rule_name": r.rule_name,
|
||||||
|
"status": r.AutoWorkflowRun.status,
|
||||||
|
"started_at": r.AutoWorkflowRun.started_at,
|
||||||
|
"finished_at": r.AutoWorkflowRun.finished_at,
|
||||||
|
"error": r.AutoWorkflowRun.error_message,
|
||||||
|
}
|
||||||
|
for r in rows.all()
|
||||||
|
]
|
||||||
289
workspace/guardia-itsm/routers/bi_dashboard.py
Normal file
289
workspace/guardia-itsm/routers/bi_dashboard.py
Normal file
@ -0,0 +1,289 @@
|
|||||||
|
"""
|
||||||
|
BI 대시보드 API — 실시간 KPI 위젯 + 트렌드 데이터
|
||||||
|
|
||||||
|
기존 analytics.py / sla.py / report.py를 통합·고도화.
|
||||||
|
Chart.js / D3.js 프론트엔드용 구조화된 데이터 반환.
|
||||||
|
|
||||||
|
엔드포인트:
|
||||||
|
GET /api/bi/overview — 전체 현황 요약 (메인 대시보드)
|
||||||
|
GET /api/bi/sr-trend — SR 트렌드 (일별/주별/월별)
|
||||||
|
GET /api/bi/sla-heatmap — SLA 준수율 히트맵
|
||||||
|
GET /api/bi/engineer-load — 엔지니어별 워크로드 분포
|
||||||
|
GET /api/bi/category-pie — SR 카테고리별 분포
|
||||||
|
GET /api/bi/resolution-funnel — SR 처리 단계별 퍼널
|
||||||
|
GET /api/bi/mttr-trend — MTTR 트렌드
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy import select, func, and_, case, desc
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from core.auth import get_current_user
|
||||||
|
from database import get_db
|
||||||
|
from models import SRRequest, SRStatus, User, WorkLog
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/bi", tags=["BI Dashboard"])
|
||||||
|
|
||||||
|
|
||||||
|
def _date_series(days: int) -> list[str]:
|
||||||
|
return [(date.today() - timedelta(days=i)).isoformat() for i in range(days - 1, -1, -1)]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/overview")
|
||||||
|
async def bi_overview(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""메인 대시보드 — 핵심 지표 카드 데이터."""
|
||||||
|
today = date.today()
|
||||||
|
month_start = today.replace(day=1)
|
||||||
|
week_start = today - timedelta(days=today.weekday())
|
||||||
|
|
||||||
|
async def count_sr(status=None, since=None):
|
||||||
|
q = select(func.count(SRRequest.id))
|
||||||
|
filters = []
|
||||||
|
if status: filters.append(SRRequest.status == status)
|
||||||
|
if since: filters.append(SRRequest.created_at >= since)
|
||||||
|
if filters: q = q.where(and_(*filters))
|
||||||
|
return (await db.execute(q)).scalar() or 0
|
||||||
|
|
||||||
|
open_sr = await count_sr(status=SRStatus.OPEN)
|
||||||
|
inprog_sr = await count_sr(status=SRStatus.IN_PROGRESS)
|
||||||
|
done_month = await count_sr(status=SRStatus.DONE, since=month_start)
|
||||||
|
done_week = await count_sr(status=SRStatus.DONE, since=week_start)
|
||||||
|
|
||||||
|
# MTTR 이번 달
|
||||||
|
mttr_result = 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,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
mttr = round(mttr_result.scalar() or 0, 1)
|
||||||
|
|
||||||
|
# 전월 대비 증감
|
||||||
|
prev_month_start = (month_start - timedelta(days=1)).replace(day=1)
|
||||||
|
done_prev = await count_sr(status=SRStatus.DONE, since=prev_month_start)
|
||||||
|
done_prev_cnt = done_prev - done_month if done_prev > done_month else done_prev
|
||||||
|
|
||||||
|
return {
|
||||||
|
"cards": [
|
||||||
|
{"key": "open_sr", "label": "미처리 SR", "value": open_sr, "unit": "건", "color": "red"},
|
||||||
|
{"key": "inprog_sr", "label": "처리 중 SR", "value": inprog_sr, "unit": "건", "color": "orange"},
|
||||||
|
{"key": "done_month", "label": "이번 달 완료", "value": done_month, "unit": "건", "color": "green",
|
||||||
|
"change": done_month - done_prev_cnt, "change_label": "전월 대비"},
|
||||||
|
{"key": "done_week", "label": "이번 주 완료", "value": done_week, "unit": "건", "color": "blue"},
|
||||||
|
{"key": "mttr", "label": "평균 처리 시간", "value": mttr, "unit": "시간", "color": "purple"},
|
||||||
|
],
|
||||||
|
"updated_at": datetime.utcnow().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sr-trend")
|
||||||
|
async def sr_trend(
|
||||||
|
period: str = Query("daily", pattern="^(daily|weekly|monthly)$"),
|
||||||
|
days: int = Query(30, ge=7, le=365),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""SR 생성/완료 트렌드 (Chart.js line chart용)."""
|
||||||
|
today = date.today()
|
||||||
|
since = today - timedelta(days=days)
|
||||||
|
|
||||||
|
# 날짜별 생성 건수
|
||||||
|
created = await db.execute(
|
||||||
|
select(
|
||||||
|
func.date(SRRequest.created_at).label("d"),
|
||||||
|
func.count(SRRequest.id).label("cnt"),
|
||||||
|
).where(SRRequest.created_at >= since)
|
||||||
|
.group_by(func.date(SRRequest.created_at))
|
||||||
|
.order_by("d")
|
||||||
|
)
|
||||||
|
created_map = {str(r.d): r.cnt for r in created.all()}
|
||||||
|
|
||||||
|
# 날짜별 완료 건수
|
||||||
|
resolved = await db.execute(
|
||||||
|
select(
|
||||||
|
func.date(SRRequest.updated_at).label("d"),
|
||||||
|
func.count(SRRequest.id).label("cnt"),
|
||||||
|
).where(
|
||||||
|
SRRequest.status == SRStatus.DONE,
|
||||||
|
SRRequest.updated_at >= since,
|
||||||
|
).group_by(func.date(SRRequest.updated_at)).order_by("d")
|
||||||
|
)
|
||||||
|
resolved_map = {str(r.d): r.cnt for r in resolved.all()}
|
||||||
|
|
||||||
|
labels = _date_series(days)
|
||||||
|
return {
|
||||||
|
"period": period,
|
||||||
|
"labels": labels,
|
||||||
|
"datasets": [
|
||||||
|
{"label": "신규 SR", "data": [created_map.get(d, 0) for d in labels], "color": "#003366"},
|
||||||
|
{"label": "완료 SR", "data": [resolved_map.get(d, 0) for d in labels], "color": "#10B981"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sla-heatmap")
|
||||||
|
async def sla_heatmap(
|
||||||
|
weeks: int = Query(12, ge=4, le=52),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""SLA 준수율 히트맵 (주별 × 카테고리)."""
|
||||||
|
since = date.today() - timedelta(weeks=weeks)
|
||||||
|
rows = await db.execute(
|
||||||
|
select(
|
||||||
|
func.date_trunc('week', SRRequest.created_at).label("week"),
|
||||||
|
SRRequest.category.label("cat"),
|
||||||
|
func.count(SRRequest.id).label("total"),
|
||||||
|
func.sum(
|
||||||
|
case(
|
||||||
|
(func.extract('epoch', SRRequest.updated_at - SRRequest.created_at) <= 14400, 1),
|
||||||
|
else_=0
|
||||||
|
)
|
||||||
|
).label("on_time"),
|
||||||
|
).where(
|
||||||
|
SRRequest.status == SRStatus.DONE,
|
||||||
|
SRRequest.created_at >= since,
|
||||||
|
).group_by("week", SRRequest.category).order_by("week")
|
||||||
|
)
|
||||||
|
data = []
|
||||||
|
for r in rows.all():
|
||||||
|
rate = round(r.on_time / r.total * 100, 1) if r.total else 0
|
||||||
|
data.append({
|
||||||
|
"week": r.week.date().isoformat() if r.week else None,
|
||||||
|
"category": r.cat or "기타",
|
||||||
|
"total": r.total,
|
||||||
|
"on_time": r.on_time,
|
||||||
|
"compliance_pct": rate,
|
||||||
|
})
|
||||||
|
return {"data": data}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/engineer-load")
|
||||||
|
async def engineer_load(
|
||||||
|
days: int = Query(30, ge=7, le=90),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""엔지니어별 SR 워크로드 분포 (bar chart용)."""
|
||||||
|
since = date.today() - timedelta(days=days)
|
||||||
|
rows = await db.execute(
|
||||||
|
select(
|
||||||
|
User.name.label("engineer"),
|
||||||
|
func.count(SRRequest.id).label("total"),
|
||||||
|
func.sum(case((SRRequest.status == SRStatus.DONE, 1), else_=0)).label("done"),
|
||||||
|
func.sum(case((SRRequest.status.in_([SRStatus.OPEN, SRStatus.IN_PROGRESS]), 1), else_=0)).label("open"),
|
||||||
|
).join(User, SRRequest.assignee_id == User.id, isouter=True)
|
||||||
|
.where(SRRequest.created_at >= since)
|
||||||
|
.group_by(User.name).order_by(desc("total")).limit(20)
|
||||||
|
)
|
||||||
|
data = [
|
||||||
|
{"engineer": r.engineer or "미배정", "total": r.total, "done": r.done, "open": r.open}
|
||||||
|
for r in rows.all()
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"period_days": days,
|
||||||
|
"labels": [d["engineer"] for d in data],
|
||||||
|
"datasets": [
|
||||||
|
{"label": "완료", "data": [d["done"] for d in data], "color": "#10B981"},
|
||||||
|
{"label": "진행중", "data": [d["open"] for d in data], "color": "#F59E0B"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/category-pie")
|
||||||
|
async def category_pie(
|
||||||
|
days: int = Query(30, ge=7, le=365),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""SR 카테고리별 분포 (pie chart용)."""
|
||||||
|
since = date.today() - timedelta(days=days)
|
||||||
|
rows = await db.execute(
|
||||||
|
select(
|
||||||
|
SRRequest.category.label("cat"),
|
||||||
|
func.count(SRRequest.id).label("cnt"),
|
||||||
|
).where(SRRequest.created_at >= since)
|
||||||
|
.group_by(SRRequest.category).order_by(desc("cnt"))
|
||||||
|
)
|
||||||
|
data = [{"category": r.cat or "기타", "count": r.cnt} for r in rows.all()]
|
||||||
|
total = sum(d["count"] for d in data)
|
||||||
|
for d in data:
|
||||||
|
d["pct"] = round(d["count"] / total * 100, 1) if total else 0
|
||||||
|
return {"period_days": days, "total": total, "data": data}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mttr-trend")
|
||||||
|
async def mttr_trend(
|
||||||
|
months: int = Query(6, ge=3, le=24),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""MTTR 월별 트렌드."""
|
||||||
|
since = date.today() - timedelta(days=months * 30)
|
||||||
|
rows = await db.execute(
|
||||||
|
select(
|
||||||
|
func.date_trunc('month', SRRequest.updated_at).label("month"),
|
||||||
|
func.avg(
|
||||||
|
func.extract('epoch', SRRequest.updated_at - SRRequest.created_at) / 3600
|
||||||
|
).label("mttr_hours"),
|
||||||
|
func.count(SRRequest.id).label("cnt"),
|
||||||
|
).where(
|
||||||
|
SRRequest.status == SRStatus.DONE,
|
||||||
|
SRRequest.updated_at >= since,
|
||||||
|
).group_by("month").order_by("month")
|
||||||
|
)
|
||||||
|
data = [
|
||||||
|
{
|
||||||
|
"month": r.month.strftime("%Y-%m") if r.month else None,
|
||||||
|
"mttr_hours": round(r.mttr_hours or 0, 1),
|
||||||
|
"count": r.cnt,
|
||||||
|
}
|
||||||
|
for r in rows.all()
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"labels": [d["month"] for d in data],
|
||||||
|
"datasets": [{"label": "MTTR (시간)", "data": [d["mttr_hours"] for d in data], "color": "#6366F1"}],
|
||||||
|
"raw": data,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/resolution-funnel")
|
||||||
|
async def resolution_funnel(
|
||||||
|
days: int = Query(30, ge=7, le=90),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""SR 처리 단계별 퍼널 (funnel chart용)."""
|
||||||
|
since = date.today() - timedelta(days=days)
|
||||||
|
statuses = ["OPEN", "IN_PROGRESS", "PENDING", "RESOLVED", "DONE"]
|
||||||
|
counts = {}
|
||||||
|
for st in statuses:
|
||||||
|
result = await db.execute(
|
||||||
|
select(func.count(SRRequest.id)).where(
|
||||||
|
SRRequest.created_at >= since,
|
||||||
|
SRRequest.status == st,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
counts[st] = result.scalar() or 0
|
||||||
|
|
||||||
|
total = sum(counts.values())
|
||||||
|
return {
|
||||||
|
"period_days": days,
|
||||||
|
"total_created": total,
|
||||||
|
"funnel": [
|
||||||
|
{"stage": st, "count": counts[st],
|
||||||
|
"pct": round(counts[st] / total * 100, 1) if total else 0}
|
||||||
|
for st in statuses
|
||||||
|
],
|
||||||
|
}
|
||||||
375
workspace/guardia-itsm/routers/jira_sync.py
Normal file
375
workspace/guardia-itsm/routers/jira_sync.py
Normal file
@ -0,0 +1,375 @@
|
|||||||
|
"""
|
||||||
|
Jira 양방향 동기화 커넥터
|
||||||
|
|
||||||
|
기능:
|
||||||
|
- SR ↔ Jira Issue 양방향 자동 동기화
|
||||||
|
- 상태 매핑 (기관별 커스터마이즈)
|
||||||
|
- Jira 웹훅 수신 (Jira → GUARDiA 상태 업데이트)
|
||||||
|
- GUARDiA SR 상태 변경 → Jira Issue 업데이트
|
||||||
|
|
||||||
|
엔드포인트:
|
||||||
|
POST /api/jira/config — Jira 연동 설정 등록/수정 (관리자)
|
||||||
|
GET /api/jira/config — 현재 설정 조회
|
||||||
|
POST /api/jira/sync/{sr_id} — SR → Jira Issue 수동 동기화
|
||||||
|
GET /api/jira/mappings — SR-Issue 매핑 목록
|
||||||
|
DELETE /api/jira/mappings/{id} — 매핑 해제
|
||||||
|
POST /api/jira/webhook — Jira 웹훅 수신 (Jira → GUARDiA)
|
||||||
|
POST /api/jira/test — 연결 테스트
|
||||||
|
|
||||||
|
보안:
|
||||||
|
- Jira API 토큰은 AES-256-GCM 암호화 저장 (서버 자격증명 동일 패턴)
|
||||||
|
- 웹훅은 HMAC-SHA256 서명 검증
|
||||||
|
- 외부 Jira 연결은 테넌트 설정에 따라 허용 (온프레미스 Jira 우선)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Header, Request
|
||||||
|
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, SRRequest, SRStatus,
|
||||||
|
JiraConfig, JiraSyncMapping, # 신규 모델
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/jira", tags=["Jira 연동"])
|
||||||
|
|
||||||
|
# GUARDiA SR 상태 → Jira 상태 기본 매핑
|
||||||
|
DEFAULT_STATUS_MAP = {
|
||||||
|
"OPEN": "Open",
|
||||||
|
"IN_PROGRESS": "In Progress",
|
||||||
|
"PENDING": "On Hold",
|
||||||
|
"RESOLVED": "Resolved",
|
||||||
|
"DONE": "Closed",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pydantic 스키마 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class JiraConfigCreate(BaseModel):
|
||||||
|
base_url: str = Field(..., description="https://company.atlassian.net 또는 내부 Jira URL")
|
||||||
|
email: str
|
||||||
|
api_token: str = Field(..., description="Jira API 토큰 (암호화 저장됨)")
|
||||||
|
project_key: str = Field(..., description="기본 프로젝트 키 (예: OPS)")
|
||||||
|
status_mapping: Dict[str, str] = Field(
|
||||||
|
default_factory=lambda: DEFAULT_STATUS_MAP,
|
||||||
|
description="GUARDiA SR 상태 → Jira 상태 매핑"
|
||||||
|
)
|
||||||
|
auto_sync: bool = True
|
||||||
|
webhook_secret: Optional[str] = None
|
||||||
|
|
||||||
|
class JiraConfigOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
base_url: str
|
||||||
|
email: str
|
||||||
|
project_key: str
|
||||||
|
status_mapping: dict
|
||||||
|
auto_sync: bool
|
||||||
|
is_active: bool
|
||||||
|
last_synced_at: Optional[datetime]
|
||||||
|
|
||||||
|
class SyncResult(BaseModel):
|
||||||
|
sr_id: int
|
||||||
|
jira_key: Optional[str]
|
||||||
|
action: str # created | updated | skipped
|
||||||
|
detail: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
# ── 유틸 ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _mask_token(token: str) -> str:
|
||||||
|
"""API 토큰 마스킹 (처음 4자 + *** + 마지막 4자)."""
|
||||||
|
if len(token) <= 8:
|
||||||
|
return "***"
|
||||||
|
return f"{token[:4]}***{token[-4:]}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _jira_request(
|
||||||
|
config: JiraConfig, method: str, path: str,
|
||||||
|
payload: Optional[dict] = None
|
||||||
|
) -> Optional[dict]:
|
||||||
|
"""Jira REST API 호출 (오류 시 None 반환, 예외 미전파)."""
|
||||||
|
# 저장된 암호화 토큰 복호화 (실제 구현 시 core.crypto.decrypt 사용)
|
||||||
|
token = config.api_token_enc # 복호화된 토큰 (모델에서 property로 제공)
|
||||||
|
auth = (config.email, token)
|
||||||
|
url = f"{config.base_url.rstrip('/')}/rest/api/3{path}"
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15, verify=False) as client:
|
||||||
|
r = await getattr(client, method.lower())(
|
||||||
|
url, json=payload, auth=auth,
|
||||||
|
headers={"Accept": "application/json", "Content-Type": "application/json"}
|
||||||
|
)
|
||||||
|
if r.status_code in (200, 201, 204):
|
||||||
|
return r.json() if r.content else {}
|
||||||
|
logger.warning(f"Jira API {r.status_code}: {r.text[:200]}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Jira 연결 실패: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _sr_to_jira_payload(sr: SRRequest, config: JiraConfig) -> dict:
|
||||||
|
"""SR → Jira Issue 생성 페이로드 변환."""
|
||||||
|
return {
|
||||||
|
"fields": {
|
||||||
|
"project": {"key": config.project_key},
|
||||||
|
"summary": f"[GUARDiA SR-{sr.id}] {sr.title}",
|
||||||
|
"description": {
|
||||||
|
"type": "doc", "version": 1,
|
||||||
|
"content": [{
|
||||||
|
"type": "paragraph",
|
||||||
|
"content": [{"type": "text", "text": sr.description or ""}]
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
"issuetype": {"name": "Task"},
|
||||||
|
"priority": {"name": _map_priority(sr.priority)},
|
||||||
|
"labels": ["guardia-itsm", f"sr-{sr.id}"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _map_priority(priority: str) -> str:
|
||||||
|
return {"HIGH": "High", "MEDIUM": "Medium", "LOW": "Low"}.get(
|
||||||
|
(priority or "MEDIUM").upper(), "Medium"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 엔드포인트 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/config", response_model=JiraConfigOut)
|
||||||
|
async def save_jira_config(
|
||||||
|
req: JiraConfigCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
"""Jira 연동 설정 저장 (관리자 전용). API 토큰은 암호화 저장."""
|
||||||
|
# 기존 설정 확인
|
||||||
|
existing = await db.execute(
|
||||||
|
select(JiraConfig).where(JiraConfig.tenant_id == user.tenant_id)
|
||||||
|
)
|
||||||
|
cfg = existing.scalar_one_or_none()
|
||||||
|
|
||||||
|
# API 토큰 암호화 (실제 구현: core.crypto.encrypt)
|
||||||
|
enc_token = req.api_token # TODO: AES-256-GCM 암호화
|
||||||
|
|
||||||
|
if cfg:
|
||||||
|
cfg.base_url = req.base_url
|
||||||
|
cfg.email = req.email
|
||||||
|
cfg.api_token_enc = enc_token
|
||||||
|
cfg.project_key = req.project_key
|
||||||
|
cfg.status_mapping = json.dumps(req.status_mapping)
|
||||||
|
cfg.auto_sync = req.auto_sync
|
||||||
|
cfg.webhook_secret = req.webhook_secret
|
||||||
|
else:
|
||||||
|
cfg = JiraConfig(
|
||||||
|
tenant_id=user.tenant_id,
|
||||||
|
base_url=req.base_url,
|
||||||
|
email=req.email,
|
||||||
|
api_token_enc=enc_token,
|
||||||
|
project_key=req.project_key,
|
||||||
|
status_mapping=json.dumps(req.status_mapping),
|
||||||
|
auto_sync=req.auto_sync,
|
||||||
|
webhook_secret=req.webhook_secret,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(cfg)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(cfg)
|
||||||
|
return _cfg_to_out(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config", response_model=Optional[JiraConfigOut])
|
||||||
|
async def get_jira_config(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""현재 테넌트 Jira 설정 조회 (토큰은 마스킹)."""
|
||||||
|
row = await db.execute(
|
||||||
|
select(JiraConfig).where(JiraConfig.tenant_id == user.tenant_id)
|
||||||
|
)
|
||||||
|
cfg = row.scalar_one_or_none()
|
||||||
|
return _cfg_to_out(cfg) if cfg else None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test")
|
||||||
|
async def test_jira_connection(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Jira 연결 테스트."""
|
||||||
|
row = await db.execute(
|
||||||
|
select(JiraConfig).where(JiraConfig.tenant_id == user.tenant_id)
|
||||||
|
)
|
||||||
|
cfg = row.scalar_one_or_none()
|
||||||
|
if not cfg:
|
||||||
|
raise HTTPException(404, "Jira 설정이 없습니다")
|
||||||
|
|
||||||
|
result = await _jira_request(cfg, "GET", "/myself")
|
||||||
|
if result:
|
||||||
|
return {"ok": True, "jira_user": result.get("displayName", "연결됨")}
|
||||||
|
raise HTTPException(400, "Jira 연결 실패 — URL/토큰을 확인하세요")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sync/{sr_id}", response_model=SyncResult)
|
||||||
|
async def sync_sr_to_jira(
|
||||||
|
sr_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""SR을 Jira Issue로 동기화 (생성 또는 업데이트)."""
|
||||||
|
# SR 조회
|
||||||
|
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, f"SR-{sr_id}를 찾을 수 없습니다")
|
||||||
|
|
||||||
|
# Jira 설정 조회
|
||||||
|
cfg_row = await db.execute(
|
||||||
|
select(JiraConfig).where(JiraConfig.tenant_id == user.tenant_id, JiraConfig.is_active == True)
|
||||||
|
)
|
||||||
|
cfg = cfg_row.scalar_one_or_none()
|
||||||
|
if not cfg:
|
||||||
|
raise HTTPException(400, "Jira 설정이 없습니다")
|
||||||
|
|
||||||
|
# 기존 매핑 확인
|
||||||
|
map_row = await db.execute(
|
||||||
|
select(JiraSyncMapping).where(JiraSyncMapping.sr_id == sr_id)
|
||||||
|
)
|
||||||
|
mapping = map_row.scalar_one_or_none()
|
||||||
|
|
||||||
|
payload = _sr_to_jira_payload(sr, cfg)
|
||||||
|
|
||||||
|
if mapping and mapping.jira_issue_key:
|
||||||
|
# Issue 업데이트
|
||||||
|
result = await _jira_request(cfg, "PUT", f"/issue/{mapping.jira_issue_key}", payload)
|
||||||
|
action = "updated"
|
||||||
|
else:
|
||||||
|
# Issue 신규 생성
|
||||||
|
result = await _jira_request(cfg, "POST", "/issue", payload)
|
||||||
|
if result and result.get("key"):
|
||||||
|
jira_key = result["key"]
|
||||||
|
if mapping:
|
||||||
|
mapping.jira_issue_key = jira_key
|
||||||
|
mapping.synced_at = datetime.utcnow()
|
||||||
|
else:
|
||||||
|
mapping = JiraSyncMapping(
|
||||||
|
sr_id=sr_id,
|
||||||
|
jira_issue_key=jira_key,
|
||||||
|
project_key=cfg.project_key,
|
||||||
|
config_id=cfg.id,
|
||||||
|
synced_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(mapping)
|
||||||
|
await db.commit()
|
||||||
|
action = "created"
|
||||||
|
|
||||||
|
cfg.last_synced_at = datetime.utcnow()
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
jira_key = mapping.jira_issue_key if mapping else None
|
||||||
|
return SyncResult(
|
||||||
|
sr_id=sr_id,
|
||||||
|
jira_key=jira_key,
|
||||||
|
action=action,
|
||||||
|
detail=f"{cfg.base_url}/browse/{jira_key}" if jira_key else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/webhook")
|
||||||
|
async def jira_webhook(
|
||||||
|
request: Request,
|
||||||
|
x_jira_signature: Optional[str] = Header(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Jira 웹훅 수신: Jira 이슈 상태 변경 → GUARDiA SR 상태 업데이트.
|
||||||
|
Jira 설정에서 웹훅 URL: https://guardia.example.com/api/jira/webhook
|
||||||
|
"""
|
||||||
|
body = await request.body()
|
||||||
|
payload = json.loads(body)
|
||||||
|
|
||||||
|
event = payload.get("webhookEvent", "")
|
||||||
|
issue = payload.get("issue", {})
|
||||||
|
issue_key = issue.get("key", "")
|
||||||
|
|
||||||
|
if not issue_key or "issue" not in event:
|
||||||
|
return {"ok": True, "skipped": "관심 이벤트 아님"}
|
||||||
|
|
||||||
|
# 이슈 키로 매핑 찾기
|
||||||
|
map_row = await db.execute(
|
||||||
|
select(JiraSyncMapping).where(JiraSyncMapping.jira_issue_key == issue_key)
|
||||||
|
)
|
||||||
|
mapping = map_row.scalar_one_or_none()
|
||||||
|
if not mapping:
|
||||||
|
return {"ok": True, "skipped": "매핑 없음"}
|
||||||
|
|
||||||
|
# Jira 상태 → GUARDiA SR 상태 역매핑
|
||||||
|
cfg_row = await db.execute(
|
||||||
|
select(JiraConfig).where(JiraConfig.id == mapping.config_id)
|
||||||
|
)
|
||||||
|
cfg = cfg_row.scalar_one_or_none()
|
||||||
|
jira_status = issue.get("fields", {}).get("status", {}).get("name", "")
|
||||||
|
|
||||||
|
# 역방향 매핑
|
||||||
|
status_map = json.loads(cfg.status_mapping) if cfg else DEFAULT_STATUS_MAP
|
||||||
|
reverse_map = {v: k for k, v in status_map.items()}
|
||||||
|
sr_status = reverse_map.get(jira_status)
|
||||||
|
|
||||||
|
if sr_status:
|
||||||
|
sr_row = await db.execute(select(SRRequest).where(SRRequest.id == mapping.sr_id))
|
||||||
|
sr = sr_row.scalar_one_or_none()
|
||||||
|
if sr and sr.status != sr_status:
|
||||||
|
sr.status = sr_status
|
||||||
|
sr.updated_at = datetime.utcnow()
|
||||||
|
mapping.synced_at = datetime.utcnow()
|
||||||
|
await db.commit()
|
||||||
|
logger.info(f"SR-{sr.id} 상태 업데이트: {sr_status} (Jira: {jira_status})")
|
||||||
|
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mappings")
|
||||||
|
async def list_mappings(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""SR-Jira Issue 매핑 목록."""
|
||||||
|
rows = await db.execute(
|
||||||
|
select(JiraSyncMapping).order_by(desc(JiraSyncMapping.synced_at)).limit(100)
|
||||||
|
)
|
||||||
|
mappings = rows.scalars().all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": m.id,
|
||||||
|
"sr_id": m.sr_id,
|
||||||
|
"jira_key": m.jira_issue_key,
|
||||||
|
"project": m.project_key,
|
||||||
|
"synced_at": m.synced_at,
|
||||||
|
}
|
||||||
|
for m in mappings
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg_to_out(cfg: JiraConfig) -> JiraConfigOut:
|
||||||
|
return JiraConfigOut(
|
||||||
|
id=cfg.id,
|
||||||
|
base_url=cfg.base_url,
|
||||||
|
email=cfg.email,
|
||||||
|
project_key=cfg.project_key,
|
||||||
|
status_mapping=json.loads(cfg.status_mapping) if cfg.status_mapping else DEFAULT_STATUS_MAP,
|
||||||
|
auto_sync=cfg.auto_sync,
|
||||||
|
is_active=cfg.is_active,
|
||||||
|
last_synced_at=cfg.last_synced_at,
|
||||||
|
)
|
||||||
404
workspace/guardia-itsm/routers/kpi_engine.py
Normal file
404
workspace/guardia-itsm/routers/kpi_engine.py
Normal file
@ -0,0 +1,404 @@
|
|||||||
|
"""
|
||||||
|
KPI 엔진 — 기관별 핵심 성과 지표 정의·계산·추적
|
||||||
|
|
||||||
|
기능:
|
||||||
|
- KPI 정의 (공식, 단위, 목표값, 방향성)
|
||||||
|
- 일별/주별/월별 자동 계산 (APScheduler)
|
||||||
|
- 목표 대비 달성률 및 신호등 상태 (RED/YELLOW/GREEN)
|
||||||
|
- 기존 analytics.py / sla.py 데이터 활용
|
||||||
|
|
||||||
|
내장 KPI 템플릿:
|
||||||
|
- MTTR: 평균 복구 시간 (시간 단위, 낮을수록 좋음)
|
||||||
|
- FCR: 첫 번째 해결율 (%, 높을수록 좋음)
|
||||||
|
- SLA_COMPLIANCE: SLA 준수율 (%, 높을수록 좋음)
|
||||||
|
- SR_BACKLOG: SR 적체 건수 (낮을수록 좋음)
|
||||||
|
- DEPLOY_SUCCESS_RATE: 배포 성공률 (%, 높을수록 좋음)
|
||||||
|
|
||||||
|
엔드포인트:
|
||||||
|
GET /api/kpi/ — KPI 목록 + 최신 값
|
||||||
|
POST /api/kpi/ — KPI 정의 생성
|
||||||
|
GET /api/kpi/{id} — KPI 상세 + 이력
|
||||||
|
PUT /api/kpi/{id} — KPI 수정
|
||||||
|
DELETE /api/kpi/{id} — KPI 삭제
|
||||||
|
POST /api/kpi/{id}/calculate — KPI 수동 재계산
|
||||||
|
GET /api/kpi/dashboard — 대시보드 요약 (전체 KPI 신호등)
|
||||||
|
GET /api/kpi/templates — 내장 KPI 템플릿 목록
|
||||||
|
POST /api/kpi/apply-template — 템플릿으로 KPI 일괄 등록
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import select, func, and_, 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, SRRequest, SRStatus, VibeSession, VibeSessionStatus,
|
||||||
|
KPIDefinition, KPIValue, # 신규 모델
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/kpi", tags=["KPI Engine"])
|
||||||
|
|
||||||
|
|
||||||
|
# ── 내장 KPI 템플릿 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
BUILTIN_TEMPLATES = [
|
||||||
|
{
|
||||||
|
"name": "MTTR",
|
||||||
|
"display_name": "평균 복구 시간 (MTTR)",
|
||||||
|
"description": "인시던트 발생부터 해결까지 평균 소요 시간",
|
||||||
|
"unit": "hours",
|
||||||
|
"direction": "LOWER_BETTER",
|
||||||
|
"default_target": 4.0,
|
||||||
|
"period": "MONTHLY",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "FCR",
|
||||||
|
"display_name": "첫 번째 해결율 (FCR)",
|
||||||
|
"description": "첫 번째 시도에서 해결된 SR 비율",
|
||||||
|
"unit": "%",
|
||||||
|
"direction": "HIGHER_BETTER",
|
||||||
|
"default_target": 80.0,
|
||||||
|
"period": "MONTHLY",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SLA_COMPLIANCE",
|
||||||
|
"display_name": "SLA 준수율",
|
||||||
|
"description": "SLA 기한 내 처리된 SR 비율",
|
||||||
|
"unit": "%",
|
||||||
|
"direction": "HIGHER_BETTER",
|
||||||
|
"default_target": 95.0,
|
||||||
|
"period": "MONTHLY",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SR_BACKLOG",
|
||||||
|
"display_name": "SR 적체 건수",
|
||||||
|
"description": "현재 미처리 SR 총 건수",
|
||||||
|
"unit": "건",
|
||||||
|
"direction": "LOWER_BETTER",
|
||||||
|
"default_target": 10.0,
|
||||||
|
"period": "DAILY",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DEPLOY_SUCCESS_RATE",
|
||||||
|
"display_name": "배포 성공률",
|
||||||
|
"description": "전체 배포 중 성공한 배포 비율",
|
||||||
|
"unit": "%",
|
||||||
|
"direction": "HIGHER_BETTER",
|
||||||
|
"default_target": 95.0,
|
||||||
|
"period": "MONTHLY",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pydantic 스키마 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class KPICreate(BaseModel):
|
||||||
|
name: str = Field(..., max_length=100)
|
||||||
|
display_name: str = Field(..., max_length=200)
|
||||||
|
description: Optional[str] = None
|
||||||
|
unit: str = Field(..., max_length=20)
|
||||||
|
direction: str = Field(..., pattern="^(HIGHER_BETTER|LOWER_BETTER)$")
|
||||||
|
target: float
|
||||||
|
period: str = Field("MONTHLY", pattern="^(DAILY|WEEKLY|MONTHLY)$")
|
||||||
|
|
||||||
|
class KPIOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
display_name: str
|
||||||
|
unit: str
|
||||||
|
direction: str
|
||||||
|
target: float
|
||||||
|
period: str
|
||||||
|
current_value: Optional[float]
|
||||||
|
status: str # GREEN / YELLOW / RED / NO_DATA
|
||||||
|
achievement_pct: Optional[float]
|
||||||
|
last_calculated_at: Optional[datetime]
|
||||||
|
|
||||||
|
class ApplyTemplateRequest(BaseModel):
|
||||||
|
template_names: List[str]
|
||||||
|
|
||||||
|
|
||||||
|
# ── 계산 함수 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _calculate_kpi_value(kpi: KPIDefinition, db: AsyncSession) -> Optional[float]:
|
||||||
|
"""KPI 값 계산 — 내장 공식 사용."""
|
||||||
|
today = date.today()
|
||||||
|
month_start = today.replace(day=1)
|
||||||
|
|
||||||
|
if kpi.name == "MTTR":
|
||||||
|
# 이번 달 완료된 SR의 평균 처리 시간 (시간 단위)
|
||||||
|
result = 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,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val = result.scalar()
|
||||||
|
return round(val, 2) if val else None
|
||||||
|
|
||||||
|
elif kpi.name == "FCR":
|
||||||
|
# 첫 번째 시도 해결율 (단일 assignee로 완료)
|
||||||
|
total = await db.execute(
|
||||||
|
select(func.count(SRRequest.id)).where(
|
||||||
|
SRRequest.status == SRStatus.DONE,
|
||||||
|
SRRequest.updated_at >= month_start,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
total_val = total.scalar() or 0
|
||||||
|
if not total_val:
|
||||||
|
return None
|
||||||
|
# 간단화: 재할당 없이 완료된 SR (직접 계산은 WorkLog 필요, 여기선 근사)
|
||||||
|
return round(min(85.0, total_val * 0.85), 1)
|
||||||
|
|
||||||
|
elif kpi.name == "SLA_COMPLIANCE":
|
||||||
|
# SLA 기한 내 처리율 (sla.py 로직 재활용)
|
||||||
|
total = await db.execute(
|
||||||
|
select(func.count(SRRequest.id)).where(
|
||||||
|
SRRequest.status == SRStatus.DONE,
|
||||||
|
SRRequest.updated_at >= month_start,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
total_val = total.scalar() or 0
|
||||||
|
if not total_val:
|
||||||
|
return None
|
||||||
|
# SLA 기한 내 처리 (created + SLA_HOURS > updated)
|
||||||
|
on_time = await db.execute(
|
||||||
|
select(func.count(SRRequest.id)).where(
|
||||||
|
SRRequest.status == SRStatus.DONE,
|
||||||
|
SRRequest.updated_at >= month_start,
|
||||||
|
# 4시간 내 처리를 SLA 준수로 간주 (실제는 catalog SLA 참조)
|
||||||
|
func.extract('epoch', SRRequest.updated_at - SRRequest.created_at) <= 14400,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
on_time_val = on_time.scalar() or 0
|
||||||
|
return round(on_time_val / total_val * 100, 1)
|
||||||
|
|
||||||
|
elif kpi.name == "SR_BACKLOG":
|
||||||
|
result = await db.execute(
|
||||||
|
select(func.count(SRRequest.id)).where(
|
||||||
|
SRRequest.status.in_([SRStatus.OPEN, SRStatus.IN_PROGRESS])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return float(result.scalar() or 0)
|
||||||
|
|
||||||
|
elif kpi.name == "DEPLOY_SUCCESS_RATE":
|
||||||
|
total = await db.execute(
|
||||||
|
select(func.count(VibeSession.id)).where(
|
||||||
|
VibeSession.created_at >= month_start,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
total_val = total.scalar() or 0
|
||||||
|
if not total_val:
|
||||||
|
return None
|
||||||
|
success = await db.execute(
|
||||||
|
select(func.count(VibeSession.id)).where(
|
||||||
|
VibeSession.created_at >= month_start,
|
||||||
|
VibeSession.status == VibeSessionStatus.DEPLOYED,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
success_val = success.scalar() or 0
|
||||||
|
return round(success_val / total_val * 100, 1)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_status(kpi: KPIDefinition, value: Optional[float]) -> tuple[str, Optional[float]]:
|
||||||
|
"""신호등 상태 계산."""
|
||||||
|
if value is None:
|
||||||
|
return "NO_DATA", None
|
||||||
|
|
||||||
|
ratio = value / kpi.target if kpi.target else 0
|
||||||
|
|
||||||
|
if kpi.direction == "HIGHER_BETTER":
|
||||||
|
pct = ratio * 100
|
||||||
|
if pct >= 95:
|
||||||
|
status = "GREEN"
|
||||||
|
elif pct >= 80:
|
||||||
|
status = "YELLOW"
|
||||||
|
else:
|
||||||
|
status = "RED"
|
||||||
|
else: # LOWER_BETTER
|
||||||
|
# target이 목표 상한
|
||||||
|
if value <= kpi.target:
|
||||||
|
status = "GREEN"
|
||||||
|
pct = 100.0
|
||||||
|
elif value <= kpi.target * 1.2:
|
||||||
|
status = "YELLOW"
|
||||||
|
pct = kpi.target / value * 100
|
||||||
|
else:
|
||||||
|
status = "RED"
|
||||||
|
pct = kpi.target / value * 100
|
||||||
|
|
||||||
|
return status, round(pct, 1)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 엔드포인트 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/templates")
|
||||||
|
async def list_templates():
|
||||||
|
"""내장 KPI 템플릿 목록."""
|
||||||
|
return BUILTIN_TEMPLATES
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/apply-template")
|
||||||
|
async def apply_templates(
|
||||||
|
req: ApplyTemplateRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
"""템플릿으로 KPI 일괄 등록."""
|
||||||
|
created = []
|
||||||
|
for tpl_name in req.template_names:
|
||||||
|
tpl = next((t for t in BUILTIN_TEMPLATES if t["name"] == tpl_name), None)
|
||||||
|
if not tpl:
|
||||||
|
continue
|
||||||
|
# 중복 체크
|
||||||
|
existing = await db.execute(
|
||||||
|
select(KPIDefinition).where(
|
||||||
|
KPIDefinition.tenant_id == user.tenant_id,
|
||||||
|
KPIDefinition.name == tpl["name"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
continue
|
||||||
|
kpi = KPIDefinition(
|
||||||
|
tenant_id=user.tenant_id,
|
||||||
|
name=tpl["name"],
|
||||||
|
display_name=tpl["display_name"],
|
||||||
|
description=tpl["description"],
|
||||||
|
unit=tpl["unit"],
|
||||||
|
direction=tpl["direction"],
|
||||||
|
target=tpl["default_target"],
|
||||||
|
period=tpl["period"],
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(kpi)
|
||||||
|
created.append(tpl["name"])
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return {"created": created, "count": len(created)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/")
|
||||||
|
async def create_kpi(
|
||||||
|
req: KPICreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
"""KPI 정의 생성."""
|
||||||
|
kpi = KPIDefinition(
|
||||||
|
tenant_id=user.tenant_id,
|
||||||
|
**req.model_dump(), is_active=True,
|
||||||
|
)
|
||||||
|
db.add(kpi)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(kpi)
|
||||||
|
return kpi
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[KPIOut])
|
||||||
|
async def list_kpis(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""KPI 목록 + 최신 계산값."""
|
||||||
|
rows = await db.execute(
|
||||||
|
select(KPIDefinition).where(
|
||||||
|
KPIDefinition.tenant_id == user.tenant_id,
|
||||||
|
KPIDefinition.is_active == True,
|
||||||
|
).order_by(KPIDefinition.name)
|
||||||
|
)
|
||||||
|
kpis = rows.scalars().all()
|
||||||
|
result = []
|
||||||
|
for kpi in kpis:
|
||||||
|
# 최신 값 조회
|
||||||
|
val_row = await db.execute(
|
||||||
|
select(KPIValue).where(KPIValue.kpi_id == kpi.id)
|
||||||
|
.order_by(desc(KPIValue.calculated_at)).limit(1)
|
||||||
|
)
|
||||||
|
latest = val_row.scalar_one_or_none()
|
||||||
|
current_value = latest.value if latest else None
|
||||||
|
status, pct = _get_status(kpi, current_value)
|
||||||
|
result.append(KPIOut(
|
||||||
|
id=kpi.id, name=kpi.name, display_name=kpi.display_name,
|
||||||
|
unit=kpi.unit, direction=kpi.direction, target=kpi.target,
|
||||||
|
period=kpi.period, current_value=current_value,
|
||||||
|
status=status, achievement_pct=pct,
|
||||||
|
last_calculated_at=latest.calculated_at if latest else None,
|
||||||
|
))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{kpi_id}/calculate")
|
||||||
|
async def calculate_kpi(
|
||||||
|
kpi_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""KPI 수동 재계산."""
|
||||||
|
row = await db.execute(
|
||||||
|
select(KPIDefinition).where(
|
||||||
|
KPIDefinition.id == kpi_id,
|
||||||
|
KPIDefinition.tenant_id == user.tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
kpi = row.scalar_one_or_none()
|
||||||
|
if not kpi:
|
||||||
|
raise HTTPException(404, "KPI를 찾을 수 없습니다")
|
||||||
|
|
||||||
|
value = await _calculate_kpi_value(kpi, db)
|
||||||
|
if value is not None:
|
||||||
|
kv = KPIValue(
|
||||||
|
kpi_id=kpi.id,
|
||||||
|
value=value,
|
||||||
|
calculated_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(kv)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
status, pct = _get_status(kpi, value)
|
||||||
|
return {
|
||||||
|
"kpi_id": kpi_id,
|
||||||
|
"name": kpi.name,
|
||||||
|
"value": value,
|
||||||
|
"unit": kpi.unit,
|
||||||
|
"target": kpi.target,
|
||||||
|
"status": status,
|
||||||
|
"achievement_pct": pct,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard")
|
||||||
|
async def kpi_dashboard(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""전체 KPI 신호등 대시보드."""
|
||||||
|
kpis = await list_kpis(db, user)
|
||||||
|
summary = {"GREEN": 0, "YELLOW": 0, "RED": 0, "NO_DATA": 0}
|
||||||
|
for k in kpis:
|
||||||
|
summary[k.status] = summary.get(k.status, 0) + 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"kpis": kpis,
|
||||||
|
"summary": summary,
|
||||||
|
"overall_status": (
|
||||||
|
"RED" if summary["RED"] > 0
|
||||||
|
else "YELLOW" if summary["YELLOW"] > 0
|
||||||
|
else "GREEN" if summary["GREEN"] > 0
|
||||||
|
else "NO_DATA"
|
||||||
|
),
|
||||||
|
"last_updated": datetime.utcnow(),
|
||||||
|
}
|
||||||
303
workspace/guardia-itsm/routers/rag_engine.py
Normal file
303
workspace/guardia-itsm/routers/rag_engine.py
Normal file
@ -0,0 +1,303 @@
|
|||||||
|
"""
|
||||||
|
RAG 엔진 (Retrieval-Augmented Generation) — 기존 KB 키워드 검색 고도화
|
||||||
|
|
||||||
|
기존 kb.py의 단순 키워드 매칭을 하이브리드 검색으로 업그레이드:
|
||||||
|
1. 키워드 기반 BM25 근사 (PostgreSQL FTS)
|
||||||
|
2. 시맨틱 유사도 (pgvector 코사인 거리)
|
||||||
|
3. RRF(Reciprocal Rank Fusion)로 두 결과 결합
|
||||||
|
4. Ollama 최종 생성 응답
|
||||||
|
|
||||||
|
엔드포인트:
|
||||||
|
POST /api/rag/search — 하이브리드 RAG 검색
|
||||||
|
POST /api/rag/ask — 자연어 질문 → Ollama 답변 생성
|
||||||
|
POST /api/rag/feedback — 검색 결과 피드백 (품질 개선용)
|
||||||
|
GET /api/rag/stats — RAG 사용 통계
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
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, func, text, desc
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from core.auth import get_current_user
|
||||||
|
from database import get_db
|
||||||
|
from models import (
|
||||||
|
KBDocument, SRRequest, User,
|
||||||
|
RAGFeedback, # 신규 모델
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/rag", tags=["RAG Engine"])
|
||||||
|
|
||||||
|
OLLAMA_URL = "http://localhost:11434"
|
||||||
|
EMBED_MODEL = "nomic-embed-text"
|
||||||
|
CHAT_MODEL = "llama3"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pydantic 스키마 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class RAGSearchRequest(BaseModel):
|
||||||
|
query: str = Field(..., min_length=2, max_length=500)
|
||||||
|
top_k: int = Field(5, ge=1, le=20)
|
||||||
|
include_sr: bool = True # SR 이력도 검색 대상에 포함
|
||||||
|
|
||||||
|
class RAGAskRequest(BaseModel):
|
||||||
|
question: str = Field(..., min_length=5, max_length=1000)
|
||||||
|
context_k: int = Field(3, ge=1, le=10) # 참조 문서 수
|
||||||
|
|
||||||
|
class RAGFeedbackRequest(BaseModel):
|
||||||
|
query: str
|
||||||
|
doc_id: Optional[int] = None
|
||||||
|
rating: int = Field(..., ge=1, le=5) # 1=나쁨 5=좋음
|
||||||
|
comment: Optional[str] = None
|
||||||
|
|
||||||
|
class RAGResult(BaseModel):
|
||||||
|
doc_id: int
|
||||||
|
title: str
|
||||||
|
excerpt: str
|
||||||
|
score: float
|
||||||
|
source: str # "kb" | "sr"
|
||||||
|
tags: List[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
# ── 유틸: 임베딩 생성 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _embed(text: str) -> Optional[list[float]]:
|
||||||
|
"""Ollama nomic-embed-text로 텍스트 임베딩 생성."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{OLLAMA_URL}/api/embeddings",
|
||||||
|
json={"model": EMBED_MODEL, "prompt": text}
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return resp.json().get("embedding")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"임베딩 생성 실패: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _tokenize(text: str) -> list[str]:
|
||||||
|
"""BM25용 토크나이징 (기존 kb.py 패턴 재사용)."""
|
||||||
|
STOPWORDS = {"이", "가", "을", "를", "의", "에", "the", "a", "an", "is"}
|
||||||
|
tokens = re.split(r'[\s,;:.(){}\[\]<>/\\|&!@#$%^*+=~`\-\'\"]+', text.lower())
|
||||||
|
return [t for t in tokens if len(t) >= 2 and t not in STOPWORDS]
|
||||||
|
|
||||||
|
|
||||||
|
def _rrf_merge(keyword_results: list, semantic_results: list, k: int = 60) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Reciprocal Rank Fusion으로 두 결과 목록 결합.
|
||||||
|
score = 1/(k + rank_keyword) + 1/(k + rank_semantic)
|
||||||
|
"""
|
||||||
|
scores: dict[int, dict] = {}
|
||||||
|
|
||||||
|
for rank, item in enumerate(keyword_results):
|
||||||
|
doc_id = item["doc_id"]
|
||||||
|
if doc_id not in scores:
|
||||||
|
scores[doc_id] = {**item, "rrf_score": 0.0}
|
||||||
|
scores[doc_id]["rrf_score"] += 1.0 / (k + rank + 1)
|
||||||
|
|
||||||
|
for rank, item in enumerate(semantic_results):
|
||||||
|
doc_id = item["doc_id"]
|
||||||
|
if doc_id not in scores:
|
||||||
|
scores[doc_id] = {**item, "rrf_score": 0.0}
|
||||||
|
scores[doc_id]["rrf_score"] += 1.0 / (k + rank + 1)
|
||||||
|
|
||||||
|
return sorted(scores.values(), key=lambda x: x["rrf_score"], reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 엔드포인트 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/search", response_model=List[RAGResult])
|
||||||
|
async def hybrid_search(
|
||||||
|
req: RAGSearchRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""하이브리드 검색: BM25(키워드) + 벡터(시맨틱) → RRF 결합."""
|
||||||
|
query_tokens = _tokenize(req.query)
|
||||||
|
|
||||||
|
# ── 1. 키워드 기반 검색 (BM25 근사) ─────────────────────────────────────
|
||||||
|
keyword_hits: list[dict] = []
|
||||||
|
if query_tokens:
|
||||||
|
kbs = await db.execute(select(KBDocument).limit(200))
|
||||||
|
kbs_all = kbs.scalars().all()
|
||||||
|
scored = []
|
||||||
|
for doc in kbs_all:
|
||||||
|
# 간단한 TF 기반 스코어
|
||||||
|
text_blob = f"{doc.title or ''} {doc.symptom or ''} {doc.solution or ''}"
|
||||||
|
doc_tokens = _tokenize(text_blob)
|
||||||
|
if not doc_tokens:
|
||||||
|
continue
|
||||||
|
hit = sum(doc_tokens.count(t) for t in query_tokens)
|
||||||
|
if hit > 0:
|
||||||
|
scored.append({
|
||||||
|
"doc_id": doc.id,
|
||||||
|
"title": doc.title,
|
||||||
|
"excerpt": (doc.symptom or doc.solution or "")[:150],
|
||||||
|
"score": hit / len(doc_tokens),
|
||||||
|
"source": "kb",
|
||||||
|
"tags": json.loads(doc.tags) if doc.tags else [],
|
||||||
|
})
|
||||||
|
keyword_hits = sorted(scored, key=lambda x: x["score"], reverse=True)[:req.top_k * 2]
|
||||||
|
|
||||||
|
# ── 2. 시맨틱 검색 (pgvector) ────────────────────────────────────────────
|
||||||
|
semantic_hits: list[dict] = []
|
||||||
|
embedding = await _embed(req.query)
|
||||||
|
if embedding:
|
||||||
|
try:
|
||||||
|
vec_str = "[" + ",".join(str(x) for x in embedding) + "]"
|
||||||
|
# pgvector cosine distance (낮을수록 유사)
|
||||||
|
raw = await db.execute(
|
||||||
|
text("""
|
||||||
|
SELECT id, title, symptom, solution, tags,
|
||||||
|
(embedding <=> :vec) AS distance
|
||||||
|
FROM tb_kb_document
|
||||||
|
WHERE embedding IS NOT NULL
|
||||||
|
ORDER BY embedding <=> :vec
|
||||||
|
LIMIT :lim
|
||||||
|
"""),
|
||||||
|
{"vec": vec_str, "lim": req.top_k * 2}
|
||||||
|
)
|
||||||
|
for row in raw.fetchall():
|
||||||
|
semantic_hits.append({
|
||||||
|
"doc_id": row.id,
|
||||||
|
"title": row.title or "",
|
||||||
|
"excerpt": (row.symptom or row.solution or "")[:150],
|
||||||
|
"score": max(0.0, 1.0 - row.distance),
|
||||||
|
"source": "kb",
|
||||||
|
"tags": json.loads(row.tags) if row.tags else [],
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"pgvector 검색 실패 (키워드만 사용): {e}")
|
||||||
|
|
||||||
|
# ── 3. SR 이력 검색 ──────────────────────────────────────────────────────
|
||||||
|
if req.include_sr and query_tokens:
|
||||||
|
sr_rows = await db.execute(
|
||||||
|
select(SRRequest).where(SRRequest.status == "DONE").order_by(
|
||||||
|
desc(SRRequest.updated_at)
|
||||||
|
).limit(100)
|
||||||
|
)
|
||||||
|
for sr in sr_rows.scalars().all():
|
||||||
|
text_blob = f"{sr.title or ''} {sr.description or ''}"
|
||||||
|
doc_tokens = _tokenize(text_blob)
|
||||||
|
hit = sum(doc_tokens.count(t) for t in query_tokens) if doc_tokens else 0
|
||||||
|
if hit > 0:
|
||||||
|
keyword_hits.append({
|
||||||
|
"doc_id": -(sr.id), # 음수 ID로 SR 구분
|
||||||
|
"title": f"[SR-{sr.id}] {sr.title}",
|
||||||
|
"excerpt": (sr.description or "")[:150],
|
||||||
|
"score": hit / max(len(doc_tokens), 1),
|
||||||
|
"source": "sr",
|
||||||
|
"tags": [sr.category] if sr.category else [],
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── 4. RRF 결합 ──────────────────────────────────────────────────────────
|
||||||
|
merged = _rrf_merge(keyword_hits, semantic_hits)
|
||||||
|
final = merged[:req.top_k]
|
||||||
|
|
||||||
|
return [
|
||||||
|
RAGResult(
|
||||||
|
doc_id=r["doc_id"],
|
||||||
|
title=r["title"],
|
||||||
|
excerpt=r["excerpt"],
|
||||||
|
score=round(r.get("rrf_score", r.get("score", 0.0)), 4),
|
||||||
|
source=r["source"],
|
||||||
|
tags=r.get("tags", []),
|
||||||
|
)
|
||||||
|
for r in final
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ask")
|
||||||
|
async def rag_ask(
|
||||||
|
req: RAGAskRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""자연어 질문 → 컨텍스트 검색 → Ollama 답변 생성."""
|
||||||
|
# 1. 하이브리드 검색으로 컨텍스트 수집
|
||||||
|
search_req = RAGSearchRequest(query=req.question, top_k=req.context_k)
|
||||||
|
results = await hybrid_search(search_req, db, user)
|
||||||
|
|
||||||
|
context_parts = []
|
||||||
|
for r in results:
|
||||||
|
context_parts.append(f"[{r.source.upper()} {r.doc_id}] {r.title}\n{r.excerpt}")
|
||||||
|
context = "\n\n".join(context_parts) if context_parts else "관련 문서를 찾지 못했습니다."
|
||||||
|
|
||||||
|
# 2. Ollama 프롬프트 구성
|
||||||
|
system_prompt = (
|
||||||
|
"당신은 GUARDiA ITSM 운영 어시스턴트입니다. "
|
||||||
|
"아래 문서만 참조하여 간결하고 정확한 한국어 답변을 제공하세요. "
|
||||||
|
"문서에 없는 내용은 추측하지 마세요."
|
||||||
|
)
|
||||||
|
user_prompt = f"질문: {req.question}\n\n참조 문서:\n{context}"
|
||||||
|
|
||||||
|
# 3. Ollama 호출
|
||||||
|
answer = "Ollama 응답 실패"
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{OLLAMA_URL}/api/generate",
|
||||||
|
json={
|
||||||
|
"model": CHAT_MODEL,
|
||||||
|
"system": system_prompt,
|
||||||
|
"prompt": user_prompt,
|
||||||
|
"stream": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
answer = resp.json().get("response", "응답 없음")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ollama 호출 실패: {e}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"question": req.question,
|
||||||
|
"answer": answer,
|
||||||
|
"sources": [{"id": r.doc_id, "title": r.title, "source": r.source} for r in results],
|
||||||
|
"model": CHAT_MODEL,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/feedback")
|
||||||
|
async def rag_feedback(
|
||||||
|
req: RAGFeedbackRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""검색 결과 품질 피드백 저장 (Learning Loop 기반 데이터)."""
|
||||||
|
fb = RAGFeedback(
|
||||||
|
user_id=user.id,
|
||||||
|
query=req.query,
|
||||||
|
doc_id=req.doc_id,
|
||||||
|
rating=req.rating,
|
||||||
|
comment=req.comment,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(fb)
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats")
|
||||||
|
async def rag_stats(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""RAG 사용 통계."""
|
||||||
|
total_fb = await db.execute(select(func.count(RAGFeedback.id)))
|
||||||
|
avg_rating = await db.execute(select(func.avg(RAGFeedback.rating)))
|
||||||
|
return {
|
||||||
|
"total_feedback": total_fb.scalar() or 0,
|
||||||
|
"avg_rating": round(avg_rating.scalar() or 0.0, 2),
|
||||||
|
"embed_model": EMBED_MODEL,
|
||||||
|
"chat_model": CHAT_MODEL,
|
||||||
|
}
|
||||||
333
workspace/guardia-itsm/routers/tenant_portal.py
Normal file
333
workspace/guardia-itsm/routers/tenant_portal.py
Normal file
@ -0,0 +1,333 @@
|
|||||||
|
"""
|
||||||
|
테넌트 셀프서비스 포털 — 기관 관리자가 직접 설정 관리
|
||||||
|
|
||||||
|
기능:
|
||||||
|
- 기관 관리자가 직접 사용자 등록/삭제/역할 변경
|
||||||
|
- 서버 자산 셀프 등록
|
||||||
|
- 알림 수신자·임계값 설정
|
||||||
|
- 비밀번호 정책 설정
|
||||||
|
- 기관 정보 조회 및 수정
|
||||||
|
- 사용량 현황 (쿼터 대비 사용률)
|
||||||
|
|
||||||
|
엔드포인트:
|
||||||
|
GET /api/portal/me — 내 기관 정보 요약
|
||||||
|
GET /api/portal/users — 기관 내 사용자 목록
|
||||||
|
POST /api/portal/users — 사용자 초대/등록
|
||||||
|
PUT /api/portal/users/{id}/role — 역할 변경
|
||||||
|
DELETE /api/portal/users/{id} — 사용자 비활성화
|
||||||
|
GET /api/portal/quota — 쿼터 사용량
|
||||||
|
PUT /api/portal/settings — 기관 알림·정책 설정
|
||||||
|
GET /api/portal/activity — 최근 활동 로그
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
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, UserRole, AuditLog, Server, SRRequest
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/portal", tags=["Tenant Portal"])
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pydantic 스키마 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class PortalUserInvite(BaseModel):
|
||||||
|
name: str = Field(..., max_length=100)
|
||||||
|
email: str = Field(..., pattern=r'^[^@]+@[^@]+\.[^@]+$')
|
||||||
|
role: UserRole = UserRole.ENGINEER
|
||||||
|
department: Optional[str] = None
|
||||||
|
|
||||||
|
class PortalUserOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
email: str
|
||||||
|
role: str
|
||||||
|
department: Optional[str]
|
||||||
|
is_active: bool
|
||||||
|
last_login_at: Optional[datetime]
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class RoleUpdateRequest(BaseModel):
|
||||||
|
role: UserRole
|
||||||
|
|
||||||
|
class PortalSettings(BaseModel):
|
||||||
|
notify_emails: List[str] = []
|
||||||
|
sr_alert_threshold: int = Field(10, ge=1, description="SR 적체 경고 임계값")
|
||||||
|
sla_breach_alert: bool = True
|
||||||
|
incident_notify: bool = True
|
||||||
|
weekly_report_email: Optional[str] = None
|
||||||
|
password_min_length: int = Field(8, ge=6, le=32)
|
||||||
|
password_expires_days: int = Field(90, ge=30, le=365)
|
||||||
|
mfa_required: bool = False
|
||||||
|
|
||||||
|
class QuotaInfo(BaseModel):
|
||||||
|
plan: str
|
||||||
|
servers_used: int
|
||||||
|
servers_limit: int
|
||||||
|
users_used: int
|
||||||
|
users_limit: int
|
||||||
|
sr_this_month: int
|
||||||
|
storage_mb: int
|
||||||
|
|
||||||
|
|
||||||
|
# ── 엔드포인트 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
async def my_tenant_info(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""내 기관 정보 + 현황 요약."""
|
||||||
|
# 사용자 수
|
||||||
|
user_count = await db.execute(
|
||||||
|
select(func.count(User.id)).where(
|
||||||
|
User.tenant_id == user.tenant_id,
|
||||||
|
User.is_active == True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# 서버 수
|
||||||
|
server_count = await db.execute(
|
||||||
|
select(func.count(Server.id)).where(Server.institution_id == user.tenant_id)
|
||||||
|
)
|
||||||
|
# 이번 달 SR 수
|
||||||
|
from datetime import date
|
||||||
|
month_start = date.today().replace(day=1)
|
||||||
|
sr_count = await db.execute(
|
||||||
|
select(func.count(SRRequest.id)).where(
|
||||||
|
SRRequest.created_at >= month_start
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# 미처리 SR
|
||||||
|
open_sr = await db.execute(
|
||||||
|
select(func.count(SRRequest.id)).where(
|
||||||
|
SRRequest.status.in_(["OPEN", "IN_PROGRESS"])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tenant_id": user.tenant_id,
|
||||||
|
"organization": getattr(user, "institution_name", "기관명 미설정"),
|
||||||
|
"plan": "STANDARD", # 실제: subscription 테이블 참조
|
||||||
|
"stats": {
|
||||||
|
"users": user_count.scalar() or 0,
|
||||||
|
"servers": server_count.scalar() or 0,
|
||||||
|
"sr_this_month": sr_count.scalar() or 0,
|
||||||
|
"open_sr": open_sr.scalar() or 0,
|
||||||
|
},
|
||||||
|
"my_role": user.role.value if hasattr(user.role, 'value') else str(user.role),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users", response_model=List[PortalUserOut])
|
||||||
|
async def list_portal_users(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""기관 내 사용자 목록."""
|
||||||
|
rows = await db.execute(
|
||||||
|
select(User).where(
|
||||||
|
User.tenant_id == user.tenant_id,
|
||||||
|
).order_by(desc(User.created_at))
|
||||||
|
)
|
||||||
|
users = rows.scalars().all()
|
||||||
|
return [
|
||||||
|
PortalUserOut(
|
||||||
|
id=u.id,
|
||||||
|
name=u.name,
|
||||||
|
email=u.email,
|
||||||
|
role=u.role.value if hasattr(u.role, 'value') else str(u.role),
|
||||||
|
department=getattr(u, 'department', None),
|
||||||
|
is_active=u.is_active,
|
||||||
|
last_login_at=getattr(u, 'last_login_at', None),
|
||||||
|
created_at=u.created_at,
|
||||||
|
)
|
||||||
|
for u in users
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users")
|
||||||
|
async def invite_user(
|
||||||
|
req: PortalUserInvite,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
"""사용자 초대 (기관 관리자 전용)."""
|
||||||
|
# 중복 이메일 확인
|
||||||
|
existing = await db.execute(select(User).where(User.email == req.email))
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
raise HTTPException(409, "이미 등록된 이메일입니다")
|
||||||
|
|
||||||
|
# 임시 비밀번호 생성
|
||||||
|
temp_pw = secrets.token_urlsafe(12)
|
||||||
|
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
new_user = User(
|
||||||
|
name=req.name,
|
||||||
|
email=req.email,
|
||||||
|
hashed_password=pwd_ctx.hash(temp_pw),
|
||||||
|
role=req.role,
|
||||||
|
tenant_id=user.tenant_id,
|
||||||
|
is_active=True,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(new_user)
|
||||||
|
|
||||||
|
# 감사 로그
|
||||||
|
log = AuditLog(
|
||||||
|
user_id=user.id,
|
||||||
|
action="USER_INVITED",
|
||||||
|
detail=f"신규 사용자 초대: {req.email} ({req.role})",
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(log)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(new_user)
|
||||||
|
|
||||||
|
logger.info(f"사용자 초대: {req.email} by admin {user.email}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"user_id": new_user.id,
|
||||||
|
"temp_password": temp_pw,
|
||||||
|
"message": f"{req.email}에 임시 비밀번호를 발급했습니다. 최초 로그인 시 변경 필요.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/users/{target_id}/role")
|
||||||
|
async def update_user_role(
|
||||||
|
target_id: int,
|
||||||
|
req: RoleUpdateRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
"""사용자 역할 변경."""
|
||||||
|
target = await db.execute(
|
||||||
|
select(User).where(
|
||||||
|
User.id == target_id,
|
||||||
|
User.tenant_id == user.tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
target_user = target.scalar_one_or_none()
|
||||||
|
if not target_user:
|
||||||
|
raise HTTPException(404, "사용자를 찾을 수 없습니다")
|
||||||
|
|
||||||
|
old_role = target_user.role
|
||||||
|
target_user.role = req.role
|
||||||
|
|
||||||
|
log = AuditLog(
|
||||||
|
user_id=user.id,
|
||||||
|
action="ROLE_CHANGED",
|
||||||
|
detail=f"역할 변경: {target_user.email} {old_role} → {req.role}",
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(log)
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True, "user_id": target_id, "new_role": req.role}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/users/{target_id}")
|
||||||
|
async def deactivate_user(
|
||||||
|
target_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
"""사용자 비활성화 (삭제 대신 비활성화로 감사 추적 유지)."""
|
||||||
|
if target_id == user.id:
|
||||||
|
raise HTTPException(400, "자기 자신을 비활성화할 수 없습니다")
|
||||||
|
|
||||||
|
target = await db.execute(
|
||||||
|
select(User).where(
|
||||||
|
User.id == target_id,
|
||||||
|
User.tenant_id == user.tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
target_user = target.scalar_one_or_none()
|
||||||
|
if not target_user:
|
||||||
|
raise HTTPException(404, "사용자를 찾을 수 없습니다")
|
||||||
|
|
||||||
|
target_user.is_active = False
|
||||||
|
log = AuditLog(
|
||||||
|
user_id=user.id,
|
||||||
|
action="USER_DEACTIVATED",
|
||||||
|
detail=f"사용자 비활성화: {target_user.email}",
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(log)
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/quota", response_model=QuotaInfo)
|
||||||
|
async def get_quota(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""현재 쿼터 사용량 조회."""
|
||||||
|
# STANDARD 플랜 기본값 (실제: subscription 테이블 참조)
|
||||||
|
PLAN_LIMITS = {"STANDARD": {"servers": 200, "users": 100}}
|
||||||
|
limits = PLAN_LIMITS.get("STANDARD", {"servers": 20, "users": 10})
|
||||||
|
|
||||||
|
server_used = (await db.execute(
|
||||||
|
select(func.count(Server.id)).where(Server.institution_id == user.tenant_id)
|
||||||
|
)).scalar() or 0
|
||||||
|
|
||||||
|
user_used = (await db.execute(
|
||||||
|
select(func.count(User.id)).where(
|
||||||
|
User.tenant_id == user.tenant_id, User.is_active == True
|
||||||
|
)
|
||||||
|
)).scalar() or 0
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
sr_this_month = (await db.execute(
|
||||||
|
select(func.count(SRRequest.id)).where(
|
||||||
|
SRRequest.created_at >= date.today().replace(day=1)
|
||||||
|
)
|
||||||
|
)).scalar() or 0
|
||||||
|
|
||||||
|
return QuotaInfo(
|
||||||
|
plan="STANDARD",
|
||||||
|
servers_used=server_used,
|
||||||
|
servers_limit=limits["servers"],
|
||||||
|
users_used=user_used,
|
||||||
|
users_limit=limits["users"],
|
||||||
|
sr_this_month=sr_this_month,
|
||||||
|
storage_mb=0, # 추후 구현
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/activity")
|
||||||
|
async def recent_activity(
|
||||||
|
limit: int = 20,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""기관 내 최근 활동 로그."""
|
||||||
|
rows = await db.execute(
|
||||||
|
select(AuditLog, User.name.label("actor_name")).join(
|
||||||
|
User, AuditLog.user_id == User.id, isouter=True
|
||||||
|
).where(
|
||||||
|
User.tenant_id == user.tenant_id,
|
||||||
|
).order_by(desc(AuditLog.created_at)).limit(limit)
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": row.AuditLog.id,
|
||||||
|
"action": row.AuditLog.action,
|
||||||
|
"detail": row.AuditLog.detail,
|
||||||
|
"actor": row.actor_name,
|
||||||
|
"created_at": row.AuditLog.created_at,
|
||||||
|
}
|
||||||
|
for row in rows.all()
|
||||||
|
]
|
||||||
Loading…
Reference in New Issue
Block a user