""" GUARDiA ITSM — AI 에이전트 관리 API 엔드포인트: GET /api/agents 에이전트 목록 POST /api/agents 에이전트 생성 GET /api/agents/{id} 에이전트 상세 PATCH /api/agents/{id} 에이전트 수정 DELETE /api/agents/{id} 에이전트 삭제 POST /api/agents/{id}/heartbeat 수동 하트비트 실행 POST /api/agents/{id}/pause 일시 중지 POST /api/agents/{id}/resume 재개 GET /api/agents/{id}/tasks 태스크 목록 POST /api/agents/{id}/tasks 태스크 직접 생성 (Developer 에이전트용) GET /api/agents/approvals 전체 승인 대기 목록 PATCH /api/agents/approvals/{id}/review 승인 또는 거부 GET /api/agents/stats 전체 에이전트 통계 GET /api/agents/orgchart 조직도 반환 (Phase 4 대시보드용) GET /api/agents/llm/health Ollama 헬스체크 GET /api/agents/llm/models 설치된 모델 목록 POST /api/agents/llm/pull 모델 다운로드 시작 RBAC: CUSTOMER 역할 전체 차단. """ from __future__ import annotations import logging from datetime import datetime, date from typing import Optional, List from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks from sqlalchemy import select, func, and_ from sqlalchemy.ext.asyncio import AsyncSession from database import get_db from models import ( User, UserRole, AgentConfig, AgentTask, AgentApproval, AgentRole, AgentStatus, LLMProvider, AgentTaskStatus, AgentApprovalStatus, AgentConfigOut, AgentConfigCreate, AgentConfigUpdate, AgentTaskOut, AgentTaskCreate, AgentApprovalOut, AgentApprovalReview, AgentStatsOut, AgentOrgNode, ) from routers.auth import get_current_user logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/agents", tags=["agents"]) # ── 공통 헬퍼 ──────────────────────────────────────────────────────────────── def _check_access(user: User) -> None: if user.role == UserRole.CUSTOMER: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="에이전트 관리는 운영 계정만 접근 가능합니다.", ) async def _get_agent_or_404(agent_id: int, db: AsyncSession) -> AgentConfig: agent = (await db.execute( select(AgentConfig).where(AgentConfig.id == agent_id) )).scalars().first() if not agent: raise HTTPException(status_code=404, detail=f"에이전트 ID {agent_id} 없음") return agent # ── 에이전트 CRUD ──────────────────────────────────────────────────────────── @router.get("", response_model=List[AgentConfigOut]) async def list_agents( role: Optional[str] = None, active_only: bool = False, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """에이전트 목록 조회.""" _check_access(current_user) q = select(AgentConfig) if role: q = q.where(AgentConfig.role == role) if active_only: q = q.where(AgentConfig.is_active.is_(True)) q = q.order_by(AgentConfig.role, AgentConfig.name) return (await db.execute(q)).scalars().all() @router.post("", response_model=AgentConfigOut, status_code=status.HTTP_201_CREATED) async def create_agent( body: AgentConfigCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """에이전트 등록.""" _check_access(current_user) if current_user.role not in (UserRole.ADMIN, UserRole.PM): raise HTTPException(status_code=403, detail="에이전트 생성은 ADMIN/PM만 가능합니다.") agent = AgentConfig( name=body.name, role=body.role, description=body.description, llm_provider=body.llm_provider, llm_model=body.llm_model, system_prompt=body.system_prompt, heartbeat_cron=body.heartbeat_cron, is_active=body.is_active, status=AgentStatus.IDLE, created_by=current_user.id, ) db.add(agent) await db.commit() await db.refresh(agent) # 하트비트 크론 등록 (APScheduler) if agent.heartbeat_cron: _register_heartbeat(agent) logger.info("에이전트 생성: id=%d role=%s user=%s", agent.id, agent.role, current_user.username) return agent @router.get("/stats", response_model=AgentStatsOut) async def get_stats( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """에이전트 전체 통계 — Phase 4 대시보드용.""" _check_access(current_user) total = (await db.execute(select(func.count()).select_from(AgentConfig))).scalar() or 0 active = (await db.execute( select(func.count()).select_from(AgentConfig).where(AgentConfig.is_active.is_(True)) )).scalar() or 0 today_start = datetime.combine(date.today(), datetime.min.time()) tasks_today = (await db.execute( select(func.count()).select_from(AgentTask) .where(AgentTask.created_at >= today_start) )).scalar() or 0 tokens_today = (await db.execute( select(func.coalesce(func.sum(AgentTask.tokens_used), 0)) .where(AgentTask.created_at >= today_start) )).scalar() or 0 pending_approvals = (await db.execute( select(func.count()).select_from(AgentApproval) .where(AgentApproval.status == AgentApprovalStatus.PENDING) )).scalar() or 0 from core.llm_client import get_llm_client llm_online = await get_llm_client().health_check() return AgentStatsOut( total_agents=total, active_agents=active, total_tasks_today=tasks_today, total_tokens_today=tokens_today, pending_approvals=pending_approvals, llm_online=llm_online, ) @router.get("/orgchart", response_model=List[AgentOrgNode]) async def get_orgchart( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """에이전트 조직도 — Phase 4 대시보드용.""" _check_access(current_user) agents = (await db.execute( select(AgentConfig).where(AgentConfig.is_active.is_(True)) )).scalars().all() # 조직 계층: CEO → CTO/PM_AGENT → DEV/QA hierarchy = { AgentRole.CEO: [], AgentRole.CTO: [AgentRole.DEVELOPER, AgentRole.QA], AgentRole.PM_AGENT: [], AgentRole.DEVELOPER: [], AgentRole.QA: [], AgentRole.INCIDENT_TRIAGE: [], AgentRole.KB_CURATOR: [], AgentRole.SSL_WATCHER: [], AgentRole.WBS_MONITOR: [], AgentRole.PM_SUGGESTER: [], } ceo_children = [AgentRole.CTO, AgentRole.PM_AGENT, AgentRole.INCIDENT_TRIAGE, AgentRole.KB_CURATOR, AgentRole.SSL_WATCHER, AgentRole.WBS_MONITOR, AgentRole.PM_SUGGESTER] def build_node(a: AgentConfig) -> AgentOrgNode: child_roles = hierarchy.get(a.role, []) child_agents = [x for x in agents if x.role in [r.value for r in child_roles]] return AgentOrgNode( id=a.id, name=a.name, role=a.role, status=a.status, children=[build_node(c) for c in child_agents], ) ceo_agents = [a for a in agents if a.role == AgentRole.CEO] return [build_node(a) for a in ceo_agents] if ceo_agents else [ AgentOrgNode(id=0, name="(CEO 미등록)", role="CEO", status="IDLE") ] @router.get("/approvals", response_model=List[AgentApprovalOut]) async def list_approvals( pending_only: bool = True, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """승인 대기 목록.""" _check_access(current_user) q = select(AgentApproval).order_by(AgentApproval.requested_at.desc()) if pending_only: q = q.where(AgentApproval.status == AgentApprovalStatus.PENDING) return (await db.execute(q.limit(50))).scalars().all() @router.patch("/approvals/{approval_id}/review", response_model=AgentApprovalOut) async def review_approval( approval_id: int, body: AgentApprovalReview, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """승인 또는 거부.""" _check_access(current_user) approval = (await db.execute( select(AgentApproval).where(AgentApproval.id == approval_id) )).scalars().first() if not approval: raise HTTPException(status_code=404, detail="승인 항목 없음") if approval.status != AgentApprovalStatus.PENDING: raise HTTPException(status_code=400, detail="이미 처리된 항목입니다.") approval.status = ( AgentApprovalStatus.APPROVED if body.approved else AgentApprovalStatus.REJECTED ) approval.notes = body.notes approval.reviewed_by = current_user.id approval.reviewed_at = datetime.now() await db.commit() await db.refresh(approval) logger.info( "승인 처리: approval_id=%d status=%s user=%s", approval_id, approval.status, current_user.username, ) return approval # ── LLM 헬스·모델 관리 ──────────────────────────────────────────────────── @router.get("/llm/health") async def llm_health(current_user: User = Depends(get_current_user)): """Ollama 서버 상태 확인.""" _check_access(current_user) from core.llm_client import get_llm_client client = get_llm_client() online = await client.health_check() models = await client.list_models() if online else [] return { "online": online, "base_url": client.base_url, "models": [{"name": m.name, "size_gb": round(m.size / 1e9, 1)} for m in models], } @router.post("/llm/pull") async def pull_model( model: str, background_tasks: BackgroundTasks, current_user: User = Depends(get_current_user), ): """Ollama 모델 다운로드 (백그라운드).""" _check_access(current_user) if current_user.role != UserRole.ADMIN: raise HTTPException(status_code=403, detail="모델 다운로드는 ADMIN만 가능합니다.") from core.llm_client import get_llm_client background_tasks.add_task(get_llm_client().pull_model, model) return {"message": f"모델 '{model}' 다운로드 시작 (백그라운드)", "model": model} # ── 에이전트 상세·수정·삭제 ─────────────────────────────────────────────── @router.get("/{agent_id}", response_model=AgentConfigOut) async def get_agent( agent_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): _check_access(current_user) return await _get_agent_or_404(agent_id, db) @router.patch("/{agent_id}", response_model=AgentConfigOut) async def update_agent( agent_id: int, body: AgentConfigUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): _check_access(current_user) agent = await _get_agent_or_404(agent_id, db) for k, v in body.model_dump(exclude_none=True).items(): setattr(agent, k, v) await db.commit() await db.refresh(agent) return agent @router.delete("/{agent_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_agent( agent_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): if current_user.role != UserRole.ADMIN: raise HTTPException(status_code=403, detail="ADMIN만 에이전트 삭제 가능합니다.") agent = await _get_agent_or_404(agent_id, db) await db.delete(agent) await db.commit() # ── 하트비트·제어 ──────────────────────────────────────────────────────────── @router.post("/{agent_id}/heartbeat") async def manual_heartbeat( agent_id: int, background_tasks: BackgroundTasks, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """수동 하트비트 트리거 (즉시 실행, 백그라운드).""" _check_access(current_user) agent = await _get_agent_or_404(agent_id, db) if not agent.is_active: raise HTTPException(status_code=400, detail="비활성 에이전트입니다.") from core.agents import get_agent_engine background_tasks.add_task(get_agent_engine().run_heartbeat, agent_id) return {"message": f"에이전트 '{agent.name}' 하트비트 시작", "agent_id": agent_id} @router.post("/{agent_id}/pause") async def pause_agent( agent_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """에이전트 일시 중지.""" _check_access(current_user) agent = await _get_agent_or_404(agent_id, db) agent.is_active = False agent.status = AgentStatus.PAUSED await db.commit() return {"message": f"에이전트 '{agent.name}' 일시 중지", "status": "PAUSED"} @router.post("/{agent_id}/resume") async def resume_agent( agent_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """에이전트 재개.""" _check_access(current_user) agent = await _get_agent_or_404(agent_id, db) agent.is_active = True agent.status = AgentStatus.IDLE await db.commit() return {"message": f"에이전트 '{agent.name}' 재개", "status": "IDLE"} # ── 태스크 ─────────────────────────────────────────────────────────────────── @router.get("/{agent_id}/tasks", response_model=List[AgentTaskOut]) async def list_tasks( agent_id: int, limit: int = 20, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): _check_access(current_user) tasks = (await db.execute( select(AgentTask) .where(AgentTask.agent_id == agent_id) .order_by(AgentTask.created_at.desc()) .limit(limit) )).scalars().all() return tasks @router.post("/{agent_id}/tasks", response_model=AgentTaskOut, status_code=status.HTTP_201_CREATED) async def create_task( agent_id: int, body: AgentTaskCreate, background_tasks: BackgroundTasks, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Developer 에이전트에게 태스크 직접 등록.""" _check_access(current_user) agent = await _get_agent_or_404(agent_id, db) task = AgentTask( agent_id=agent_id, title=body.title, description=body.description, status=AgentTaskStatus.PENDING, input_data=body.input_data, created_at=datetime.now(), ) db.add(task) await db.commit() await db.refresh(task) # Developer 에이전트면 바로 하트비트 실행 if agent.role == AgentRole.DEVELOPER and agent.is_active: from core.agents import get_agent_engine background_tasks.add_task(get_agent_engine().run_heartbeat, agent_id) return task # ── 스케줄러 등록 헬퍼 ──────────────────────────────────────────────────── def _register_heartbeat(agent: AgentConfig) -> None: """APScheduler에 에이전트 하트비트 크론 잡 등록.""" try: from core.scheduler import get_scheduler from apscheduler.triggers.cron import CronTrigger scheduler = get_scheduler() if scheduler is None or not scheduler.running: return from core.agents import get_agent_engine job_id = f"agent_heartbeat_{agent.id}" scheduler.add_job( get_agent_engine().run_heartbeat, CronTrigger.from_crontab(agent.heartbeat_cron, timezone="Asia/Seoul"), args=[agent.id], id=job_id, name=f"에이전트 하트비트: {agent.name}", replace_existing=True, misfire_grace_time=300, ) logger.info("에이전트 하트비트 등록: job_id=%s cron=%s", job_id, agent.heartbeat_cron) except Exception as exc: logger.warning("에이전트 하트비트 스케줄 등록 실패: %s", exc) # ── 에이전트 간 메시지 API (Priority 3) ─────────────────────────────────────── @router.get("/{agent_id}/messages", summary="수신 메시지 목록") async def get_agent_messages( agent_id: int, unread_only: bool = False, limit: int = 50, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """에이전트가 수신한 메시지 목록을 반환한다.""" _check_access(current_user) from models import AgentMessage, AgentMessageOut q = select(AgentMessage).where(AgentMessage.to_agent_id == agent_id) if unread_only: q = q.where(AgentMessage.is_read.is_(False)) q = q.order_by(AgentMessage.created_at.desc()).limit(limit) msgs = (await db.execute(q)).scalars().all() return [AgentMessageOut.model_validate(m) for m in msgs] @router.post("/{agent_id}/messages", status_code=201, summary="메시지 전송") async def send_agent_message( agent_id: int, payload: dict, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """ 에이전트에게 메시지를 전송한다. Body: { "from_agent_id": int | null, "message_type": "TASK_DELEGATION|STATUS_UPDATE|ESCALATION", "subject": "string", "body": "string", "metadata_json": "string" } """ _check_access(current_user) from models import AgentMessage, AgentMessageOut msg = AgentMessage( from_agent_id=payload.get("from_agent_id"), to_agent_id=agent_id, message_type=payload.get("message_type", "STATUS_UPDATE"), subject=payload.get("subject", ""), body=payload.get("body"), metadata_json=payload.get("metadata_json"), ) db.add(msg) await db.commit() await db.refresh(msg) return AgentMessageOut.model_validate(msg) @router.patch("/messages/{msg_id}/read", summary="메시지 읽음 처리") async def mark_message_read( msg_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """메시지를 읽음 처리한다.""" _check_access(current_user) from models import AgentMessage, AgentMessageOut msg = await db.get(AgentMessage, msg_id) if not msg: raise HTTPException(404, "메시지를 찾을 수 없습니다.") msg.is_read = True msg.read_at = datetime.utcnow() await db.commit() await db.refresh(msg) return AgentMessageOut.model_validate(msg) # ── 파인튜닝 API (Priority 3) ───────────────────────────────────────────────── @router.post("/finetune/start", summary="파인튜닝 시작 (ADMIN only)") async def start_finetune( background_tasks: BackgroundTasks, payload: dict = {}, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """ tb_agent_task(COMPLETED) 데이터로 Ollama 커스텀 모델 파인튜닝을 시작한다. Body: { "model_name": "guardia-agent-v2", "limit": 1000 } """ if current_user.role != UserRole.ADMIN: raise HTTPException(403, "ADMIN 권한이 필요합니다.") import os model_name = payload.get("model_name", "guardia-agent-v2") limit = int(payload.get("limit", 1000)) dataset_path = f"/opt/guardia/finetune/{model_name}.jsonl" async def _run_finetune(): from core.llm_client import get_llm_client llm = get_llm_client() count = await llm.export_finetune_dataset(dataset_path, limit=limit) if count == 0: logger.error("[finetune] 데이터셋 내보내기 실패 또는 데이터 없음") return ok = await llm.fine_tune(dataset_path, model_name) if ok: logger.info("[finetune] 파인튜닝 완료: model=%s", model_name) else: logger.error("[finetune] 파인튜닝 실패: model=%s", model_name) background_tasks.add_task(_run_finetune) return {"status": "started", "model_name": model_name, "dataset_path": dataset_path} @router.get("/finetune/status", summary="파인튜닝 진행 상태 조회") async def get_finetune_status( current_user: User = Depends(get_current_user), ): """파인튜닝 진행 상태를 반환한다 (Ollama 모델 목록 기반).""" _check_access(current_user) from core.llm_client import get_llm_client llm = get_llm_client() models = await llm.list_models() return {"models": [{"name": m.name, "size": m.size} for m in models]}