""" C-3: Problem Management API 라우터 엔드포인트: POST /api/problem/ — 문제 레코드 생성 GET /api/problem/ — 문제 목록 GET /api/problem/{prb_id} — 문제 상세 PATCH /api/problem/{prb_id} — 문제 수정 POST /api/problem/{prb_id}/rca — RCA 기록 POST /api/problem/{prb_id}/workaround — 임시 해결 기록 POST /api/problem/{prb_id}/resolve — 해결 처리 POST /api/problem/{prb_id}/close — 종결 처리 POST /api/problem/{prb_id}/notes — 활동 노트 추가 GET /api/problem/{prb_id}/notes — 활동 노트 목록 GET /api/problem/known-errors — Known Error DB GET /api/problem/stats — Problem 통계 """ from __future__ import annotations import json import logging from datetime import datetime, timedelta from typing import List, Optional from fastapi import APIRouter, Body, Depends, HTTPException, Query from sqlalchemy import select, desc, func, and_, or_ from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user from database import get_db from models import ( User, UserRole, ProblemRecord, ProblemRecordOut, ProblemRecordCreate, ProblemNote, ProblemNoteCreate, ProblemStatus, ProblemCategory, ) logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/problem", tags=["problem"]) async def _next_problem_id(db: AsyncSession) -> str: today = datetime.utcnow().strftime("%Y%m%d") prefix = f"PRB-{today}-" last = (await db.execute( select(ProblemRecord.problem_id) .where(ProblemRecord.problem_id.like(f"{prefix}%")) .order_by(desc(ProblemRecord.problem_id)).limit(1) )).scalar() seq = 1 if not last else (int(last.split("-")[-1]) + 1 if last else 1) return f"{prefix}{seq:04d}" async def _get_problem(db: AsyncSession, prb_id: str) -> ProblemRecord: prb = (await db.execute( select(ProblemRecord).where(ProblemRecord.problem_id == prb_id) )).scalars().first() if not prb: raise HTTPException(404, f"문제 레코드 {prb_id}를 찾을 수 없습니다.") return prb @router.post("/", response_model=ProblemRecordOut, status_code=201) async def create_problem( body: ProblemRecordCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """문제 레코드 생성.""" problem_id = await _next_problem_id(db) prb = ProblemRecord( problem_id = problem_id, title = body.title, description = body.description, status = ProblemStatus.OPEN.value, priority = body.priority.upper(), category = body.category.upper(), related_sr_ids = json.dumps(body.related_sr_ids or [], ensure_ascii=False), ci_id = body.ci_id, affected_users = body.affected_users, incident_count = body.incident_count, total_downtime_min = body.total_downtime_min, created_by = current_user.username, ) db.add(prb) await db.commit() await db.refresh(prb) return prb @router.get("/known-errors", response_model=List[ProblemRecordOut]) async def known_errors( limit: int = Query(20, ge=1, le=100), db: AsyncSession = Depends(get_db), _u: User = Depends(get_current_user), ): """Known Error DB 조회.""" rows = (await db.execute( select(ProblemRecord) .where(ProblemRecord.known_error == True) .order_by(desc(ProblemRecord.updated_at)) .limit(limit) )).scalars().all() return rows @router.get("/stats") async def problem_stats( days: int = Query(30, ge=1, le=365), db: AsyncSession = Depends(get_db), _u: User = Depends(get_current_user), ): """Problem 통계.""" since = datetime.utcnow() - timedelta(days=days) total = (await db.execute( select(func.count()).select_from(ProblemRecord).where(ProblemRecord.created_at >= since) )).scalar() or 0 by_status = {} for st, cnt in (await db.execute( select(ProblemRecord.status, func.count()) .where(ProblemRecord.created_at >= since) .group_by(ProblemRecord.status) )).all(): by_status[st] = cnt known_err_count = (await db.execute( select(func.count()).select_from(ProblemRecord) .where(ProblemRecord.known_error == True) )).scalar() or 0 return { "period_days": days, "total": total, "by_status": by_status, "known_errors": known_err_count, "open": by_status.get("OPEN", 0), "investigating": by_status.get("INVESTIGATING", 0), "rca_done": by_status.get("RCA_DONE", 0), } @router.get("/", response_model=List[ProblemRecordOut]) async def list_problems( status: Optional[str] = Query(None), priority: Optional[str] = Query(None), category: Optional[str] = Query(None), limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0), db: AsyncSession = Depends(get_db), _u: User = Depends(get_current_user), ): """문제 레코드 목록.""" conditions = [] if status: conditions.append(ProblemRecord.status == status.upper()) if priority: conditions.append(ProblemRecord.priority == priority.upper()) if category: conditions.append(ProblemRecord.category == category.upper()) q = select(ProblemRecord) if conditions: q = q.where(and_(*conditions)) q = q.order_by(desc(ProblemRecord.created_at)).limit(limit).offset(offset) return (await db.execute(q)).scalars().all() @router.get("/{prb_id}", response_model=ProblemRecordOut) async def get_problem(prb_id: str, db: AsyncSession = Depends(get_db), _u: User = Depends(get_current_user)): return await _get_problem(db, prb_id) @router.patch("/{prb_id}", response_model=ProblemRecordOut) async def update_problem( prb_id: str, body: dict = Body(...), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """문제 레코드 수정.""" prb = await _get_problem(db, prb_id) for k, v in body.items(): if hasattr(prb, k) and k not in ("id", "problem_id", "created_at"): setattr(prb, k, v) await db.commit() await db.refresh(prb) return prb @router.post("/{prb_id}/rca") async def record_rca( prb_id: str, root_cause: str = Body(..., embed=True), permanent_fix: Optional[str] = Body(None, embed=True), known_error: bool = Body(False, embed=True), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """RCA (근본 원인 분석) 기록.""" prb = await _get_problem(db, prb_id) prb.root_cause = root_cause prb.permanent_fix = permanent_fix prb.known_error = known_error prb.status = ProblemStatus.RCA_DONE.value prb.rca_completed_at = datetime.utcnow() note = ProblemNote( problem_id_fk = prb.id, note_type = "RCA", content = f"RCA 완료: {root_cause[:200]}", author = current_user.username, ) db.add(note) await db.commit() return {"problem_id": prb_id, "status": prb.status, "known_error": known_error} @router.post("/{prb_id}/workaround") async def record_workaround( prb_id: str, workaround: str = Body(..., embed=True), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """임시 해결 방법 기록.""" prb = await _get_problem(db, prb_id) prb.workaround = workaround if prb.status not in (ProblemStatus.RESOLVED.value, ProblemStatus.CLOSED.value): prb.status = ProblemStatus.WORKAROUND.value note = ProblemNote( problem_id_fk = prb.id, note_type = "WORKAROUND", content = f"임시 해결책: {workaround[:200]}", author = current_user.username, ) db.add(note) await db.commit() return {"problem_id": prb_id, "status": prb.status} @router.post("/{prb_id}/resolve") async def resolve_problem( prb_id: str, resolution: Optional[str] = Body(None, embed=True), rfc_id: Optional[str] = Body(None, embed=True), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """문제 해결 처리.""" prb = await _get_problem(db, prb_id) prb.status = ProblemStatus.RESOLVED.value prb.resolved_at = datetime.utcnow() if resolution: prb.permanent_fix = resolution if rfc_id: prb.rfc_id = rfc_id note = ProblemNote( problem_id_fk = prb.id, note_type = "RESOLUTION", content = resolution or "문제 해결 완료", author = current_user.username, ) db.add(note) await db.commit() return {"problem_id": prb_id, "status": prb.status} @router.post("/{prb_id}/close") async def close_problem( prb_id: str, note_text: Optional[str] = Body(None, embed=True), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """문제 종결 처리.""" prb = await _get_problem(db, prb_id) if prb.status != ProblemStatus.RESOLVED.value: raise HTTPException(400, f"RESOLVED 상태가 아닙니다: {prb.status}") prb.status = ProblemStatus.CLOSED.value prb.closed_at = datetime.utcnow() await db.commit() return {"problem_id": prb_id, "status": prb.status} @router.post("/{prb_id}/notes") async def add_note( prb_id: str, body: ProblemNoteCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """활동 노트 추가.""" prb = await _get_problem(db, prb_id) if prb.status == ProblemStatus.INVESTIGATING.value or prb.status == ProblemStatus.OPEN.value: prb.status = ProblemStatus.INVESTIGATING.value note = ProblemNote( problem_id_fk = prb.id, note_type = body.note_type.upper(), content = body.content, author = current_user.username, ) db.add(note) await db.commit() return {"ok": True, "note_type": body.note_type} @router.get("/{prb_id}/notes") async def get_notes( prb_id: str, db: AsyncSession = Depends(get_db), _u: User = Depends(get_current_user), ): """활동 노트 목록.""" prb = await _get_problem(db, prb_id) notes = (await db.execute( select(ProblemNote) .where(ProblemNote.problem_id_fk == prb.id) .order_by(ProblemNote.created_at) )).scalars().all() return [ {"id": n.id, "note_type": n.note_type, "content": n.content, "author": n.author, "created_at": n.created_at.isoformat()} for n in notes ] # ── G-5: 자동 RCA 분석 ──────────────────────────────────────────────────────── @router.post("/{prb_id}/auto-rca") async def auto_rca_problem( prb_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Ollama LLM으로 Problem RCA 초안 자동 생성 후 레코드에 저장.""" prb = await _get_problem(db, prb_id) try: from core.auto_rca import analyze_problem_rca result = await analyze_problem_rca(prb.id, db) except Exception as e: raise HTTPException(500, f"RCA 분석 오류: {str(e)[:200]}") rca_data = result["rca"] prb.root_cause = rca_data.get("root_cause", "") prb.permanent_fix = json.dumps(rca_data.get("prevention", []), ensure_ascii=False) prb.rca_completed_at = datetime.utcnow() prb.updated_at = datetime.utcnow() await db.commit() return result