- itsm/ -> workspace/guardia-itsm/ - manager/ -> workspace/guardia-manager/ - app/ -> workspace/guardia-messenger/ - manual/ -> workspace/guardia-docs/ workspace/zioinfo-web/ unchanged. git mv preserves full commit history. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
181 lines
6.1 KiB
Python
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()
|