guardia-itsm/routers/kb_agent.py
DESKTOP-TKLFCPRython 64c27c3509 feat(itsm): G-1~G-12 확장 기능 + 하네스/봇/설치스크립트 구현
G-1: 메신저 Webhook Relay + _send_to_room 실제 httpx 호출 구현
G-2: POST /api/tasks/bulk SR 대량작업 엔드포인트 (최대 100건)
G-3: 라이선스 만료 알림 스케줄러 (매일 09:00 KST)
G-4: 체험판 upgrade_banner 필드 + license.py 배너 로직
G-5: core/auto_rca.py + incidents/problem auto-rca 엔드포인트
G-6: core/deploy_impact.py + vibe impact-analysis 엔드포인트
G-7: core/ticket_classifier.py + SR 생성 시 AI 분류 + ai-suggestion API
G-8: VulnPatchRecord 모델 + vuln_scan 패치추적 4개 엔드포인트
G-9: core/jira_sync.py + gateway Jira/Confluence 연동 엔드포인트
G-10: core/push_notify.py + routers/push.py + PushSubscription 모델
G-11: approvals 다중승인 (위임/서명/기한초과/마감연장)
G-12: alembic.ini + migrations/ + cicd/migrate_to_postgres.sh

하네스: guardia-orchestrator 확장기능 Phase 반영
봇명령어: /sr /status /license /bulk 슬래시 명령어 추가
설치스크립트: setup/ (Ubuntu, CentOS, RHEL, Windows) --test 옵션 포함

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:18:52 +09:00

306 lines
9.7 KiB
Python

"""
B-4: KB 자동 업데이트 에이전트 API 라우터
엔드포인트:
POST /api/kb-agent/run — 에이전트 즉시 실행 (해결 SR 일괄 처리)
POST /api/kb-agent/analyze/{sr_id} — 특정 SR → KB 분석 및 생성
GET /api/kb-agent/candidates — KB 후보 목록 (미처리 완료 SR)
GET /api/kb-agent/stats — 에이전트 처리 통계
POST /api/kb/ — KB 수동 생성
PUT /api/kb/{kb_id} — KB 수정
"""
from __future__ import annotations
import logging
import re
from datetime import datetime, timedelta
from typing import List, Optional
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import desc, select, and_, func
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db
from models import KBDocument, KBDocumentOut, SRRequest, SRStatus
from core.kb_agent import (
auto_create_kb_from_sr,
run_kb_agent_batch,
classify_category,
extract_tags_rule,
compute_similarity,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/kb-agent", tags=["kb-agent"])
# ── doc_id 생성 헬퍼 ─────────────────────────────────────────────────────────
async def _next_doc_id(db: AsyncSession) -> str:
"""KB-YYYYMMDD-NNNN 형식의 문서 ID 생성."""
today = datetime.utcnow().strftime("%Y%m%d")
prefix = f"KB-{today}-"
# 오늘 생성된 최대 번호 조회
q = select(KBDocument.doc_id).where(
KBDocument.doc_id.like(f"{prefix}%")
).order_by(desc(KBDocument.doc_id)).limit(1)
last = (await db.execute(q)).scalar()
if last:
try:
seq = int(last.split("-")[-1]) + 1
except ValueError:
seq = 1
else:
seq = 1
return f"{prefix}{seq:04d}"
# ── 에이전트 실행 ──────────────────────────────────────────────────────────────
class RunAgentRequest(BaseModel):
days_back: int = 7
max_sr: int = 20
use_llm: bool = True
model: str = "llama3"
@router.post("/run")
async def run_kb_agent(
body: RunAgentRequest,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
):
"""
KB 자동 업데이트 에이전트 실행.
최근 N일 완료된 SR을 분석하여 KB 문서 자동 생성.
"""
# 동기적 실행 (최대 20건이므로 백그라운드 불필요)
try:
result = await run_kb_agent_batch(
db = db,
days_back= body.days_back,
max_sr = body.max_sr,
use_llm = body.use_llm,
model = body.model,
)
return {
"status": "completed",
"run_at": datetime.utcnow().isoformat(),
**result,
}
except Exception as e:
logger.error("[B-4] 에이전트 실행 오류: %s", e)
raise HTTPException(500, f"에이전트 실행 오류: {str(e)[:100]}")
@router.post("/analyze/{sr_id}")
async def analyze_sr(
sr_id: str,
use_llm: bool = Query(True),
model: str = Query("llama3"),
db: AsyncSession = Depends(get_db),
):
"""특정 SR을 분석하여 KB 문서 생성."""
result = await auto_create_kb_from_sr(
db=db, sr_id=sr_id, use_llm=use_llm, model=model
)
if result is None:
raise HTTPException(404, f"SR '{sr_id}'를 찾을 수 없습니다.")
# KB ID가 있으면 상세 반환
if result.get("kb_id"):
kb = (await db.execute(
select(KBDocument).where(KBDocument.id == result["kb_id"])
)).scalars().first()
if kb:
result["kb_detail"] = {
"doc_id": kb.doc_id,
"title": kb.title,
"category": kb.category,
"tags": kb.tags,
}
return result
# ── KB 후보 목록 ──────────────────────────────────────────────────────────────
@router.get("/candidates")
async def list_kb_candidates(
days_back: int = Query(30, ge=1, le=90),
limit: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
"""KB 미처리 완료 SR 목록 (KB 후보)."""
since = datetime.utcnow() - timedelta(days=days_back)
# 이미 처리된 SR ID
processed = (await db.execute(
select(KBDocument.source_sr_id).where(KBDocument.source_sr_id.isnot(None))
)).scalars().all()
processed_set = set(processed)
q = (
select(SRRequest)
.where(
and_(
SRRequest.status == "COMPLETED",
SRRequest.updated_at >= since,
)
)
.order_by(desc(SRRequest.updated_at))
.limit(limit * 2)
)
srs = (await db.execute(q)).scalars().all()
candidates = []
for sr in srs:
if sr.sr_id in processed_set:
continue
candidates.append({
"sr_id": sr.sr_id,
"title": sr.title,
"sr_type": str(sr.sr_type) if sr.sr_type else None,
"priority": str(sr.priority) if sr.priority else None,
"completed_at": sr.updated_at.isoformat() if sr.updated_at else None,
})
if len(candidates) >= limit:
break
return {
"total": len(candidates),
"candidates": candidates,
}
# ── KB 수동 생성 / 수정 ───────────────────────────────────────────────────────
class KBCreateRequest(BaseModel):
title: str
category: Optional[str] = "일반"
symptoms: Optional[str] = None
cause: Optional[str] = None
solution: Optional[str] = None
commands: Optional[str] = None
tags: Optional[str] = None
sr_type: Optional[str] = None
source_sr_id: Optional[str] = None
@router.post("/kb", response_model=KBDocumentOut, status_code=201,
tags=["kb"])
async def create_kb(
body: KBCreateRequest,
db: AsyncSession = Depends(get_db),
):
"""KB 문서 수동 생성."""
doc_id = await _next_doc_id(db)
# 자동 태그 보강
auto_tags = extract_tags_rule(
body.title, body.symptoms or "", body.solution or ""
)
existing_tags = body.tags or ""
merged_tags = existing_tags + (" " if existing_tags and auto_tags else "") + " ".join(auto_tags)
kb = KBDocument(
doc_id = doc_id,
title = body.title,
category = body.category or classify_category(body.title, body.symptoms or ""),
symptoms = body.symptoms,
cause = body.cause,
solution = body.solution,
commands = body.commands,
tags = merged_tags.strip(),
sr_type = body.sr_type,
source_sr_id = body.source_sr_id,
author = "user",
published = False,
created_at= datetime.utcnow(),
)
db.add(kb)
await db.commit()
await db.refresh(kb)
return kb
class KBUpdateRequest(BaseModel):
title: Optional[str] = None
category: Optional[str] = None
symptoms: Optional[str] = None
cause: Optional[str] = None
solution: Optional[str] = None
commands: Optional[str] = None
tags: Optional[str] = None
published: Optional[bool] = None
@router.put("/kb/{kb_id}", response_model=KBDocumentOut, tags=["kb"])
async def update_kb(
kb_id: int,
body: KBUpdateRequest,
db: AsyncSession = Depends(get_db),
):
"""KB 문서 수정."""
kb = (await db.execute(
select(KBDocument).where(KBDocument.id == kb_id)
)).scalars().first()
if not kb:
raise HTTPException(404, f"KB {kb_id}를 찾을 수 없습니다.")
for field, val in body.model_dump(exclude_none=True).items():
setattr(kb, field, val)
kb.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(kb)
return kb
# ── 에이전트 통계 ─────────────────────────────────────────────────────────────
@router.get("/stats")
async def get_kb_agent_stats(
db: AsyncSession = Depends(get_db),
):
"""KB 에이전트 처리 통계."""
total_kb = (await db.execute(
select(func.count()).select_from(KBDocument)
)).scalar() or 0
agent_created = (await db.execute(
select(func.count()).select_from(KBDocument)
.where(KBDocument.author == "kb-agent")
)).scalar() or 0
user_created = (await db.execute(
select(func.count()).select_from(KBDocument)
.where(KBDocument.author != "kb-agent")
)).scalar() or 0
published = (await db.execute(
select(func.count()).select_from(KBDocument)
.where(KBDocument.published == True)
)).scalar() or 0
# 완료 SR 중 KB 미생성 수
total_completed = (await db.execute(
select(func.count()).select_from(SRRequest)
.where(SRRequest.status == "COMPLETED")
)).scalar() or 0
processed_count = (await db.execute(
select(func.count()).select_from(KBDocument)
.where(KBDocument.source_sr_id.isnot(None))
)).scalar() or 0
return {
"total_kb_documents": total_kb,
"agent_created": agent_created,
"user_created": user_created,
"published": published,
"completed_sr_total": total_completed,
"kb_coverage_pct": round(processed_count / max(total_completed, 1) * 100, 1),
}