# Phase 7 — 자율성 코어: 결재함 + 자동화 엔진 > 한 줄 요약: 아리의 제품 철학 **"할까요?가 아니라 이미 해뒀어요"** 를 실제 동작으로 옮긴다 — 승인 큐(**결재함**)와 자연어 한 문장으로 만드는 **자동화 규칙 엔진**(event_bus·evaluator·nl_parser·suggester)을 백엔드와 프론트로 구현하고, `automation.matched → approval.enqueued → approval.executed → 하루 마감 집계` 연합 루프의 첫 고리를 완성한다. > 이 문서는 **포스트-MVP 세트의 일부** — 먼저 `dev/overview.md` 와 `dev/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. [개요 & 목표](#1-개요--목표) 2. [선행조건 · 산출물](#2-선행조건--산출물) 3. [상세 구현 — 백엔드](#3-상세-구현--백엔드) 4. [상세 구현 — 프론트엔드](#4-상세-구현--프론트엔드) 5. [데이터 / 타입 / API 계약](#5-데이터--타입--api-계약) 6. [디자인 충실도 노트](#6-디자인-충실도-노트) 7. [상태 처리 · 엣지 케이스](#7-상태-처리--엣지-케이스) 8. [연합 이벤트 (발행 / 구독)](#8-연합-이벤트-발행--구독) 9. [테스팅 & 검증](#9-테스팅--검증) 10. [완료 기준 (DoD)](#10-완료-기준-dod) 11. [다음 단계](#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.matched` → `approval.enqueued`, `approval.executed` → `wrap`(하루 마감) 집계 입력. 알림 `notification.triaged` → `suggester` → `automation.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`: *"반복되는 손길을 발견하면 새 규칙을 먼저 제안해요."* | `suggester` 가 `approval_log`/`automation_run_log` 패턴 → `automation_suggestion`. (스케줄러 자동 실행은 phase-14, 여기선 수동 트리거 엔드포인트.) | --- ## 2. 선행조건 · 산출물 ### 2.1 선행조건 (의존 phase) | 의존 | 상속받는 것 | |---|---| | `phase-2-backend.md` | `models.py`/`schemas.py` 패턴, 라우터 prefix 규약(라우터 내부 prefix 없음 → `main.py`의 `include_router(prefix="/api")`), `seed.py`의 `run_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"`. ```python # 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)으로, 표시 라벨은 프론트가 매핑한다. ```python # 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는 **컬럼 추가 + 신규 테이블 생성**이다. ```python # 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) ```python # 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:" | "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` 연합 이벤트 모델을 그대로 쓴다. ```python # 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 구독) ``` ```python # 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` 연합 고리다. ```python # 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개 문장은 **골든 케이스**로 정확히 재현한다. ```python # 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`의 단순 빈도 패턴으로 후보를 만든다(데모는 결정적 규칙). ```python # 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.jsx`의 `initStatus(autonomy)`를 백엔드 `derive_queue()`로 이식한다. 이것이 **자율성 레벨이 큐를 실시간 재구성**하는 정본 로직이다. ```python # 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 ``` > `approve` 와 `execute` 의 차이: high-risk 카드는 사용자가 `cta`(보내기/전달/일시정지)를 누르면 `approve`→실행으로 이어진다. 의미상 동일 전이(`pending→executed`)이며, API는 명확성을 위해 `/approve` 와 `/execute` 를 모두 제공하되 둘 다 `service.approve()` 로 귀결한다(low 자동 실행 카드는 이미 executed라 `/undo`만 의미 있음). ### 3.9 라우터 — `routers/approvals.py` ```python # 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` ```python # 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_dashboard`의 `Approval` 시드(a1~a6)는 status/source 필드만 추가로 채우도록 통합**한다(중복 적재 금지 — `_seed_approvals`로 단일화). ```python # 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:00–11: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]` 중 `결재함`/`자동화` 두 항목을 실제 라우트로 승격한다. `Topbar`의 `MAIN`(13항목)은 그대로 유지하되 `appr`→`/approvals`, `auto`→`/automation` 로 연결. 결재함 badge `3`은 `GET /api/approvals` 의 `badges_appr` 로 동적화 가능(기본은 원본 상수 3과 일치). ``` frontend/app/approvals/page.tsx # current="appr" frontend/app/automation/page.tsx # current="auto" ``` ### 4.2 결재함 — `ApprovalCard.tsx` (approve.jsx 이식) 원본 `approve.jsx`의 `initStatus`/pending/done/approve/undo/approveAll 로직과 마크업을 1:1로 옮기되, 상태 소스는 백엔드 `GET /api/approvals`(이미 derive_queue 적용된 pending/done)로 바꾼다. 클래스명·문구·아이콘은 그대로. ```tsx // 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 (

아리 결재함

확인만 하면 끝 · 오늘 {saved} 아껴드렸어요
{pending.length > 0 ? 대기 {pending.length}건 : 모두 처리됨}
{/* 승인 대기 */} {pending.length === 0 ? (
결재할 게 없어요 — 아리가 알아서 하고 있어요. 아래에서 언제든 되돌릴 수 있어요.
) : (
{pending.map((it) => (
{it.title}
{it.detail}
))} {pending.length >= 2 && ( )}
)} {/* 자동 처리됨 (되돌리기) */} {done.length > 0 && (
처리됨 — 탭 한 번으로 되돌릴 수 있어요
{done.map((it) => (
{it.title} {it.time}
))}
)} {/* 활동 로그(대시보드 위젯에서만 showLog=true) */} {showLog && log && (
밤사이 조용히 한 일 {autoCountNight}건
{log.map((l, i) => (
{l.time} {l.text}
))}
)}
); } ``` ### 4.3 자율성 카드 — `AutonomyCard.tsx` (approve-page.jsx MODES 이식) ```tsx // 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 (

아리 자율성

어디까지 맡길지 정해요
{MODES.map((m) => ( ))}
어떤 모드든, 아리가 한 일은 전부 기록되고 되돌릴 수 있어요.
); } ``` ### 4.4 결재함 페이지 + `useApprovals` 훅 ```tsx // 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(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 }; } ``` ```tsx // 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
; return (
오늘 {data.saved_today} 아껴드렸어요 밤사이 자동 처리 {data.auto_count_night}건

아리 결재함 확인만 하면 끝

{/* 활동 로그 카드 — approve-page.jsx 와 동일 */}

활동 로그

아리가 조용히 한 일
{data.auto_count_night}건
{data.log.map((l) => (
{l.time} {l.text}
))}
보내기·결제·삭제는 자율 모드에서도 항상 로그에 강조 표시돼요.
); } ``` ### 4.5 자동화 페이지 — `Flow.tsx` + 4뷰 + `useAutomation` 원본 `auto.jsx`의 `Flow`, `RulesView`, `NewView`, `SuggestView`, `LogView`, `AU_RAIL`, `AU_HEAD`, 토스트를 React 컴포넌트로 옮긴다. `SubRail`은 phase-1의 공용 컴포넌트. 카드는 `auto.css`의 `.board { grid-template-columns: 1fr; }`로 **100% 폭**. ```tsx // 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 (
트리거{p.trigger} {p.cond && (<> 조건{p.cond} )} 동작{p.action}
); } export const AU_TONE: Record = { mail: "var(--blue)", cal: "var(--violet)", focus: "var(--coral)", life: "var(--green)" }; export const AU_CAT: Record = { mail: "메일", cal: "일정", focus: "집중", life: "라이프" }; ``` ```tsx // frontend/lib/hooks/useAutomation.ts (요약) export function useAutomation() { const [page, setPage] = useState(null); const load = useCallback(async () => setPage(await api.get("/automation")), []); useEffect(() => { load(); }, [load]); const parse = (text: string) => api.post("/automation/parse", { text }) as Promise; 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`) 뷰로 전환 + 토스트 `'' 규칙을 켰어요.`(원본 문구) + `rl-new` 애니메이션(백엔드 `fresh=true`). ```tsx // 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 = { 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(null); const { page, parse, createRule, toggle, accept, dismiss } = useAutomation(); if (!page) return
; 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 (
활성 규칙 {activeN}개 이번 주 {page.stats.runs_week}회 실행 {page.stats.saved} 아꼈어요

{h.title} {h.em}

{/* view 별 RulesView / NewView / SuggestView / LogView 렌더 (생략) */}
{toast &&
{toast}
}
); } ``` > 신규 아이콘 가드: `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건 대기): ```json { "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/approvals` 는 **pending 6건 / done 0건**(승인 우선은 전부 대기). `{"level":"full_auto"}` 면 **pending 0건 / done 6건**(전부 자동, 빈 큐 → `appr-empty` 렌더). `POST /api/automation/parse` 본문 `{"text":"출장 전날엔 저녁 일정 비워줘"}` (골든 예시): ```json { "matched": true, "name": "출장 전날 저녁 비우기", "cat": "cal", "parse": {"trigger":"출장 전날이 되면","cond":"18시 이후 일정이 있으면","action":"다른 날로 옮기자고 제안"}, "model": "example", "confidence": 0.95 } ``` `POST /api/automation/rules` 본문(미리보기 결과 그대로): ```json {"name":"출장 전날 저녁 비우기","cat":"cal", "trigger":"출장 전날이 되면","cond":"18시 이후 일정이 있으면","action":"다른 날로 옮기자고 제안"} ``` 응답: ```json {"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` 대응(요약) ```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); }` — `--tone` 은 `var(--${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 실행 명령 ```bash # 백엔드 (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`) ```python 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`) ```python 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`) ```python 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`) ```python 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 — 결재 카드 · 자율성 토글 · 규칙 토글 ```tsx // frontend/tests/ApprovalCard.test.tsx it("pending 0이면 빈 상태 문구를 보여준다", () => { render({}} 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({}} onUndo={()=>{}} onApproveAll={()=>{}} />); expect(screen.getByText("보내기")).toBeInTheDocument(); expect(screen.getByText("수정")).toBeInTheDocument(); }); it("2건 이상이면 모두 승인 버튼", () => { const p=[{...base,id:"a4"},{...base,id:"a5"}] as any; render({}} onUndo={()=>{}} onApproveAll={()=>{}} />); expect(screen.getByText(/모두 승인 \(2건\)/)).toBeInTheDocument(); }); // AutonomyCard.test.tsx it("선택 모드에 on 클래스, 클릭 시 콜백", () => { const onPick = vi.fn(); render(); 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(); fireEvent.click(screen.getByLabelText(/수아님 메일은 바로 끄기/)); expect(toggle).toHaveBeenCalledWith("r1"); }); ``` ### 9.7 Playwright — E2E ```ts // 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.py`에 `prefix="/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.matched`→`approval.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 일정 데이터를 소비하는 첫 실연동 예시가 된다.