You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
128 lines
4.4 KiB
Python
128 lines
4.4 KiB
Python
# backend/app/automation/evaluator.py
|
|
# 이벤트 → 규칙 매칭 → 동작/승인 enqueue. automation.matched → approval enqueue 연합 고리.
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import UTC, datetime
|
|
|
|
from sqlmodel import Session, select
|
|
|
|
from ..models import (
|
|
Approval,
|
|
ApprovalLog,
|
|
ApprovalSource,
|
|
ApprovalStatus,
|
|
AutomationRule,
|
|
AutomationRunLog,
|
|
AutonomyLevel,
|
|
AutonomySetting,
|
|
RiskLevel,
|
|
)
|
|
from .event_bus import Event, EventBus
|
|
from .events import APPROVAL_ENQUEUED, APPROVAL_EXECUTED, AUTOMATION_MATCHED
|
|
|
|
# cat → 결재 카드 기본 tone/icon(원본 매핑과 일관)
|
|
CAT_TONE = {"mail": "blue", "cal": "violet", "focus": "coral", "life": "green"}
|
|
CAT_ICON = {"mail": "mail", "cal": "cal", "focus": "zap", "life": "wallet"}
|
|
|
|
# 어떤 동작이 high-risk 인가 — 보내기/결제/삭제/전달 키워드
|
|
HIGH_RISK_RE = ("보내", "결제", "삭제", "전달", "발송", "일시정지", "송금")
|
|
|
|
|
|
def _risk_of(action: str) -> str:
|
|
return RiskLevel.high.value if any(k in action for k in HIGH_RISK_RE) else RiskLevel.low.value
|
|
|
|
|
|
def _autonomy(s: Session) -> str:
|
|
row = s.get(AutonomySetting, "default")
|
|
return row.level if row else AutonomyLevel.mixed.value
|
|
|
|
|
|
def match_rules(s: Session, trigger_key: str) -> list[AutomationRule]:
|
|
"""trigger_key(예: 'mail.newsletter')와 켜진 규칙을 매칭.
|
|
데모는 cat 기반 단순 매칭 + trigger 문자열 포함. 실연동(phase-13)이 정교화."""
|
|
rules = s.exec(select(AutomationRule).where(AutomationRule.on == True)).all() # noqa: E712
|
|
head = trigger_key.split(".")[0]
|
|
tail = trigger_key.split(".")[-1]
|
|
return [r for r in rules if head in (r.cat, "") or tail in r.trigger]
|
|
|
|
|
|
def enqueue_from_rule(s: Session, rule: AutomationRule, ctx: dict, bus: EventBus) -> Approval:
|
|
"""규칙 동작을 승인 큐에 넣는다. low+mixed이상=자동 실행(executed), 그 외=pending."""
|
|
risk = _risk_of(rule.action)
|
|
level = _autonomy(s)
|
|
auto_run = risk == RiskLevel.low.value and level in (
|
|
AutonomyLevel.mixed.value,
|
|
AutonomyLevel.full_auto.value,
|
|
)
|
|
ap = Approval(
|
|
id="ap-" + uuid.uuid4().hex[:8],
|
|
icon=CAT_ICON.get(rule.cat, "spark"),
|
|
tone=CAT_TONE.get(rule.cat, "blue"),
|
|
risk=risk,
|
|
time=("자동 실행됨" if auto_run else "확인 필요"),
|
|
title=ctx.get("title", rule.action),
|
|
detail=ctx.get("detail", f"{rule.name} · {rule.trigger}"),
|
|
cta=ctx.get("cta", "" if risk == RiskLevel.low.value else "실행"),
|
|
alt=ctx.get("alt", "" if risk == RiskLevel.low.value else "나중에"),
|
|
undo_label=(ctx.get("undo_label", "되돌리기") if risk == RiskLevel.low.value else ""),
|
|
status=(ApprovalStatus.executed.value if auto_run else ApprovalStatus.pending.value),
|
|
source=ApprovalSource.automation.value,
|
|
rule_id=rule.id,
|
|
executed_at=(datetime.now(UTC) if auto_run else None),
|
|
)
|
|
s.add(ap)
|
|
s.add(
|
|
AutomationRunLog(
|
|
id="arun-" + uuid.uuid4().hex[:8],
|
|
time="방금",
|
|
rule_id=rule.id,
|
|
rule=rule.name,
|
|
text=ctx.get("text", rule.action),
|
|
)
|
|
)
|
|
if auto_run:
|
|
s.add(
|
|
ApprovalLog(
|
|
id="alog-" + uuid.uuid4().hex[:8],
|
|
time="방금",
|
|
text=ctx.get("text", rule.action),
|
|
approval_id=ap.id,
|
|
)
|
|
)
|
|
rule.runs += 1
|
|
rule.last = "방금"
|
|
s.add(rule)
|
|
s.commit()
|
|
s.refresh(ap)
|
|
# 결재 enqueue 정본 이벤트명 = approval.enqueued
|
|
bus.publish(
|
|
APPROVAL_ENQUEUED,
|
|
{
|
|
"approval_id": ap.id,
|
|
"rule_id": rule.id,
|
|
"risk": risk,
|
|
"title": ap.title,
|
|
"auto_run": auto_run,
|
|
},
|
|
)
|
|
if auto_run:
|
|
bus.publish(
|
|
APPROVAL_EXECUTED,
|
|
{"approval_id": ap.id, "rule_id": rule.id, "risk": risk, "title": ap.title},
|
|
)
|
|
return ap
|
|
|
|
|
|
def register(bus: EventBus, session_factory) -> None:
|
|
"""버스에 evaluator 핸들러 등록. session_factory()는 Session 컨텍스트를 yield."""
|
|
|
|
def on_matched(ev: Event) -> None:
|
|
trigger_key = ev.payload.get("trigger_key", "")
|
|
ctx = ev.payload.get("ctx", {})
|
|
with session_factory() as s:
|
|
for rule in match_rules(s, trigger_key):
|
|
enqueue_from_rule(s, rule, ctx, bus)
|
|
|
|
bus.subscribe(AUTOMATION_MATCHED, on_matched)
|