[하네스] - 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>
353 lines
12 KiB
Python
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, "실행 이력을 찾을 수 없습니다.")
|