""" 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), }