[하네스] - 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>
276 lines
11 KiB
Python
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
|