guardia-itsm/routers/rpa.py
DESKTOP-TKLFCPRython 79973261b0 feat(rpa): GUARDiA ITSM RPA 봇 기능 구현 + 하네스 구성
[하네스]
- agents/validation-learner.md: 소스 AST 파싱 validation 학습 에이전트
- agents/rpa-bot.md: 학습 규칙 참조 자동화 실행 에이전트
- skills/rpa-orchestrator/SKILL.md: RPA E2E 워크플로우 스킬
- skills/rpa-validation/SKILL.md: 소스 기반 validation 학습 스킬

[구현]
- core/rpa_engine.py: ValidationLearner(AST 파서) + RPAValidator + RPAExecutor
- routers/rpa.py: 11개 API 엔드포인트
  POST /api/rpa/validations/learn   — models.py AST 파싱 → 1357개 규칙 학습
  GET  /api/rpa/validations         — 학습 규칙 조회 (119 endpoints)
  POST /api/rpa/tasks               — RPA 작업 등록
  POST /api/rpa/execute             — 즉시 실행 (validation + API 호출)
  GET  /api/rpa/executions          — 실행 이력

[테스트 결과]
- validation 학습: 140개 스키마 / 1357개 규칙 / 119개 엔드포인트
- WRONG_TYPE → enum 오류 감지 정확
- 필수 필드 누락 → validation 오류 상세 반환
- 실행 이력 조회 정상

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 16:10:41 +09:00

353 lines
12 KiB
Python

"""
RPA (Robotic Process Automation) 라우터
- Validation 학습: 프로젝트 소스(models.py) AST 파싱
- RPA 작업 등록/수정/삭제/실행
- 실행 이력 조회
"""
from __future__ import annotations
import os
from datetime import datetime
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks
from pydantic import BaseModel
from sqlalchemy import select, func, delete
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import User
from core.rpa_engine import ValidationLearner, RPAValidator, RPAExecutor
router = APIRouter(prefix="/api/rpa", tags=["rpa"])
# ── 인메모리 저장소 (DB 미적용 시 fallback) ──────────────────────────────────
# 실제 운영 시 SQLAlchemy 모델로 교체
_validation_rules: Dict[str, List[Dict]] = {} # endpoint → rules
_rpa_tasks: Dict[int, Dict] = {}
_rpa_executions: List[Dict] = []
_task_id_seq = 1
# ── Schemas ──────────────────────────────────────────────────────────────────
class LearnRequest(BaseModel):
endpoints: str = "all" # "all" 또는 특정 endpoint
overwrite: bool = True
class RPATaskCreate(BaseModel):
task_name: str
task_type: str # SR_CREATE | SR_STATUS_UPDATE | APPROVAL_PROCESS | ...
schedule: Optional[str] = None # cron expression
payload_template: Dict[str, Any] = {}
is_active: bool = True
description: Optional[str] = None
class RPATaskOut(BaseModel):
id: int
task_name: str
task_type: str
schedule: Optional[str]
payload_template: Dict[str, Any]
is_active: bool
description: Optional[str]
created_at: str
last_run: Optional[str]
class ExecuteRequest(BaseModel):
task_type: str
payload: Dict[str, Any]
dry_run: bool = False
class ExecuteOut(BaseModel):
execution_id: int
task_type: str
status: str
dry_run: bool
validation_errors: List[str] = []
result: Optional[Dict] = None
error: Optional[str] = None
started_at: str
completed_at: Optional[str] = None
# ── Validation 학습 ──────────────────────────────────────────────────────────
@router.post("/validations/learn")
async def learn_validations(
req: LearnRequest,
current_user: User = Depends(get_current_user),
):
"""
프로젝트 소스(models.py)를 AST 파싱하여 validation 규칙 학습.
"""
learner = ValidationLearner()
try:
result = learner.learn_from_source()
except Exception as e:
raise HTTPException(500, f"소스 파싱 실패: {e}")
rules = result["rules"]
schemas = result["schemas"]
if req.overwrite:
_validation_rules.clear()
learned = 0
for rule in rules:
ep = rule["endpoint"]
if ep not in _validation_rules:
_validation_rules[ep] = []
# 중복 필드 제거
existing = {r["field_name"] for r in _validation_rules[ep]}
if rule["field_name"] not in existing:
_validation_rules[ep].append(rule)
learned += 1
return {
"learned": learned,
"schemas": schemas,
"endpoints_mapped": len(_validation_rules),
"summary": {ep: len(rules_) for ep, rules_ in _validation_rules.items()},
}
@router.get("/validations")
async def get_validations(
endpoint: Optional[str] = Query(None),
current_user: User = Depends(get_current_user),
):
"""학습된 validation 규칙 조회."""
if endpoint:
return {"endpoint": endpoint, "rules": _validation_rules.get(endpoint, [])}
return {
"total_endpoints": len(_validation_rules),
"total_rules": sum(len(v) for v in _validation_rules.values()),
"endpoints": list(_validation_rules.keys()),
}
# ── RPA 작업 관리 ─────────────────────────────────────────────────────────────
@router.post("/tasks", response_model=RPATaskOut)
async def create_rpa_task(
body: RPATaskCreate,
current_user: User = Depends(get_current_user),
):
"""RPA 작업 등록."""
global _task_id_seq
task = {
"id": _task_id_seq,
"task_name": body.task_name,
"task_type": body.task_type,
"schedule": body.schedule,
"payload_template": body.payload_template,
"is_active": body.is_active,
"description": body.description,
"created_at": datetime.now().isoformat(),
"last_run": None,
"created_by": current_user.username,
}
_rpa_tasks[_task_id_seq] = task
_task_id_seq += 1
return task
@router.get("/tasks", response_model=List[RPATaskOut])
async def list_rpa_tasks(
is_active: Optional[bool] = Query(None),
task_type: Optional[str] = Query(None),
current_user: User = Depends(get_current_user),
):
tasks = list(_rpa_tasks.values())
if is_active is not None:
tasks = [t for t in tasks if t["is_active"] == is_active]
if task_type:
tasks = [t for t in tasks if t["task_type"] == task_type]
return tasks
@router.get("/tasks/{task_id}", response_model=RPATaskOut)
async def get_rpa_task(task_id: int, current_user: User = Depends(get_current_user)):
task = _rpa_tasks.get(task_id)
if not task:
raise HTTPException(404, "RPA 작업을 찾을 수 없습니다.")
return task
@router.put("/tasks/{task_id}", response_model=RPATaskOut)
async def update_rpa_task(
task_id: int,
body: RPATaskCreate,
current_user: User = Depends(get_current_user),
):
task = _rpa_tasks.get(task_id)
if not task:
raise HTTPException(404, "RPA 작업을 찾을 수 없습니다.")
task.update({
"task_name": body.task_name,
"task_type": body.task_type,
"schedule": body.schedule,
"payload_template": body.payload_template,
"is_active": body.is_active,
"description": body.description,
})
return task
@router.delete("/tasks/{task_id}")
async def delete_rpa_task(task_id: int, current_user: User = Depends(get_current_user)):
if task_id not in _rpa_tasks:
raise HTTPException(404, "RPA 작업을 찾을 수 없습니다.")
del _rpa_tasks[task_id]
return {"deleted": task_id}
# ── RPA 실행 ─────────────────────────────────────────────────────────────────
@router.post("/execute", response_model=ExecuteOut)
async def execute_rpa(
body: ExecuteRequest,
current_user: User = Depends(get_current_user),
):
"""
단발성 RPA 실행.
1. payload를 학습된 validation 규칙으로 검증
2. dry_run=false 시 실제 API 호출
"""
exec_id = len(_rpa_executions) + 1
started = datetime.now().isoformat()
# Validation 규칙 찾기
executor_map = {
"SR_CREATE": "POST /api/tasks",
"SR_STATUS_UPDATE": "PATCH /api/tasks/status",
"APPROVAL_PROCESS": "POST /api/approvals",
"INCIDENT_CREATE": "POST /api/incidents",
}
endpoint_key = executor_map.get(body.task_type, f"POST /api/{body.task_type.lower()}")
rules = _validation_rules.get(endpoint_key, [])
# Validation 검증
validator = RPAValidator(rules)
errors = validator.validate(body.payload)
if errors:
record = {
"execution_id": exec_id,
"task_type": body.task_type,
"status": "VALIDATION_FAILED",
"dry_run": body.dry_run,
"validation_errors": errors,
"result": None,
"error": f"{len(errors)}개 validation 오류",
"started_at": started,
"completed_at": datetime.now().isoformat(),
"actor": current_user.username,
}
_rpa_executions.append(record)
return record
if body.dry_run:
record = {
"execution_id": exec_id,
"task_type": body.task_type,
"status": "DRY_RUN_OK",
"dry_run": True,
"validation_errors": [],
"result": {"message": "Validation 통과. dry_run=true이므로 실제 실행 생략."},
"error": None,
"started_at": started,
"completed_at": datetime.now().isoformat(),
"actor": current_user.username,
}
_rpa_executions.append(record)
return record
# 실제 실행
base_url = os.getenv("ITSM_BASE_URL", "http://localhost:8001")
token = current_user.username # 실제 환경에서는 서비스 토큰 사용
executor = RPAExecutor(base_url=base_url, token=token)
try:
result = await executor.execute(body.task_type, body.payload, dry_run=False)
except Exception as e:
result = {"status": "FAILED", "error": str(e)}
record = {
"execution_id": exec_id,
"task_type": body.task_type,
"status": result.get("status", "FAILED"),
"dry_run": False,
"validation_errors": [],
"result": result.get("response"),
"error": result.get("error"),
"started_at": started,
"completed_at": datetime.now().isoformat(),
"actor": current_user.username,
}
_rpa_executions.append(record)
return record
@router.post("/tasks/{task_id}/run", response_model=ExecuteOut)
async def run_rpa_task(
task_id: int,
dry_run: bool = Query(False),
current_user: User = Depends(get_current_user),
):
"""등록된 RPA 작업 즉시 실행."""
task = _rpa_tasks.get(task_id)
if not task:
raise HTTPException(404, "RPA 작업을 찾을 수 없습니다.")
if not task["is_active"]:
raise HTTPException(400, "비활성 작업입니다. 먼저 활성화하세요.")
req = ExecuteRequest(
task_type=task["task_type"],
payload=task["payload_template"],
dry_run=dry_run,
)
result = await execute_rpa(req, current_user)
# last_run 갱신
_rpa_tasks[task_id]["last_run"] = datetime.now().isoformat()
return result
# ── 실행 이력 ──────────────────────────────────────────────────────────────────
@router.get("/executions")
async def list_executions(
status: Optional[str] = Query(None),
task_type: Optional[str] = Query(None),
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_current_user),
):
execs = list(_rpa_executions)
if status:
execs = [e for e in execs if e["status"] == status]
if task_type:
execs = [e for e in execs if e["task_type"] == task_type]
total = len(execs)
start = (page - 1) * size
return {
"total": total,
"page": page,
"size": size,
"items": list(reversed(execs))[start:start + size],
}
@router.get("/executions/{execution_id}")
async def get_execution(
execution_id: int,
current_user: User = Depends(get_current_user),
):
for e in _rpa_executions:
if e["execution_id"] == execution_id:
return e
raise HTTPException(404, "실행 이력을 찾을 수 없습니다.")