- 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>
347 lines
12 KiB
Python
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)
|