|
|
# 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:<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` 연합 이벤트 모델을 그대로 쓴다.
|
|
|
|
|
|
```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 (
|
|
|
<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 이식)
|
|
|
|
|
|
```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 (
|
|
|
<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` 훅
|
|
|
|
|
|
```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<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 };
|
|
|
}
|
|
|
```
|
|
|
|
|
|
```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 <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.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 (
|
|
|
<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: "라이프" };
|
|
|
```
|
|
|
|
|
|
```tsx
|
|
|
// 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`).
|
|
|
|
|
|
```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<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건 대기):
|
|
|
```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(<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
|
|
|
|
|
|
```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 일정 데이터를 소비하는 첫 실연동 예시가 된다.
|