zioinfo-mail/itsm/routers/kb.py
DESKTOP-TKLFCPR\ython e228faabf5 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

181 lines
6.1 KiB
Python

"""
Knowledge Base router — on-premise 기술 문서 검색 + SR 자동 추천.
외부 API 없이 순수 Python 키워드 매칭으로 RAG 유사 기능 구현:
1. 쿼리/문서 토크나이징
2. 키워드 히트 수 + 위치 가중치 기반 스코어링
3. SR 설명 + 작업 로그 에러를 합산해 자동 추천
"""
import re
from typing import List, Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import KBDocument, KBDocumentOut, KBSearchResult, SRRequest, User, WorkLog
router = APIRouter(prefix="/api/kb", tags=["kb"])
# ── 한국어 + 영문 공통 불용어 ────────────────────────────────────────────────
_STOPWORDS = {
"", "", "", "", "", "", "에서", "", "", "", "", "",
"이다", "있다", "하다", "되다", "않다", "", "", "또는", "", "",
"the", "a", "an", "is", "are", "was", "were", "in", "on", "at", "to",
"for", "of", "and", "or", "not", "with", "by", "from", "be",
}
_MIN_TOKEN_LEN = 2
def _tokenize(text: str) -> list[str]:
"""공백·특수문자 기준 분리, 불용어 제거, 소문자 정규화."""
if not text:
return []
tokens = re.split(r'[\s,;:.(){}\[\]<>/\\|&!@#$%^*+=~`\-\'\"]+', text.lower())
return [t for t in tokens if len(t) >= _MIN_TOKEN_LEN and t not in _STOPWORDS]
def _score_doc(query_tokens: list[str], doc: KBDocument) -> tuple[float, list[str]]:
"""
문서 관련도 스코어 계산.
title/tags 히트: 가중치 2.0
symptoms/cause 히트: 가중치 1.5
solution/commands 히트: 가중치 1.0
"""
if not query_tokens:
return 0.0, []
# 필드별 가중치 텍스트
weighted_fields = [
(doc.title or "", 2.0),
(doc.tags or "", 2.0),
(doc.symptoms or "", 1.5),
(doc.cause or "", 1.5),
(doc.solution or "", 1.0),
(doc.commands or "", 1.0),
]
matched: set[str] = set()
raw_score = 0.0
for field_text, weight in weighted_fields:
field_lower = field_text.lower()
for token in query_tokens:
if token in field_lower:
raw_score += weight
matched.add(token)
# 정규화: 최대 가능 점수 대비 비율
max_possible = len(query_tokens) * 2.0 # 모든 토큰이 title/tags 히트 시
normalized = raw_score / max(max_possible, 1.0)
# 0.0~1.0 범위 클리핑
return round(min(normalized, 1.0), 4), sorted(matched)
async def _search_docs(
db: AsyncSession,
query: str,
sr_type_hint: Optional[str] = None,
limit: int = 5,
) -> list[dict]:
"""핵심 검색 함수 — 라우터·추천 양쪽에서 재사용."""
tokens = _tokenize(query)
if not tokens:
return []
res = await db.execute(select(KBDocument))
docs = res.scalars().all()
results = []
for doc in docs:
score, matched = _score_doc(tokens, doc)
if score <= 0:
continue
# SR 유형 힌트 일치 시 보너스
if sr_type_hint and doc.sr_type == sr_type_hint:
score = min(score + 0.15, 1.0)
results.append({"doc": doc, "score": score, "matched": matched})
results.sort(key=lambda x: -x["score"])
return results[:limit]
# ── Endpoints ──────────────────────────────────────────────────────────────────
@router.get("", response_model=List[KBSearchResult])
async def search_kb(
q: str = Query(..., min_length=2, description="검색 키워드"),
limit: int = Query(5, ge=1, le=20),
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""키워드 검색 — 관련 기술 문서 반환."""
hits = await _search_docs(db, q, limit=limit)
return [
KBSearchResult(
doc=KBDocumentOut.model_validate(h["doc"]),
score=h["score"],
matched_keywords=h["matched"],
)
for h in hits
]
@router.get("/suggest/{sr_id}", response_model=List[KBSearchResult])
async def suggest_for_sr(
sr_id: str,
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""
SR 설명 + 작업 로그 에러 텍스트를 종합해 관련 KB 문서 자동 추천.
최대 3건 반환.
"""
# SR 조회
r = await db.execute(select(SRRequest).where(SRRequest.sr_id == sr_id))
sr = r.scalars().first()
if not sr:
return []
# 쿼리 소스 합산: 제목 + 설명 + 최근 작업 로그 결과(에러 행)
query_parts = [sr.title or "", sr.description or ""]
wl_res = await db.execute(
select(WorkLog).where(WorkLog.sr_id == sr_id)
.order_by(WorkLog.created_at.desc()).limit(10)
)
for wlog in wl_res.scalars().all():
# 에러·경고 라인만 추출
for line in (wlog.result or "").splitlines():
if any(kw in line.upper() for kw in ("ERROR", "WARN", "ORA-", "EXCEPTION", "FAILED")):
query_parts.append(line)
combined_query = " ".join(query_parts)
hits = await _search_docs(db, combined_query, sr_type_hint=sr.sr_type, limit=3)
return [
KBSearchResult(
doc=KBDocumentOut.model_validate(h["doc"]),
score=h["score"],
matched_keywords=h["matched"],
)
for h in hits
]
@router.get("/list", response_model=List[KBDocumentOut])
async def list_kb(
category: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""전체 KB 문서 목록 (카테고리 필터 선택)."""
q = select(KBDocument).order_by(KBDocument.category, KBDocument.doc_id)
if category:
q = q.where(KBDocument.category == category)
res = await db.execute(q)
return res.scalars().all()