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.
ari_assistant/dev/phase-7-approvals-automatio...

106 KiB

Phase 7 — 자율성 코어: 결재함 + 자동화 엔진

한 줄 요약: 아리의 제품 철학 "할까요?가 아니라 이미 해뒀어요" 를 실제 동작으로 옮긴다 — 승인 큐(결재함)와 자연어 한 문장으로 만드는 자동화 규칙 엔진(event_bus·evaluator·nl_parser·suggester)을 백엔드와 프론트로 구현하고, automation.matched → approval.enqueued → approval.executed → 하루 마감 집계 연합 루프의 첫 고리를 완성한다.

이 문서는 포스트-MVP 세트의 일부 — 먼저 dev/overview.mddev/post-mvp-overview.md 를 읽으세요. 선행(상속): phase-2-backend.md(모델/스키마/라우터/시드/LLM 추상화 규약), phase-1-design-system.md(토큰·Icon·셸·SubRail). MVP 전체: phase-0-foundation.md ~ phase-6-integration.md. 후속: phase-8-calendar-meetings.md(일정+회의, 액션→작업), phase-9-mail-notifications.md(메일→작업/일정, 알림→자동화 제안), phase-13-integrations.md(mock→real 커넥터), phase-14-proactive-agent.md(스케줄러·능동 알림).


0. 목차

  1. 개요 & 목표
  2. 선행조건 · 산출물
  3. 상세 구현 — 백엔드
  4. 상세 구현 — 프론트엔드
  5. 데이터 / 타입 / API 계약
  6. 디자인 충실도 노트
  7. 상태 처리 · 엣지 케이스
  8. 연합 이벤트 (발행 / 구독)
  9. 테스팅 & 검증
  10. 완료 기준 (DoD)
  11. 다음 단계

1. 개요 & 목표

1.1 이 phase가 끝나면 무엇이 동작하는가

포스트-MVP의 최우선 phase다. MVP(작업·인박스·대시보드)에만 의존하며, 아리를 "할 일 앱"에서 "스스로 일하는 비서"로 바꾸는 두 개의 페이지와 그 뒤의 엔진을 만든다.

  • 결재함 페이지(/approvals): 아리가 미리 처리한 일을 low-risk = 자동 실행(되돌리기만), high-risk = 승인 대기로 보여준다. "모두 승인", "오늘 N분 아껴드렸어요", 밤사이 자동 처리 로그, 그리고 핵심 컨트롤인 자율성 설정 카드(approval_first | mixed | full_auto)가 큐를 실시간 재구성한다. 원본 approve.jsx/approve-page.jsx/approve.css/approve-data.js를 픽셀 충실 재현(savedToday 47분, autoCountNight 7, items a1~a6, log 4건).
  • 자동화 페이지(/automation): 좌측 SubRail 4뷰 — 내 규칙(토글 목록 + "작동 방식"), 새 자동화(자연어 한 문장 → 트리거·조건·동작 미리보기 → 생성 → 토스트), 아리 제안(반복 패턴 탐지, 레일 dot), 실행 기록. 원본 auto.jsx/auto-data.js/auto.css 재현(rules r1~r7, suggests s1/s2, examples 3건, log 6건, stats).
  • 자동화 엔진(backend/app/automation/): rule 모델 + event_bus(내부 이벤트 발행/구독) + evaluator(이벤트 → 규칙 매칭 → 동작/승인 enqueue) + nl_parser(자연어 한 문장 → 규칙, LLM provider 사용 + heuristic 폴백) + suggester(반복 패턴 탐지 → 규칙 제안).
  • 승인 큐(backend/app/approvals/ + 모델): approval + autonomy_setting + approval_log. low+mixed 이상이면 자동 실행(되돌리기), high면 승인 대기. 자율성 레벨이 큐 분기를 결정.
  • 연합 루프 첫 고리: automation.matchedapproval.enqueued, approval.executedwrap(하루 마감) 집계 입력. 알림 notification.triagedsuggesterautomation.suggested(phase-9 연계 지점만 명시).
  • pytest(evaluator·nl_parser heuristic·승인 상태 전이), API 통합, Vitest(결재 카드·자율성 토글·규칙 토글), Playwright(자연어→규칙 생성→목록 복귀, low 자동/high 승인/되돌리기, 자율성 레벨 변경 재구성)가 전부 green.

1.2 제품 철학 매핑

원본 문구 (REF) 이 phase에서 구현
approve-data.js: "아리는 '할까요?'라고 묻지 않고 미리 해둔다." risk=low 는 자율성 mixed/full_auto 에서 선실행 → 되돌리기. approval.status 전이 pending→executed→undone.
approve-data.js: "risk: low → 자율성 '혼합' 이상에서 자동 실행 (되돌리기 가능)" autonomy_setting.level 이 큐를 재구성: initStatus(level) 로직을 백엔드 derive_queue() 로 이식.
auto.jsx: "하고 싶은 일을 한 문장으로 적으세요. 트리거·조건·동작은 아리가 찾아요." nl_parser 가 자연어 → {trigger, cond, action, cat} 구조화. LLM generate_json + 휴리스틱 폴백.
auto.jsx: "반복되는 손길을 발견하면 새 규칙을 먼저 제안해요." suggesterapproval_log/automation_run_log 패턴 → automation_suggestion. (스케줄러 자동 실행은 phase-14, 여기선 수동 트리거 엔드포인트.)

2. 선행조건 · 산출물

2.1 선행조건 (의존 phase)

의존 상속받는 것
phase-2-backend.md models.py/schemas.py 패턴, 라우터 prefix 규약(라우터 내부 prefix 없음 → main.pyinclude_router(prefix="/api")), seed.pyrun_seed(session=None, reset=True) 단일 진입점, LLM get_provider()/generate_json/Classification, config.py Settings(env 주입), 테스트 conftest(in-memory + dependency_overrides[get_provider]). 기존 approval/event/goal/briefing 읽기전용 시드는 그대로 두고, 결재함 페이지는 이 phase에서 쓰기 가능한 approval 모델로 확장한다(§3.2 마이그레이션 노트).
phase-1-design-system.md tokens.css(dash.css :root 이식), Icon(중앙 paths 맵), Topbar(13항목, appr/auto active), SubRail(아이콘+툴팁+dot+sep), GlassCard, (placeholder) → 실제 페이지로 승격.
phase-5-dashboard.md 대시보드 결재함 요약 카드(approvals_summary, high-risk만 최대 3건)는 이미 존재 — 이 phase에서 결재함 페이지가 실행(승인/되돌리기)을 담당하고, 대시보드는 요약만 유지(원본 approve-page.jsx 주석: "대시보드는 요약만, 실행은 여기서.").

주의: post-mvp-overview.md는 이 세트의 진입 문서로, 횡단 아키텍처 명명(backend/app/automation/, event_bus, evaluator, nl_parser, suggester, approval/autonomy_setting/approval_log)의 정본이다. 이 문서는 그 명명을 1바이트도 바꾸지 않는다.

2.2 산출물 (Deliverables)

backend/
├─ app/
│  ├─ models.py                 (확장) Approval 확장 + AutonomySetting / ApprovalLog
│  │                                   AutomationRule / AutomationSuggestion
│  │                                   AutomationRunLog / AutomationStats
│  ├─ schemas.py                (확장) 결재/자율성/규칙/제안/파싱미리보기/로그 스키마
│  ├─ seed.py                   (확장) _seed_approvals / _seed_automation (REF 이식)
│  ├─ automation/
│  │  ├─ __init__.py
│  │  ├─ event_bus.py           내부 이벤트 발행/구독 (in-process)
│  │  ├─ events.py              이벤트 타입 상수 (capture.classified ... approval.executed)
│  │  ├─ evaluator.py           이벤트 → 규칙 매칭 → 동작/승인 enqueue
│  │  ├─ nl_parser.py           자연어 1문장 → 규칙 (LLM generate_json + heuristic 폴백)
│  │  └─ suggester.py           반복 패턴 탐지 → automation_suggestion
│  ├─ approvals/
│  │  ├─ __init__.py
│  │  └─ service.py             derive_queue / approve / undo / execute / approve_all / autonomy
│  └─ routers/
│     ├─ approvals.py           GET/approve/undo/execute/approve_all + autonomy 설정
│     └─ automation.py          규칙 CRUD·토글 / 파싱 미리보기 / 제안 수락·무시 / 실행기록 / 수동 트리거
├─ migrations/versions/xxxx_phase7.py   (Alembic) 신규 테이블 + approval 확장
└─ tests/
   ├─ test_approvals_service.py        derive_queue / 상태 전이
   ├─ test_api_approvals.py            결재 API 통합
   ├─ test_nl_parser.py                휴리스틱 파서 골든 케이스
   ├─ test_evaluator.py                event_bus → evaluator → enqueue
   ├─ test_suggester.py                패턴 탐지
   └─ test_api_automation.py           규칙/제안/로그 API 통합

frontend/
├─ app/
│  ├─ approvals/page.tsx        결재함 페이지 (approve-page.jsx 이식)
│  └─ automation/page.tsx       자동화 페이지 (auto.jsx 이식, SubRail 4뷰)
├─ components/approvals/
│  ├─ ApprovalCard.tsx          승인 대기 + 처리됨 + (옵션)로그 (approve.jsx 이식)
│  └─ AutonomyCard.tsx          자율성 설정 3옵션 (approve-page.jsx MODES 이식)
├─ components/automation/
│  ├─ RulesView.tsx  NewView.tsx  SuggestView.tsx  LogView.tsx  Flow.tsx  Toast.tsx
├─ lib/hooks/
│  ├─ useApprovals.ts           큐 + 자율성 + 액션
│  └─ useAutomation.ts          규칙/제안/로그 + 파싱 미리보기
├─ lib/types.ts                 (확장) Approval / Autonomy / Rule / Suggestion / Parse / RunLog
├─ styles/approve.css           (이식) approve.css 그대로
└─ styles/auto.css              (이식) auto.css 그대로

3. 상세 구현 — 백엔드

3.1 디렉터리 & 명명 (횡단 아키텍처 고정)

post-mvp-overview.md의 명명을 그대로 쓴다. 엔진은 backend/app/automation/, 승인 서비스는 backend/app/approvals/. 라우터는 routers/approvals.py, routers/automation.py. 라우터 내부 prefix는 없다(phase-2 규약) — main.py에서만 prefix="/api".

# backend/app/main.py  (phase-2 의 include 목록에 두 줄 추가)
from .routers import people, tree, tasks, inbox, dashboard, llm  # 기존
from .routers import approvals, automation                       # 신규(phase-7)
# ...
app.include_router(approvals.router, prefix="/api", tags=["approvals"])
app.include_router(automation.router, prefix="/api", tags=["automation"])

3.2 models.py 확장

phase-2의 enum/now() 헬퍼·str PK 규약을 그대로 따른다. tone 집합은 blue|violet|coral|green|amber|ink|faint 고정. 신규 enum은 영문 값(상태 머신/risk)으로, 표시 라벨은 프론트가 매핑한다.

# backend/app/models.py  (phase-2 파일 하단에 추가)
from enum import Enum
from typing import Optional
from datetime import datetime
from sqlmodel import SQLModel, Field
from .models import now  # 같은 모듈이면 직접 사용. (예시상 표기)


# ---------- 결재함 enum ----------
class RiskLevel(str, Enum):
    low = "low"          # 되돌릴 수 있는 일(일정 이동·메일 정리) — mixed 이상 자동 실행
    high = "high"        # 보내기·결제·타인 전달 — 항상 승인 대기


class ApprovalStatus(str, Enum):
    pending = "pending"      # 승인 대기
    approved = "approved"    # high 승인 직후(실행 직전 표시용; 실무상 execute로 이어짐)
    executed = "executed"    # 실행 완료(자동/승인)
    undone = "undone"        # 되돌림


class ApprovalSource(str, Enum):
    automation = "automation"
    agent = "agent"
    mail = "mail"
    calendar = "calendar"
    finance = "finance"
    inbox = "inbox"
    life = "life"
    system = "system"


class AutonomyLevel(str, Enum):
    approval_first = "approval_first"   # 승인 우선 (원본 id "approve")
    mixed = "mixed"                     # 혼합 (추천)
    full_auto = "full_auto"             # 완전 자율 (원본 id "auto")


# ---------- 결재함 모델 ----------
class Approval(SQLModel, table=True):
    """phase-2 의 읽기전용 Approval 을 '쓰기 가능'으로 확장.
    기존 필드(icon/tone/risk/time/title/detail/cta/alt/undo_label/sort_order)는 그대로 유지하고
    아래 status/source/created_at/executed_at/undo_at 만 추가한다(마이그레이션 노트 참조)."""
    __tablename__ = "approval"
    id: str = Field(primary_key=True)                # "a1"~"a6", 신규 "ap-xxxx"
    icon: str
    tone: str                                        # blue|violet|coral|green|amber|...
    risk: str = RiskLevel.low                        # "low" | "high" (문자열 저장; enum value)
    time: str = ""                                   # 표시용 시각/상태 라벨("07:42","보내기 대기")
    title: str
    detail: str = ""
    cta: str = ""                                    # high: "보내기"/"전달"/"일시정지"
    alt: str = ""                                    # high: "수정"/"내가 할게"/"유지"
    undo_label: str = ""                             # low: "원래 시간으로"/"되돌리기"/"블록 해제"
    sort_order: int = 0
    # --- 신규(phase-7) ---
    status: str = ApprovalStatus.pending             # 상태 머신
    source: str = ApprovalSource.system              # 발생원
    rule_id: Optional[str] = Field(default=None, foreign_key="automation_rule.id")
    created_at: datetime = Field(default_factory=now)
    executed_at: Optional[datetime] = None
    undone_at: Optional[datetime] = None


class AutonomySetting(SQLModel, table=True):
    """단일 row(id="default"). 데모 단일 사용자(지우)의 자율성 레벨."""
    __tablename__ = "autonomy_setting"
    id: str = Field(default="default", primary_key=True)        # 단일 row id="default" (TEXT PK)
    level: str = AutonomyLevel.mixed                            # 기본 '혼합'(원본 추천)
    updated_at: datetime = Field(default_factory=now)


class ApprovalLog(SQLModel, table=True):
    """결재함 활동 로그 — '밤사이 조용히 한 일'. approve-data.js log 이식."""
    __tablename__ = "approval_log"
    id: str = Field(primary_key=True)                # "alog1"...
    time: str                                        # 표시용("08:55","어제 23:10")
    text: str
    approval_id: Optional[str] = Field(default=None, foreign_key="approval.id")
    sort_order: int = 0
    created_at: datetime = Field(default_factory=now)


# ---------- 자동화 모델 ----------
class AutomationRule(SQLModel, table=True):
    __tablename__ = "automation_rule"
    id: str = Field(primary_key=True)                # "r1"~"r7", 신규 "rule-xxxx"
    name: str
    cat: str                                         # mail | cal | focus | life (원본 cat)
    trigger: str
    cond: Optional[str] = None                       # None 이면 조건 칩 미표시
    action: str
    on: bool = True                                  # 켜짐/꺼짐 (원본 r.on)
    last: str = ""                                   # "오늘 09:12"/"방금 만듦"/"지난주 금"
    runs: int = 0                                    # 이번 주 실행 횟수
    fresh: bool = False                              # 막 생성됨 → 프론트 rl-new 애니메이션
    source: str = "user"                             # user | suggestion | example
    created_at: datetime = Field(default_factory=now)


class AutomationSuggestion(SQLModel, table=True):
    __tablename__ = "automation_suggestion"
    id: str = Field(primary_key=True)                # "asug1"... (시드 원본 s1/s2)
    pattern: str                                     # "월요일 아침마다 ..."
    offer_name: str
    offer_cat: str
    offer_trigger: str
    offer_cond: Optional[str] = None
    offer_action: str
    status: str = "open"                             # open | accepted | dismissed
    created_at: datetime = Field(default_factory=now)


class AutomationRunLog(SQLModel, table=True):
    __tablename__ = "automation_run_log"
    id: str = Field(primary_key=True)                # "arun1"...
    time: str                                        # "12:48"/"어제 18:30"
    rule_id: Optional[str] = Field(default=None, foreign_key="automation_rule.id")
    rule: str                                        # 규칙명 스냅샷("영수증 자동 정리")
    text: str                                        # "점심 결제 13,500원 → 식비로 분류"
    undone: bool = False
    sort_order: int = 0
    created_at: datetime = Field(default_factory=now)


class AutomationStats(SQLModel, table=True):
    """단일 row(id=1). 페이지 eyebrow 통계. auto-data.js stats 이식."""
    __tablename__ = "automation_stats"
    id: Optional[int] = Field(default=None, primary_key=True)   # 단일 row id=1
    active: int = 0                                  # 활성 규칙 수(파생; 시드값 7)
    runs_week: int = 0                              # 이번 주 실행(시드 31)
    saved: str = ""                                  # "1시간 40분"

마이그레이션 노트 (migrations/versions/xxxx_phase7.py)

phase-2가 이미 approval 테이블(읽기전용 필드)을 만들었으므로, 이 phase는 컬럼 추가 + 신규 테이블 생성이다.

# migrations/versions/xxxx_phase7.py (핵심)
import sqlmodel
import sqlalchemy as sa
from alembic import op

def upgrade():
    # 1) approval 확장 (기존 행 보존; 기본값으로 채움)
    with op.batch_alter_table("approval") as b:   # SQLite는 batch 모드 필수
        b.add_column(sa.Column("status", sqlmodel.sql.sqltypes.AutoString(), server_default="pending"))
        b.add_column(sa.Column("source", sqlmodel.sql.sqltypes.AutoString(), server_default="system"))
        b.add_column(sa.Column("rule_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True))
        b.add_column(sa.Column("created_at", sa.DateTime(), nullable=True))
        b.add_column(sa.Column("executed_at", sa.DateTime(), nullable=True))
        b.add_column(sa.Column("undone_at", sa.DateTime(), nullable=True))
    # 2) 신규 테이블 — autonomy_setting / approval_log / automation_* (생략: create_table 6개)
    # ... op.create_table("autonomy_setting", ...) 등

def downgrade():
    for t in ("automation_stats","automation_run_log","automation_suggestion",
              "automation_rule","approval_log","autonomy_setting"):
        op.drop_table(t)
    with op.batch_alter_table("approval") as b:
        for c in ("undone_at","executed_at","created_at","rule_id","source","status"):
            b.drop_column(c)

autogenerate 주의(phase-2와 동일): SQLModel 컬럼은 sqlmodel.sql.sqltypes.AutoString 으로 잡히므로 마이그레이션 파일 상단에 import sqlmodel 이 필요하다. SQLite 컬럼 추가/삭제는 batch_alter_table 로 감싼다. 왕복 검증: alembic downgrade -1 && alembic upgrade head.

3.3 schemas.py 확장 (frontend/lib/types.ts 와 1:1)

# backend/app/schemas.py  (추가)
from typing import Optional, Literal
from datetime import datetime
from pydantic import BaseModel


# ---------- 결재함 ----------
class ApprovalOut(BaseModel):
    id: str
    icon: str
    tone: str
    risk: str                            # "low" | "high"
    time: str
    title: str
    detail: str
    cta: str
    alt: str
    undo_label: str
    status: str                          # pending|approved|executed|undone
    source: str
    rule_id: Optional[str] = None
    sort_order: int

class ApprovalLogOut(BaseModel):
    id: str
    time: str
    text: str
    approval_id: Optional[str] = None

class AutonomyOut(BaseModel):
    level: str                           # approval_first|mixed|full_auto

class AutonomyPatch(BaseModel):
    level: str                           # 셋 중 하나만 허용(검증)

class ApprovalQueueOut(BaseModel):
    """결재함 페이지 응답 정본. 자율성 레벨로 큐를 분기한 결과."""
    autonomy: str                        # 현재 레벨
    saved_today: str                     # "47분"
    auto_count_night: int                # 7
    pending: list[ApprovalOut]           # 승인 대기(high + (approval_first면 low도))
    done: list[ApprovalOut]              # 처리됨(되돌리기 가능)
    log: list[ApprovalLogOut]
    badges_appr: int                     # 상단 결재함 배지 = pending 수


# ---------- 자동화 ----------
class FlowOut(BaseModel):
    trigger: str
    cond: Optional[str] = None
    action: str

class RuleOut(BaseModel):
    id: str
    name: str
    cat: str                             # mail|cal|focus|life
    trigger: str
    cond: Optional[str] = None
    action: str
    on: bool
    last: str
    runs: int
    fresh: bool
    source: str

class RuleCreate(BaseModel):
    name: str
    cat: str
    trigger: str
    cond: Optional[str] = None
    action: str
    source: Optional[str] = "user"

class RulePatch(BaseModel):
    name: Optional[str] = None
    cat: Optional[str] = None
    trigger: Optional[str] = None
    cond: Optional[str] = None
    action: Optional[str] = None
    on: Optional[bool] = None

class ParseRequest(BaseModel):
    text: str                            # "출장 전날엔 저녁 일정 비워줘"

class ParsePreviewOut(BaseModel):
    matched: bool                        # 파싱 성공 여부
    name: str
    cat: str                             # mail|cal|focus|life
    parse: FlowOut
    model: str                           # "ollama:<model>" | "heuristic" | "example"
    confidence: float

class SuggestionOut(BaseModel):
    id: str
    pattern: str
    offer: FlowOut
    offer_name: str
    offer_cat: str
    status: str

class RunLogOut(BaseModel):
    id: str
    time: str
    rule: str
    text: str
    undone: bool

class AutomationStatsOut(BaseModel):
    active: int
    runs_week: int
    saved: str

class AutomationPageOut(BaseModel):
    """자동화 페이지 응답 정본(한 번에 모든 뷰 데이터)."""
    stats: AutomationStatsOut
    rules: list[RuleOut]
    suggests: list[SuggestionOut]
    log: list[RunLogOut]
    examples: list[ParsePreviewOut]      # 칩 예시 3건(파싱 결과 포함)

3.4 자동화 엔진 — automation/events.py, event_bus.py

event_bus는 in-process 발행/구독이다(MVP/프로토타입은 동기 디스패치; phase-14가 worker/로 비동기·스케줄 실행을 얹는다). 이벤트 타입은 post-mvp-overview.md 연합 이벤트 모델을 그대로 쓴다.

# backend/app/automation/events.py
# 연합 이벤트 타입 — post-mvp-overview.md "연합 이벤트 모델" 정본.
CAPTURE_CLASSIFIED   = "capture.classified"
TASK_CREATED         = "task.created"
MEETING_ENDED        = "meeting.ended"      # phase-8
MAIL_RECEIVED        = "mail.received"       # phase-9
AUTOMATION_MATCHED   = "automation.matched"
AUTOMATION_SUGGESTED = "automation.suggested"  # suggester 가 발행하는 제안 이벤트
APPROVAL_ENQUEUED    = "approval.enqueued"     # 결재 enqueue (approval.created 아님)
APPROVAL_EXECUTED    = "approval.executed"
APPROVAL_UNDONE      = "approval.undone"
NOTIFICATION_TRIAGED = "notification.triaged"  # phase-9 (suggester 구독)
# backend/app/automation/event_bus.py
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Callable


@dataclass
class Event:
    type: str
    payload: dict = field(default_factory=dict)
    at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))


class EventBus:
    """in-process 동기 이벤트 버스. 핸들러는 (Event) -> None.
    프로토타입은 publish 즉시 동기 디스패치. phase-14 worker 가 비동기/스케줄로 확장."""
    def __init__(self) -> None:
        self._subs: dict[str, list[Callable[[Event], None]]] = defaultdict(list)
        self.history: list[Event] = []           # 테스트/디버그용 발행 기록

    def subscribe(self, event_type: str, handler: Callable[[Event], None]) -> None:
        self._subs[event_type].append(handler)

    def publish(self, event_type: str, payload: dict | None = None) -> Event:
        ev = Event(type=event_type, payload=payload or {})
        self.history.append(ev)
        for h in list(self._subs.get(event_type, [])):
            h(ev)                                # 핸들러 예외는 격리(아래 safe 래퍼 권장)
        return ev


# 전역 단일 버스(데모). 테스트는 새 인스턴스를 주입해 격리한다.
bus = EventBus()

# 모듈 레벨 편의 export — post-mvp-overview.md event_bus 정본.
# 다른 문서가 `from app.automation.event_bus import bus` 든
# `from app.automation.event_bus import publish`(또는 emit) 든 모두 동작한다.
publish = bus.publish        # publish('automation.matched', {...})
subscribe = bus.subscribe    # subscribe('automation.matched', handler)
emit = bus.publish           # emit 은 publish 의 별칭(동일 동작)

import 정본: from app.automation.event_bus import bus(→ bus.publish('x', {...}))와 from app.automation.event_bus import publish(→ publish('x', {...}), emit 도 동일)가 둘 다 정상이다. app/events.py·app/event_bus.py 같은 다른 경로/심볼을 정본으로 쓰지 않는다 — 항상 app/automation/event_bus.py.

격리 권장: 핸들러는 try/except로 감싸 한 구독자의 예외가 다른 구독자를 막지 않도록 한다. 테스트는 EventBus()를 새로 만들어 evaluator.register(bus, session_factory) 로 격리한다.

3.5 automation/evaluator.py — 이벤트 → 규칙 매칭 → 동작/승인 enqueue

evaluator는 AUTOMATION_MATCHED 류 트리거 이벤트를 받아 켜진 규칙과 매칭하고, 동작을 승인 큐에 enqueue한다. low면 자율성 레벨에 따라 자동 실행(되돌리기), high면 항상 pending. 이것이 automation.matched → approval enqueue 연합 고리다.

# backend/app/automation/evaluator.py
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from sqlmodel import Session, select
from ..models import (AutomationRule, AutomationRunLog, Approval, ApprovalLog,
                      AutonomySetting, RiskLevel, ApprovalStatus, ApprovalSource,
                      AutonomyLevel)
from .events import AUTOMATION_MATCHED, APPROVAL_ENQUEUED, APPROVAL_EXECUTED
from .event_bus import EventBus, Event

# 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 if any(k in action for k in HIGH_RISK_RE) else RiskLevel.low


def _autonomy(s: Session) -> str:
    row = s.get(AutonomySetting, "default")
    return row.level if row else AutonomyLevel.mixed


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
    return [r for r in rules if trigger_key.split(".")[0] in (r.cat, "") or
            trigger_key.split(".")[-1] 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 and level in (AutonomyLevel.mixed, AutonomyLevel.full_auto)) \
        or (level == AutonomyLevel.full_auto)
    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 else "실행"),
        alt=ctx.get("alt", "" if risk == RiskLevel.low else "나중에"),
        undo_label=ctx.get("undo_label", "되돌리기") if risk == RiskLevel.low else "",
        status=(ApprovalStatus.executed if auto_run else ApprovalStatus.pending),
        source=ApprovalSource.automation, rule_id=rule.id,
        executed_at=(datetime.now(timezone.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 (approval.created 아님)
    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)

데모 결정성: 외부 이벤트(메일 도착 등)는 phase-9/13에서 커넥터가 발행한다. 이 phase는 수동 트리거 엔드포인트(POST /api/automation/trigger)로 AUTOMATION_MATCHED를 발행해 루프를 데모/테스트한다(§3.9).

3.6 automation/nl_parser.py — 자연어 한 문장 → 규칙

핵심: LLM provider.generate_json(prompt, schema)를 쓰되, 휴리스틱 폴백으로 오프라인/CI에서도 동작(phase-2 분류 파서와 동일 패턴). 원본 auto-data.js examples의 3개 문장은 골든 케이스로 정확히 재현한다.

# backend/app/automation/nl_parser.py
from __future__ import annotations
import re
from dataclasses import dataclass
from ..llm.provider import LLMProvider, get_provider


@dataclass
class ParsedRule:
    matched: bool
    name: str
    cat: str                  # mail|cal|focus|life
    trigger: str
    cond: str | None
    action: str
    model: str
    confidence: float


# 원본 auto-data.js examples 골든 매핑(텍스트 정확 일치 → 결정적 미리보기)
EXAMPLES = {
    "출장 전날엔 저녁 일정 비워줘": ParsedRule(
        True, "출장 전날 저녁 비우기", "cal",
        "출장 전날이 되면", "18시 이후 일정이 있으면", "다른 날로 옮기자고 제안",
        "example", 0.95),
    "뉴스레터는 모아서 저녁에 보여줘": ParsedRule(
        True, "뉴스레터는 저녁에", "mail",
        "뉴스레터 메일 도착", None, "받은편지함 건너뛰고 18:30 다이제스트로",
        "example", 0.95),
    "운동을 3일 거르면 산책 잡아줘": ParsedRule(
        True, "산책 리마인더", "life",
        "운동 기록 3일 연속 없음", "저녁에 빈 시간이 있으면", "30분 산책 블록 제안",
        "example", 0.95),
}

# 카테고리 키워드(휴리스틱)
CAT_RE = [
    ("mail",  re.compile(r"(메일|뉴스레터|받은편지함|회신|발송|스팸)")),
    ("cal",   re.compile(r"(일정|회의|미팅|캘린더|블록|비워|출장|약속)")),
    ("focus", re.compile(r"(집중|딥 ?워크|방해 금지|알림 보류|리포트 준비)")),
    ("life",  re.compile(r"(운동|산책|영수증|결제|구독|수면|건강|지출|카드)")),
]

NL_SYSTEM = (
    "너는 한국어 비서 '아리'의 자동화 규칙 파서다. 사용자의 한 문장을 "
    "{trigger(언제), cond(조건; 없으면 null), action(무엇을), cat, name} 으로 분해한다. "
    "cat 은 mail|cal|focus|life 중 하나. name 은 8자 내외 한국어 규칙 이름. "
    "보내기·결제·삭제·전달 같은 위험 동작은 'cond'에 확인 단서를 남겨라."
)


def _build_prompt(text: str) -> str:
    return (
        f'문장: "{text}"\n\n'
        "아래 JSON 스키마로만 답하라:\n"
        "{\n"
        '  "name": "규칙 이름(한국어, 8자 내외)",\n'
        '  "cat": "mail|cal|focus|life",\n'
        '  "trigger": "언제(트리거)",\n'
        '  "cond": "조건 또는 null",\n'
        '  "action": "무엇을(동작)"\n'
        "}"
    )


def _heuristic(text: str) -> ParsedRule:
    t = (text or "").strip()
    if not t:
        return ParsedRule(False, "", "cal", "", None, "", "heuristic", 0.0)
    cat = "cal"
    for c, rx in CAT_RE:
        if rx.search(t):
            cat = c
            break
    # 트리거/조건/동작 분해(아주 단순한 규칙 — 데모/CI 동작 보장)
    cond = None
    m = re.search(r"(.+?(되면|도착|없으면|이면|거르면|걸리면))\s*(.*)", t)
    if m:
        trigger, action = m.group(1).strip(), (m.group(3).strip() or t)
    else:
        trigger, action = t, t
    name = (action[:8] or t[:8]).strip()
    return ParsedRule(True, name, cat, trigger, cond, action, "heuristic", 0.55)


def parse_rule(text: str, provider: LLMProvider | None = None) -> ParsedRule:
    t = (text or "").strip()
    if t in EXAMPLES:                       # 골든: 데모 결정성 보장
        return EXAMPLES[t]
    prov = provider or get_provider()
    try:
        data = prov.generate_json(NL_SYSTEM + "\n" + _build_prompt(t), schema={"cat": "str"})
        cat = data.get("cat") if data.get("cat") in ("mail", "cal", "focus", "life") else None
        if cat and data.get("action"):
            return ParsedRule(
                True, str(data.get("name") or data["action"][:8]).strip(), cat,
                str(data.get("trigger", t)).strip(),
                (str(data["cond"]).strip() if data.get("cond") else None),
                str(data["action"]).strip(),
                f"llm:{getattr(prov, 'model', prov.name)}", float(data.get("confidence", 0.8)))
    except Exception:
        pass
    return _heuristic(t)                     # 폴백

3.7 automation/suggester.py — 반복 패턴 탐지 → 제안

스케줄러 자동 실행은 phase-14. suggester 는 두 경로를 모두 지원한다: (1) notification.triaged 이벤트 구독(phase-9 알림 트리아지의 "나중에" 반복 패턴이 입력), (2) 수동 트리거 엔드포인트(POST /api/automation/suggest/scan). 제안이 생성되면 automation.suggested발행한다(automation.suggestion_candidate 아님). 시드 제안 s1/s2는 그대로 두고, 스캐너는 automation_run_log/approval_log의 단순 빈도 패턴으로 후보를 만든다(데모는 결정적 규칙).

# backend/app/automation/suggester.py
from __future__ import annotations
import uuid
from sqlmodel import Session, select
from ..models import AutomationRunLog, AutomationSuggestion
from .events import NOTIFICATION_TRIAGED, AUTOMATION_SUGGESTED
from .event_bus import EventBus, Event

# 데모용 결정적 패턴 규칙: (감지 텍스트 부분일치, 제안)
PATTERNS = [
    {"match": "다이제스트", "pattern": "저녁 다이제스트를 자주 쓰시네요.",
     "offer": {"name": "아침에도 다이제스트", "cat": "mail",
               "trigger": "08:00", "cond": None, "action": "밤사이 메일 묶음 브리핑"}},
]


def scan(s: Session, bus: EventBus | None = None) -> list[AutomationSuggestion]:
    """run_log 를 훑어 반복 패턴이 임계 이상이면 제안 생성(중복 방지).
    생성된 제안마다 automation.suggested 발행(bus 가 주어지면)."""
    logs = s.exec(select(AutomationRunLog)).all()
    existing = {x.offer_name for x in s.exec(select(AutomationSuggestion)).all()}
    created: list[AutomationSuggestion] = []
    for pat in PATTERNS:
        hits = [l for l in logs if pat["match"] in (l.text or "")]
        if len(hits) >= 3 and pat["offer"]["name"] not in existing:   # 임계=3
            o = pat["offer"]
            sug = AutomationSuggestion(
                id="asug-" + uuid.uuid4().hex[:8], pattern=pat["pattern"],
                offer_name=o["name"], offer_cat=o["cat"], offer_trigger=o["trigger"],
                offer_cond=o["cond"], offer_action=o["action"], status="open")
            s.add(sug); created.append(sug)
    s.commit()
    if bus:
        for sug in created:
            bus.publish(AUTOMATION_SUGGESTED, {"suggestion_id": sug.id,
                                               "offer_name": sug.offer_name, "pattern": sug.pattern})
    return created


def register(bus: EventBus, session_factory) -> None:
    """버스에 suggester 핸들러 등록 — notification.triaged 구독.
    phase-9 알림 트리아지가 발행하는 "나중에" 반복 패턴을 입력으로 받아 scan → automation.suggested 체인."""
    def on_triaged(ev: Event) -> None:
        with session_factory() as s:
            scan(s, bus)
    bus.subscribe(NOTIFICATION_TRIAGED, on_triaged)

연합 체인: notification.triaged → (suggester 구독) → automation.suggested. suggester 는 이 구독과 수동 scan 두 경로를 모두 지원하며 제안 생성 시 automation.suggested 를 발행한다.

3.8 approvals/service.py — 큐 분기 + 상태 전이

원본 approve.jsxinitStatus(autonomy)를 백엔드 derive_queue()로 이식한다. 이것이 자율성 레벨이 큐를 실시간 재구성하는 정본 로직이다.

# backend/app/approvals/service.py
from __future__ import annotations
from datetime import datetime, timezone
from sqlmodel import Session, select
from ..models import (Approval, ApprovalLog, AutonomySetting,
                      ApprovalStatus, RiskLevel, AutonomyLevel)
from ..automation.event_bus import bus
from ..automation.events import APPROVAL_EXECUTED, APPROVAL_UNDONE


def get_autonomy(s: Session) -> str:
    row = s.get(AutonomySetting, "default")
    if not row:
        row = AutonomySetting(id="default", level=AutonomyLevel.mixed)
        s.add(row); s.commit(); s.refresh(row)
    return row.level


def set_autonomy(s: Session, level: str) -> str:
    if level not in (AutonomyLevel.approval_first, AutonomyLevel.mixed, AutonomyLevel.full_auto):
        raise ValueError("invalid autonomy level")
    row = s.get(AutonomySetting, "default") or AutonomySetting(id="default")
    row.level = level
    row.updated_at = datetime.now(timezone.utc)
    s.add(row); s.commit()
    return level


def derive_status(level: str, risk: str) -> str:
    """원본 approve.jsx initStatus(autonomy) 이식.
    auto: 전부 done(executed). mixed: low=done, high=pending. approve: 전부 pending."""
    if level == AutonomyLevel.full_auto:
        return ApprovalStatus.executed
    if level == AutonomyLevel.mixed:
        return ApprovalStatus.executed if risk == RiskLevel.low else ApprovalStatus.pending
    return ApprovalStatus.pending                       # approval_first


def derive_queue(s: Session) -> dict:
    """자율성 레벨로 모든 approval 의 표시 상태를 파생.
    단, 사용자가 명시적으로 undone/approved 한 것은 그 상태를 존중(override)."""
    level = get_autonomy(s)
    rows = s.exec(select(Approval).order_by(Approval.sort_order)).all()
    pending, done = [], []
    for a in rows:
        if a.status == ApprovalStatus.undone:           # 사용자가 되돌림 → 대기로
            eff = ApprovalStatus.pending
        elif a.status == ApprovalStatus.executed and a.executed_at and a.rule_id:
            eff = ApprovalStatus.executed               # 엔진이 실제 실행한 건은 고정
        else:
            eff = derive_status(level, a.risk)           # 시드/표시용은 레벨로 파생
        (done if eff == ApprovalStatus.executed else pending).append(a)
    return {"level": level, "pending": pending, "done": done}


def approve(s: Session, aid: str) -> Approval:
    a = s.get(Approval, aid)
    a.status = ApprovalStatus.executed
    a.executed_at = datetime.now(timezone.utc)
    s.add(a); s.commit(); s.refresh(a)
    s.add(ApprovalLog(id="alog-" + aid + "-x", time="방금",
                      text=a.title, approval_id=aid)); s.commit()
    bus.publish(APPROVAL_EXECUTED, {"approval_id": aid, "risk": a.risk, "title": a.title})
    return a


def undo(s: Session, aid: str) -> Approval:
    a = s.get(Approval, aid)
    a.status = ApprovalStatus.undone
    a.undone_at = datetime.now(timezone.utc)
    s.add(a); s.commit(); s.refresh(a)
    bus.publish(APPROVAL_UNDONE, {"approval_id": aid, "title": a.title})
    return a


def approve_all(s: Session) -> int:
    q = derive_queue(s)
    n = 0
    for a in q["pending"]:
        approve(s, a.id); n += 1
    return n

approveexecute 의 차이: high-risk 카드는 사용자가 cta(보내기/전달/일시정지)를 누르면 approve→실행으로 이어진다. 의미상 동일 전이(pending→executed)이며, API는 명확성을 위해 /approve/execute 를 모두 제공하되 둘 다 service.approve() 로 귀결한다(low 자동 실행 카드는 이미 executed라 /undo만 의미 있음).

3.9 라우터 — routers/approvals.py

# backend/app/routers/approvals.py
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from ..db import get_session
from ..models import Approval, ApprovalLog, AutomationStats, RiskLevel, ApprovalStatus
from ..schemas import (ApprovalQueueOut, ApprovalOut, ApprovalLogOut,
                       AutonomyOut, AutonomyPatch)
from ..approvals import service

router = APIRouter()                      # prefix 없음. main.py 에서 prefix="/api" 등록.

SAVED_TODAY = "47분"                       # 원본 approve-data.js savedToday
AUTO_COUNT_NIGHT = 7                       # 원본 autoCountNight

def _out(a: Approval) -> ApprovalOut:
    return ApprovalOut(id=a.id, icon=a.icon, tone=a.tone, risk=a.risk, time=a.time,
                       title=a.title, detail=a.detail, cta=a.cta, alt=a.alt,
                       undo_label=a.undo_label, status=a.status, source=a.source,
                       rule_id=a.rule_id, sort_order=a.sort_order)

@router.get("/approvals", response_model=ApprovalQueueOut)
def get_queue(s: Session = Depends(get_session)):
    q = service.derive_queue(s)
    logs = s.exec(select(ApprovalLog).order_by(ApprovalLog.sort_order)).all()
    return ApprovalQueueOut(
        autonomy=q["level"], saved_today=SAVED_TODAY, auto_count_night=AUTO_COUNT_NIGHT,
        pending=[_out(a) for a in q["pending"]], done=[_out(a) for a in q["done"]],
        log=[ApprovalLogOut(id=l.id, time=l.time, text=l.text, approval_id=l.approval_id) for l in logs],
        badges_appr=len(q["pending"]))

@router.post("/approvals/{aid}/approve", response_model=ApprovalOut)
def approve(aid: str, s: Session = Depends(get_session)):
    if not s.get(Approval, aid): raise HTTPException(404, "approval not found")
    return _out(service.approve(s, aid))

@router.post("/approvals/{aid}/execute", response_model=ApprovalOut)
def execute(aid: str, s: Session = Depends(get_session)):
    if not s.get(Approval, aid): raise HTTPException(404, "approval not found")
    return _out(service.approve(s, aid))   # high-risk cta 실행 = approve 와 동일 전이

@router.post("/approvals/{aid}/undo", response_model=ApprovalOut)
def undo(aid: str, s: Session = Depends(get_session)):
    if not s.get(Approval, aid): raise HTTPException(404, "approval not found")
    return _out(service.undo(s, aid))

@router.post("/approvals/approve-all")
def approve_all(s: Session = Depends(get_session)):
    return {"approved": service.approve_all(s)}

@router.get("/approvals/autonomy", response_model=AutonomyOut)
def get_autonomy(s: Session = Depends(get_session)):
    return AutonomyOut(level=service.get_autonomy(s))

@router.patch("/approvals/autonomy", response_model=AutonomyOut)
def set_autonomy(body: AutonomyPatch, s: Session = Depends(get_session)):
    try:
        return AutonomyOut(level=service.set_autonomy(s, body.level))
    except ValueError as e:
        raise HTTPException(422, str(e))

3.10 라우터 — routers/automation.py

# backend/app/routers/automation.py
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from ..db import get_session
from ..models import (AutomationRule, AutomationSuggestion, AutomationRunLog,
                      AutomationStats)
from ..schemas import (AutomationPageOut, AutomationStatsOut, RuleOut, RuleCreate,
                       RulePatch, ParseRequest, ParsePreviewOut, FlowOut,
                       SuggestionOut, RunLogOut)
from ..automation.nl_parser import parse_rule, EXAMPLES
from ..automation.suggester import scan
from ..automation.event_bus import bus
from ..automation.events import AUTOMATION_MATCHED

router = APIRouter()                      # prefix 없음. main.py 에서 prefix="/api" 등록.

def _rule_out(r: AutomationRule) -> RuleOut:
    return RuleOut(id=r.id, name=r.name, cat=r.cat, trigger=r.trigger, cond=r.cond,
                   action=r.action, on=r.on, last=r.last, runs=r.runs,
                   fresh=r.fresh, source=r.source)

def _sug_out(g: AutomationSuggestion) -> SuggestionOut:
    return SuggestionOut(id=g.id, pattern=g.pattern,
                         offer=FlowOut(trigger=g.offer_trigger, cond=g.offer_cond, action=g.offer_action),
                         offer_name=g.offer_name, offer_cat=g.offer_cat, status=g.status)

@router.get("/automation", response_model=AutomationPageOut)
def get_page(s: Session = Depends(get_session)):
    rules = s.exec(select(AutomationRule).order_by(AutomationRule.created_at)).all()
    sugs = s.exec(select(AutomationSuggestion).where(AutomationSuggestion.status == "open")).all()
    logs = s.exec(select(AutomationRunLog).order_by(AutomationRunLog.sort_order)).all()
    st = s.exec(select(AutomationStats)).first()
    active = sum(1 for r in rules if r.on)
    stats = AutomationStatsOut(active=active,
                               runs_week=(st.runs_week if st else 0),
                               saved=(st.saved if st else ""))
    examples = []
    for txt, pr in EXAMPLES.items():
        examples.append(ParsePreviewOut(matched=True, name=pr.name, cat=pr.cat,
                        parse=FlowOut(trigger=pr.trigger, cond=pr.cond, action=pr.action),
                        model=pr.model, confidence=pr.confidence))
    return AutomationPageOut(stats=stats, rules=[_rule_out(r) for r in rules],
                             suggests=[_sug_out(g) for g in sugs],
                             log=[RunLogOut(id=l.id, time=l.time, rule=l.rule, text=l.text, undone=l.undone) for l in logs],
                             examples=examples)

@router.post("/automation/parse", response_model=ParsePreviewOut)
def parse(body: ParseRequest):
    pr = parse_rule(body.text)
    return ParsePreviewOut(matched=pr.matched, name=pr.name, cat=pr.cat,
                           parse=FlowOut(trigger=pr.trigger, cond=pr.cond, action=pr.action),
                           model=pr.model, confidence=pr.confidence)

@router.post("/automation/rules", response_model=RuleOut)
def create_rule(body: RuleCreate, s: Session = Depends(get_session)):
    r = AutomationRule(id="rule-" + uuid.uuid4().hex[:8], name=body.name, cat=body.cat,
                       trigger=body.trigger, cond=body.cond, action=body.action,
                       on=True, last="방금 만듦", runs=0, fresh=True, source=body.source or "user")
    s.add(r); s.commit(); s.refresh(r)
    return _rule_out(r)

@router.patch("/automation/rules/{rid}", response_model=RuleOut)
def patch_rule(rid: str, body: RulePatch, s: Session = Depends(get_session)):
    r = s.get(AutomationRule, rid)
    if not r: raise HTTPException(404, "rule not found")
    for k, v in body.model_dump(exclude_unset=True).items():
        setattr(r, k, v)
    s.add(r); s.commit(); s.refresh(r)
    return _rule_out(r)

@router.post("/automation/rules/{rid}/toggle", response_model=RuleOut)
def toggle_rule(rid: str, s: Session = Depends(get_session)):
    r = s.get(AutomationRule, rid)
    if not r: raise HTTPException(404, "rule not found")
    r.on = not r.on
    s.add(r); s.commit(); s.refresh(r)
    return _rule_out(r)

@router.delete("/automation/rules/{rid}")
def delete_rule(rid: str, s: Session = Depends(get_session)):
    r = s.get(AutomationRule, rid)
    if not r: raise HTTPException(404, "rule not found")
    s.delete(r); s.commit()
    return {"deleted": rid}

@router.post("/automation/suggestions/{sid}/accept", response_model=RuleOut)
def accept_suggestion(sid: str, s: Session = Depends(get_session)):
    g = s.get(AutomationSuggestion, sid)
    if not g: raise HTTPException(404, "suggestion not found")
    g.status = "accepted"; s.add(g)
    r = AutomationRule(id="rule-" + uuid.uuid4().hex[:8], name=g.offer_name, cat=g.offer_cat,
                       trigger=g.offer_trigger, cond=g.offer_cond, action=g.offer_action,
                       on=True, last="방금 만듦", runs=0, fresh=True, source="suggestion")
    s.add(r); s.commit(); s.refresh(r)
    return _rule_out(r)

@router.post("/automation/suggestions/{sid}/dismiss")
def dismiss_suggestion(sid: str, s: Session = Depends(get_session)):
    g = s.get(AutomationSuggestion, sid)
    if not g: raise HTTPException(404, "suggestion not found")
    g.status = "dismissed"; s.add(g); s.commit()
    return {"dismissed": sid}

@router.post("/automation/suggest/scan")
def suggest_scan(s: Session = Depends(get_session)):
    created = scan(s, bus)              # 생성 제안마다 automation.suggested 발행
    return {"created": [g.id for g in created]}

@router.get("/automation/log", response_model=list[RunLogOut])
def get_log(s: Session = Depends(get_session)):
    logs = s.exec(select(AutomationRunLog).order_by(AutomationRunLog.sort_order)).all()
    return [RunLogOut(id=l.id, time=l.time, rule=l.rule, text=l.text, undone=l.undone) for l in logs]

@router.post("/automation/trigger")
def manual_trigger(trigger_key: str, s: Session = Depends(get_session)):
    """데모/테스트용 수동 트리거 — AUTOMATION_MATCHED 발행(스케줄러는 phase-14).
    evaluator 가 구독해 매칭 규칙을 승인 큐에 enqueue 한다."""
    ev = bus.publish(AUTOMATION_MATCHED, {"trigger_key": trigger_key,
                                          "ctx": {"title": f"{trigger_key} 규칙 실행"}})
    return {"published": ev.type, "trigger_key": trigger_key}

3.11 seed.py 확장 — REF 데이터 이식

phase-2의 run_seed(session, reset) / _seed_dashboard(s) 패턴을 그대로 따라, _seed_approvals(s)·_seed_automation(s)_run() 안에서 호출한다. 기존 phase-2 _seed_dashboardApproval 시드(a1~a6)는 status/source 필드만 추가로 채우도록 통합한다(중복 적재 금지 — _seed_approvals로 단일화).

# backend/app/seed.py  (추가)
from .models import (Approval, ApprovalLog, AutonomySetting, AutomationRule,
                     AutomationSuggestion, AutomationRunLog, AutomationStats,
                     RiskLevel, ApprovalStatus, ApprovalSource, AutonomyLevel)

# ---- 결재함 (REF approve-data.js) ----
# (id, icon, tone, risk, time, title, detail, cta, alt, undo_label)
APPROVALS = [
    ("a1","cal","coral","low","07:42","치과 예약을 16:00로 옮겼어요",
     "14시 분기 전략 미팅과 겹침 · 병원 예약 시스템에서 빈 슬롯 확인 후 변경","","","원래 시간으로"),
    ("a2","mail","blue","low","06:10","영수증·뉴스레터 7통을 정리했어요",
     "영수증 3통 → 금융 폴더 · 뉴스레터 4통 → 읽을거리, 받은편지함은 중요한 것만 남김","","","되돌리기"),
    ("a3","cal","violet","low","07:40","내일 오전 딥 워크 2시간을 예약했어요",
     "분기 리포트 마감(내일 18시) 대비 · 9:0011:00, 방해 금지로 설정","","","블록 해제"),
    ("a4","mail","violet","high","보내기 대기","현우님께 회신 초안이 준비됐어요",
     "“잘 받았어요! 금요일 오전까지 화면별 코멘트 정리해서 드릴게요.”","보내기","수정",""),
    ("a5","users","green","high","전달 대기","민서님께 ‘데이터 전처리’ 위임 요청",
     "오늘 일정 과부하 감지 · 맥락 요약과 마감(목)을 담은 요청 메시지 작성 완료","전달","내가 할게",""),
    ("a6","wallet","amber","high","확인 필요","Netflix 일시정지를 추천해요",
     "최근 2개월 시청 기록 없음 · 모레 17,000원 결제 예정 — 정지 절차는 준비해뒀어요","일시정지","유지",""),
]
# (time, text) — REF approve-data.js log
APPROVAL_LOG = [
    ("08:55","스탠드업 직전 — 어제 진행 요약 노트 생성"),
    ("07:30","출근 경로 확인 · 평소보다 4분 빠른 경로로 안내 예약"),
    ("06:00","구독 결제 캘린더 동기화 (Netflix·Spotify 6/9)"),
    ("어제 23:10","수면 모드 — 알림 음소거 · 내일 브리핑 예약"),
]
# source 매핑(risk/icon 기반): cal→calendar, mail→mail, users→system, wallet→finance
SRC = {"cal": ApprovalSource.calendar, "mail": ApprovalSource.mail,
       "users": ApprovalSource.system, "wallet": ApprovalSource.finance}

def _seed_approvals(s: Session) -> None:
    for i, (aid, icon, tone, risk, time, title, detail, cta, alt, undo) in enumerate(APPROVALS):
        # 시드 초기 status: 페이지 응답은 derive_queue 가 자율성으로 재파생하므로 pending 으로 둔다.
        s.add(Approval(id=aid, icon=icon, tone=tone, risk=risk, time=time, title=title,
                       detail=detail, cta=cta, alt=alt, undo_label=undo, sort_order=i,
                       status=ApprovalStatus.pending, source=SRC.get(icon, ApprovalSource.system)))
    for i, (time, text) in enumerate(APPROVAL_LOG):
        s.add(ApprovalLog(id=f"alog{i+1}", time=time, text=text, sort_order=i))
    s.add(AutonomySetting(id="default", level=AutonomyLevel.mixed))   # 기본 '혼합'(원본 useState("mixed")), 단일 row id="default"

# ---- 자동화 (REF auto-data.js) ----
# (id, on, cat, name, trigger, cond, action, last, runs)
RULES = [
    ("r1", True,  "mail",  "수아님 메일은 바로", "수아님 발신 메일", None, "즉시 알림 + 3줄 요약", "오늘 09:12", 9),
    ("r2", True,  "focus", "딥 워크 보호막", "집중 모드 시작", "긴급 표시가 아니면", "알림 보류 + 메신저 상태 '집중'", "오늘 10:00", 5),
    ("r3", True,  "cal",   "30분 빈틈 채우기", "일정 사이 30분 빈틈", "이동이 없으면", "가벼운 할 일 자동 배치", "어제 15:40", 7),
    ("r4", True,  "mail",  "뉴스레터는 저녁에", "뉴스레터 도착", None, "보관 후 저녁 다이제스트로 묶음", "어제 18:30", 14),
    ("r5", True,  "life",  "영수증 자동 정리", "카드 결제 알림", None, "지출 기록 + 카테고리 분류", "오늘 12:48", 11),
    ("r6", False, "cal",   "금요일 오후 비우기", "금 13:00 이후 회의 초대", "내가 필수 참석자가 아니면", "다음 주 시간 제안", "지난주 금", 2),
    ("r7", True,  "life",  "수면 모드 연동", "23:00이 되면", "내일 첫 일정이 9시 전이면", "수면 모드 제안 + 기상 알람 조정", "어제 23:00", 6),
]
# (id, pattern, offer:{name,cat,trigger,cond,action})
SUGGESTS = [
    ("s1","월요일 아침마다 주간 리포트 초안을 직접 만드시네요. 최근 4주 연속이에요.",
     "주간 리포트 미리 준비","focus","일요일 21:00",None,"지난주 작업·일정으로 초안 생성"),
    ("s2","18시 이후 도착한 메일은 다음 날 아침에 읽으세요. 이번 주 밤 알림만 14번 울렸어요.",
     "퇴근 후 메일은 아침에","mail","18:00~08:00 메일 도착","VIP가 아니면","아침 브리핑으로 묶음"),
]
# (time, rule, text) — REF auto-data.js log
RUN_LOG = [
    ("12:48","영수증 자동 정리","점심 결제 13,500원 → 식비로 분류"),
    ("10:00","딥 워크 보호막","알림 4건 보류 — 16:00에 묶음 전달 예정"),
    ("09:12","수아님 메일은 바로","분기 리포트 피드백 도착 → 요약과 함께 알림"),
    ("08:55","30분 빈틈 채우기","10:30 빈틈에 '온보딩 문구 검토' 배치"),
    ("어제 18:30","뉴스레터는 저녁에","뉴스레터 5통 → 저녁 다이제스트로 묶음"),
    ("어제 23:00","수면 모드 연동","내일 8:30 첫 회의 → 수면 모드 제안"),
]

def _seed_automation(s: Session) -> None:
    rule_by_name = {}
    for rid, on, cat, name, trig, cond, action, last, runs in RULES:
        r = AutomationRule(id=rid, name=name, cat=cat, trigger=trig, cond=cond, action=action,
                           on=on, last=last, runs=runs, fresh=False, source="user")
        s.add(r); rule_by_name[name] = rid
    for sid, pat, oname, ocat, otrig, ocond, oaction in SUGGESTS:
        s.add(AutomationSuggestion(id="asug-" + sid, pattern=pat, offer_name=oname,
              offer_cat=ocat, offer_trigger=otrig, offer_cond=ocond, offer_action=oaction, status="open"))
    for i, (time, rule, text) in enumerate(RUN_LOG):
        s.add(AutomationRunLog(id=f"arun{i+1}", time=time, rule_id=rule_by_name.get(rule),
                               rule=rule, text=text, sort_order=i))
    s.add(AutomationStats(id=1, active=7, runs_week=31, saved="1시간 40분"))

_run(s, reset) 의 reset 삭제 목록에 신규 테이블 6개를 추가하고, phase-2의 _seed_dashboard 에서 Approval 적재 부분을 제거(또는 _seed_approvals 로 단일화)한다. _run 끝에서 _seed_approvals(s)·_seed_automation(s) 를 호출한 뒤 commit. 결과 시드 기대값: rules 7개(켜짐 6 = r6 꺼짐), suggests open 2개, run_log 6개(오늘 4 + 어제 2), approval 6개(low 3 + high 3), approval_log 4개, autonomy=mixed, stats={7,31,"1시간 40분"}.


4. 상세 구현 — 프론트엔드

4.1 라우팅 — placeholder → 실제 페이지

phase-1의 (placeholder)/[slug]결재함/자동화 두 항목을 실제 라우트로 승격한다. TopbarMAIN(13항목)은 그대로 유지하되 appr/approvals, auto/automation 로 연결. 결재함 badge 3GET /api/approvalsbadges_appr 로 동적화 가능(기본은 원본 상수 3과 일치).

frontend/app/approvals/page.tsx     # current="appr"
frontend/app/automation/page.tsx    # current="auto"

4.2 결재함 — ApprovalCard.tsx (approve.jsx 이식)

원본 approve.jsxinitStatus/pending/done/approve/undo/approveAll 로직과 마크업을 1:1로 옮기되, 상태 소스는 백엔드 GET /api/approvals(이미 derive_queue 적용된 pending/done)로 바꾼다. 클래스명·문구·아이콘은 그대로.

// frontend/components/approvals/ApprovalCard.tsx
"use client";
import { Icon } from "@/components/Icon";
import type { ApprovalOut } from "@/lib/types";

type Props = {
  saved: string; pending: ApprovalOut[]; done: ApprovalOut[];
  autoCountNight?: number; log?: { time: string; text: string }[]; showLog?: boolean;
  onApprove: (id: string) => void; onUndo: (id: string) => void; onApproveAll: () => void;
};

export function ApprovalCard({ saved, pending, done, autoCountNight, log, showLog,
                               onApprove, onUndo, onApproveAll }: Props) {
  return (
    <section className="card appr sp2">
      <div className="ch">
        <div className="ico lime"><Icon name="spark" /></div>
        <div className="htext">
          <h3>아리 결재함</h3>
          <div className="sub">확인만 하면  · 오늘 {saved} 아껴드렸어요</div>
        </div>
        {pending.length > 0
          ? <span className="count warm">대기 {pending.length}</span>
          : <span className="count">모두 처리됨</span>}
      </div>

      {/* 승인 대기 */}
      {pending.length === 0 ? (
        <div className="appr-empty">
          <span className="ae-tick"><Icon name="tick" w={3} /></span>
          결재할  없어요  아리가 알아서 하고 있어요. 아래에서 언제든 되돌릴  있어요.
        </div>
      ) : (
        <div className="appr-list">
          {pending.map((it) => (
            <div className="ap-row" key={it.id}>
              <div className="ap-ic" style={{ ["--tone" as any]: `var(--${it.tone})` }}>
                <Icon name={it.icon} />
              </div>
              <div className="ap-body">
                <div className="ap-title">{it.title}</div>
                <div className="ap-detail">{it.detail}</div>
              </div>
              <div className="ap-acts">
                <button className="ap-ok" onClick={() => onApprove(it.id)}>
                  <Icon name="tick" w={3} />{it.cta || "승인"}
                </button>
                <button className="ap-alt">{it.alt || "나중에"}</button>
              </div>
            </div>
          ))}
          {pending.length >= 2 && (
            <button className="ap-all" onClick={onApproveAll}>
              <Icon name="tick" w={3} />모두 승인 ({pending.length})
            </button>
          )}
        </div>
      )}

      {/* 자동 처리됨 (되돌리기) */}
      {done.length > 0 && (
        <div className="appr-done">
          <div className="ad-label">처리됨    번으로 되돌릴  있어요</div>
          {done.map((it) => (
            <div className="ad-row" key={it.id}>
              <span className="ad-tick"><Icon name="tick" w={3} /></span>
              <span className="ad-title">{it.title}</span>
              <span className="ad-time">{it.time}</span>
              <button className="ad-undo" onClick={() => onUndo(it.id)}>
                <Icon name="swap" />{it.undo_label || "되돌리기"}
              </button>
            </div>
          ))}
        </div>
      )}

      {/* 활동 로그(대시보드 위젯에서만 showLog=true) */}
      {showLog && log && (
        <div className="appr-log">
          <div className="ad-label">밤사이 조용히   {autoCountNight}</div>
          {log.map((l, i) => (
            <div className="al-row" key={i}>
              <span className="al-time">{l.time}</span>
              <span className="al-text">{l.text}</span>
            </div>
          ))}
        </div>
      )}
    </section>
  );
}

4.3 자율성 카드 — AutonomyCard.tsx (approve-page.jsx MODES 이식)

// frontend/components/approvals/AutonomyCard.tsx
"use client";
import { Icon } from "@/components/Icon";

// 원본 approve-page.jsx MODES — id 만 백엔드 enum 으로 매핑(approve→approval_first, auto→full_auto)
export const MODES = [
  { id: "approval_first", title: "승인 우선",
    desc: "모든 실행 전에 내 확인을 받아요. 아리를 알아가는 단계에 좋아요." },
  { id: "mixed", title: "혼합", rec: true,
    desc: "되돌릴 수 있는 일(일정 이동·메일 정리)은 자동, 보내기·결제·위임은 승인 대기." },
  { id: "full_auto", title: "완전 자율",
    desc: "다 해두고 보고만 해요. 모든 실행은 탭 한 번으로 되돌릴 수 있어요." },
];

export function AutonomyCard({ autonomy, onPick }:
  { autonomy: string; onPick: (id: string) => void }) {
  return (
    <section className="card auton">
      <div className="ch">
        <div className="ico"><Icon name="shield" /></div>
        <div className="htext"><h3>아리 자율성</h3><div className="sub">어디까지 맡길지 정해요</div></div>
      </div>
      <div className="auton-list">
        {MODES.map((m) => (
          <button key={m.id} className={"au-opt" + (autonomy === m.id ? " on" : "")}
                  onClick={() => onPick(m.id)} aria-pressed={autonomy === m.id}>
            <span className="au-radio" />
            <span className="au-body">
              <span className="au-title">{m.title}{m.rec && <em className="au-rec">추천</em>}</span>
              <span className="au-desc">{m.desc}</span>
            </span>
          </button>
        ))}
      </div>
      <div className="auton-note">
        <Icon name="swap" />어떤 모드든, 아리가  일은 전부 기록되고 되돌릴  있어요.
      </div>
    </section>
  );
}

4.4 결재함 페이지 + useApprovals

// frontend/lib/hooks/useApprovals.ts
"use client";
import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api";
import type { ApprovalQueue } from "@/lib/types";

export function useApprovals() {
  const [data, setData] = useState<ApprovalQueue | null>(null);
  const load = useCallback(async () => setData(await api.get("/approvals")), []);
  useEffect(() => { load(); }, [load]);

  const setAutonomy = async (level: string) => {
    await api.patch("/approvals/autonomy", { level });   // 변경 즉시 큐 재구성
    await load();                                          // GET /approvals 가 derive_queue 반영
  };
  const approve = async (id: string) => { await api.post(`/approvals/${id}/approve`); await load(); };
  const undo = async (id: string) => { await api.post(`/approvals/${id}/undo`); await load(); };
  const approveAll = async () => { await api.post("/approvals/approve-all"); await load(); };
  return { data, setAutonomy, approve, undo, approveAll, reload: load };
}
// frontend/app/approvals/page.tsx
"use client";
import { Topbar } from "@/components/Topbar";
import { Icon } from "@/components/Icon";
import { ApprovalCard } from "@/components/approvals/ApprovalCard";
import { AutonomyCard } from "@/components/approvals/AutonomyCard";
import { useApprovals } from "@/lib/hooks/useApprovals";
import "@/styles/approve.css";

export default function ApprovalsPage() {
  const { data, setAutonomy, approve, undo, approveAll } = useApprovals();
  if (!data) return <div className="dash"><Topbar current="appr" /></div>;

  return (
    <div className="dash">
      <Topbar current="appr" />
      <div className="pagehead">
        <div>
          <div className="ph-eyebrow">
            <span>오늘 {data.saved_today} 아껴드렸어요</span>
            <span className="sep" />
            <span>밤사이 자동 처리 {data.auto_count_night}</span>
          </div>
          <h1 className="ph-title">아리 결재함 <em>확인만 하면 </em></h1>
        </div>
      </div>
      <div className="work">
        <div className="board">
          <ApprovalCard saved={data.saved_today} pending={data.pending} done={data.done}
            onApprove={approve} onUndo={undo} onApproveAll={approveAll} />
          <AutonomyCard autonomy={data.autonomy} onPick={setAutonomy} />
          {/* 활동 로그 카드 — approve-page.jsx 와 동일 */}
          <section className="card">
            <div className="ch">
              <div className="ico"><Icon name="clock" /></div>
              <div className="htext"><h3>활동 로그</h3><div className="sub">아리가 조용히  </div></div>
              <span className="count">{data.auto_count_night}</span>
            </div>
            <div className="appr-log pg">
              {data.log.map((l) => (
                <div className="al-row" key={l.id}>
                  <span className="al-time">{l.time}</span>
                  <span className="al-text">{l.text}</span>
                </div>
              ))}
            </div>
            <div className="auton-note dim">
              <Icon name="shield" />보내기·결제·삭제는 자율 모드에서도 항상 로그에 강조 표시돼요.
            </div>
          </section>
        </div>
      </div>
    </div>
  );
}

4.5 자동화 페이지 — Flow.tsx + 4뷰 + useAutomation

원본 auto.jsxFlow, RulesView, NewView, SuggestView, LogView, AU_RAIL, AU_HEAD, 토스트를 React 컴포넌트로 옮긴다. SubRail은 phase-1의 공용 컴포넌트. 카드는 auto.css.board { grid-template-columns: 1fr; }100% 폭.

// frontend/components/automation/Flow.tsx
import { Icon } from "@/components/Icon";
export function Flow({ p, small }: { p: { trigger: string; cond?: string | null; action: string }; small?: boolean }) {
  return (
    <div className={"fl" + (small ? " rl-flow" : "")}>
      <span className="fl-chip tg"><span className="k">트리거</span><span className="v">{p.trigger}</span></span>
      {p.cond && (<>
        <span className="fl-arr"><Icon name="arrow" /></span>
        <span className="fl-chip cd"><span className="k">조건</span><span className="v">{p.cond}</span></span>
      </>)}
      <span className="fl-arr"><Icon name="arrow" /></span>
      <span className="fl-chip ac"><span className="k">동작</span><span className="v">{p.action}</span></span>
    </div>
  );
}

export const AU_TONE: Record<string, string> = {
  mail: "var(--blue)", cal: "var(--violet)", focus: "var(--coral)", life: "var(--green)" };
export const AU_CAT: Record<string, string> = { mail: "메일", cal: "일정", focus: "집중", life: "라이프" };
// frontend/lib/hooks/useAutomation.ts (요약)
export function useAutomation() {
  const [page, setPage] = useState<AutomationPage | null>(null);
  const load = useCallback(async () => setPage(await api.get("/automation")), []);
  useEffect(() => { load(); }, [load]);

  const parse = (text: string) => api.post("/automation/parse", { text }) as Promise<ParsePreview>;
  const createRule = async (b: RuleCreate) => { await api.post("/automation/rules", b); await load(); };
  const toggle = async (id: string) => { await api.post(`/automation/rules/${id}/toggle`); await load(); };
  const accept = async (id: string) => { await api.post(`/automation/suggestions/${id}/accept`); await load(); };
  const dismiss = async (id: string) => { await api.post(`/automation/suggestions/${id}/dismiss`); await load(); };
  return { page, parse, createRule, toggle, accept, dismiss, reload: load };
}

NewView의 입력은 원본처럼 examples 텍스트 정확 일치면 미리보기를 즉시 그리되, 자유 입력은 POST /api/automation/parse(디바운스 ~300ms)로 미리보기를 받아 그린다. 미리보기 matched면 "이 규칙 만들기"가 활성. 생성 시 createRule({name, cat, trigger, cond, action}) → 목록(rules) 뷰로 전환 + 토스트 '<name>' 규칙을 켰어요.(원본 문구) + rl-new 애니메이션(백엔드 fresh=true).

// frontend/app/automation/page.tsx (골격)
"use client";
import { useState } from "react";
import { Topbar } from "@/components/Topbar";
import { SubRail } from "@/components/SubRail";
import { Icon } from "@/components/Icon";
import { useAutomation } from "@/lib/hooks/useAutomation";
import "@/styles/auto.css";

const AU_HEAD: Record<string, { title: string; em: string }> = {
  rules:   { title: "내 규칙",   em: "켜두면 알아서 굴러가요" },
  new:     { title: "새 자동화", em: "말하듯 적으면 규칙이 돼요" },
  suggest: { title: "아리 제안", em: "반복되는 일을 자동으로" },
  log:     { title: "실행 기록", em: "규칙이 움직인 순간들" },
};
const AU_RAIL = (sugN: number) => [
  { id: "rules", icon: "zap", label: "내 규칙" },
  { id: "new", icon: "plus", label: "새 자동화" },
  { sep: true },
  { id: "suggest", icon: "brain", label: "아리 제안", dot: sugN > 0 },
  { id: "log", icon: "clock", label: "실행 기록" },
];

export default function AutomationPage() {
  const [view, setView] = useState("rules");
  const [toast, setToast] = useState<string | null>(null);
  const { page, parse, createRule, toggle, accept, dismiss } = useAutomation();
  if (!page) return <div className="dash" data-screen-label="자동화"><Topbar current="auto" /></div>;

  const activeN = page.rules.filter((r) => r.on).length;
  const h = AU_HEAD[view];
  const fire = (name: string) => { setToast(`${name} 규칙을 켰어요.`); setView("rules"); setTimeout(() => setToast(null), 3200); };

  return (
    <div className="dash" data-screen-label="자동화">
      <Topbar current="auto" />
      <div className="pagehead"><div>
        <div className="ph-eyebrow">
          <span>활성 규칙 {activeN}</span><span className="sep" />
          <span>이번  {page.stats.runs_week} 실행</span><span className="sep" />
          <span>{page.stats.saved} 아꼈어요</span>
        </div>
        <h1 className="ph-title">{h.title} <em>{h.em}</em></h1>
      </div></div>
      <div className="work">
        <SubRail items={AU_RAIL(page.suggests.length)} active={view} onPick={setView} />
        {/* view 별 RulesView / NewView / SuggestView / LogView 렌더 (생략) */}
      </div>
      {toast && <div className="au-toast"><Icon name="tick" />{toast}</div>}
    </div>
  );
}

신규 아이콘 가드: auto.jsx/approve.jsx가 쓰는 아이콘 키(spark, zap, shield, brain, clock, tick, swap, pen, plus, arrow, cal, mail, users, wallet)는 모두 phase-1의 shell.jsx P 맵에 이미 존재한다(누락 없음). 누락 키는 phase-1 Icon.tsx의 가드(빈 path)로 렌더 깨짐 방지.


5. 데이터 / 타입 / API 계약

5.1 엔드포인트 표

Method Path 본문 응답 비고
GET /api/approvals ApprovalQueueOut derive_queue 적용(자율성 반영)
POST /api/approvals/{id}/approve ApprovalOut pending→executed
POST /api/approvals/{id}/execute ApprovalOut high cta 실행(=approve)
POST /api/approvals/{id}/undo ApprovalOut executed→undone(=대기로 복귀)
POST /api/approvals/approve-all {approved:int} 현재 pending 전부 승인
GET /api/approvals/autonomy AutonomyOut 현재 레벨
PATCH /api/approvals/autonomy AutonomyPatch AutonomyOut 레벨 변경 → 큐 재구성
GET /api/automation AutomationPageOut stats/rules/suggests/log/examples
POST /api/automation/parse ParseRequest ParsePreviewOut 자연어→규칙 미리보기
POST /api/automation/rules RuleCreate RuleOut 규칙 생성(fresh=true)
PATCH /api/automation/rules/{id} RulePatch RuleOut 수정
POST /api/automation/rules/{id}/toggle RuleOut on 토글
DELETE /api/automation/rules/{id} {deleted} 삭제
POST /api/automation/suggestions/{id}/accept RuleOut 제안→규칙(source=suggestion)
POST /api/automation/suggestions/{id}/dismiss {dismissed} 무시
POST /api/automation/suggest/scan {created:[]} 패턴 스캔(수동; 스케줄러 phase-14)
GET /api/automation/log RunLogOut[] 실행 기록
POST /api/automation/trigger?trigger_key= {published, trigger_key} 수동 트리거(데모)

5.2 대표 요청 / 응답 JSON

GET /api/approvals (자율성 mixed 기준 — low 3건 자동, high 3건 대기):

{
  "autonomy": "mixed",
  "saved_today": "47분",
  "auto_count_night": 7,
  "pending": [
    {"id":"a4","icon":"mail","tone":"violet","risk":"high","time":"보내기 대기",
     "title":"현우님께 회신 초안이 준비됐어요",
     "detail":"“잘 받았어요! 금요일 오전까지 화면별 코멘트 정리해서 드릴게요.”",
     "cta":"보내기","alt":"수정","undo_label":"","status":"pending","source":"mail","rule_id":null,"sort_order":3},
    {"id":"a5","icon":"users","tone":"green","risk":"high","time":"전달 대기",
     "title":"민서님께 ‘데이터 전처리’ 위임 요청","cta":"전달","alt":"내가 할게","status":"pending","sort_order":4,"undo_label":"","detail":"...","source":"system","rule_id":null},
    {"id":"a6","icon":"wallet","tone":"amber","risk":"high","time":"확인 필요",
     "title":"Netflix 일시정지를 추천해요","cta":"일시정지","alt":"유지","status":"pending","sort_order":5,"undo_label":"","detail":"...","source":"finance","rule_id":null}
  ],
  "done": [
    {"id":"a1","icon":"cal","tone":"coral","risk":"low","time":"07:42",
     "title":"치과 예약을 16:00로 옮겼어요","undo_label":"원래 시간으로","status":"executed","cta":"","alt":"","detail":"...","source":"calendar","rule_id":null,"sort_order":0},
    {"id":"a2","icon":"mail","tone":"blue","risk":"low","time":"06:10",
     "title":"영수증·뉴스레터 7통을 정리했어요","undo_label":"되돌리기","status":"executed","sort_order":1,"cta":"","alt":"","detail":"...","source":"mail","rule_id":null},
    {"id":"a3","icon":"cal","tone":"violet","risk":"low","time":"07:40",
     "title":"내일 오전 딥 워크 2시간을 예약했어요","undo_label":"블록 해제","status":"executed","sort_order":2,"cta":"","alt":"","detail":"...","source":"calendar","rule_id":null}
  ],
  "log": [
    {"id":"alog1","time":"08:55","text":"스탠드업 직전 — 어제 진행 요약 노트 생성","approval_id":null},
    {"id":"alog2","time":"07:30","text":"출근 경로 확인 · 평소보다 4분 빠른 경로로 안내 예약","approval_id":null},
    {"id":"alog3","time":"06:00","text":"구독 결제 캘린더 동기화 (Netflix·Spotify 6/9)","approval_id":null},
    {"id":"alog4","time":"어제 23:10","text":"수면 모드 — 알림 음소거 · 내일 브리핑 예약","approval_id":null}
  ],
  "badges_appr": 3
}

PATCH /api/approvals/autonomy 본문 {"level":"approval_first"} → 응답 {"level":"approval_first"}. 직후 GET /api/approvalspending 6건 / done 0건(승인 우선은 전부 대기). {"level":"full_auto"}pending 0건 / done 6건(전부 자동, 빈 큐 → appr-empty 렌더).

POST /api/automation/parse 본문 {"text":"출장 전날엔 저녁 일정 비워줘"} (골든 예시):

{
  "matched": true, "name": "출장 전날 저녁 비우기", "cat": "cal",
  "parse": {"trigger":"출장 전날이 되면","cond":"18시 이후 일정이 있으면","action":"다른 날로 옮기자고 제안"},
  "model": "example", "confidence": 0.95
}

POST /api/automation/rules 본문(미리보기 결과 그대로):

{"name":"출장 전날 저녁 비우기","cat":"cal",
 "trigger":"출장 전날이 되면","cond":"18시 이후 일정이 있으면","action":"다른 날로 옮기자고 제안"}

응답:

{"id":"rule-9a8b7c6d","name":"출장 전날 저녁 비우기","cat":"cal",
 "trigger":"출장 전날이 되면","cond":"18시 이후 일정이 있으면","action":"다른 날로 옮기자고 제안",
 "on":true,"last":"방금 만듦","runs":0,"fresh":true,"source":"user"}

POST /api/automation/trigger?trigger_key=mail.newsletter → evaluator 가 r4(뉴스레터는 저녁에, low) 매칭 → autonomy=mixed 이므로 자동 실행(executed) + approval_log/run_log 추가 + approval.executed 발행. 응답 {"published":"automation.matched","trigger_key":"mail.newsletter"}.

5.3 lib/types.ts 대응(요약)

export type Risk = "low" | "high";
export type ApprovalStatus = "pending" | "approved" | "executed" | "undone";
export type Autonomy = "approval_first" | "mixed" | "full_auto";
export type Cat = "mail" | "cal" | "focus" | "life";

export interface ApprovalOut {
  id: string; icon: string; tone: string; risk: Risk; time: string;
  title: string; detail: string; cta: string; alt: string; undo_label: string;
  status: ApprovalStatus; source: string; rule_id: string | null; sort_order: number;
}
export interface ApprovalQueue {
  autonomy: Autonomy; saved_today: string; auto_count_night: number;
  pending: ApprovalOut[]; done: ApprovalOut[];
  log: { id: string; time: string; text: string; approval_id: string | null }[];
  badges_appr: number;
}
export interface Flow { trigger: string; cond: string | null; action: string; }
export interface RuleOut { id: string; name: string; cat: Cat; trigger: string;
  cond: string | null; action: string; on: boolean; last: string; runs: number; fresh: boolean; source: string; }
export interface ParsePreview { matched: boolean; name: string; cat: Cat; parse: Flow; model: string; confidence: number; }
export interface SuggestionOut { id: string; pattern: string; offer: Flow; offer_name: string; offer_cat: Cat; status: string; }
export interface RunLogOut { id: string; time: string; rule: string; text: string; undone: boolean; }
export interface AutomationPage { stats: { active: number; runs_week: number; saved: string };
  rules: RuleOut[]; suggests: SuggestionOut[]; log: RunLogOut[]; examples: ParsePreview[]; }

6. 디자인 충실도 노트

REF: design-reference/assets/approve.jsx, approve-page.jsx, approve.css, approve-data.js, auto.jsx, auto-data.js, auto.css, shell.jsx. 토큰은 phase-1 tokens.css(dash.css :root).

6.1 결재함 (approve.css)

  • lime CTA 헤더 아이콘: .appr .ico.lime { background: var(--lime); color: var(--lime-ink); }. 대기 카운트 .count.warm { color:#fff; background: var(--coral); }.
  • 승인 대기 행 .ap-row: padding:12px 13px; border-radius: var(--radius-sm); border:1px solid var(--glass-brd); background: var(--glass-2); hover→var(--card-2). 아이콘 박스 .ap-ic { width:34px; height:34px; border-radius:10px; background: color-mix(in oklab, var(--tone) 14%, transparent); color: var(--tone); }--tonevar(--${it.tone}) 인라인 주입.
  • 승인 버튼 .ap-ok: lime pill — color: var(--lime-ink); background: var(--lime); padding:8px 14px; border-radius:999px; hover var(--lime-hi) + translateY(-1px). 보조 .ap-alt(muted). 모두 승인 .ap-all(2건 이상): background: var(--fill); color: var(--on-fill); border-radius:13px;.
  • 빈 상태 .appr-empty: green tint 그라데이션 + .ae-tick(green 원형 체크). 문구 "결재할 게 없어요 — 아리가 알아서 하고 있어요. 아래에서 언제든 되돌릴 수 있어요." 정확 유지.
  • 처리됨/되돌리기 .appr-done: 라벨 .ad-label(uppercase, letter-spacing:0.05em), 행 .ad-row(border-top: 1px solid var(--line)), .ad-tick(green 16% 박스), 되돌리기 .ad-undo hover→coral.
  • 활동 로그 .appr-log: 시각 .al-time(mono, width:64px, 우측정렬), 텍스트 .al-text(muted). 페이지 단독 카드는 .appr-log.pg(상단 경계 제거).

6.2 자율성 카드 (approve.css)

  • .au-opt: gap:11px; align-items:flex-start; border:1px solid var(--glass-brd); background: var(--glass-2);. 선택 .au-opt.on: lime 22% 그라데이션 + border-color: color-mix(in oklab, var(--lime-ink) 26%, var(--glass-brd)); box-shadow: var(--shadow-sm);.
  • 라디오 .au-radio(18px 원형, on이면 border-color: var(--lime-ink) + 8px lime-ink dot). 추천 배지 .au-rec: color: var(--lime-ink); background: var(--lime);.
  • 노트 .auton-note: .ic coral. dim 변형은 muted. 문구 "어떤 모드든, 아리가 한 일은 전부 기록되고 되돌릴 수 있어요.", "보내기·결제·삭제는 자율 모드에서도 항상 로그에 강조 표시돼요." 정확 유지.
  • 모드 텍스트(approve-page.jsx): 승인 우선/혼합(추천)/완전 자율 3개의 title·desc를 토씨까지 유지. id는 백엔드 enum(approval_first/mixed/full_auto)으로 매핑하되 표시 라벨은 한국어 그대로.

6.3 자동화 (auto.css)

  • 풀폭 보드: .dash .board { grid-template-columns: 1fr; gap:16px; } — 카드 100% 폭(원본 명시). 작동 방식은 @media (min-width:900px)에서 .how-list 3열 그리드.
  • 토글 스위치 .sw: 42px×24px; border-radius:999px; background: var(--card-3); on→var(--green) + knob translateX(18px).
  • 플로우 칩 .fl-chip: 트리거 .tg .k(blue)·조건 .cd .k(amber)·동작 .ac .k(coral). 화살표 .fl-arr(faint). 컴팩트 변형 .rl-flow(목록용).
  • 규칙 행 .rl-row: gap:13px; border-top:1px solid var(--line); off→.rl-body opacity:0.5. 카테고리 배지 .rl-cat(흰 글자, AU_TONE[cat] 배경). 신규 .rl-new 애니메이션 rlin 0.5s.
  • 새 자동화 hero: 입력 .cmd(I d="pen" + input + .send 화살표 버튼, disabled opacity:0.4). 예시 칩 .chip(I d="spark"). 미리보기 빈 상태 .nv-empty + .nv-empty-ic(coral). "이 규칙 만들기" .rb-make-btn(fill).
  • 아리 제안 .sg-card: 패턴 .sg-pat(I d="spark" violet), 오퍼 .sg-offer(card 배경), 버튼 .sg-yes(fill)/.sg-no(glass).
  • 실행 기록 .lg-row: 시각 .lg-time(mono, 66px), 규칙 배지 .lg-rule(pill), 본문 .lg-text, 되돌리기 .lg-undo.
  • 토스트 .au-toast: position:fixed; bottom:28px; left:50%; transform:translateX(-50%); background: var(--fill); color: var(--on-fill); border-radius:999px; .ic lime. 애니메이션 toastin 0.3s.
  • eyebrow/타이틀(auto.jsx): 활성 규칙 N개 · 이번 주 31회 실행 · 1시간 40분 아꼈어요, 뷰별 AU_HEAD title/em 문구를 그대로(예 "내 규칙 — 켜두면 알아서 굴러가요").

7. 상태 처리 · 엣지 케이스

상황 처리
빈 큐(다 처리됨) pending.length===0.appr-empty 그린 카드("결재할 게 없어요 — 아리가 알아서 하고 있어요"). full_auto 레벨이면 시드 6건이 전부 done으로 가 빈 큐가 자연 발생.
자율성 approval_first derive_queue 가 모든 approval 을 pending 으로 → done 0건, "모두 승인" 버튼(≥2건) 노출. badges_appr=6.
자율성 mixed(기본) low 3건 done, high 3건 pending. badges_appr=3(원본 Topbar 배지와 일치).
자율성 full_auto low+high 전부 done → pending 0(빈 큐). 단, 사용자가 명시적으로 undo한 건은 pending 유지(override).
되돌리기 토스트 undo 후 큐 재로드. 카드가 done→pending 으로 이동(애니메이션은 CSS). 백엔드 status=undone, derive_queue 가 pending 으로 표시.
모두 승인 approve-all 은 현재 pending 전부 executed. 응답 {approved:n}. 직후 done 으로 이동.
파싱 실패(자유 입력) parse_rule 가 휴리스틱으로도 matched:false(빈 입력) 또는 낮은 confidence. 미리보기는 .nv-empty 유지, "이 규칙 만들기" disabled. LLM 오류 시 휴리스틱 폴백(예외 위로 던지지 않음).
규칙 토글 off r.on=false.rl-row.off(body opacity 0.5). evaluator match_rules 가 꺼진 규칙 제외. eyebrow 활성 수 감소.
제안 0건 suggests.length===0 → SubRail dot 사라짐 + .sg-empty("지금은 새 제안이 없어요. 패턴이 보이면 알려드릴게요.").
LLM(Ollama) 미가용 nl_parser.parse_rule_heuristic 폴백. model 필드로 표기(heuristic). 골든 예시 3건은 LLM 없이도 결정적(example).
high-risk 자동 실행 금지 full_auto 라도 evaluator enqueue_from_rule_risk_of(action)가 high면 항상 pending(보내기·결제·삭제·전달 키워드). 원본 "위험한 건 물어봐요" 규칙.
승인 후 재요청(idempotent) 이미 executed 인 건에 /undo → undone, 다시 /approve → executed. 상태 머신이 멱등 전이 허용.

8. 연합 이벤트 (발행 / 구독)

이 phase는 연합 루프의 엔진이다. event_bus(§3.4)를 통해 발행/구독하며, 이벤트 타입은 automation/events.py 상수.

8.1 이 phase가 발행하는 이벤트

이벤트 발행 시점 payload
automation.matched POST /api/automation/trigger(데모) 또는 phase-9/13 커넥터가 트리거 {trigger_key, ctx}
automation.suggested suggester 가 제안 생성(scan, notification.triaged 구독 경로 포함) {suggestion_id, offer_name, pattern}
approval.enqueued 규칙 동작이 승인 큐에 enqueue(enqueue_from_rule) {approval_id, rule_id?, risk, title, auto_run}
approval.executed low 자동 실행(enqueue_from_rule) 또는 사용자 승인(service.approve) {approval_id, rule_id?, risk, title}
approval.undone 사용자 되돌리기(service.undo) {approval_id, title}

8.2 이 phase가 구독하는 이벤트

이벤트 구독자 동작
automation.matched evaluator.on_matched 켜진 규칙 매칭 → enqueue_from_rule → approval enqueue(approval.enqueued 발행; low+mixed↑=자동 실행, 아니면 pending).
notification.triaged(phase-9) suggester.on_triaged 알림 "나중에" 반복 패턴 → scan → 제안 생성 + automation.suggested 발행. (수동 scan 도 동일 경로.)
capture.classified(phase-4) (선택) suggester 입력 인박스 분류 반복 패턴을 제안 후보로(스캔은 수동).

8.3 다른 phase로의 연계 지점

[automation.matched]  ──evaluator──▶  [approval.enqueued]  ──derive_queue──▶  결재함 페이지
                                              │
                                   low+mixed↑ │ 자동 실행
                                              ▼
                                   [approval.executed]  ──▶  phase-12 하루 마감(wrap)
                                                              "오늘 아리가 처리한 N건/아낀 시간" 집계 입력
                                                              (saved_today/autoCountNight 의 동적 소스)

[알림 "나중에" 반복]  ──phase-9──▶  [notification.triaged]  ──suggester──▶  [automation.suggested]
   (phase-9 알림 트리아지가 "18시 이후 메일 14번 나중에" 같은 패턴을 발행하면
    이 phase 의 suggester 가 notification.triaged 를 구독해 's2: 퇴근 후 메일은 아침에' 류 제안을
    만들고 automation.suggested 를 발행한다 — 시드 s2 가 그 예시)
  • phase-12(하루 마감): approval.executed/approval.undone 누계가 wrap의 "오늘 자동 처리 N건·아낀 시간" 집계 입력. 현재 saved_today="47분"·auto_count_night=7은 상수지만, wrap이 이벤트 누계로 동적화한다(연계 지점만 명시, 구현은 phase-12).
  • phase-9(메일·알림): 알림 "나중에" 패턴(notification.triaged)을 suggester 가 구독해 자동화 제안을 생성하고 automation.suggested 를 발행. 시드 s2("퇴근 후 메일은 아침에")가 그 결과 형태.
  • phase-14(능동 에이전트): worker/ 스케줄러가 suggester.scan·evaluator 평가를 백그라운드로 주기 실행(이 phase는 수동 트리거 엔드포인트로 대체).

9. 테스팅 & 검증

9.1 실행 명령

# 백엔드 (backend/ 에서)
uv run alembic upgrade head
uv run python -m app.seed
uv run pytest -q                                   # 전체
uv run pytest tests/test_approvals_service.py -q   # 큐 분기/상태 전이
uv run pytest tests/test_nl_parser.py -q           # 자연어 파서 골든
uv run pytest tests/test_evaluator.py -q           # event_bus → evaluator → enqueue
uv run pytest -k "automation or approvals" -q
uv run uvicorn app.main:app --reload --port 8000

# 수동 확인(curl)
curl -s localhost:8000/api/approvals | jq '{autonomy, pending:(.pending|length), done:(.done|length)}'
curl -s -X PATCH localhost:8000/api/approvals/autonomy -H 'content-type: application/json' -d '{"level":"approval_first"}'
curl -s localhost:8000/api/approvals | jq '{pending:(.pending|length), done:(.done|length)}'   # 6,0
curl -s -X POST localhost:8000/api/automation/parse -H 'content-type: application/json' -d '{"text":"출장 전날엔 저녁 일정 비워줘"}' | jq
curl -s -X POST 'localhost:8000/api/automation/trigger?trigger_key=mail.newsletter' | jq

# 프론트 (frontend/ 에서)
pnpm vitest run                                    # 컴포넌트 단위
pnpm playwright test approvals.spec.ts automation.spec.ts
pnpm playwright test --grep @a11y                  # axe

9.2 pytest — 승인 서비스 / 상태 전이 (test_approvals_service.py)

def test_derive_queue_mixed(client):
    q = client.get("/api/approvals").json()
    assert q["autonomy"] == "mixed"
    assert len(q["pending"]) == 3 and len(q["done"]) == 3   # high 3 대기, low 3 자동
    assert {a["risk"] for a in q["pending"]} == {"high"}
    assert {a["risk"] for a in q["done"]} == {"low"}

def test_autonomy_approval_first_all_pending(client):
    client.patch("/api/approvals/autonomy", json={"level": "approval_first"})
    q = client.get("/api/approvals").json()
    assert len(q["pending"]) == 6 and len(q["done"]) == 0
    assert q["badges_appr"] == 6

def test_autonomy_full_auto_empty_queue(client):
    client.patch("/api/approvals/autonomy", json={"level": "full_auto"})
    q = client.get("/api/approvals").json()
    assert len(q["pending"]) == 0 and len(q["done"]) == 6   # 빈 큐 → appr-empty

def test_invalid_autonomy_422(client):
    r = client.patch("/api/approvals/autonomy", json={"level": "bogus"})
    assert r.status_code == 422

def test_approve_and_undo_cycle(client):
    a4 = client.post("/api/approvals/a4/approve").json()
    assert a4["status"] == "executed"
    back = client.post("/api/approvals/a4/undo").json()
    assert back["status"] == "undone"
    q = client.get("/api/approvals").json()        # undone → 다시 pending 으로 표시
    assert any(a["id"] == "a4" for a in q["pending"])

def test_approve_all(client):
    n = client.post("/api/approvals/approve-all").json()["approved"]
    assert n == 3                                   # mixed 의 high 3건
    q = client.get("/api/approvals").json()
    assert len(q["pending"]) == 0

def test_saved_and_count_constants(client):
    q = client.get("/api/approvals").json()
    assert q["saved_today"] == "47분" and q["auto_count_night"] == 7
    assert len(q["log"]) == 4

9.3 pytest — 자연어 파서 (test_nl_parser.py)

import pytest
from app.automation.nl_parser import parse_rule

GOLDEN = [
    ("출장 전날엔 저녁 일정 비워줘", "cal", "출장 전날이 되면", "18시 이후 일정이 있으면", "다른 날로 옮기자고 제안"),
    ("뉴스레터는 모아서 저녁에 보여줘", "mail", "뉴스레터 메일 도착", None, "받은편지함 건너뛰고 18:30 다이제스트로"),
    ("운동을 3일 거르면 산책 잡아줘", "life", "운동 기록 3일 연속 없음", "저녁에 빈 시간이 있으면", "30분 산책 블록 제안"),
]

@pytest.mark.parametrize("text,cat,trig,cond,action", GOLDEN)
def test_golden_examples(text, cat, trig, cond, action):
    r = parse_rule(text)
    assert r.matched and r.cat == cat
    assert r.trigger == trig and r.cond == cond and r.action == action
    assert r.model == "example"

def test_heuristic_fallback_classifies_cat():
    r = parse_rule("영수증 들어오면 자동으로 정리해줘")   # examples 미일치 → 휴리스틱
    assert r.matched and r.cat == "life" and r.model == "heuristic"

def test_empty_input_not_matched():
    r = parse_rule("")
    assert not r.matched

LLM 경로는 provider.generate_json를 모킹(monkeypatch)해 JSON 반환을 검증하고, 모킹 예외 시 휴리스틱 폴백이 동작하는지 확인한다(phase-2 ollama 모킹 패턴 재사용).

9.4 pytest — evaluator / event_bus (test_evaluator.py)

from app.automation.event_bus import EventBus
from app.automation import evaluator
from app.automation.events import AUTOMATION_MATCHED

def test_low_rule_auto_executes_under_mixed(client, session):
    s, engine = session
    bus = EventBus()
    def factory():
        from sqlmodel import Session
        return Session(engine)
    evaluator.register(bus, factory)
    bus.publish(AUTOMATION_MATCHED, {"trigger_key": "mail.뉴스레터", "ctx": {"title": "뉴스레터 묶음"}})
    q = client.get("/api/approvals").json()
    # 새 자동 실행 approval(source=automation, low) 이 done 에 추가됨
    autos = [a for a in q["done"] if a["source"] == "automation"]
    assert autos, "low 규칙이 mixed 에서 자동 실행되어 done 큐에 들어가야 한다"

def test_high_action_always_pending(client, session):
    s, engine = session
    bus = EventBus()
    from app.models import AutomationRule
    s.add(AutomationRule(id="rh", name="송금 자동", cat="life",
          trigger="청구서 도착", cond=None, action="자동 송금하기", on=True)); s.commit()
    def factory():
        from sqlmodel import Session
        return Session(engine)
    evaluator.register(bus, factory)
    bus.publish(AUTOMATION_MATCHED, {"trigger_key": "life.청구서", "ctx": {}})
    q = client.get("/api/approvals").json()
    assert any(a["source"] == "automation" and a["risk"] == "high" for a in q["pending"])

def test_approval_executed_event_published():
    bus = EventBus()
    bus.publish(AUTOMATION_MATCHED, {"trigger_key": "x.y"})
    assert bus.history[-1].type == AUTOMATION_MATCHED

9.5 pytest — suggester / 자동화 API (test_suggester.py, test_api_automation.py)

def test_automation_page_seed(client):
    p = client.get("/api/automation").json()
    assert len(p["rules"]) == 7
    assert sum(1 for r in p["rules"] if r["on"]) == 6        # r6 꺼짐
    assert len(p["suggests"]) == 2 and len(p["log"]) == 6
    assert p["stats"] == {"active": 6, "runs_week": 31, "saved": "1시간 40분"}  # active=켜진 수
    assert len(p["examples"]) == 3

def test_create_rule_fresh(client):
    r = client.post("/api/automation/rules", json={
        "name": "출장 전날 저녁 비우기", "cat": "cal",
        "trigger": "출장 전날이 되면", "cond": "18시 이후 일정이 있으면", "action": "다른 날로 옮기자고 제안"}).json()
    assert r["fresh"] is True and r["on"] is True and r["last"] == "방금 만듦"
    p = client.get("/api/automation").json()
    assert any(x["name"] == "출장 전날 저녁 비우기" for x in p["rules"])

def test_toggle_rule(client):
    before = client.get("/api/automation").json()["rules"]
    r1 = next(x for x in before if x["id"] == "r1")
    t = client.post("/api/automation/rules/r1/toggle").json()
    assert t["on"] == (not r1["on"])

def test_accept_suggestion_creates_rule(client):
    r = client.post("/api/automation/suggestions/asug-s1/accept").json()
    assert r["source"] == "suggestion" and r["name"] == "주간 리포트 미리 준비"
    p = client.get("/api/automation").json()
    assert all(g["id"] != "asug-s1" for g in p["suggests"])   # open 목록에서 빠짐

def test_dismiss_suggestion(client):
    client.post("/api/automation/suggestions/asug-s2/dismiss")
    p = client.get("/api/automation").json()
    assert all(g["id"] != "asug-s2" for g in p["suggests"])

def test_parse_endpoint_golden(client):
    r = client.post("/api/automation/parse", json={"text": "뉴스레터는 모아서 저녁에 보여줘"}).json()
    assert r["matched"] and r["cat"] == "mail" and r["parse"]["cond"] is None

9.6 Vitest — 결재 카드 · 자율성 토글 · 규칙 토글

// frontend/tests/ApprovalCard.test.tsx
it("pending 0이면 빈 상태 문구를 보여준다", () => {
  render(<ApprovalCard saved="47분" pending={[]} done={[]} onApprove={()=>{}} onUndo={()=>{}} onApproveAll={()=>{}} />);
  expect(screen.getByText(/결재할 게 없어요/)).toBeInTheDocument();
});
it("high 카드의 cta/alt 라벨을 렌더한다", () => {
  const p=[{id:"a4",icon:"mail",tone:"violet",risk:"high",time:"보내기 대기",title:"현우님께 회신 초안이 준비됐어요",detail:"d",cta:"보내기",alt:"수정",undo_label:"",status:"pending",source:"mail",rule_id:null,sort_order:3}];
  render(<ApprovalCard saved="47분" pending={p} done={[]} onApprove={()=>{}} onUndo={()=>{}} onApproveAll={()=>{}} />);
  expect(screen.getByText("보내기")).toBeInTheDocument();
  expect(screen.getByText("수정")).toBeInTheDocument();
});
it("2건 이상이면 모두 승인 버튼", () => {
  const p=[{...base,id:"a4"},{...base,id:"a5"}] as any;
  render(<ApprovalCard saved="47분" pending={p} done={[]} onApprove={()=>{}} onUndo={()=>{}} onApproveAll={()=>{}} />);
  expect(screen.getByText(/모두 승인 \(2건\)/)).toBeInTheDocument();
});

// AutonomyCard.test.tsx
it("선택 모드에 on 클래스, 클릭 시 콜백", () => {
  const onPick = vi.fn();
  render(<AutonomyCard autonomy="mixed" onPick={onPick} />);
  expect(screen.getByText("혼합").closest(".au-opt")).toHaveClass("on");
  fireEvent.click(screen.getByText("완전 자율"));
  expect(onPick).toHaveBeenCalledWith("full_auto");
});

// RulesView.test.tsx
it("토글 스위치 클릭 시 toggle(id) 호출", () => {
  const toggle = vi.fn();
  render(<RulesView rules={[{id:"r1",name:"수아님 메일은 바로",cat:"mail",trigger:"t",cond:null,action:"a",on:true,last:"오늘 09:12",runs:9,fresh:false,source:"user"}]} toggle={toggle} />);
  fireEvent.click(screen.getByLabelText(/수아님 메일은 바로 끄기/));
  expect(toggle).toHaveBeenCalledWith("r1");
});

9.7 Playwright — E2E

// frontend/playwright/automation.spec.ts
test("자연어 → 규칙 생성 → 목록 복귀 + 토스트", async ({ page }) => {
  await page.goto("/automation");
  await page.getByLabel("새 자동화").click();
  await page.getByText("출장 전날엔 저녁 일정 비워줘").click();      // 예시 칩
  await expect(page.getByText("아리가 이렇게 이해했어요")).toBeVisible();
  await expect(page.getByText("출장 전날이 되면")).toBeVisible();     // 트리거 칩
  await page.getByRole("button", { name: "이 규칙 만들기" }).click();
  await expect(page.locator(".au-toast")).toContainText("‘출장 전날 저녁 비우기’ 규칙을 켰어요.");
  await expect(page.locator(".rl-row.rl-new")).toBeVisible();          // 목록에 새 규칙
});

test("규칙 토글 끄면 off 스타일", async ({ page }) => {
  await page.goto("/automation");
  const row = page.locator(".rl-row", { hasText: "수아님 메일은 바로" });
  await row.getByRole("button").first().click();                      // sw 토글
  await expect(row).toHaveClass(/off/);
});

// frontend/playwright/approvals.spec.ts
test("mixed: low 자동(되돌리기) / high 승인 대기", async ({ page }) => {
  await page.goto("/approvals");
  await expect(page.locator(".appr-list .ap-row")).toHaveCount(3);     // high 3 대기
  await expect(page.locator(".appr-done .ad-row")).toHaveCount(3);     // low 3 처리됨
  await expect(page.getByText("보내기")).toBeVisible();
});

test("되돌리기 → 대기로 이동", async ({ page }) => {
  await page.goto("/approvals");
  await page.locator(".ad-row", { hasText: "치과 예약을 16:00로" }).getByRole("button").click();
  await expect(page.locator(".ap-row", { hasText: "치과 예약을 16:00로" })).toBeVisible();
});

test("자율성 레벨 변경 → 큐 재구성", async ({ page }) => {
  await page.goto("/approvals");
  await page.getByText("승인 우선").click();
  await expect(page.locator(".appr-list .ap-row")).toHaveCount(6);     // 전부 대기
  await page.getByText("완전 자율").click();
  await expect(page.getByText(/결재할 게 없어요/)).toBeVisible();      // 빈 큐
});

9.8 수동 QA 체크리스트

  • 결재함 진입 시 eyebrow "오늘 47분 아껴드렸어요 · 밤사이 자동 처리 7건", 타이틀 "아리 결재함 확인만 하면 끝".
  • mixed: 승인 대기 3건(현우 회신/민서 위임/Netflix), 처리됨 3건(치과/영수증/딥워크), 활동 로그 4건.
  • "모두 승인 (3건)" 클릭 → pending 비고 빈 상태 카드.
  • 처리됨 행의 되돌리기 라벨이 카드별로 다름("원래 시간으로"/"되돌리기"/"블록 해제").
  • 자율성 "승인 우선"→6건 대기, "완전 자율"→빈 큐, "혼합"→3/3. 추천 배지는 혼합에만.
  • 자동화 eyebrow "활성 규칙 6개 · 이번 주 31회 실행 · 1시간 40분 아꼈어요"(r6 꺼짐 반영).
  • SubRail 4개(내 규칙/새 자동화/아리 제안[dot]/실행 기록). 제안 2건이면 dot 표시.
  • 새 자동화: 예시 칩 3개, 입력 → 미리보기(트리거→조건→동작), "이 규칙 만들기" → 목록 + 토스트 + rl-new 애니메이션.
  • 카테고리 배지 색: 메일=blue, 일정=violet, 집중=coral, 라이프=green.
  • 다크 모드 토글 시 글래스/색 토큰 정상, 라임 CTA 가독.
  • axe: 결재함/자동화 위반 0. 토글 aria-label, 자율성 옵션 aria-pressed, SubRail aria-current.

9.9 통과 기준

  • 백엔드 pytest 전부 green: derive_queue(mixed 3/3, approval_first 6/0, full_auto 0/6), 상태 전이(approve/undo/approve_all), nl_parser 골든 3건 + 휴리스틱 폴백, evaluator low 자동/high 대기, 자동화 API 시드 기대값.
  • Vitest: 결재 카드 빈/충만, 자율성 토글, 규칙 토글 전부 green.
  • Playwright: 자연어→규칙 생성→복귀+토스트, low 자동/high 대기, 되돌리기, 자율성 레벨 재구성 전부 green. axe 위반 0.
  • 원본 문구/색/px가 §6 인용과 1바이트도 다르지 않음(savedToday 47분, autoCountNight 7, rules 7건/켜짐 6, runs_week 31, saved "1시간 40분").

10. 완료 기준 (DoD)

  • models.py에 Approval 확장 + AutonomySetting/ApprovalLog/AutomationRule/AutomationSuggestion/AutomationRunLog/AutomationStats 추가, Alembic 왕복 통과(downgrade -1 && upgrade head).
  • automation/(event_bus·events·evaluator·nl_parser·suggester)·approvals/service.py 구현, 명명이 post-mvp-overview.md 횡단 아키텍처와 일치.
  • routers/approvals.py·routers/automation.py가 §5.1 엔드포인트 전부 제공, main.pyprefix="/api" 등록(라우터 내부 prefix 없음).
  • seed.py가 REF(approve-data.js/auto-data.js) 값을 정확 이식(a1~a6, log 4, rules r1~r7, suggests s1/s2, run_log 6, stats), run_seed(session,reset) 단일 진입점 유지.
  • 결재함 페이지(/approvals)·자동화 페이지(/automation)가 원본 픽셀 충실 재현(approve.css/auto.css 이식, SubRail 4뷰, 풀폭 카드).
  • 자율성 레벨이 큐를 실시간 재구성(derive_queue), low 자동 실행+되돌리기, high 승인 대기 동작.
  • 연합: automation.matchedapproval.enqueued, approval.executed 발행, suggester 가 notification.triaged 구독 + automation.suggested 발행(하루 마감 연계 지점 문서화).
  • LLM 자연어 파서 + 휴리스틱 폴백, 골든 예시 3건 결정적, 패턴 탐지 수동 트리거(스케줄러는 phase-14 명시).
  • §9 모든 테스트 green, 수동 QA 체크리스트 통과, axe 위반 0.

11. 다음 단계

다음 문서: phase-8-calendar-meetings.md — 일정 + 회의 도우미. 회의 종료(meeting.ended) → 액션 아이템 추출 → 작업 실체화 연합, 일정↔작업↔집중 모드(focus_block) 연동. 이 phase의 approval/automation_rule(특히 cal/focus 카테고리: "30분 빈틈 채우기", "딥 워크 보호막", "출장 전날 저녁 비우기")이 일정 페이지의 자동 배치·집중 블록과 직접 연동된다. evaluator 의 cal 트리거(일정 사이 빈틈, 회의 초대)가 phase-8 일정 데이터를 소비하는 첫 실연동 예시가 된다.