guardia-itsm/core/learning.py
2026-05-30 23:02:43 +09:00

906 lines
32 KiB
Python

"""
Self-Improving Learning Loop — GUARDiA
4개 피드백 루프:
1. RecurrenceTracker — 재발 패턴 자동 감지 + Problem 티켓 격상
2. SolutionEffectiveness — KB 솔루션 효과 추적 + 검증된 룬북 승격
3. AdaptiveThreshold — 이상 탐지 임계값 자동 보정
4. LessonMiner — 패턴 마이닝 → 교훈 자동 생성
"""
from __future__ import annotations
import hashlib
import json
import logging
import re
import statistics
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple
from sqlalchemy import select, and_, desc, func
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger(__name__)
# ── 한국어/영어 불용어 ─────────────────────────────────────────────────────────
_STOPWORDS = {
"", "", "", "", "", "", "", "", "", "", "또한",
"the", "a", "an", "is", "are", "was", "were", "for", "with", "and",
"sr", "요청", "작업", "처리", "확인", "수행", "필요", "발생",
}
_TECH_PATTERNS = [
(r'\b(tomcat|jboss|weblogic|jeus|websphere)\b', "was"),
(r'\b(nginx|apache|iis|webtob)\b', "web"),
(r'\b(oracle|mysql|postgresql|mssql|tibero)\b', "db"),
(r'\b(cpu|memory|메모리|heap|힙)\b', "resource"),
(r'\b(disk|디스크|storage)\b', "disk"),
(r'\b(deploy|배포|release|릴리즈)\b', "deploy"),
(r'\b(restart|재기동|재시작)\b', "restart"),
(r'\b(error|오류|에러|exception|예외)\b', "error"),
(r'\b(timeout|타임아웃|연결|connection)\b', "network"),
(r'\b(ssl|tls|인증서|certificate)\b', "ssl"),
(r'\b(backup|백업)\b', "backup"),
(r'\b(장애|incident|down|다운)\b', "incident"),
]
# ── 텍스트 지문 유틸 ───────────────────────────────────────────────────────────
def _tokenize(text: str) -> set:
text = text.lower()
tokens = re.split(r'[\s,;:.()[\]{}!?]+', text)
return {t for t in tokens if len(t) >= 2 and t not in _STOPWORDS}
def _extract_tech_keywords(text: str) -> List[str]:
text = text.lower()
found = []
for pattern, label in _TECH_PATTERNS:
if re.search(pattern, text):
found.append(label)
return sorted(set(found))
def _jaccard(a: set, b: set) -> float:
if not a or not b:
return 0.0
return len(a & b) / len(a | b)
def _make_pattern_key(inst_id: Optional[int], sr_type: str, keywords: List[str]) -> str:
parts = [str(inst_id or "global"), sr_type] + sorted(keywords)
return "|".join(parts)
def _hash_key(key: str) -> str:
return hashlib.sha256(key.encode()).hexdigest()[:16]
# ═══════════════════════════════════════════════════════════════════════════════
# 1. RecurrenceTracker — 재발 패턴 감지
# ═══════════════════════════════════════════════════════════════════════════════
async def detect_recurrence(
db: AsyncSession,
sr_id: str,
title: str,
description: str,
sr_type: str,
inst_id: Optional[int] = None,
escalate_threshold: int = 3,
similarity_threshold: float = 0.35,
) -> Dict:
"""
신규 SR과 유사한 재발 패턴이 있는지 감지한다.
Returns: {
"recurrence_found": bool,
"pattern_id": int | None,
"occurrence_count": int,
"escalated": bool,
"problem_id": str | None,
"similar_sr_ids": list,
"suggested_kb_ids": list,
}
"""
from models import RecurrencePattern, KBDocument
text = f"{title} {description}"
tech_kw = _extract_tech_keywords(text)
tokens = _tokenize(text)
pattern_key = _make_pattern_key(inst_id, sr_type, tech_kw)
pattern_hash = _hash_key(pattern_key)
# 동일 패턴 해시 먼저 검색
existing = (await db.execute(
select(RecurrencePattern).where(RecurrencePattern.pattern_hash == pattern_hash)
)).scalars().first()
# 해시 미일치 → 유사도 기반 폴백 검색 (최근 90일)
if not existing:
since = datetime.utcnow() - timedelta(days=90)
candidates = (await db.execute(
select(RecurrencePattern).where(
and_(
RecurrencePattern.last_seen_at >= since,
RecurrencePattern.sr_type == sr_type,
)
).order_by(desc(RecurrencePattern.occurrence_count)).limit(50)
)).scalars().all()
for cand in candidates:
sig_tokens = _tokenize(cand.keyword_signature or "")
sim = _jaccard(tokens, sig_tokens)
if sim >= similarity_threshold:
existing = cand
break
escalated = False
problem_id = None
if existing:
# 재발 카운트 업데이트
sr_ids = existing.sr_ids or []
if sr_id not in sr_ids:
sr_ids.append(sr_id)
existing.sr_ids = sr_ids
existing.occurrence_count = len(sr_ids)
existing.last_seen_at = datetime.utcnow()
await db.flush()
# 임계값 초과 시 Problem 티켓 자동 생성
if existing.occurrence_count >= escalate_threshold and not existing.escalated:
pid = await _escalate_to_problem(db, existing, title, sr_type, inst_id)
existing.escalated = True
existing.problem_id = pid
escalated = True
problem_id = pid
await db.flush()
await db.commit()
# 연관 KB 제안
suggested_kbs = await _find_kb_for_pattern(db, existing.keyword_signature or text)
return {
"recurrence_found": True,
"pattern_id": existing.id,
"occurrence_count": existing.occurrence_count,
"escalated": escalated,
"problem_id": problem_id,
"similar_sr_ids": (existing.sr_ids or [])[-5:],
"suggested_kb_ids": suggested_kbs,
}
# 신규 패턴 등록
new_pattern = RecurrencePattern(
pattern_hash = pattern_hash,
pattern_key = pattern_key[:200],
sr_type = sr_type,
inst_id = inst_id,
keyword_signature = " ".join(sorted(tokens)[:30]),
tech_keywords = ",".join(tech_kw),
occurrence_count = 1,
first_seen_at = datetime.utcnow(),
last_seen_at = datetime.utcnow(),
sr_ids = [sr_id],
escalated = False,
)
db.add(new_pattern)
await db.commit()
await db.refresh(new_pattern)
return {
"recurrence_found": False,
"pattern_id": new_pattern.id,
"occurrence_count": 1,
"escalated": False,
"problem_id": None,
"similar_sr_ids": [],
"suggested_kb_ids": [],
}
async def _escalate_to_problem(
db: AsyncSession,
pattern: "RecurrencePattern",
title: str,
sr_type: str,
inst_id: Optional[int],
) -> Optional[str]:
"""재발 패턴을 Problem 티켓으로 자동 격상."""
try:
from models import ProblemRecord
today = datetime.utcnow().strftime("%Y%m%d")
prefix = f"PRB-{today}-"
last = (await db.execute(
select(ProblemRecord.prb_id)
.where(ProblemRecord.prb_id.like(f"{prefix}%"))
.order_by(desc(ProblemRecord.prb_id))
.limit(1)
)).scalar()
seq = int(last.split("-")[-1]) + 1 if last else 1
prb_id = f"{prefix}{seq:04d}"
sr_count = pattern.occurrence_count
prb = ProblemRecord(
prb_id = prb_id,
title = f"[자동감지] 반복 장애: {title[:80]}",
description = (
f"학습 엔진이 동일 유형 SR이 {sr_count}회 반복됨을 자동 감지했습니다.\n"
f"SR 유형: {sr_type}\n"
f"연관 SR: {', '.join((pattern.sr_ids or [])[-5:])}\n"
f"기술 키워드: {pattern.tech_keywords or '-'}\n\n"
f"근본 원인 분석(RCA) 후 영구 조치가 필요합니다."
),
status = "OPEN",
priority = "HIGH",
inst_id = inst_id,
created_at = datetime.utcnow(),
source = "learning-engine",
)
db.add(prb)
await db.flush()
logger.info("재발 패턴 → Problem 티켓 생성: %s (SR %d회)", prb_id, sr_count)
return prb_id
except Exception as e:
logger.warning("Problem 티켓 자동 생성 실패: %s", e)
return None
async def _find_kb_for_pattern(
db: AsyncSession,
keyword_text: str,
limit: int = 3,
) -> List[int]:
"""패턴 키워드와 유사한 KB 문서 ID 목록 반환."""
try:
from models import KBDocument
tokens = _tokenize(keyword_text)
rows = (await db.execute(
select(KBDocument)
.order_by(desc(KBDocument.created_at))
.limit(100)
)).scalars().all()
scored = []
for doc in rows:
doc_tokens = _tokenize(f"{doc.title} {doc.symptoms or ''} {doc.tags or ''}")
sim = _jaccard(tokens, doc_tokens)
if sim > 0.15:
scored.append((sim, doc.id))
scored.sort(reverse=True)
return [doc_id for _, doc_id in scored[:limit]]
except Exception:
return []
# ═══════════════════════════════════════════════════════════════════════════════
# 2. SolutionEffectivenessTracker — KB 솔루션 효과 추적
# ═══════════════════════════════════════════════════════════════════════════════
async def record_kb_usage(
db: AsyncSession,
sr_id: str,
kb_doc_id: str,
kb_id: Optional[int] = None,
) -> Dict:
"""SR 해결에 사용된 KB를 기록한다."""
from models import SolutionFeedback
# 이미 기록된 경우 중복 방지
existing = (await db.execute(
select(SolutionFeedback).where(
and_(
SolutionFeedback.sr_id == sr_id,
SolutionFeedback.kb_doc_id == kb_doc_id,
)
)
)).scalars().first()
if existing:
return {"recorded": False, "reason": "이미 기록됨", "feedback_id": existing.id}
fb = SolutionFeedback(
sr_id = sr_id,
kb_id = kb_id,
kb_doc_id = kb_doc_id,
applied_at = datetime.utcnow(),
resolved = True,
)
db.add(fb)
await db.commit()
await db.refresh(fb)
logger.info("KB 사용 기록: SR=%s KB=%s", sr_id, kb_doc_id)
return {"recorded": True, "feedback_id": fb.id}
async def check_solution_effectiveness(
db: AsyncSession,
feedback_id: int,
recurrence_window_days: int = 7,
) -> Dict:
"""
피드백 기록 이후 같은 패턴이 재발했는지 확인하고 효과 점수를 업데이트한다.
자동 프로모션: KBDocument.effectiveness_score >= 10 → LessonLearned 생성
"""
from models import SolutionFeedback, SRRequest, KBDocument
fb = (await db.execute(
select(SolutionFeedback).where(SolutionFeedback.id == feedback_id)
)).scalars().first()
if not fb:
return {"error": "feedback not found"}
sr = (await db.execute(
select(SRRequest).where(SRRequest.sr_id == fb.sr_id)
)).scalars().first()
if not sr:
return {"error": "SR not found"}
# 해결 후 recurrence_window_days 내 같은 기관 동일 유형 SR 재발 확인
since = fb.applied_at
until = since + timedelta(days=recurrence_window_days)
recurred = (await db.execute(
select(SRRequest).where(
and_(
SRRequest.sr_id != fb.sr_id,
SRRequest.inst_id == sr.inst_id,
SRRequest.sr_type == sr.sr_type,
SRRequest.created_at >= since,
SRRequest.created_at <= until,
)
)
)).scalars().first()
if recurred:
fb.recurred_within_days = (recurred.created_at - since).days
fb.effectiveness_score = -1
else:
fb.recurred_within_days = None
fb.effectiveness_score = 1
fb.checked_at = datetime.utcnow()
await db.flush()
# KB 문서 효과 점수 누적
score_delta = fb.effectiveness_score
if fb.kb_doc_id:
kb_doc = (await db.execute(
select(KBDocument).where(KBDocument.doc_id == fb.kb_doc_id)
)).scalars().first()
if kb_doc:
current = getattr(kb_doc, "effectiveness_score", 0) or 0
kb_doc.effectiveness_score = current + score_delta
await db.flush()
# 자동 프로모션 체크
if kb_doc.effectiveness_score >= 10:
await _promote_kb_to_lesson(db, kb_doc)
await db.commit()
return {
"feedback_id": feedback_id,
"sr_id": fb.sr_id,
"kb_doc_id": fb.kb_doc_id,
"effectiveness_score": fb.effectiveness_score,
"recurred": recurred is not None,
"recurred_within_days": fb.recurred_within_days,
}
async def _promote_kb_to_lesson(db: AsyncSession, kb_doc) -> Optional[Dict]:
"""KB 효과 점수 10 이상 → 검증된 룬북(LessonLearned)으로 자동 승격."""
from models import LessonLearned
# 이미 승격됐으면 스킵
existing = (await db.execute(
select(LessonLearned).where(LessonLearned.promoted_from_kb_id == kb_doc.id)
)).scalars().first()
if existing:
return None
today = datetime.utcnow().strftime("%Y%m%d")
prefix = f"LESSON-{today}-"
last = (await db.execute(
select(LessonLearned.lesson_id)
.where(LessonLearned.lesson_id.like(f"{prefix}%"))
.order_by(desc(LessonLearned.lesson_id))
.limit(1)
)).scalar()
seq = int(last.split("-")[-1]) + 1 if last else 1
lesson_id = f"{prefix}{seq:04d}"
lesson = LessonLearned(
lesson_id = lesson_id,
title = f"[검증됨] {kb_doc.title}",
category = kb_doc.category or "일반",
problem_pattern = kb_doc.symptoms or "",
root_cause = kb_doc.cause or "",
effective_solution = kb_doc.solution or "",
prevention = "",
confidence_score = kb_doc.effectiveness_score,
source_kb_ids = [kb_doc.id],
source_sr_ids = ([kb_doc.source_sr_id] if kb_doc.source_sr_id else []),
promoted_from_kb_id = kb_doc.id,
is_verified = True,
created_at = datetime.utcnow(),
)
db.add(lesson)
await db.flush()
logger.info("KB %d → LessonLearned %s 자동 승격 (효과점수=%d)",
kb_doc.id, lesson_id, kb_doc.effectiveness_score)
return {"lesson_id": lesson_id, "kb_id": kb_doc.id}
# ═══════════════════════════════════════════════════════════════════════════════
# 3. AdaptiveThresholdCalibrator — 이상 탐지 임계값 자동 보정
# ═══════════════════════════════════════════════════════════════════════════════
# 임계값 보정 경계 (기본값의 50%~200% 범위)
_THRESHOLD_MIN_RATIO = 0.5
_THRESHOLD_MAX_RATIO = 2.0
_FALSE_POSITIVE_TRIGGER = 0.25 # 오탐률 25% 초과 시 임계값 상향
_MISSED_TRIGGER = 3 # 누락 탐지 3회 초과 시 임계값 하향
_ADJUSTMENT_STEP = 0.08 # 1회 조정 폭 8%
async def record_anomaly_outcome(
db: AsyncSession,
source: str,
metric_type: str,
was_actual_incident: bool,
base_threshold: Optional[float] = None,
) -> Dict:
"""
이상 탐지 이벤트가 실제 장애로 이어졌는지 기록한다.
was_actual_incident=True → True Positive
was_actual_incident=False → False Positive (오탐)
"""
from models import AdaptiveThreshold
from core.anomaly import DEFAULT_THRESHOLDS
rec = (await db.execute(
select(AdaptiveThreshold).where(
and_(
AdaptiveThreshold.source == source,
AdaptiveThreshold.metric_type == metric_type,
)
)
)).scalars().first()
if not rec:
# 기본 임계값 참조
if base_threshold is None:
info = DEFAULT_THRESHOLDS.get(metric_type)
base_threshold = info[0] if info else 90.0
rec = AdaptiveThreshold(
source = source,
metric_type = metric_type,
base_threshold = base_threshold,
adapted_threshold = base_threshold,
true_positive = 0,
false_positive = 0,
missed_count = 0,
adaptation_count = 0,
created_at = datetime.utcnow(),
)
db.add(rec)
await db.flush()
if was_actual_incident:
rec.true_positive += 1
else:
rec.false_positive += 1
await db.commit()
return {"source": source, "metric_type": metric_type,
"true_positive": rec.true_positive, "false_positive": rec.false_positive}
async def record_missed_detection(
db: AsyncSession,
source: str,
metric_type: str,
) -> Dict:
"""실제 장애였는데 탐지 못한 경우(누락 탐지)를 기록한다."""
from models import AdaptiveThreshold
from core.anomaly import DEFAULT_THRESHOLDS
rec = (await db.execute(
select(AdaptiveThreshold).where(
and_(
AdaptiveThreshold.source == source,
AdaptiveThreshold.metric_type == metric_type,
)
)
)).scalars().first()
if not rec:
info = DEFAULT_THRESHOLDS.get(metric_type)
base = info[0] if info else 90.0
rec = AdaptiveThreshold(
source=source, metric_type=metric_type,
base_threshold=base, adapted_threshold=base,
true_positive=0, false_positive=0, missed_count=0,
adaptation_count=0, created_at=datetime.utcnow(),
)
db.add(rec)
await db.flush()
rec.missed_count += 1
await db.commit()
return {"source": source, "metric_type": metric_type, "missed_count": rec.missed_count}
async def calibrate_threshold(
db: AsyncSession,
source: str,
metric_type: str,
) -> Dict:
"""
누적된 오탐/누락 데이터를 기반으로 임계값을 자동 보정한다.
Returns: {"adjusted": bool, "old_threshold": float, "new_threshold": float, "reason": str}
"""
from models import AdaptiveThreshold
rec = (await db.execute(
select(AdaptiveThreshold).where(
and_(
AdaptiveThreshold.source == source,
AdaptiveThreshold.metric_type == metric_type,
)
)
)).scalars().first()
if not rec:
return {"adjusted": False, "reason": "기록 없음"}
total = rec.true_positive + rec.false_positive
old_thr = rec.adapted_threshold
new_thr = old_thr
reason = "변경 없음"
if total >= 5:
fp_rate = rec.false_positive / total
if fp_rate > _FALSE_POSITIVE_TRIGGER:
# 오탐 많음 → 임계값 상향 (더 높은 값에서만 알림)
new_thr = old_thr * (1 + _ADJUSTMENT_STEP)
reason = f"오탐률 {fp_rate:.0%} > {_FALSE_POSITIVE_TRIGGER:.0%} → 임계값 상향"
if rec.missed_count > _MISSED_TRIGGER:
# 누락 탐지 많음 → 임계값 하향 (더 민감하게)
new_thr = old_thr * (1 - _ADJUSTMENT_STEP)
reason = f"누락 탐지 {rec.missed_count}회 > {_MISSED_TRIGGER}회 → 임계값 하향"
# 범위 제한
base = rec.base_threshold
new_thr = max(base * _THRESHOLD_MIN_RATIO, min(base * _THRESHOLD_MAX_RATIO, new_thr))
new_thr = round(new_thr, 2)
if abs(new_thr - old_thr) < 0.01:
return {"adjusted": False, "old_threshold": old_thr,
"new_threshold": new_thr, "reason": reason}
rec.adapted_threshold = new_thr
rec.last_adapted_at = datetime.utcnow()
rec.adaptation_count += 1
# 보정 후 카운터 부분 리셋 (누적 오염 방지)
rec.false_positive = rec.false_positive // 2
rec.true_positive = rec.true_positive // 2
rec.missed_count = 0
await db.commit()
logger.info("임계값 보정: %s/%s %.2f%.2f (%s)", source, metric_type, old_thr, new_thr, reason)
return {
"adjusted": True,
"source": source,
"metric_type": metric_type,
"old_threshold": old_thr,
"new_threshold": new_thr,
"reason": reason,
}
async def get_adapted_threshold(
db: AsyncSession,
source: str,
metric_type: str,
) -> Optional[float]:
"""현재 적응형 임계값 조회 (없으면 None — 기본값 사용)."""
from models import AdaptiveThreshold
rec = (await db.execute(
select(AdaptiveThreshold.adapted_threshold).where(
and_(
AdaptiveThreshold.source == source,
AdaptiveThreshold.metric_type == metric_type,
)
)
)).scalar()
return rec
# ═══════════════════════════════════════════════════════════════════════════════
# 4. LessonMiner — 패턴 마이닝 & 교훈 자동 생성
# ═══════════════════════════════════════════════════════════════════════════════
async def run_lesson_mining(
db: AsyncSession,
days_back: int = 30,
min_occurrences: int = 3,
) -> Dict:
"""
최근 N일 재발 패턴을 분석하여 교훈(LessonLearned)을 자동 생성한다.
Returns: {"patterns_analyzed": int, "lessons_created": int, "lessons_updated": int}
"""
from models import RecurrencePattern, SolutionFeedback, KBDocument, LessonLearned
since = datetime.utcnow() - timedelta(days=days_back)
patterns = (await db.execute(
select(RecurrencePattern).where(
and_(
RecurrencePattern.last_seen_at >= since,
RecurrencePattern.occurrence_count >= min_occurrences,
)
).order_by(desc(RecurrencePattern.occurrence_count))
)).scalars().all()
created = 0
updated = 0
for pat in patterns:
sr_ids = pat.sr_ids or []
if not sr_ids:
continue
# 이 패턴의 SR들에서 사용된 KB 중 가장 효과적인 것 선택
best_kb = await _find_best_kb_for_pattern_srs(db, sr_ids)
# 기존 Lesson 확인
existing_lesson = (await db.execute(
select(LessonLearned).where(
LessonLearned.lesson_id.like(f"%-PAT{pat.id}")
)
)).scalars().first()
if existing_lesson:
# 업데이트
existing_lesson.confidence_score = pat.occurrence_count
existing_lesson.source_sr_ids = sr_ids[-10:]
if best_kb:
existing_lesson.effective_solution = best_kb.get("solution", existing_lesson.effective_solution)
existing_lesson.updated_at = datetime.utcnow()
updated += 1
else:
# 신규 생성
today = datetime.utcnow().strftime("%Y%m%d")
lesson_id = f"LESSON-{today}-PAT{pat.id}"
solution_text = ""
root_cause_text = ""
if best_kb:
solution_text = best_kb.get("solution", "")
root_cause_text = best_kb.get("cause", "")
lesson = LessonLearned(
lesson_id = lesson_id,
title = f"[반복패턴] {pat.sr_type} 재발 {pat.occurrence_count}회 — {(pat.tech_keywords or '').replace(',', '/')}",
category = _infer_category(pat.tech_keywords or ""),
problem_pattern = pat.keyword_signature or "",
root_cause = root_cause_text or "반복 패턴 기반 분석 중",
effective_solution = solution_text or "KB 문서 참조 또는 전문가 조치 필요",
prevention = f"동일 유형({pat.sr_type}) SR이 {pat.occurrence_count}회 반복됨. 근본 원인 제거 필요.",
confidence_score = pat.occurrence_count,
source_kb_ids = ([best_kb["id"]] if best_kb else []),
source_sr_ids = sr_ids[-10:],
is_verified = False,
created_at = datetime.utcnow(),
)
db.add(lesson)
created += 1
# pending 피드백 효과 일괄 검증
await _batch_check_pending_feedbacks(db)
# 임계값 일괄 보정
calibration_results = await _batch_calibrate_thresholds(db)
await db.commit()
return {
"patterns_analyzed": len(patterns),
"lessons_created": created,
"lessons_updated": updated,
"thresholds_calibrated": calibration_results,
"run_at": datetime.utcnow().isoformat(),
}
async def _find_best_kb_for_pattern_srs(
db: AsyncSession,
sr_ids: List[str],
) -> Optional[Dict]:
"""패턴의 SR들에서 가장 효과적으로 사용된 KB 반환."""
from models import SolutionFeedback, KBDocument
feedbacks = (await db.execute(
select(SolutionFeedback).where(
and_(
SolutionFeedback.sr_id.in_(sr_ids),
SolutionFeedback.effectiveness_score > 0,
SolutionFeedback.kb_doc_id.isnot(None),
)
).order_by(desc(SolutionFeedback.effectiveness_score))
.limit(1)
)).scalars().first()
if not feedbacks or not feedbacks.kb_doc_id:
return None
kb = (await db.execute(
select(KBDocument).where(KBDocument.doc_id == feedbacks.kb_doc_id)
)).scalars().first()
if not kb:
return None
return {
"id": kb.id,
"doc_id": kb.doc_id,
"title": kb.title,
"solution": kb.solution,
"cause": kb.cause,
}
async def _batch_check_pending_feedbacks(db: AsyncSession) -> int:
"""효과 미검증 피드백을 일괄 처리한다."""
from models import SolutionFeedback
cutoff = datetime.utcnow() - timedelta(days=7)
pending = (await db.execute(
select(SolutionFeedback).where(
and_(
SolutionFeedback.checked_at.is_(None),
SolutionFeedback.applied_at <= cutoff,
)
).limit(50)
)).scalars().all()
checked = 0
for fb in pending:
try:
await check_solution_effectiveness(db, fb.id)
checked += 1
except Exception as e:
logger.debug("피드백 %d 검증 실패: %s", fb.id, e)
return checked
async def _batch_calibrate_thresholds(db: AsyncSession) -> int:
"""적응형 임계값 일괄 보정."""
from models import AdaptiveThreshold
recs = (await db.execute(
select(AdaptiveThreshold)
.where(
(AdaptiveThreshold.true_positive + AdaptiveThreshold.false_positive) >= 5
)
.limit(100)
)).scalars().all()
calibrated = 0
for rec in recs:
result = await calibrate_threshold(db, rec.source, rec.metric_type)
if result.get("adjusted"):
calibrated += 1
return calibrated
def _infer_category(tech_keywords: str) -> str:
kw = tech_keywords.lower()
if "deploy" in kw: return "배포"
if "was" in kw: return "WAS"
if "db" in kw: return "DB"
if "web" in kw: return "웹서버"
if "resource" in kw: return "서버 운영"
if "network" in kw: return "네트워크"
if "ssl" in kw: return "보안"
if "incident" in kw: return "장애 대응"
return "일반"
# ═══════════════════════════════════════════════════════════════════════════════
# 통계 요약
# ═══════════════════════════════════════════════════════════════════════════════
async def get_learning_stats(db: AsyncSession) -> Dict:
"""학습 엔진 현황 통계 요약."""
from models import (
RecurrencePattern, SolutionFeedback, AdaptiveThreshold, LessonLearned, KBDocument
)
pattern_count = (await db.execute(
select(func.count()).select_from(RecurrencePattern)
)).scalar() or 0
escalated_count = (await db.execute(
select(func.count()).select_from(RecurrencePattern)
.where(RecurrencePattern.escalated == True)
)).scalar() or 0
feedback_count = (await db.execute(
select(func.count()).select_from(SolutionFeedback)
)).scalar() or 0
effective_count = (await db.execute(
select(func.count()).select_from(SolutionFeedback)
.where(SolutionFeedback.effectiveness_score > 0)
)).scalar() or 0
lesson_count = (await db.execute(
select(func.count()).select_from(LessonLearned)
)).scalar() or 0
verified_lesson_count = (await db.execute(
select(func.count()).select_from(LessonLearned)
.where(LessonLearned.is_verified == True)
)).scalar() or 0
threshold_count = (await db.execute(
select(func.count()).select_from(AdaptiveThreshold)
)).scalar() or 0
adapted_count = (await db.execute(
select(func.count()).select_from(AdaptiveThreshold)
.where(AdaptiveThreshold.adaptation_count > 0)
)).scalar() or 0
# 가장 자주 재발하는 패턴 Top 5
top_patterns = (await db.execute(
select(RecurrencePattern)
.order_by(desc(RecurrencePattern.occurrence_count))
.limit(5)
)).scalars().all()
return {
"recurrence": {
"total_patterns": pattern_count,
"escalated": escalated_count,
"top_recurring": [
{
"id": p.id,
"sr_type": p.sr_type,
"count": p.occurrence_count,
"tech_keywords": p.tech_keywords,
"last_seen": p.last_seen_at.isoformat() if p.last_seen_at else None,
}
for p in top_patterns
],
},
"solution_effectiveness": {
"total_feedbacks": feedback_count,
"effective": effective_count,
"effectiveness_rate": round(effective_count / feedback_count, 3) if feedback_count else 0,
},
"lessons": {
"total": lesson_count,
"verified": verified_lesson_count,
},
"adaptive_thresholds": {
"total_tracked": threshold_count,
"adapted": adapted_count,
},
"generated_at": datetime.utcnow().isoformat(),
}