zioinfo-mail/itsm/routers/rpa.py
DESKTOP-TKLFCPR\ython c6b28c1584 feat(rpa): RPA 엔진 개선 — 스키마 필터링·라우터 스캔·영속 저장·크론 연동
[개선 내용]
1. 스키마 필터링: Out/Response/Data 제외 → Create/Update/In만 학습
   - 140개 스키마 → 73개 입력 스키마, 1357개 → 672개 규칙 (노이즈 제거)
2. 라우터 자동 스캔: routers/*.py AST 파싱 → 엔드포인트-스키마 정확 매핑
3. 영속 저장: rpa_rules.json → 서비스 재시작 시 자동 복구
4. 서비스 시작 자동 학습: 규칙 파일 없을 때 즉시 학습
5. APScheduler 연동: schedule(cron) 설정 시 자동 크론 등록/해제
6. /api/rpa/status: 시스템 현황 요약 엔드포인트 추가
7. /api/rpa/validations/schemas: 스키마별 필드 수 조회
8. /api/rpa/tasks/{id}/toggle: 작업 활성/비활성 토글

[테스트 결과 - 전체 통과]
- T1 RPA 상태: 73 endpoints, 672 rules, 자동 학습 확인
- T4 dry_run 정상: validation_errors=[] ✓
- T5 오류 감지: 4개 오류 정확 (title 필수·enum 2개·requested_by 필수)
- T6 작업 등록: APScheduler 크론 등록 포함
- T7 등록 작업 실행: DRY_RUN_OK ✓
- T8 이력 조회: status 필터 정상

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

503 lines
17 KiB
Python

"""
RPA (Robotic Process Automation) 라우터
- Validation 학습: models.py AST + routers/ 스캔
- 규칙 영속: rpa_rules.json
- RPA 작업 등록/수정/삭제/실행 + 크론 스케줄러 연동
- 실행 이력 조회
"""
from __future__ import annotations
import json
import os
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
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,
load_rules, save_rules, TASK_ENDPOINT_MAP,
)
router = APIRouter(prefix="/api/rpa", tags=["rpa"])
# ── 인메모리 저장소 (재시작 시 rpa_rules.json로 복구) ─────────────────────
_validation_rules: Dict[str, List[Dict]] = {} # endpoint → rules (런타임)
_rpa_tasks: Dict[int, Dict] = {}
_rpa_executions: List[Dict] = []
_task_id_seq = 1
ITSM_BASE = os.getenv("ITSM_BASE_URL", "http://127.0.0.1:9001")
def _init_rules_from_file() -> None:
"""서비스 시작 시 rpa_rules.json에서 규칙 복구."""
global _validation_rules
loaded = load_rules()
if loaded:
_validation_rules.update(loaded)
total = sum(len(v) for v in loaded.values())
print(f"[RPA] 저장된 validation 규칙 복구: {len(loaded)}개 엔드포인트, {total}개 규칙")
def auto_learn() -> Dict:
"""서비스 시작 시 자동 학습 (규칙 파일 없을 때)."""
learner = ValidationLearner()
result = learner.learn_from_source()
rules = result["rules"]
_validation_rules.clear()
for r in rules:
ep = r["endpoint"]
_validation_rules.setdefault(ep, [])
if not any(x["field_name"] == r["field_name"] for x in _validation_rules[ep]):
_validation_rules[ep].append(r)
return result
# ── 초기화: 파일에서 규칙 복구, 없으면 즉시 학습 ─────────────────────────
_init_rules_from_file()
if not _validation_rules:
try:
auto_learn()
print("[RPA] 초기 Validation 자동 학습 완료")
except Exception as e:
print(f"[RPA] 초기 학습 실패 (수동으로 POST /api/rpa/validations/learn 호출): {e}")
# ── Schemas ──────────────────────────────────────────────────────────────────
class LearnRequest(BaseModel):
endpoints: str = "all"
overwrite: bool = True
class RPATaskCreate(BaseModel):
task_name: str
task_type: str
schedule: Optional[str] = None
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
# ── Validation 학습 ──────────────────────────────────────────────────────────
@router.post("/validations/learn")
async def learn_validations(
req: LearnRequest,
current_user: User = Depends(get_current_user),
):
"""models.py + routers/ 소스 분석으로 validation 규칙 학습."""
learner = ValidationLearner()
try:
result = learner.learn_from_source()
except Exception as e:
raise HTTPException(500, f"소스 파싱 실패: {e}")
rules = result["rules"]
if req.overwrite:
_validation_rules.clear()
learned = 0
for r in rules:
ep = r["endpoint"]
_validation_rules.setdefault(ep, [])
existing = {x["field_name"] for x in _validation_rules[ep]}
if r["field_name"] not in existing:
_validation_rules[ep].append(r)
learned += 1
return {
"learned": learned,
"schemas": result["schemas"],
"endpoints_mapped": len(_validation_rules),
"total_rules": sum(len(v) for v in _validation_rules.values()),
"summary": {ep: len(rs) for ep, rs in list(_validation_rules.items())[:10]},
}
@router.get("/validations")
async def get_validations(
endpoint: Optional[str] = Query(None),
schema: Optional[str] = Query(None),
current_user: User = Depends(get_current_user),
):
"""학습된 validation 규칙 조회."""
if endpoint:
rules = _validation_rules.get(endpoint, [])
if schema:
rules = [r for r in rules if r.get("schema_class") == schema]
return {"endpoint": endpoint, "rule_count": len(rules), "rules": rules}
return {
"total_endpoints": len(_validation_rules),
"total_rules": sum(len(v) for v in _validation_rules.values()),
"endpoints": list(_validation_rules.keys()),
}
@router.get("/validations/schemas")
async def list_schemas(current_user: User = Depends(get_current_user)):
"""학습된 스키마 목록과 각 필드 수."""
schema_map: Dict[str, int] = {}
for rules in _validation_rules.values():
for r in rules:
sc = r.get("schema_class", "")
schema_map[sc] = schema_map.get(sc, 0) + 1
return {"schemas": schema_map}
# ── RPA 작업 관리 ─────────────────────────────────────────────────────────────
@router.post("/tasks", response_model=RPATaskOut)
async def create_rpa_task(
body: RPATaskCreate,
current_user: User = Depends(get_current_user),
):
global _task_id_seq
if body.task_type not in TASK_ENDPOINT_MAP:
raise HTTPException(400,
f"지원하지 않는 task_type. 허용값: {list(TASK_ENDPOINT_MAP.keys())}")
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
# APScheduler에 크론 등록
if body.schedule and body.is_active:
_register_cron(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 작업을 찾을 수 없습니다.")
# 기존 크론 제거
_unregister_cron(task_id)
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,
})
if body.schedule and body.is_active:
_register_cron(task)
return task
@router.patch("/tasks/{task_id}/toggle")
async def toggle_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 작업을 찾을 수 없습니다.")
task["is_active"] = not task["is_active"]
if task["is_active"] and task.get("schedule"):
_register_cron(task)
else:
_unregister_cron(task_id)
return {"id": task_id, "is_active": task["is_active"]}
@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 작업을 찾을 수 없습니다.")
_unregister_cron(task_id)
del _rpa_tasks[task_id]
return {"deleted": task_id}
# ── RPA 실행 ─────────────────────────────────────────────────────────────────
@router.post("/execute")
async def execute_rpa(
body: ExecuteRequest,
current_user: User = Depends(get_current_user),
):
"""단발성 RPA 실행 (validation → 실행 → 이력 기록)."""
global _rpa_executions
exec_id = len(_rpa_executions) + 1
started = datetime.now().isoformat()
# 해당 task_type의 엔드포인트 규칙 찾기
from core.rpa_engine import TASK_ENDPOINT_MAP
method_path = TASK_ENDPOINT_MAP.get(body.task_type)
if not method_path:
raise HTTPException(400, f"알 수 없는 task_type: {body.task_type}. 허용값: {list(TASK_ENDPOINT_MAP.keys())}")
ep_key = f"{method_path[0]} {method_path[1]}"
# path template → 실제 key (예: PATCH /api/tasks/{sr_id}/status → PATCH /api/tasks/status)
ep_key_norm = ep_key.split("{")[0].rstrip("/")
rules = _validation_rules.get(ep_key, []) or _validation_rules.get(ep_key_norm, [])
# Validation
validator = RPAValidator(rules)
errors = validator.validate(body.payload)
record: Dict[str, Any] = {
"execution_id": exec_id,
"task_type": body.task_type,
"dry_run": body.dry_run,
"validation_errors": errors,
"started_at": started,
"actor": current_user.username,
}
if errors:
record.update(status="VALIDATION_FAILED",
error=f"{len(errors)}개 validation 오류", result=None,
completed_at=datetime.now().isoformat())
_rpa_executions.append(record)
return record
if body.dry_run:
record.update(status="DRY_RUN_OK",
result={"message": "Validation 통과. dry_run=true이므로 실제 실행 생략."},
error=None, completed_at=datetime.now().isoformat())
_rpa_executions.append(record)
return record
# 실제 실행
executor = RPAExecutor(base_url=ITSM_BASE, token=_get_service_token(current_user))
try:
result = await executor.execute(body.task_type, body.payload, dry_run=False)
except Exception as e:
result = {"status": "FAILED", "error": str(e)}
record.update(
status=result.get("status", "FAILED"),
result=result.get("response"),
error=result.get("error"),
completed_at=datetime.now().isoformat(),
)
_rpa_executions.append(record)
return record
@router.post("/tasks/{task_id}/run")
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)
_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.get("status") == status]
if task_type:
execs = [e for e in execs if e.get("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, "실행 이력을 찾을 수 없습니다.")
@router.get("/status")
async def rpa_status(current_user: User = Depends(get_current_user)):
"""RPA 시스템 현황 요약."""
return {
"validation_endpoints": len(_validation_rules),
"validation_rules": sum(len(v) for v in _validation_rules.values()),
"tasks_total": len(_rpa_tasks),
"tasks_active": sum(1 for t in _rpa_tasks.values() if t["is_active"]),
"executions_total": len(_rpa_executions),
"executions_success": sum(1 for e in _rpa_executions if e.get("status") == "SUCCESS"),
"executions_failed": sum(1 for e in _rpa_executions
if e.get("status") in ("FAILED", "VALIDATION_FAILED")),
"supported_task_types": list(TASK_ENDPOINT_MAP.keys()),
}
# ── APScheduler 연동 ──────────────────────────────────────────────────────────
def _register_cron(task: Dict) -> None:
"""APScheduler에 크론 잡 등록."""
try:
from core.scheduler import scheduler
cron = task.get("schedule", "")
if not cron:
return
parts = cron.split()
if len(parts) < 5:
return
minute, hour, day, month, day_of_week = parts[:5]
job_id = f"rpa_task_{task['id']}"
scheduler.add_job(
_run_task_background,
trigger="cron",
id=job_id,
replace_existing=True,
minute=minute, hour=hour,
day=day, month=month,
day_of_week=day_of_week,
args=[task["id"]],
)
print(f"[RPA] 크론 등록: {job_id} ({cron})")
except Exception as e:
print(f"[RPA] 크론 등록 실패 (task_id={task['id']}): {e}")
def _unregister_cron(task_id: int) -> None:
try:
from core.scheduler import scheduler
job_id = f"rpa_task_{task_id}"
if scheduler.get_job(job_id):
scheduler.remove_job(job_id)
except Exception:
pass
def _run_task_background(task_id: int) -> None:
"""크론에 의해 백그라운드에서 호출되는 RPA 실행 함수."""
import asyncio
task = _rpa_tasks.get(task_id)
if not task or not task["is_active"]:
return
exec_id = len(_rpa_executions) + 1
started = datetime.now().isoformat()
ep = TASK_ENDPOINT_MAP.get(task["task_type"], ("", ""))[1]
ep_key = f"{TASK_ENDPOINT_MAP.get(task['task_type'], ('POST',''))[0]} {ep}"
rules = _validation_rules.get(ep_key, [])
validator = RPAValidator(rules)
errors = validator.validate(task["payload_template"])
record: Dict[str, Any] = {
"execution_id": exec_id,
"task_type": task["task_type"],
"dry_run": False,
"validation_errors": errors,
"started_at": started,
"actor": "rpa-scheduler",
}
if errors:
record.update(status="VALIDATION_FAILED",
error=f"{len(errors)}개 validation 오류", result=None,
completed_at=datetime.now().isoformat())
else:
async def _run():
executor = RPAExecutor(base_url=ITSM_BASE, token="")
return await executor.execute(task["task_type"], task["payload_template"])
try:
loop = asyncio.new_event_loop()
result = loop.run_until_complete(_run())
loop.close()
except Exception as e:
result = {"status": "FAILED", "error": str(e)}
record.update(
status=result.get("status", "FAILED"),
result=result.get("response"),
error=result.get("error"),
completed_at=datetime.now().isoformat(),
)
_rpa_executions.append(record)
_rpa_tasks[task_id]["last_run"] = datetime.now().isoformat()
print(f"[RPA Scheduler] task_id={task_id} status={record['status']}")
def _get_service_token(user: User) -> str:
"""서비스 계정용 토큰 생성 (내부 API 호출용)."""
from core.auth import create_access_token
return create_access_token({"sub": user.username, "role": user.role})