zioinfo-mail/itsm/routers/learning.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

347 lines
12 KiB
Python

"""
Self-Improving Learning Loop — API Router
엔드포인트:
GET /api/learning/stats — 학습 현황 통계
GET /api/learning/patterns — 재발 패턴 목록
GET /api/learning/patterns/{id} — 패턴 상세
GET /api/learning/lessons — 검증된 교훈 목록
GET /api/learning/thresholds — 적응형 임계값 현황
POST /api/learning/feedback — KB 사용 피드백 기록
POST /api/learning/feedback/{id}/check — 피드백 효과 즉시 검증
POST /api/learning/thresholds/outcome — 이상 탐지 결과 기록
POST /api/learning/thresholds/missed — 누락 탐지 기록
POST /api/learning/thresholds/calibrate — 임계값 즉시 보정
POST /api/learning/mine — 패턴 마이닝 즉시 실행
POST /api/learning/detect-recurrence — SR 재발 감지 (수동 호출)
"""
from datetime import datetime
from typing import List, Optional
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy import select, desc
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import (
RecurrencePattern, SolutionFeedback, AdaptiveThreshold, LessonLearned, User
)
router = APIRouter(prefix="/api/learning", tags=["learning"])
# ── Pydantic I/O ───────────────────────────────────────────────────────────────
class FeedbackCreate(BaseModel):
sr_id: str
kb_doc_id: str
kb_id: Optional[int] = None
class OutcomeRecord(BaseModel):
source: str
metric_type: str
was_actual_incident: bool
base_threshold: Optional[float] = None
class MissedRecord(BaseModel):
source: str
metric_type: str
class CalibrateRequest(BaseModel):
source: str
metric_type: str
class RecurrenceRequest(BaseModel):
sr_id: str
title: str
description: str
sr_type: str
inst_id: Optional[int] = None
class MineRequest(BaseModel):
days_back: int = 30
min_occurrences: int = 3
# ── 통계 요약 ──────────────────────────────────────────────────────────────────
@router.get("/stats")
async def get_stats(
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
from core.learning import get_learning_stats
return await get_learning_stats(db)
# ── 재발 패턴 ──────────────────────────────────────────────────────────────────
@router.get("/patterns")
async def list_patterns(
limit: int = Query(50, le=200),
offset: int = Query(0, ge=0),
sr_type: Optional[str] = None,
escalated_only: bool = False,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
q = select(RecurrencePattern)
if sr_type:
q = q.where(RecurrencePattern.sr_type == sr_type)
if escalated_only:
q = q.where(RecurrencePattern.escalated == True)
q = q.order_by(desc(RecurrencePattern.occurrence_count)).offset(offset).limit(limit)
rows = (await db.execute(q)).scalars().all()
return [
{
"id": p.id,
"sr_type": p.sr_type,
"tech_keywords": p.tech_keywords,
"occurrence_count": p.occurrence_count,
"first_seen_at": p.first_seen_at.isoformat() if p.first_seen_at else None,
"last_seen_at": p.last_seen_at.isoformat() if p.last_seen_at else None,
"escalated": p.escalated,
"problem_id": p.problem_id,
"sr_ids": (p.sr_ids or [])[-5:],
}
for p in rows
]
@router.get("/patterns/{pattern_id}")
async def get_pattern(
pattern_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
p = (await db.execute(
select(RecurrencePattern).where(RecurrencePattern.id == pattern_id)
)).scalars().first()
if not p:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="패턴을 찾을 수 없습니다")
return {
"id": p.id,
"pattern_key": p.pattern_key,
"sr_type": p.sr_type,
"inst_id": p.inst_id,
"keyword_signature": p.keyword_signature,
"tech_keywords": p.tech_keywords,
"occurrence_count": p.occurrence_count,
"first_seen_at": p.first_seen_at.isoformat() if p.first_seen_at else None,
"last_seen_at": p.last_seen_at.isoformat() if p.last_seen_at else None,
"escalated": p.escalated,
"problem_id": p.problem_id,
"sr_ids": p.sr_ids or [],
}
# ── 재발 감지 ──────────────────────────────────────────────────────────────────
@router.post("/detect-recurrence")
async def detect_recurrence(
body: RecurrenceRequest,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
from core.learning import detect_recurrence as _detect
return await _detect(
db = db,
sr_id = body.sr_id,
title = body.title,
description = body.description,
sr_type = body.sr_type,
inst_id = body.inst_id,
)
# ── 솔루션 효과 피드백 ─────────────────────────────────────────────────────────
@router.post("/feedback")
async def create_feedback(
body: FeedbackCreate,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
from core.learning import record_kb_usage
return await record_kb_usage(db, body.sr_id, body.kb_doc_id, body.kb_id)
@router.post("/feedback/{feedback_id}/check")
async def check_feedback(
feedback_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
from core.learning import check_solution_effectiveness
return await check_solution_effectiveness(db, feedback_id)
@router.get("/feedback")
async def list_feedback(
limit: int = Query(50, le=200),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
rows = (await db.execute(
select(SolutionFeedback)
.order_by(desc(SolutionFeedback.applied_at))
.offset(offset).limit(limit)
)).scalars().all()
return [
{
"id": f.id,
"sr_id": f.sr_id,
"kb_doc_id": f.kb_doc_id,
"applied_at": f.applied_at.isoformat() if f.applied_at else None,
"effectiveness_score": f.effectiveness_score,
"recurred_within_days": f.recurred_within_days,
"checked_at": f.checked_at.isoformat() if f.checked_at else None,
}
for f in rows
]
# ── 교훈 목록 ──────────────────────────────────────────────────────────────────
@router.get("/lessons")
async def list_lessons(
limit: int = Query(50, le=200),
offset: int = Query(0, ge=0),
verified_only: bool = False,
category: Optional[str] = None,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
q = select(LessonLearned)
if verified_only:
q = q.where(LessonLearned.is_verified == True)
if category:
q = q.where(LessonLearned.category == category)
q = q.order_by(desc(LessonLearned.confidence_score)).offset(offset).limit(limit)
rows = (await db.execute(q)).scalars().all()
return [
{
"id": l.id,
"lesson_id": l.lesson_id,
"title": l.title,
"category": l.category,
"problem_pattern": l.problem_pattern,
"root_cause": l.root_cause,
"effective_solution": l.effective_solution,
"prevention": l.prevention,
"confidence_score": l.confidence_score,
"is_verified": l.is_verified,
"created_at": l.created_at.isoformat() if l.created_at else None,
}
for l in rows
]
# ── 적응형 임계값 ──────────────────────────────────────────────────────────────
@router.get("/thresholds")
async def list_thresholds(
source: Optional[str] = None,
metric_type: Optional[str] = None,
adapted_only: bool = False,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
q = select(AdaptiveThreshold)
if source:
q = q.where(AdaptiveThreshold.source == source)
if metric_type:
q = q.where(AdaptiveThreshold.metric_type == metric_type)
if adapted_only:
q = q.where(AdaptiveThreshold.adaptation_count > 0)
q = q.order_by(desc(AdaptiveThreshold.adaptation_count)).limit(200)
rows = (await db.execute(q)).scalars().all()
return [
{
"id": t.id,
"source": t.source,
"metric_type": t.metric_type,
"base_threshold": t.base_threshold,
"adapted_threshold": t.adapted_threshold,
"change_pct": round(
(t.adapted_threshold - t.base_threshold) / t.base_threshold * 100, 1
) if t.base_threshold else 0,
"true_positive": t.true_positive,
"false_positive": t.false_positive,
"missed_count": t.missed_count,
"adaptation_count": t.adaptation_count,
"last_adapted_at": t.last_adapted_at.isoformat() if t.last_adapted_at else None,
}
for t in rows
]
@router.post("/thresholds/outcome")
async def record_outcome(
body: OutcomeRecord,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
from core.learning import record_anomaly_outcome
return await record_anomaly_outcome(
db, body.source, body.metric_type,
body.was_actual_incident, body.base_threshold,
)
@router.post("/thresholds/missed")
async def record_missed(
body: MissedRecord,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
from core.learning import record_missed_detection
return await record_missed_detection(db, body.source, body.metric_type)
@router.post("/thresholds/calibrate")
async def calibrate(
body: CalibrateRequest,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
from core.learning import calibrate_threshold
return await calibrate_threshold(db, body.source, body.metric_type)
@router.post("/thresholds/calibrate-all")
async def calibrate_all(
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
"""모든 적응형 임계값 일괄 보정."""
rows = (await db.execute(select(AdaptiveThreshold))).scalars().all()
results = []
from core.learning import calibrate_threshold
for rec in rows:
r = await calibrate_threshold(db, rec.source, rec.metric_type)
if r.get("adjusted"):
results.append(r)
return {"calibrated": len(results), "results": results}
# ── 패턴 마이닝 ────────────────────────────────────────────────────────────────
@router.post("/mine")
async def mine_lessons(
body: MineRequest,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
from core.learning import run_lesson_mining
return await run_lesson_mining(db, body.days_back, body.min_occurrences)