guardia-itsm/core/rpa_engine.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

276 lines
11 KiB
Python

"""
RPA Engine — 소스 기반 Validation 학습 + 자동화 실행
"""
from __future__ import annotations
import ast
import inspect
import importlib
import re
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete
BASE_DIR = Path(__file__).resolve().parent.parent # itsm/
# ── Validation 학습 ─────────────────────────────────────────────────────────
class ValidationLearner:
"""프로젝트 소스(models.py)를 AST 파싱하여 Pydantic 스키마 validation 규칙 추출."""
ENUM_MAP: Dict[str, List[str]] = {
"SRType": ["DEPLOY", "RESTART", "LOG", "INQUIRY", "OTHER"],
"SRStatus": ["RECEIVED","PARSED","PENDING_APPROVAL","APPROVED",
"IN_PROGRESS","PENDING_PM_VALIDATION","COMPLETED",
"FAILED_ROLLBACK","REJECTED"],
"Priority": ["CRITICAL", "HIGH", "MEDIUM", "LOW"],
"ApprovalResult":["PENDING", "APPROVED", "REJECTED"],
}
# 엔드포인트 → 스키마 매핑 (routers/ 분석으로 자동 보완)
ENDPOINT_SCHEMA: Dict[str, str] = {
"POST /api/tasks": "SRCreate",
"PATCH /api/tasks/status": "SRStatusUpdate",
"POST /api/approvals": "ApprovalCreate",
"POST /api/institutions": "InstitutionCreate",
"PUT /api/institutions": "InstitutionCreate",
"POST /api/servers": "ServerCreate",
"POST /api/incidents": "IncidentCreate",
"POST /api/change": "RFCCreate",
"POST /api/problems": "ProblemCreate",
"POST /api/catalog": "ServiceCatalogCreate",
}
def learn_from_source(self) -> Dict[str, Any]:
"""models.py AST 파싱으로 validation 규칙 추출."""
models_path = BASE_DIR / "models.py"
source = models_path.read_text(encoding="utf-8")
tree = ast.parse(source)
rules: List[Dict] = []
schemas_found: List[str] = []
for node in ast.walk(tree):
if not isinstance(node, ast.ClassDef):
continue
# BaseModel 상속 클래스만
bases = [getattr(b, "id", "") for b in node.bases]
if "BaseModel" not in bases:
continue
class_name = node.name
schemas_found.append(class_name)
# 엔드포인트 찾기
endpoint = self._find_endpoint(class_name)
for item in node.body:
if not isinstance(item, ast.AnnAssign):
continue
rule = self._extract_field_rule(item, class_name, endpoint)
if rule:
rules.append(rule)
return {"rules": rules, "schemas": schemas_found}
def _extract_field_rule(self, node: ast.AnnAssign,
class_name: str, endpoint: str) -> Optional[Dict]:
"""단일 필드 annotation에서 validation 규칙 추출."""
if not isinstance(node.target, ast.Name):
return None
field_name = node.target.id
if field_name.startswith("_"):
return None
annotation = node.annotation
# ast.AnnAssign: .value = 기본값 (없으면 None) → None이면 required
is_required = node.value is None
field_type = "str"
allowed_values: List[str] = []
constraints: Dict = {}
# 타입 분석
type_str = ast.unparse(annotation) if hasattr(ast, "unparse") else str(annotation)
# Optional[X] → is_required=False
if "Optional" in type_str:
is_required = False
inner = re.sub(r"Optional\[(.+)\]", r"\1", type_str)
type_str = inner
# Enum 타입
for enum_name, vals in self.ENUM_MAP.items():
if enum_name in type_str:
field_type = "enum"
allowed_values = vals
break
else:
if "int" in type_str:
field_type = "int"
elif "float" in type_str:
field_type = "float"
elif "bool" in type_str:
field_type = "bool"
elif "List" in type_str or "list" in type_str:
field_type = "list"
elif "datetime" in type_str.lower():
field_type = "datetime"
else:
field_type = "str"
return {
"endpoint": endpoint,
"schema_class": class_name,
"field_name": field_name,
"field_type": field_type,
"is_required": is_required,
"allowed_values": allowed_values,
"constraints": constraints,
"learned_at": datetime.now().isoformat(),
}
def _find_endpoint(self, class_name: str) -> str:
for ep, schema in self.ENDPOINT_SCHEMA.items():
if schema == class_name:
return ep
# 자동 추론: SRCreate → POST /api/tasks (by name pattern)
name_lower = class_name.lower().replace("create", "").replace("update", "")
return f"POST /api/{name_lower}s"
# ── Validation 검증기 ────────────────────────────────────────────────────────
class RPAValidator:
"""tb_rpa_validation 규칙으로 payload 검증."""
def __init__(self, rules: List[Dict]):
self.rules = {r["field_name"]: r for r in rules}
def validate(self, payload: Dict[str, Any]) -> List[str]:
"""
payload 검증. 오류 목록 반환 (빈 리스트 = 통과).
"""
errors: List[str] = []
for field_name, rule in self.rules.items():
val = payload.get(field_name)
# 필수 필드 검사
if rule["is_required"] and (val is None or val == ""):
errors.append(f"[{field_name}] 필수 항목입니다.")
continue
if val is None:
continue # optional이고 값 없으면 skip
# Enum 검사
if rule["field_type"] == "enum" and rule["allowed_values"]:
if val not in rule["allowed_values"]:
errors.append(
f"[{field_name}] 허용값: {rule['allowed_values']} 중 하나여야 합니다. (입력: {val!r})"
)
# 타입 검사
elif rule["field_type"] == "int":
try:
int(val)
except (TypeError, ValueError):
errors.append(f"[{field_name}] 정수 타입이어야 합니다.")
elif rule["field_type"] == "bool":
if not isinstance(val, bool):
errors.append(f"[{field_name}] 불리언(true/false) 타입이어야 합니다.")
# 제약 조건 검사
c = rule.get("constraints", {})
if c.get("max_length") and isinstance(val, str):
if len(val) > c["max_length"]:
errors.append(f"[{field_name}] 최대 {c['max_length']}자 초과.")
if c.get("min_length") and isinstance(val, str):
if len(val) < c["min_length"]:
errors.append(f"[{field_name}] 최소 {c['min_length']}자 이상 필요.")
if c.get("ge") is not None and isinstance(val, (int, float)):
if val < c["ge"]:
errors.append(f"[{field_name}] {c['ge']} 이상이어야 합니다.")
if c.get("le") is not None and isinstance(val, (int, float)):
if val > c["le"]:
errors.append(f"[{field_name}] {c['le']} 이하여야 합니다.")
return errors
# ── RPA 실행 엔진 ────────────────────────────────────────────────────────────
class RPAExecutor:
"""RPA 작업 실행기 — validation 후 ITSM API 호출."""
def __init__(self, base_url: str, token: str):
self.base_url = base_url.rstrip("/")
self.headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
async def execute(
self,
task_type: str,
payload: Dict[str, Any],
dry_run: bool = False,
retry: int = 3,
) -> Dict[str, Any]:
"""
단발성 RPA 실행.
dry_run=True → validation만 수행, API 호출 없음.
"""
endpoint_map = {
"SR_CREATE": ("POST", "/api/tasks"),
"SR_STATUS_UPDATE": ("PATCH", f"/api/tasks/{payload.get('sr_id', 0)}/status"),
"APPROVAL_PROCESS": ("POST", "/api/approvals"),
"INCIDENT_CREATE": ("POST", "/api/incidents"),
"SHELL_EXEC": ("POST", "/api/ssh/exec"),
}
if task_type not in endpoint_map:
return {"status": "FAILED", "error": f"알 수 없는 task_type: {task_type}"}
method, path = endpoint_map[task_type]
result = {"task_type": task_type, "endpoint": f"{method} {path}", "dry_run": dry_run}
if dry_run:
result["status"] = "DRY_RUN_OK"
result["message"] = "Validation 통과. dry_run=true이므로 실제 실행 생략."
return result
# 실제 API 호출 (재시도 포함)
url = f"{self.base_url}{path}"
last_err = None
async with httpx.AsyncClient(timeout=30) as client:
for attempt in range(1, retry + 1):
try:
resp = getattr(client, method.lower())
r = await resp(url, json=payload, headers=self.headers)
if r.status_code < 300:
result["status"] = "SUCCESS"
result["response"] = r.json()
return result
elif r.status_code < 500:
# 4xx → 재시도 없음
result["status"] = "FAILED"
result["error"] = r.json()
return result
else:
last_err = f"HTTP {r.status_code}: {r.text[:200]}"
except Exception as e:
last_err = str(e)
if attempt < retry:
import asyncio
await asyncio.sleep(2 ** attempt) # 지수 백오프
result["status"] = "FAILED"
result["error"] = f"{retry}회 재시도 후 실패: {last_err}"
return result