zioinfo-mail/workspace/guardia-itsm/routers/kb_agent.py
DESKTOP-TKLFCPR\ython cfe2901a55 refactor(structure): consolidate all projects under workspace/
- 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>
2026-05-31 23:50:56 +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),
}