|
|
# Phase 9 — 메일 + 알림 트리아지
|
|
|
|
|
|
> 한 줄 요약: 3계정 통합 메일(아리가 새 메일을 읽고 **요약·우선순위·분류·할일/일정 추출→작업·캘린더 전송·회신 초안**)과 알림 트리아지(**지금 / 나중에 / 아리가 처리** 3분류 + 집중 보호·묶음 배달 + 발신자 규칙, 분류 *이유 칩* + *되돌리기*)를 mock-first 커넥터·LLM provider(요약/분류/추출/초안, heuristic 폴백) 위에 구현하고, **메일→작업/일정(federation)**·**알림 "나중에" 반복→자동화 제안**·**메일 회신 발송=high-risk→결재함** 연합을 완성한다.
|
|
|
|
|
|
> 이 문서는 포스트-MVP 세트의 일부 — 먼저 `dev/overview.md` 와 `dev/post-mvp-overview.md` 를 읽으세요.
|
|
|
> 선행: `phase-2-backend.md`(모델/스키마/라우터/시드/LLM 추상화 규약), `phase-7-approvals-automation.md`(결재함 approval·autonomy·event_bus·suggester), `phase-8-calendar-meetings.md`(calendar/event 모델, 액션→작업). 후속: `phase-10-research-travel.md`.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 0. 목차
|
|
|
|
|
|
1. [개요 & 목표](#1-개요--목표)
|
|
|
2. [선행 조건 / 산출물](#2-선행-조건--산출물)
|
|
|
3. [상세 구현 — 백엔드](#3-상세-구현--백엔드)
|
|
|
4. [상세 구현 — 메일 프론트엔드](#4-상세-구현--메일-프론트엔드)
|
|
|
5. [상세 구현 — 알림 트리아지 프론트엔드](#5-상세-구현--알림-트리아지-프론트엔드)
|
|
|
6. [데이터 / 타입 / API 계약](#6-데이터--타입--api-계약)
|
|
|
7. [디자인 충실도 노트](#7-디자인-충실도-노트)
|
|
|
8. [상태 처리 · 엣지 케이스 · 오프라인](#8-상태-처리--엣지-케이스--오프라인)
|
|
|
9. [연합 이벤트 (발행/구독)](#9-연합-이벤트-발행구독)
|
|
|
10. [테스팅 & 검증](#10-테스팅--검증)
|
|
|
11. [완료 기준 (Definition of Done)](#11-완료-기준-definition-of-done)
|
|
|
12. [다음 단계](#12-다음-단계)
|
|
|
|
|
|
---
|
|
|
|
|
|
## 1. 개요 & 목표
|
|
|
|
|
|
이 phase가 끝나면 두 페이지가 동작한다 — **메일(`/mail`)** 과 **알림(`/notifications`)**.
|
|
|
|
|
|
### 1.1 메일 — "여러 계정을 한 곳에서, 아리가 미리 읽어둔다"
|
|
|
|
|
|
원본 `REF/assets/mail.jsx`의 3분할 레이아웃(좌 사이드바=계정/폴더, 가운데=메일 목록, 우=리딩+아리 분석)을 픽셀 충실하게 재현한다. 핵심 가치는 **아리가 새 메일을 읽고 할 일·일정·답장·정리를 미리 준비**하는 것 — 제품 철학 "할까요?가 아니라 이미 해뒀어요"의 메일판:
|
|
|
|
|
|
- **3계정 통합 수신**: `work`(회사 · Lumi Workspace), `personal`(개인 · Google), `side`(사이드 · HEY). 전체 받은편지함 + 계정별 필터.
|
|
|
- **아리 정리(smart view)**: 새 메일을 읽고 **할 일 / 일정 / 답장 초안**으로 트리아지한 히어로 화면(원본 `Triage`).
|
|
|
- **AI 분석 패널**: 메일을 열면 `ai{summary, priority, category, tasks[], events[], replies[], file}`를 카드로 보여주고, 각 제안을 **작업/일정으로 한 번에 추가**(`POST /api/mail/{id}/extract`로 실체화 → 작업 페이지·일정 페이지에 등장)하거나 **회신 초안을 작성창에 채워** 보낼 수 있다.
|
|
|
- **회신 발송 = high-risk**: 보내기는 `phase-7` 결재함으로 흘러간다("이미 초안은 써뒀어요, 보내기만 확인").
|
|
|
|
|
|
### 1.2 알림 — "중요한 것만 울려요"
|
|
|
|
|
|
원본 `REF/assets/notify.jsx`의 SubRail 3뷰를 재현한다:
|
|
|
|
|
|
- **트리아지**: `now`(지금) / `later`(나중에) / `held`(아리가 처리) 3버킷 + 오늘 요약(`stats: total/seen/filtered`). 각 알림에는 **왜 그렇게 분류했는지 이유 칩**(`why` + `tone`)과 **되돌리기**(held→now), **나중에로 미루기**(now→later) 액션.
|
|
|
- **집중 보호 · 묶음 배달**: 딥 워크 보호(`guard.on/until/held`), 조용한 시간(`quiet`), 묶음 배달(`digests`).
|
|
|
- **발신자 규칙**: 누구는 즉시, 누구는 다이제스트로(`senders`).
|
|
|
|
|
|
### 1.3 이 phase가 실현하는 연합(federation)
|
|
|
|
|
|
| 연합 | 흐름 | 관련 phase |
|
|
|
|---|---|---|
|
|
|
| 메일→작업 | `mail.received` → 아리 분석 → `POST /api/mail/{id}/extract`(task) → `task` 생성 → 작업 페이지 등장 | `phase-3-tasks.md` |
|
|
|
| 메일→일정 | 동일 extract(event) → `event` 생성 → 일정 페이지 등장 | `phase-8-calendar-meetings.md` |
|
|
|
| 메일 회신 발송 | `replies` 초안 → 보내기 = high-risk → `approval`(source=`mail`) → 결재함 | `phase-7-approvals-automation.md` |
|
|
|
| 알림 "나중에" 반복 | `notification.triaged`(deferred) 반복 패턴 → suggester → `automation_suggestion` | `phase-7-approvals-automation.md` |
|
|
|
| 발신자 규칙 적용 | `sender_rule` → 새 알림 트리아지 시 버킷 결정 | (내부) |
|
|
|
|
|
|
> CONTRACT 명명 고정: 자동화 엔진 `backend/app/automation/`, 승인 큐 `approval`/`autonomy_setting`, 커넥터 `backend/app/connectors/`(도메인 `mail`/`chat`), 연합 이벤트 `event_bus`. 이 문서는 phase-7 가 정의한 이름을 **그대로 참조**한다.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 2. 선행 조건 / 산출물
|
|
|
|
|
|
### 2.1 선행 조건 (의존 phase)
|
|
|
|
|
|
| 의존 | 무엇을 가져오는가 |
|
|
|
|---|---|
|
|
|
| `phase-2-backend.md` | SQLModel/Pydantic 패턴, 라우터 prefix 규약(라우터 내부 prefix 없음, `main.py`에서만 `prefix="/api"`), `run_seed(session=None, reset=True)`, LLM `LLMProvider`/`get_provider`/`Classification`/`generate_json`, `Person` 테이블·`people` 시드, `to_node`(task 직렬화) |
|
|
|
| `phase-7-approvals-automation.md` | `Approval`(risk/status/source/undo_label/cta/alt/detail), `AutonomySetting`(level), `automation/event_bus.py`(publish/subscribe), `automation/suggester.py`, `connectors/base.py` + `MockConnector` 기반 인터페이스 |
|
|
|
| `phase-8-calendar-meetings.md` | `Calendar`/`Event` 모델, 일정 생성 헬퍼(메일 extract→event 가 동일 테이블에 쓴다) |
|
|
|
| `phase-1-design-system.md` | `Topbar`(13항목, `noti` badge), `SubRail`, `Icon`(중앙 paths 맵), `tokens.css`(dash.css `:root`) |
|
|
|
|
|
|
> 메일 페이지는 **dash.css 토큰을 그대로 쓰되 페이지 전용 `mail.css`를 얹는다**(원본 `mail.css`는 자체 `:root`를 복제하지만, 우리 프로젝트는 `tokens.css` 단일 출처를 따른다 — §7.1 참조). 알림 페이지는 `dash.css` 위에 `notify.css`만 얹는다(원본 그대로).
|
|
|
|
|
|
### 2.2 산출물 (Deliverables)
|
|
|
|
|
|
```
|
|
|
backend/app/
|
|
|
├─ models.py (+) MailAccount, MailFolder, Email, Sent, Draft,
|
|
|
│ Notification, NotificationGuard, Digest, SenderRule, NotifyStats
|
|
|
├─ schemas.py (+) 메일/알림 I/O 스키마
|
|
|
├─ seed.py (+) seed_mail(), seed_notify() (run_seed 내부 호출)
|
|
|
├─ connectors/
|
|
|
│ ├─ mail.py MailConnector(base) + MockMailConnector(시드 기반)
|
|
|
│ └─ chat.py ChatConnector(base) + MockChatConnector(알림 src=chat)
|
|
|
├─ services/
|
|
|
│ ├─ mail_ai.py analyze_email(): summary/priority/category/tasks/events/replies/file
|
|
|
│ │ (LLM provider, heuristic 폴백)
|
|
|
│ └─ triage.py classify_notification(): 발신자 규칙+가드→버킷 결정 + 이유(why/tone)
|
|
|
└─ routers/
|
|
|
├─ mail.py GET 목록/상세, POST extract(→task/event), POST reply-draft, POST send(→approval)
|
|
|
└─ notify.py GET 트리아지/요약, POST reclassify(now↔later↔held), POST undo,
|
|
|
GET/POST/PATCH senders, GET digests, POST guard
|
|
|
|
|
|
frontend/
|
|
|
├─ app/mail/page.tsx 3분할 메일 (사이드바/목록/리더+아리 분석/작성 슬라이드오버/트리아지)
|
|
|
├─ app/notifications/page.tsx SubRail 3뷰 (트리아지/집중 보호/발신자 규칙)
|
|
|
├─ components/mail/
|
|
|
│ ├─ MailSidebar.tsx MailList.tsx MailRow.tsx Reader.tsx AriPanel.tsx
|
|
|
│ ├─ Sug.tsx Triage.tsx Compose.tsx
|
|
|
├─ components/notify/
|
|
|
│ ├─ TriageStream.tsx WhyChip.tsx SrcIcon.tsx GuardPanel.tsx SenderRules.tsx
|
|
|
├─ lib/hooks/useMail.ts useNotify.ts
|
|
|
├─ styles/mail.css (REF/assets/mail.css 이식, :root 제거 → tokens.css 사용)
|
|
|
└─ styles/notify.css (REF/assets/notify.css 그대로 이식)
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
## 3. 상세 구현 — 백엔드
|
|
|
|
|
|
### 3.0 모델 — `models.py` (추가분)
|
|
|
|
|
|
CONTRACT 데이터 모델 규약: **모든 PK 는 TEXT(str)**, enum tone 집합 `blue|violet|coral|green|amber|ink|faint`, 한국어 UI 문구는 원본 데이터 파일 그대로. 메일의 `ai{}` 묶음은 분석 결과로 여러 테이블/행이 될 수 있으나, **데모 결정성**을 위해 시드는 원본 `mail-data.js`의 `ai`를 JSON으로 보존(`Email.ai_json`)하고, 추출 시 이 JSON을 풀어 `task`/`event`로 실체화한다.
|
|
|
|
|
|
```python
|
|
|
# backend/app/models.py (추가)
|
|
|
from __future__ import annotations
|
|
|
from datetime import datetime, timezone
|
|
|
from enum import Enum
|
|
|
from typing import Optional
|
|
|
from sqlmodel import SQLModel, Field, Column, JSON
|
|
|
|
|
|
|
|
|
def now() -> datetime:
|
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
|
|
|
# ---------- enums (값은 원본 데이터 고정) ----------
|
|
|
class MailPriority(str, Enum):
|
|
|
high = "높음"
|
|
|
normal = "보통"
|
|
|
low = "낮음"
|
|
|
|
|
|
|
|
|
class NotifyBucket(str, Enum):
|
|
|
now = "now" # 지금 (직접 봐야 함)
|
|
|
later = "later" # 나중에 (묶어서 다이제스트)
|
|
|
held = "held" # 아리가 처리 (자동 · 되돌리기 가능)
|
|
|
|
|
|
|
|
|
# ============================================================
|
|
|
# 메일 (3계정 통합)
|
|
|
# ============================================================
|
|
|
class MailAccount(SQLModel, table=True):
|
|
|
__tablename__ = "mail_account"
|
|
|
id: str = Field(primary_key=True) # "work" | "personal" | "side"
|
|
|
name: str # "회사" | "개인" | "사이드"
|
|
|
email: str # "jiwoo@lumi.co"
|
|
|
tone: str = "ink" # "coral" | "blue" | "violet" (acc-pip 색)
|
|
|
kind: str = "" # "Lumi Workspace" | "Google" | "HEY"
|
|
|
unread: int = 0 # 시드 표시값 (실제 카운트는 계산으로도 가능)
|
|
|
connector: str = "mock" # CONNECTOR_MAIL=mock|real (phase-13)
|
|
|
sort_order: int = 0
|
|
|
|
|
|
|
|
|
class MailFolder(SQLModel, table=True):
|
|
|
__tablename__ = "mail_folder"
|
|
|
id: str = Field(primary_key=True) # "inbox"|"starred"|"sent"|"drafts"|"archive"
|
|
|
name: str # "받은편지함" 등
|
|
|
icon: str = "inbox"
|
|
|
sort_order: int = 0
|
|
|
|
|
|
|
|
|
class Email(SQLModel, table=True):
|
|
|
__tablename__ = "email"
|
|
|
id: str = Field(primary_key=True) # "m1"..."m8"
|
|
|
account: str = Field(foreign_key="mail_account.id")
|
|
|
from_key: str # people 키 ("hyunwoo" 등). 'from'은 예약어라 from_key
|
|
|
to: str = "" # "나", "나, 재무팀" (표시용 문자열)
|
|
|
subject: str = ""
|
|
|
time: str = "" # "8분 전" (상대 표시값, 원본 그대로)
|
|
|
date: str = "" # "오늘 09:12" (상세 표시값)
|
|
|
read: bool = False
|
|
|
starred: bool = False
|
|
|
archived: bool = False # 보관함 상태 (런타임 토글)
|
|
|
has_attach: bool = False
|
|
|
labels: list = Field(default_factory=list, sa_column=Column(JSON)) # ["디자인"]
|
|
|
preview: str = ""
|
|
|
body: list = Field(default_factory=list, sa_column=Column(JSON)) # 문단 배열
|
|
|
attachments: list = Field(default_factory=list, sa_column=Column(JSON)) # [{name,size}]
|
|
|
ai_json: dict = Field(default_factory=dict, sa_column=Column(JSON)) # summary/priority/category/tasks/events/replies/file
|
|
|
sort_order: int = 0
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
|
|
|
|
|
|
class Sent(SQLModel, table=True):
|
|
|
__tablename__ = "sent"
|
|
|
id: str = Field(primary_key=True) # "s1","s2" (보낸편지함)
|
|
|
account: str = Field(foreign_key="mail_account.id")
|
|
|
to: str = "" # "현우" (표시용)
|
|
|
to_key: str = "" # people 키
|
|
|
subject: str = ""
|
|
|
time: str = ""
|
|
|
date: str = ""
|
|
|
preview: str = ""
|
|
|
sort_order: int = 0
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
|
|
|
|
|
|
class Draft(SQLModel, table=True):
|
|
|
__tablename__ = "draft"
|
|
|
id: str = Field(primary_key=True) # "d1"
|
|
|
account: str = Field(foreign_key="mail_account.id")
|
|
|
to: str = ""
|
|
|
subject: str = ""
|
|
|
time: str = ""
|
|
|
preview: str = ""
|
|
|
body: str = ""
|
|
|
sort_order: int = 0
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
|
|
|
|
|
|
# ============================================================
|
|
|
# 알림 트리아지
|
|
|
# ============================================================
|
|
|
class Notification(SQLModel, table=True):
|
|
|
__tablename__ = "notification"
|
|
|
id: str = Field(primary_key=True) # "n1"..., "l1"..., "h1"...
|
|
|
bucket: NotifyBucket = NotifyBucket.now # now|later|held
|
|
|
src: str = "app" # "mail"|"cal"|"chat"|"fin"|"app"
|
|
|
app: str = "" # "메일 · 회사", "캘린더"
|
|
|
title: str = ""
|
|
|
sum: str = "" # 본문 요약 (원본 키 'sum' 보존)
|
|
|
time: str = "" # "09:12", "오전", "어제"
|
|
|
why: str = "" # 분류 이유 칩 텍스트 ("VIP 발신자")
|
|
|
tone: str = "ink" # 이유 칩 색 (coral|blue|violet|green|amber|muted)
|
|
|
act: str = "" # 주요 액션 라벨 ("답장 초안 보기") — now 전용
|
|
|
held_by_rule: str = "" # held 일 때 적용된 sender_rule id (되돌리기 추적)
|
|
|
sort_order: int = 0
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
|
|
|
|
|
|
class NotificationGuard(SQLModel, table=True):
|
|
|
"""단일 row(id=1). 집중 보호 + 조용한 시간."""
|
|
|
__tablename__ = "notification_guard"
|
|
|
id: Optional[int] = Field(default=None, primary_key=True)
|
|
|
on: bool = True
|
|
|
until: str = "16:00"
|
|
|
held: int = 4 # 보류 중 알림 수
|
|
|
quiet: str = "22:30 – 07:00"
|
|
|
|
|
|
|
|
|
class Digest(SQLModel, table=True):
|
|
|
__tablename__ = "digest"
|
|
|
id: str = Field(primary_key=True) # "dg-am" 등 (TEXT PK)
|
|
|
time: str = "" # "09:00"
|
|
|
name: str = "" # "아침 브리핑"
|
|
|
desc: str = "" # "밤사이 메일·뉴스·일정 변화"
|
|
|
count: int = 0
|
|
|
done: bool = False
|
|
|
sort_order: int = 0
|
|
|
|
|
|
|
|
|
class SenderRule(SQLModel, table=True):
|
|
|
__tablename__ = "sender_rule"
|
|
|
id: str = Field(primary_key=True) # "sr1"...
|
|
|
who: str = "" # "수아님"
|
|
|
from_label: str = "" # "메일 · 메신저" (원본 'from')
|
|
|
rule: str = "" # "항상 즉시"
|
|
|
bucket: NotifyBucket = NotifyBucket.now # 규칙이 강제하는 기본 버킷
|
|
|
tone: str = "ink"
|
|
|
sort_order: int = 0
|
|
|
|
|
|
|
|
|
class NotifyStats(SQLModel, table=True):
|
|
|
"""단일 row(id=1). 오늘 요약."""
|
|
|
__tablename__ = "notify_stats"
|
|
|
id: Optional[int] = Field(default=None, primary_key=True)
|
|
|
total: int = 0 # 47
|
|
|
seen: int = 0 # 6
|
|
|
filtered: str = "" # "87%"
|
|
|
later_more: int = 0 # laterMore=6 (묶은 여분)
|
|
|
held_more: int = 0 # heldMore=23
|
|
|
```
|
|
|
|
|
|
> `Email.from_key`: 원본 `mail-data.js`의 `from`은 파이썬 예약어가 아니지만 SQL/Pydantic 혼동을 막기 위해 `from_key`로 저장하고, **응답 스키마에서 `from`으로 직렬화**한다(§3.2). `body`/`ai_json`/`labels`/`attachments`는 `sa_column=Column(JSON)`로 SQLite JSON 컬럼에 저장(phase-8 meeting JSON 패턴과 동일).
|
|
|
|
|
|
### 3.1 시드 — `seed.py` (추가분)
|
|
|
|
|
|
원본 `REF/assets/mail-data.js`, `REF/assets/notify-data.js`의 값을 **그대로** 적재한다. `people`은 phase-2 시드를 재사용하되, 메일에만 등장하는 발신자(대표/우리카드/독서모임/Stripe/지원자)는 **메일 전용 person 시드**로 추가한다.
|
|
|
|
|
|
```python
|
|
|
# backend/app/seed.py (추가)
|
|
|
from .models import (
|
|
|
Person, MailAccount, MailFolder, Email, Sent, Draft,
|
|
|
Notification, NotificationGuard, Digest, SenderRule, NotifyStats,
|
|
|
MailPriority, NotifyBucket,
|
|
|
)
|
|
|
|
|
|
# ---- 메일 전용 추가 발신자 (REF/assets/mail-data.js people) ----
|
|
|
# phase-2 의 jiwoo/hyunwoo/minseo/jaeho/sua 는 이미 시드됨. 메일에만 나오는 인물만 추가.
|
|
|
MAIL_PEOPLE = [
|
|
|
# (id, name, initial, color, role)
|
|
|
("daepyo", "서지원 대표", "서", "var(--coral)", "대표이사"),
|
|
|
("bank", "우리카드", "우", "oklch(0.6 0.12 250)", "카드 명세"),
|
|
|
("book", "수요 독서모임", "독", "oklch(0.62 0.1 150)", "모임 운영"),
|
|
|
("stripe", "Stripe", "S", "oklch(0.6 0.15 285)", "결제 영수증"),
|
|
|
("recruit", "박서연 (지원자)", "박", "oklch(0.64 0.12 30)", "프로덕트 디자이너 지원"),
|
|
|
]
|
|
|
# 메일에는 role 이 있다. Person 에 role 컬럼이 없으면 무시되거나(엄격모드 아님),
|
|
|
# Person 에 role: str = "" 를 추가해 함께 저장(권장). 본 phase 에서 Person.role(default="") 추가.
|
|
|
|
|
|
# ---- 계정 (REF mail-data.js accounts) ----
|
|
|
MAIL_ACCOUNTS = [
|
|
|
("work", "회사", "jiwoo@lumi.co", "coral", "Lumi Workspace", 3),
|
|
|
("personal", "개인", "jiwoo.kim@gmail.com", "blue", "Google", 2),
|
|
|
("side", "사이드", "jiwoo@hey.com", "violet", "HEY", 1),
|
|
|
]
|
|
|
MAIL_FOLDERS = [
|
|
|
("inbox", "받은편지함", "inbox", 0),
|
|
|
("starred", "별표", "star", 1),
|
|
|
("sent", "보낸편지함", "send", 2),
|
|
|
("drafts", "임시보관함", "file", 3),
|
|
|
("archive", "보관함", "archive", 4),
|
|
|
]
|
|
|
|
|
|
# ---- emails (REF mail-data.js emails m1~m8) — ai 묶음을 그대로 ai_json 으로 ----
|
|
|
# (요약: m1 온보딩 v3 / m2 분기 전략 미팅 / m3 1:1 안건 / m4 우리카드 명세 /
|
|
|
# m5 독서모임 / m6 채용 지원 / m7 Stripe 영수증 / m8 리텐션 코호트 v2)
|
|
|
EMAILS = [
|
|
|
{
|
|
|
"id": "m1", "account": "work", "from_key": "hyunwoo", "to": "나",
|
|
|
"subject": "온보딩 시안 v3 — 금요일까지 피드백 부탁드려요",
|
|
|
"time": "8분 전", "date": "오늘 09:12", "read": False, "starred": False, "has_attach": True,
|
|
|
"attachments": [{"name": "온보딩_v3.fig", "size": "4.2MB"},
|
|
|
{"name": "변경사항_요약.pdf", "size": "320KB"}],
|
|
|
"labels": ["디자인"],
|
|
|
"preview": "지우님, 리뷰에서 나온 코멘트 반영해서 v3 정리했어요. 환영·권한요청·음성안내 3개 화면이 바뀌었고…",
|
|
|
"body": ["지우님, 안녕하세요. 현우입니다.",
|
|
|
"지난 디자인 리뷰에서 주신 코멘트 반영해서 온보딩 시안 v3 정리했습니다. 이번에 바뀐 건 크게 세 화면이에요.",
|
|
|
"1) 환영 화면 — 카피 톤을 조금 더 친근하게 바꿨고, 일러스트는 교체 요청 넣어둔 상태입니다.",
|
|
|
"2) 권한 요청 화면 — 요청 순서를 재배치했어요. (알림 → 위치 → 연락처)",
|
|
|
"3) 음성 안내 화면 — 신규 추가했습니다. 한 번 봐주시면 좋겠어요.",
|
|
|
"금요일(6/13)까지 피드백 주시면 다음 스프린트에 바로 반영하겠습니다. 피그마 링크랑 변경사항 요약본 첨부했어요. 감사합니다!"],
|
|
|
"ai": {
|
|
|
"summary": "온보딩 시안 v3 도착 — 3개 화면(환영·권한요청·음성안내) 수정. 금요일 6/13까지 피드백 요청.",
|
|
|
"priority": "높음", "category": "검토 요청",
|
|
|
"tasks": [
|
|
|
{"text": "온보딩 시안 v3 피드백 정리", "project": "온보딩 리디자인 · 와이어프레임", "due": "6/13", "prio": "높음"},
|
|
|
{"text": "음성 안내 화면 신규 검토", "project": "온보딩 리디자인 · 음성 인터페이스", "due": "6/12", "prio": "보통"},
|
|
|
],
|
|
|
"events": [],
|
|
|
"replies": [
|
|
|
{"tone": "수락 · 일정 약속", "preview": "금요일까지 피드백 드릴게요",
|
|
|
"body": "현우님, 잘 받았어요! 오늘 중으로 살펴보고 금요일 오전까지 화면별로 코멘트 정리해서 드릴게요. 음성 안내 화면은 특히 꼼꼼히 볼게요. 수고하셨어요 👏"},
|
|
|
{"tone": "질문 추가", "preview": "권한 요청 순서 근거가 궁금해요",
|
|
|
"body": "현우님, 시안 잘 봤어요. 권한 요청 순서를 알림 → 위치 → 연락처로 두신 근거가 따로 있을까요? 리서치 결과랑 연결해서 보면 좋을 것 같아 여쭤봐요."},
|
|
|
{"tone": "간단 확인", "preview": "확인했고 회신 예정",
|
|
|
"body": "확인했습니다, 현우님! 금요일까지 피드백 드릴게요."},
|
|
|
],
|
|
|
"file": {"project": "온보딩 리디자인", "reason": "‘와이어프레임’ 작업과 직접 연결돼요"},
|
|
|
},
|
|
|
},
|
|
|
# ... m2~m8 동일 형식으로 mail-data.js 의 값 그대로 (지면상 생략, 실제 시드는 8건 전부) ...
|
|
|
]
|
|
|
```
|
|
|
|
|
|
> **반드시 8건 전부(m1~m8) 이식**하되, 위처럼 원본 `mail-data.js`의 필드를 1:1로 옮긴다. `m2`(분기 전략 미팅)는 `events:[{title:"분기 전략 미팅", date:"6/10", day:"오늘", time:"14:00", dur:"60분", place:"대회의실"}]`처럼 **event 추출 케이스**를 포함하므로 extract→일정 federation 테스트의 핵심 케이스가 된다. `m7`(Stripe)은 `tasks/events/replies` 모두 비어 `priority:"낮음"` — 빈 상태 렌더 케이스. `read:true`인 메일(m6/m7/m8)은 트리아지에서 제외(원본 `fresh = emails.filter(m => !m.read && m.ai)`).
|
|
|
|
|
|
```python
|
|
|
# ---- 보낸편지함 / 임시보관 (REF mail-data.js sent/drafts) ----
|
|
|
SENT = [
|
|
|
("s1", "work", "현우", "hyunwoo", "온보딩 리뷰 일정 공유", "어제", "6월 9일 11:00",
|
|
|
"현우님, 다음 리뷰는 목요일 오전으로 잡았어요…"),
|
|
|
("s2", "work", "민서", "minseo", "회고 문서 잘 봤어요", "3일 전", "6월 7일 16:20",
|
|
|
"민서님, 회고 문서 정리 깔끔하네요. 액션 아이템만…"),
|
|
|
]
|
|
|
DRAFTS = [
|
|
|
("d1", "work", "재무팀", "예산 재배분 안건 정리 (작성 중)", "1시간 전",
|
|
|
"예산 재배분 관련해서 세 가지 시나리오를…", ""),
|
|
|
]
|
|
|
|
|
|
# ---- 알림 (REF notify-data.js) ----
|
|
|
NOTIFY_STATS = dict(total=47, seen=6, filtered="87%", later_more=6, held_more=23)
|
|
|
|
|
|
NOTIF_NOW = [
|
|
|
("n1", "mail", "메일 · 회사", "수아님 — 분기 리포트 피드백",
|
|
|
"수치 2곳 수정 요청, 목요일까지 회신을 원해요.", "09:12", "VIP 발신자", "coral", "답장 초안 보기"),
|
|
|
("n2", "cal", "캘린더", "디자인 리뷰 15분 전",
|
|
|
"14:00 · 회의실 B · 현우·민서 참석 — 브리핑 준비됨.", "13:45", "곧 시작", "blue", "브리핑 열기"),
|
|
|
("n3", "chat", "팀 메신저", "현우님이 나를 멘션",
|
|
|
"“온보딩 검수, 지우님 확인 필요해요” — #온보딩 채널.", "11:20", "직접 멘션", "violet", "스레드 열기"),
|
|
|
("n4", "fin", "카드", "평소와 다른 결제 감지",
|
|
|
"해외 온라인 결제 42,000원 — 본인 결제가 맞나요?", "10:31", "보안", "coral", "확인하기"),
|
|
|
("n5", "app", "배포 시스템", "온보딩 v2 배포 완료",
|
|
|
"QA 시작 가능 — 체크리스트가 작업 보드에 추가됐어요.", "10:05", "내 프로젝트", "green", "작업 보기"),
|
|
|
("n6", "mail", "메일 · 회사", "민서님 — 1:1 노트 공유",
|
|
|
"지난 1:1 액션 2건을 완료로 표시했어요.", "08:40", "팀", "blue", "노트 보기"),
|
|
|
]
|
|
|
NOTIF_LATER = [
|
|
|
("l1", "mail", "메일 · 개인", "뉴스레터 5통 묶음",
|
|
|
"프로덕트 위클리 외 4종 — 저녁 다이제스트에 들어가요.", "오전", "다이제스트", "violet"),
|
|
|
("l2", "app", "분석 도구", "주간 지표 리포트 도착",
|
|
|
"온보딩 전환율 +2.1%p — 자세한 건 리서치에 정리해둘게요.", "07:00", "주간 보고", "blue"),
|
|
|
("l3", "chat", "팀 메신저", "#일반 채널 새 글 12개",
|
|
|
"회식 장소 투표가 열렸어요 — 마감은 내일 정오.", "오전", "낮은 우선순위", "muted"),
|
|
|
("l4", "fin", "구독", "음악 구독 결제 예정 D-3",
|
|
|
"월 10,900원 — 지난달 재생 시간이 절반으로 줄었어요.", "어제", "결제 예고", "amber"),
|
|
|
("l5", "app", "디자인 툴", "코멘트 3건",
|
|
|
"온보딩 화면 시안에 민서님이 코멘트를 남겼어요.", "어제", "낮은 우선순위", "muted"),
|
|
|
("l6", "mail", "메일 · 회사", "사내 워크숍 신청 안내",
|
|
|
"7월 워크숍 신청 마감 6/20 — 일정 비어 있어요.", "어제", "마감 여유", "muted"),
|
|
|
]
|
|
|
NOTIF_HELD = [
|
|
|
("h1", "mail", "메일 · 전체", "프로모션·마케팅 18건 자동 보관",
|
|
|
"광고성 메일은 읽지 않고 보관함으로 옮겼어요.", "오늘", "규칙: 자동 보관", "muted", "sr5"),
|
|
|
("h2", "cal", "캘린더", "회의 초대 자동 수락",
|
|
|
"목 11:00 ‘QA 싱크’ — 빈 시간 확인 후 수락했어요.", "10:40", "빈 시간 일치", "green", ""),
|
|
|
("h3", "app", "쇼핑", "배송 출발 → 일정에 기록",
|
|
|
"내일 도착 예정 — 캘린더에 메모로 달아뒀어요.", "09:30", "정보성", "blue", ""),
|
|
|
("h4", "mail", "메일 · 개인", "인증·OTP 메일 4건 정리",
|
|
|
"사용이 끝난 인증 메일은 휴지통으로 보냈어요.", "오늘", "일회성", "muted", ""),
|
|
|
("h5", "app", "앱 푸시", "광고 푸시 3건 차단",
|
|
|
"같은 앱에서 반복된 프로모션 푸시를 묶어 차단했어요.", "오늘", "반복 광고", "muted", ""),
|
|
|
("h6", "chat", "메일 + 메신저", "중복 알림 합침",
|
|
|
"같은 문서 공유 알림이 두 채널로 와서 하나로 합쳤어요.", "08:15", "중복", "blue", ""),
|
|
|
]
|
|
|
DIGESTS = [
|
|
|
("dg-am", "09:00", "아침 브리핑", "밤사이 메일·뉴스·일정 변화", 11, True),
|
|
|
("dg-noon", "13:00", "점심 묶음", "오전의 ‘나중에’ 알림", 7, True),
|
|
|
("dg-eve", "18:30", "저녁 다이제스트", "뉴스레터·소셜·구독 소식", 12, False),
|
|
|
]
|
|
|
# (id, who, from_label, rule, bucket, tone)
|
|
|
SENDERS = [
|
|
|
("sr1", "수아님", "메일 · 메신저", "항상 즉시", "now", "coral"),
|
|
|
("sr2", "현우 · 민서 · 재호", "팀 메신저", "멘션만 즉시", "now", "blue"),
|
|
|
("sr3", "뉴스레터 12종", "메일", "저녁 다이제스트", "later", "violet"),
|
|
|
("sr4", "은행 · 카드", "푸시", "이상 거래만 즉시", "now", "green"),
|
|
|
("sr5", "마케팅 · 프로모션", "메일 · 푸시", "자동 보관", "held", "amber"),
|
|
|
]
|
|
|
|
|
|
|
|
|
def seed_mail(s) -> None:
|
|
|
# 메일 전용 발신자 (role 포함)
|
|
|
for pid, name, ini, color, role in MAIL_PEOPLE:
|
|
|
if not s.get(Person, pid):
|
|
|
s.add(Person(id=pid, name=name, initial=ini, color=color, is_me=False, role=role))
|
|
|
for i, (aid, name, email, tone, kind, unread) in enumerate(MAIL_ACCOUNTS):
|
|
|
s.add(MailAccount(id=aid, name=name, email=email, tone=tone, kind=kind,
|
|
|
unread=unread, connector="mock", sort_order=i))
|
|
|
for fid, name, icon, order in MAIL_FOLDERS:
|
|
|
s.add(MailFolder(id=fid, name=name, icon=icon, sort_order=order))
|
|
|
for i, m in enumerate(EMAILS):
|
|
|
s.add(Email(
|
|
|
id=m["id"], account=m["account"], from_key=m["from_key"], to=m.get("to", ""),
|
|
|
subject=m["subject"], time=m["time"], date=m["date"],
|
|
|
read=m.get("read", False), starred=m.get("starred", False),
|
|
|
has_attach=m.get("has_attach", False), labels=m.get("labels", []),
|
|
|
preview=m.get("preview", ""), body=m.get("body", []),
|
|
|
attachments=m.get("attachments", []), ai_json=m.get("ai", {}), sort_order=i))
|
|
|
for i, (sid, acc, to, tokey, subj, time, date, prev) in enumerate(SENT):
|
|
|
s.add(Sent(id=sid, account=acc, to=to, to_key=tokey, subject=subj,
|
|
|
time=time, date=date, preview=prev, sort_order=i))
|
|
|
for i, (did, acc, to, subj, time, prev, body) in enumerate(DRAFTS):
|
|
|
s.add(Draft(id=did, account=acc, to=to, subject=subj, time=time,
|
|
|
preview=prev, body=body, sort_order=i))
|
|
|
s.commit()
|
|
|
|
|
|
|
|
|
def seed_notify(s) -> None:
|
|
|
s.add(NotifyStats(id=1, **NOTIFY_STATS))
|
|
|
s.add(NotificationGuard(id=1, on=True, until="16:00", held=4, quiet="22:30 – 07:00"))
|
|
|
rows = (
|
|
|
[(r, NotifyBucket.now) for r in NOTIF_NOW] +
|
|
|
[(r, NotifyBucket.later) for r in NOTIF_LATER] +
|
|
|
[(r, NotifyBucket.held) for r in NOTIF_HELD]
|
|
|
)
|
|
|
order = 0
|
|
|
for tup, bucket in rows:
|
|
|
if bucket == NotifyBucket.held:
|
|
|
nid, src, app, title, summ, time, why, tone, rule = tup
|
|
|
else:
|
|
|
nid, src, app, title, summ, time, why, tone, *rest = tup
|
|
|
rule = ""
|
|
|
s.add(Notification(id=nid, bucket=bucket, src=src, app=app, title=title, sum=summ,
|
|
|
time=time, why=why, tone=tone,
|
|
|
act=(tup[8] if bucket == NotifyBucket.now else ""),
|
|
|
held_by_rule=rule, sort_order=order))
|
|
|
order += 1
|
|
|
for i, (did, time, name, desc, count, done) in enumerate(DIGESTS):
|
|
|
s.add(Digest(id=did, time=time, name=name, desc=desc, count=count, done=done, sort_order=i))
|
|
|
for i, (rid, who, frm, rule, bucket, tone) in enumerate(SENDERS):
|
|
|
s.add(SenderRule(id=rid, who=who, from_label=frm, rule=rule,
|
|
|
bucket=NotifyBucket(bucket), tone=tone, sort_order=i))
|
|
|
s.commit()
|
|
|
```
|
|
|
|
|
|
`run_seed` 통합(phase-2 `_run` 본문 끝, `_seed_dashboard(s)` 다음에 호출 — phase-7/8 와 동일 패턴):
|
|
|
|
|
|
```python
|
|
|
def _run(s: Session, reset: bool) -> None:
|
|
|
...
|
|
|
_seed_dashboard(s)
|
|
|
seed_approvals(s) # phase-7
|
|
|
seed_automation(s) # phase-7
|
|
|
seed_calendar(s) # phase-8
|
|
|
seed_mail(s) # ← 이 phase
|
|
|
seed_notify(s) # ← 이 phase
|
|
|
s.commit()
|
|
|
```
|
|
|
|
|
|
> `reset=True` 시 삭제 대상 테이블 목록에 신규 10개 테이블을 추가한다(FK 순서: `Email/Sent/Draft → MailFolder → MailAccount`, `Notification/Digest/SenderRule/NotificationGuard/NotifyStats`). `Person.role` 컬럼은 phase-2 `Person`에 `role: str = ""` 추가 + alembic 마이그레이션(`add column person.role`).
|
|
|
|
|
|
### 3.2 스키마 — `schemas.py` (추가분)
|
|
|
|
|
|
`Email.from_key` → 응답 `from`으로 직렬화하기 위해 Pydantic `alias` 를 쓴다(`populate_by_name=True`).
|
|
|
|
|
|
```python
|
|
|
# backend/app/schemas.py (추가)
|
|
|
from pydantic import BaseModel, Field, ConfigDict
|
|
|
|
|
|
|
|
|
# ---------- 메일 ----------
|
|
|
class MailAccountOut(BaseModel):
|
|
|
id: str; name: str; email: str; tone: str; kind: str; unread: int
|
|
|
|
|
|
|
|
|
class MailFolderOut(BaseModel):
|
|
|
id: str; name: str; icon: str
|
|
|
|
|
|
|
|
|
class AiTaskOut(BaseModel):
|
|
|
text: str; project: str; due: str = ""; prio: str = ""
|
|
|
|
|
|
class AiEventOut(BaseModel):
|
|
|
title: str; date: str = ""; day: str = ""; time: str = ""; dur: str = ""; place: str = ""
|
|
|
|
|
|
class AiReplyOut(BaseModel):
|
|
|
tone: str; preview: str; body: str
|
|
|
|
|
|
class AiFileOut(BaseModel):
|
|
|
project: str; reason: str
|
|
|
|
|
|
class AiAnalysisOut(BaseModel):
|
|
|
summary: str = ""
|
|
|
priority: str = "보통" # 높음|보통|낮음
|
|
|
category: str = ""
|
|
|
tasks: list[AiTaskOut] = []
|
|
|
events: list[AiEventOut] = []
|
|
|
replies: list[AiReplyOut] = []
|
|
|
file: AiFileOut | None = None
|
|
|
|
|
|
|
|
|
class EmailRowOut(BaseModel):
|
|
|
"""목록 행 (가벼움)."""
|
|
|
model_config = ConfigDict(populate_by_name=True)
|
|
|
id: str
|
|
|
account: str
|
|
|
from_: str = Field(alias="from") # 응답 키는 'from'
|
|
|
to: str
|
|
|
subject: str
|
|
|
time: str
|
|
|
read: bool
|
|
|
starred: bool
|
|
|
has_attach: bool
|
|
|
labels: list[str] = []
|
|
|
preview: str
|
|
|
ai: AiAnalysisOut | None = None # 목록에서도 pill 표시에 ai 필요
|
|
|
|
|
|
|
|
|
class EmailDetailOut(EmailRowOut):
|
|
|
date: str
|
|
|
body: list[str] = []
|
|
|
attachments: list[dict] = []
|
|
|
|
|
|
|
|
|
class ExtractRequest(BaseModel):
|
|
|
kind: str # "task" | "event" | "file"
|
|
|
index: int = 0 # ai.tasks[index] / ai.events[index]
|
|
|
|
|
|
|
|
|
class ExtractResponse(BaseModel):
|
|
|
kind: str # "task"|"event"|"file"
|
|
|
task: "TaskNode | None" = None # kind=task 면 생성된 작업
|
|
|
event_id: str | None = None # kind=event 면 생성된 일정 id
|
|
|
project: str | None = None # kind=file 면 정리된 프로젝트명
|
|
|
message: str # "작업에 추가했어요" 등 토스트 문구
|
|
|
|
|
|
|
|
|
class ExtractAllResponse(BaseModel):
|
|
|
created_task_ids: list[str] = []
|
|
|
created_event_ids: list[str] = []
|
|
|
filed_project: str | None = None
|
|
|
message: str
|
|
|
|
|
|
|
|
|
class ReplyDraftRequest(BaseModel):
|
|
|
reply_index: int | None = None # ai.replies[index] 선택. None 이면 LLM 새 초안
|
|
|
tone: str | None = None # "더 정중하게" 등 (compose AI 보조)
|
|
|
|
|
|
|
|
|
class ReplyDraftOut(BaseModel):
|
|
|
to: str # "현우 <hyunwoo@lumi.co>"
|
|
|
from_account: str # 메일 account
|
|
|
subject: str # "Re: ..."
|
|
|
body: str
|
|
|
|
|
|
|
|
|
class SendRequest(BaseModel):
|
|
|
from_account: str
|
|
|
to: str
|
|
|
subject: str
|
|
|
body: str
|
|
|
in_reply_to: str | None = None # 원본 email id
|
|
|
|
|
|
|
|
|
class SendResponse(BaseModel):
|
|
|
approval_id: str # 생성된 결재(high-risk) id
|
|
|
status: str # "pending" (결재함 대기)
|
|
|
message: str # "보낼 준비가 됐어요 — 결재함에서 확인하세요"
|
|
|
|
|
|
|
|
|
# ---------- 알림 ----------
|
|
|
class NotificationOut(BaseModel):
|
|
|
id: str
|
|
|
bucket: str # now|later|held
|
|
|
src: str
|
|
|
app: str
|
|
|
title: str
|
|
|
sum: str
|
|
|
time: str
|
|
|
why: str
|
|
|
tone: str
|
|
|
act: str = ""
|
|
|
|
|
|
|
|
|
class TriageOut(BaseModel):
|
|
|
now: list[NotificationOut]
|
|
|
later: list[NotificationOut]
|
|
|
held: list[NotificationOut]
|
|
|
counts: dict # {now,later,held} (later/held 는 +more 합산)
|
|
|
stats: dict # {total,seen,filtered}
|
|
|
|
|
|
|
|
|
class ReclassifyNotifRequest(BaseModel):
|
|
|
to_bucket: str # "later" | "held" | "now"
|
|
|
|
|
|
|
|
|
class GuardOut(BaseModel):
|
|
|
on: bool; until: str; held: int; quiet: str
|
|
|
|
|
|
class GuardPatch(BaseModel):
|
|
|
on: bool
|
|
|
|
|
|
class DigestOut(BaseModel):
|
|
|
id: str; time: str; name: str; desc: str; count: int; done: bool
|
|
|
|
|
|
class SenderRuleOut(BaseModel):
|
|
|
id: str; who: str; from_label: str = Field(serialization_alias="from"); rule: str; tone: str; bucket: str
|
|
|
|
|
|
class SenderRuleCreate(BaseModel):
|
|
|
who: str; from_label: str; rule: str; bucket: str = "now"; tone: str = "ink"
|
|
|
```
|
|
|
|
|
|
> `EmailRowOut.from_`/`SenderRuleOut.from_label`은 `alias`/`serialization_alias`로 원본 키(`from`)를 보존한다. 프론트 `lib/types.ts`는 `from: string`을 쓴다(원본 `mail.jsx`가 `m.from`을 참조하므로 키 일치 필수).
|
|
|
|
|
|
### 3.3 커넥터 — `connectors/mail.py`, `connectors/chat.py`
|
|
|
|
|
|
CONTRACT: 커넥터 추상화 = 도메인별 base 인터페이스 + `MockConnector`(시드 기반) + `RealConnector`(phase-13). 설정 `CONNECTOR_MAIL=mock|real`(기본 mock). 페이지는 **mock-first**.
|
|
|
|
|
|
```python
|
|
|
# backend/app/connectors/mail.py
|
|
|
from abc import ABC, abstractmethod
|
|
|
from sqlmodel import Session, select
|
|
|
from ..models import Email, MailAccount, Sent
|
|
|
|
|
|
|
|
|
class MailConnector(ABC):
|
|
|
"""phase-13 이 RealConnector(Gmail/Workspace/HEY API)로 같은 인터페이스를 끼운다."""
|
|
|
@abstractmethod
|
|
|
def list_inbox(self, s: Session, account: str | None) -> list[Email]: ...
|
|
|
@abstractmethod
|
|
|
def get(self, s: Session, email_id: str) -> Email | None: ...
|
|
|
@abstractmethod
|
|
|
def send(self, s: Session, *, from_account: str, to: str, subject: str, body: str) -> str:
|
|
|
"""실제 발송. mock 은 Sent 행을 만들고 id 반환. (실 발송은 결재 승인 후 호출)"""
|
|
|
|
|
|
|
|
|
class MockMailConnector(MailConnector):
|
|
|
"""시드 기반. CONNECTOR_MAIL=mock (기본)."""
|
|
|
def list_inbox(self, s, account):
|
|
|
q = select(Email).where(Email.archived == False).order_by(Email.sort_order) # noqa: E712
|
|
|
if account:
|
|
|
q = q.where(Email.account == account)
|
|
|
return s.exec(q).all()
|
|
|
|
|
|
def get(self, s, email_id):
|
|
|
return s.get(Email, email_id)
|
|
|
|
|
|
def send(self, s, *, from_account, to, subject, body):
|
|
|
import uuid
|
|
|
sid = "snt-" + uuid.uuid4().hex[:8]
|
|
|
s.add(Sent(id=sid, account=from_account, to=to, subject=subject,
|
|
|
time="방금", date="오늘", preview=body[:40]))
|
|
|
s.commit()
|
|
|
return sid
|
|
|
|
|
|
|
|
|
def get_mail_connector() -> MailConnector:
|
|
|
from ..config import get_settings
|
|
|
mode = getattr(get_settings(), "connector_mail", "mock")
|
|
|
if mode == "real":
|
|
|
from .real_mail import RealMailConnector # phase-13
|
|
|
return RealMailConnector()
|
|
|
return MockMailConnector()
|
|
|
```
|
|
|
|
|
|
`connectors/chat.py`는 동일 패턴의 `ChatConnector`/`MockChatConnector`(알림 `src=chat`의 멘션 스트림을 시드에서 읽음). `config.py`에 `connector_mail: str = "mock"`, `connector_chat: str = "mock"` 추가(env `CONNECTOR_MAIL`/`CONNECTOR_CHAT`).
|
|
|
|
|
|
### 3.4 메일 AI 서비스 — `services/mail_ai.py`
|
|
|
|
|
|
아리 분석은 **LLM provider**(요약/분류/추출/초안)로 수행하고, 미가용/오프라인/테스트 시 **시드 `ai_json`을 그대로 반환**(데모 결정성). 즉 시드 메일은 이미 분석돼 있으므로 폴백은 "저장된 분석 재사용"이고, 신규 메일(커넥터가 새로 가져온)에 한해 LLM을 부른다.
|
|
|
|
|
|
```python
|
|
|
# backend/app/services/mail_ai.py
|
|
|
from sqlmodel import Session
|
|
|
from ..models import Email
|
|
|
from ..llm.provider import LLMProvider
|
|
|
|
|
|
ANALYZE_SCHEMA = {
|
|
|
"type": "object",
|
|
|
"properties": {
|
|
|
"summary": {"type": "string"},
|
|
|
"priority": {"type": "string", "enum": ["높음", "보통", "낮음"]},
|
|
|
"category": {"type": "string"},
|
|
|
"tasks": {"type": "array", "items": {"type": "object"}},
|
|
|
"events": {"type": "array", "items": {"type": "object"}},
|
|
|
"replies": {"type": "array", "items": {"type": "object"}},
|
|
|
"file": {"type": ["object", "null"]},
|
|
|
},
|
|
|
"required": ["summary", "priority", "category"],
|
|
|
}
|
|
|
|
|
|
ANALYZE_PROMPT = """당신은 한국어 비서 '아리'입니다. 아래 이메일을 읽고 JSON 으로 정리하세요.
|
|
|
- summary: 한두 줄 요약
|
|
|
- priority: 높음|보통|낮음 (마감/발신자 중요도 기준)
|
|
|
- category: 분류 라벨 (예: 검토 요청, 마감 임박, 채용, 영수증)
|
|
|
- tasks: [{{text, project, due, prio}}] (해야 할 행동)
|
|
|
- events: [{{title, date, day, time, dur, place}}] (시간이 정해진 일정)
|
|
|
- replies: [{{tone, preview, body}}] (보낼 만한 회신 초안)
|
|
|
- file: {{project, reason}} (정리하면 좋은 프로젝트) 또는 null
|
|
|
|
|
|
제목: {subject}
|
|
|
보낸 사람: {sender}
|
|
|
본문:
|
|
|
{body}
|
|
|
"""
|
|
|
|
|
|
|
|
|
def analyze_email(s: Session, email: Email, provider: LLMProvider) -> dict:
|
|
|
"""신규/재분석용. 시드 메일은 ai_json 이 이미 있으면 그대로 사용(결정성)."""
|
|
|
if email.ai_json:
|
|
|
return email.ai_json
|
|
|
prompt = ANALYZE_PROMPT.format(
|
|
|
subject=email.subject, sender=email.from_key, body="\n".join(email.body))
|
|
|
try:
|
|
|
result = provider.generate_json(prompt, ANALYZE_SCHEMA)
|
|
|
except Exception:
|
|
|
result = _heuristic_analyze(email) # provider 실패 시 규칙 폴백
|
|
|
email.ai_json = result
|
|
|
s.add(email); s.commit()
|
|
|
return result
|
|
|
|
|
|
|
|
|
def _heuristic_analyze(email: Email) -> dict:
|
|
|
"""LLM 미가용/오프라인 폴백 — phase-2 HeuristicProvider 정신과 동일.
|
|
|
시간표현(\\d시/\\d:\\d\\d) → event 후보, 행동동사(부탁/검토/회신) → task 후보."""
|
|
|
import re
|
|
|
text = email.subject + " " + " ".join(email.body)
|
|
|
tasks, events = [], []
|
|
|
if re.search(r"\d{1,2}시|\d{1,2}:\d{2}", text):
|
|
|
events.append({"title": email.subject[:24], "date": "", "day": "", "time": "", "dur": "", "place": ""})
|
|
|
if re.search(r"부탁|검토|회신|피드백|정리|확인", text):
|
|
|
tasks.append({"text": email.subject[:30], "project": "받은편지함", "due": "", "prio": "보통"})
|
|
|
prio = "높음" if ("[중요]" in email.subject or "마감" in text) else "보통"
|
|
|
return {"summary": email.preview[:60], "priority": prio, "category": "메일",
|
|
|
"tasks": tasks, "events": events, "replies": [], "file": None}
|
|
|
```
|
|
|
|
|
|
### 3.5 알림 트리아지 서비스 — `services/triage.py`
|
|
|
|
|
|
발신자 규칙 + 가드 → 버킷 결정 + **이유(why/tone)**. 시드 알림은 이미 버킷이 정해져 있으나, 새 알림(커넥터가 들고 온 `mail.received`/`chat` 멘션)은 이 함수가 분류한다. "나중에" 반복 패턴은 suggester(phase-7)로 넘긴다.
|
|
|
|
|
|
```python
|
|
|
# backend/app/services/triage.py
|
|
|
from sqlmodel import Session, select
|
|
|
from ..models import SenderRule, NotificationGuard, Notification, NotifyBucket
|
|
|
|
|
|
|
|
|
def classify_notification(s: Session, *, src: str, sender: str, title: str) -> dict:
|
|
|
"""새 알림 1건의 버킷/이유 결정. 반환 {bucket, why, tone, held_by_rule}."""
|
|
|
# 1) 발신자 규칙 우선
|
|
|
for r in s.exec(select(SenderRule).order_by(SenderRule.sort_order)).all():
|
|
|
if sender and (sender in r.who or r.who in sender):
|
|
|
why = {"now": f"규칙: {r.rule}", "later": "다이제스트", "held": f"규칙: {r.rule}"}[r.bucket.value]
|
|
|
return {"bucket": r.bucket.value, "why": why, "tone": r.tone,
|
|
|
"held_by_rule": r.id if r.bucket == NotifyBucket.held else ""}
|
|
|
# 2) 가드(딥 워크) 중이면 긴급 외 보류 → later
|
|
|
guard = s.get(NotificationGuard, 1)
|
|
|
if guard and guard.on and src not in ("cal",): # 캘린더 임박 알림은 통과
|
|
|
return {"bucket": "later", "why": "집중 보호 중", "tone": "muted", "held_by_rule": ""}
|
|
|
# 3) 기본 — 직접 봐야 하는 src 는 now
|
|
|
if src in ("mail", "chat", "cal", "fin"):
|
|
|
return {"bucket": "now", "why": "기본", "tone": "blue", "held_by_rule": ""}
|
|
|
return {"bucket": "later", "why": "낮은 우선순위", "tone": "muted", "held_by_rule": ""}
|
|
|
|
|
|
|
|
|
def detect_defer_pattern(s: Session, threshold: int = 3) -> list[dict]:
|
|
|
"""'나중에'로 반복 미룬 발신자/앱 패턴 탐지 → 자동화 제안 후보.
|
|
|
phase-7 suggester 가 호출해 automation_suggestion 으로 만든다."""
|
|
|
counts: dict[str, int] = {}
|
|
|
for n in s.exec(select(Notification).where(Notification.bucket == NotifyBucket.later)).all():
|
|
|
if n.why == "내가 미룸": # 사용자가 직접 미룬 것만 카운트(자동 다이제스트 제외)
|
|
|
counts[n.app] = counts.get(n.app, 0) + 1
|
|
|
return [{"app": app, "count": c, "offer": f"‘{app}’ 알림을 항상 저녁 다이제스트로 묶을까요?"}
|
|
|
for app, c in counts.items() if c >= threshold]
|
|
|
```
|
|
|
|
|
|
### 3.6 라우터 — `routers/mail.py`
|
|
|
|
|
|
라우터 prefix 규약: 내부 prefix 없이 경로 정의, `main.py`에서 `include_router(mail.router, prefix="/api", tags=["mail"])`.
|
|
|
|
|
|
```python
|
|
|
# backend/app/routers/mail.py
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
|
from sqlmodel import Session, select
|
|
|
import uuid
|
|
|
from ..db import get_session
|
|
|
from ..models import (Email, MailAccount, MailFolder, Sent, Draft, Task, Person,
|
|
|
TaskStatus, Prio, Approval)
|
|
|
from ..schemas import (MailAccountOut, MailFolderOut, EmailRowOut, EmailDetailOut,
|
|
|
ExtractRequest, ExtractResponse, ExtractAllResponse,
|
|
|
ReplyDraftRequest, ReplyDraftOut, SendRequest, SendResponse)
|
|
|
from ..connectors.mail import get_mail_connector
|
|
|
from ..services.mail_ai import analyze_email
|
|
|
from ..llm.provider import LLMProvider, get_provider
|
|
|
from ..routers.tasks import to_node
|
|
|
# phase-8 일정 생성 헬퍼
|
|
|
from ..routers.calendar import create_event_from_extract # {title,date,day,time,dur,place} → event id
|
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# 프로젝트명(라벨) → project_id 매핑 (extract 시 작업 행선지 결정)
|
|
|
PROJECT_LABEL_MAP = {
|
|
|
"온보딩 리디자인 · 와이어프레임": "onb-wire",
|
|
|
"온보딩 리디자인 · 음성 인터페이스": "onb-voice",
|
|
|
"온보딩 리디자인": "onb",
|
|
|
"경영 전략 · 분기 리포트": "biz-report",
|
|
|
"경영 전략 · 예산 관리": "biz-budget",
|
|
|
"경영 전략": "biz",
|
|
|
"팀 운영 · 채용 & 온보딩": "team-hr",
|
|
|
"팀 운영": "team",
|
|
|
"개인 · 성장": "me",
|
|
|
"개인": "me",
|
|
|
}
|
|
|
|
|
|
def _row_out(s: Session, e: Email) -> EmailRowOut:
|
|
|
return EmailRowOut.model_validate({
|
|
|
"id": e.id, "account": e.account, "from": e.from_key, "to": e.to,
|
|
|
"subject": e.subject, "time": e.time, "read": e.read, "starred": e.starred,
|
|
|
"has_attach": e.has_attach, "labels": e.labels, "preview": e.preview,
|
|
|
"ai": e.ai_json or None,
|
|
|
})
|
|
|
|
|
|
@router.get("/mail/accounts", response_model=list[MailAccountOut])
|
|
|
def accounts(s: Session = Depends(get_session)):
|
|
|
return s.exec(select(MailAccount).order_by(MailAccount.sort_order)).all()
|
|
|
|
|
|
@router.get("/mail/folders", response_model=list[MailFolderOut])
|
|
|
def folders(s: Session = Depends(get_session)):
|
|
|
return s.exec(select(MailFolder).order_by(MailFolder.sort_order)).all()
|
|
|
|
|
|
@router.get("/mail", response_model=list[EmailRowOut])
|
|
|
def list_mail(account: str | None = Query(None),
|
|
|
folder: str = Query("smart"),
|
|
|
s: Session = Depends(get_session)):
|
|
|
conn = get_mail_connector()
|
|
|
if folder == "starred":
|
|
|
rows = [e for e in conn.list_inbox(s, account) if e.starred]
|
|
|
elif folder == "archive":
|
|
|
rows = s.exec(select(Email).where(Email.archived == True)).all() # noqa: E712
|
|
|
else:
|
|
|
rows = conn.list_inbox(s, account)
|
|
|
return [_row_out(s, e) for e in rows]
|
|
|
|
|
|
@router.get("/mail/{eid}", response_model=EmailDetailOut)
|
|
|
def get_mail(eid: str, s: Session = Depends(get_session)):
|
|
|
e = get_mail_connector().get(s, eid)
|
|
|
if not e:
|
|
|
raise HTTPException(404, "email not found")
|
|
|
if not e.read: # 열람 = 읽음 처리
|
|
|
e.read = True; s.add(e); s.commit()
|
|
|
d = _row_out(s, e).model_dump(by_alias=True)
|
|
|
d.update({"date": e.date, "body": e.body, "attachments": e.attachments})
|
|
|
return EmailDetailOut.model_validate(d)
|
|
|
|
|
|
@router.post("/mail/{eid}/extract", response_model=ExtractResponse)
|
|
|
def extract(eid: str, body: ExtractRequest, s: Session = Depends(get_session)):
|
|
|
e = s.get(Email, eid)
|
|
|
if not e:
|
|
|
raise HTTPException(404, "email not found")
|
|
|
ai = e.ai_json or {}
|
|
|
if body.kind == "task":
|
|
|
t = ai["tasks"][body.index]
|
|
|
pid = PROJECT_LABEL_MAP.get(t["project"], "me")
|
|
|
tid = "t-" + uuid.uuid4().hex[:8]
|
|
|
task = Task(id=tid, title=t["text"], project_id=pid, status=TaskStatus.todo,
|
|
|
assignee_id="jiwoo", prio=Prio(t.get("prio") or "보통"),
|
|
|
notes=f"‘{e.subject}’ 메일에서 아리가 추출한 작업이에요.")
|
|
|
s.add(task); s.commit(); s.refresh(task)
|
|
|
node = to_node(s, task, s.exec(select(Task)).all())
|
|
|
return ExtractResponse(kind="task", task=node, message="작업에 추가했어요")
|
|
|
if body.kind == "event":
|
|
|
ev = ai["events"][body.index]
|
|
|
ev_id = create_event_from_extract(s, ev, source=f"mail:{eid}") # phase-8
|
|
|
return ExtractResponse(kind="event", event_id=ev_id, message="일정에 추가했어요")
|
|
|
if body.kind == "file":
|
|
|
proj = ai["file"]["project"]
|
|
|
return ExtractResponse(kind="file", project=proj, message=f"‘{proj}’에 정리했어요")
|
|
|
raise HTTPException(400, "kind must be task|event|file")
|
|
|
|
|
|
@router.post("/mail/{eid}/extract-all", response_model=ExtractAllResponse)
|
|
|
def extract_all(eid: str, s: Session = Depends(get_session)):
|
|
|
"""원본 '모두 적용' (AriPanel ap-allbar)."""
|
|
|
e = s.get(Email, eid)
|
|
|
if not e:
|
|
|
raise HTTPException(404, "email not found")
|
|
|
ai = e.ai_json or {}
|
|
|
task_ids, event_ids = [], []
|
|
|
for i in range(len(ai.get("tasks", []))):
|
|
|
task_ids.append(extract(eid, ExtractRequest(kind="task", index=i), s).task.id)
|
|
|
for i in range(len(ai.get("events", []))):
|
|
|
event_ids.append(extract(eid, ExtractRequest(kind="event", index=i), s).event_id)
|
|
|
filed = ai.get("file", {}).get("project") if ai.get("file") else None
|
|
|
return ExtractAllResponse(created_task_ids=task_ids, created_event_ids=event_ids,
|
|
|
filed_project=filed, message="아리가 준비한 작업을 모두 적용했어요")
|
|
|
|
|
|
@router.post("/mail/{eid}/reply-draft", response_model=ReplyDraftOut)
|
|
|
def reply_draft(eid: str, body: ReplyDraftRequest, s: Session = Depends(get_session),
|
|
|
provider: LLMProvider = Depends(get_provider)):
|
|
|
e = s.get(Email, eid)
|
|
|
if not e:
|
|
|
raise HTTPException(404, "email not found")
|
|
|
p = s.get(Person, e.from_key)
|
|
|
to = f"{p.name} <{p.email}>" if p and getattr(p, 'email', None) else (p.name if p else e.from_key)
|
|
|
ai = e.ai_json or {}
|
|
|
if body.reply_index is not None and ai.get("replies"):
|
|
|
draft = ai["replies"][body.reply_index]["body"]
|
|
|
else:
|
|
|
# LLM 새 초안 (미가용 시 기본 문구)
|
|
|
try:
|
|
|
draft = provider.generate_json(
|
|
|
f"이 메일에 보낼 정중한 한국어 회신 초안 한 문단:\n{e.subject}\n{' '.join(e.body)}",
|
|
|
{"type": "object", "properties": {"body": {"type": "string"}}}).get("body", "")
|
|
|
except Exception:
|
|
|
draft = "안녕하세요,\n\n메일 잘 받았습니다. 확인하고 빠른 시일 내 회신드리겠습니다.\n\n감사합니다.\n지우 드림"
|
|
|
return ReplyDraftOut(to=to, from_account=e.account, subject="Re: " + e.subject, body=draft)
|
|
|
|
|
|
@router.post("/mail/send", response_model=SendResponse)
|
|
|
def send(body: SendRequest, s: Session = Depends(get_session)):
|
|
|
"""회신/새 메일 보내기 = high-risk → 결재함(phase-7). 실제 발송은 승인 후."""
|
|
|
aid = "ap-" + uuid.uuid4().hex[:8]
|
|
|
appr = Approval(
|
|
|
id=aid, icon="mail", tone="violet", risk="high", status="pending", source="mail",
|
|
|
time="보내기 대기", title=f"{body.to}에게 메일을 보낼 준비가 됐어요",
|
|
|
detail=f"제목: {body.subject}\n\n{body.body[:120]}…",
|
|
|
cta="보내기", alt="수정", undo_label="")
|
|
|
s.add(appr); s.commit()
|
|
|
return SendResponse(approval_id=aid, status="pending",
|
|
|
message="보낼 준비가 됐어요 — 결재함에서 확인하세요")
|
|
|
```
|
|
|
|
|
|
> `Approval`의 `status`/`source` 필드는 phase-7 모델 정의. `risk="high"`라 mixed/full_auto 에서도 자동 실행되지 않고 결재함 대기(CONTRACT: high=승인 대기). 결재함에서 `cta="보내기"` 승인 시 phase-7 evaluator 가 `get_mail_connector().send(...)` 호출 → `Sent` 행 생성 → `approval.executed` 이벤트(하루 마감 합산).
|
|
|
|
|
|
### 3.7 라우터 — `routers/notify.py`
|
|
|
|
|
|
```python
|
|
|
# backend/app/routers/notify.py
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
|
from sqlmodel import Session, select
|
|
|
import uuid
|
|
|
from ..db import get_session
|
|
|
from ..models import (Notification, NotificationGuard, Digest, SenderRule, NotifyStats,
|
|
|
NotifyBucket)
|
|
|
from ..schemas import (NotificationOut, TriageOut, ReclassifyNotifRequest,
|
|
|
GuardOut, GuardPatch, DigestOut, SenderRuleOut, SenderRuleCreate)
|
|
|
from ..automation.event_bus import publish # phase-7
|
|
|
from ..services.triage import detect_defer_pattern
|
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def _out(n: Notification) -> NotificationOut:
|
|
|
return NotificationOut(id=n.id, bucket=n.bucket.value, src=n.src, app=n.app,
|
|
|
title=n.title, sum=n.sum, time=n.time, why=n.why,
|
|
|
tone=n.tone, act=n.act)
|
|
|
|
|
|
@router.get("/notifications", response_model=TriageOut)
|
|
|
def triage(s: Session = Depends(get_session)):
|
|
|
rows = s.exec(select(Notification).order_by(Notification.sort_order)).all()
|
|
|
stats = s.get(NotifyStats, 1)
|
|
|
buckets = {"now": [], "later": [], "held": []}
|
|
|
for n in rows:
|
|
|
buckets[n.bucket.value].append(_out(n))
|
|
|
counts = {
|
|
|
"now": len(buckets["now"]),
|
|
|
"later": len(buckets["later"]) + (stats.later_more if stats else 0),
|
|
|
"held": len(buckets["held"]) + (stats.held_more if stats else 0),
|
|
|
}
|
|
|
return TriageOut(now=buckets["now"], later=buckets["later"], held=buckets["held"],
|
|
|
counts=counts,
|
|
|
stats={"total": stats.total, "seen": stats.seen, "filtered": stats.filtered} if stats else {})
|
|
|
|
|
|
@router.post("/notifications/{nid}/reclassify", response_model=NotificationOut)
|
|
|
def reclassify(nid: str, body: ReclassifyNotifRequest, s: Session = Depends(get_session)):
|
|
|
n = s.get(Notification, nid)
|
|
|
if not n:
|
|
|
raise HTTPException(404, "notification not found")
|
|
|
prev = n.bucket.value
|
|
|
n.bucket = NotifyBucket(body.to_bucket)
|
|
|
# 이유 칩 갱신 (원본: now→later 는 '내가 미룸/muted', held→now 되돌림은 '되돌림/amber')
|
|
|
if body.to_bucket == "later":
|
|
|
n.why, n.tone, n.act = "내가 미룸", "muted", ""
|
|
|
elif body.to_bucket == "now" and prev == "held":
|
|
|
n.why, n.tone = "되돌림", "amber"
|
|
|
s.add(n); s.commit()
|
|
|
# 연합: 사용자가 직접 미룬 알림 → 반복 패턴 탐지 → suggester 후보 이벤트
|
|
|
if body.to_bucket == "later":
|
|
|
publish("notification.triaged", {"id": nid, "to": "later", "by": "user"})
|
|
|
for cand in detect_defer_pattern(s):
|
|
|
publish("automation.suggested", cand) # phase-7 suggester 구독
|
|
|
return _out(n)
|
|
|
|
|
|
@router.post("/notifications/{nid}/undo", response_model=NotificationOut)
|
|
|
def undo(nid: str, s: Session = Depends(get_session)):
|
|
|
"""held → now 되돌리기 (원본 undo)."""
|
|
|
n = s.get(Notification, nid)
|
|
|
if not n:
|
|
|
raise HTTPException(404, "notification not found")
|
|
|
n.bucket = NotifyBucket.now
|
|
|
n.why, n.tone = "되돌림", "amber"
|
|
|
s.add(n); s.commit()
|
|
|
publish("notification.triaged", {"id": nid, "to": "now", "by": "undo"})
|
|
|
return _out(n)
|
|
|
|
|
|
@router.get("/notifications/guard", response_model=GuardOut)
|
|
|
def get_guard(s: Session = Depends(get_session)):
|
|
|
g = s.get(NotificationGuard, 1)
|
|
|
return GuardOut(on=g.on, until=g.until, held=g.held, quiet=g.quiet)
|
|
|
|
|
|
@router.post("/notifications/guard", response_model=GuardOut)
|
|
|
def set_guard(body: GuardPatch, s: Session = Depends(get_session)):
|
|
|
g = s.get(NotificationGuard, 1)
|
|
|
g.on = body.on; s.add(g); s.commit()
|
|
|
return GuardOut(on=g.on, until=g.until, held=g.held, quiet=g.quiet)
|
|
|
|
|
|
@router.get("/notifications/digests", response_model=list[DigestOut])
|
|
|
def digests(s: Session = Depends(get_session)):
|
|
|
return s.exec(select(Digest).order_by(Digest.sort_order)).all()
|
|
|
|
|
|
@router.get("/notifications/senders", response_model=list[SenderRuleOut])
|
|
|
def senders(s: Session = Depends(get_session)):
|
|
|
rows = s.exec(select(SenderRule).order_by(SenderRule.sort_order)).all()
|
|
|
return [SenderRuleOut(id=r.id, who=r.who, from_label=r.from_label, rule=r.rule,
|
|
|
tone=r.tone, bucket=r.bucket.value) for r in rows]
|
|
|
|
|
|
@router.post("/notifications/senders", response_model=SenderRuleOut)
|
|
|
def add_sender(body: SenderRuleCreate, s: Session = Depends(get_session)):
|
|
|
rid = "sr-" + uuid.uuid4().hex[:6]
|
|
|
last = s.exec(select(SenderRule)).all()
|
|
|
r = SenderRule(id=rid, who=body.who, from_label=body.from_label, rule=body.rule,
|
|
|
bucket=NotifyBucket(body.bucket), tone=body.tone, sort_order=len(last))
|
|
|
s.add(r); s.commit()
|
|
|
return SenderRuleOut(id=r.id, who=r.who, from_label=r.from_label, rule=r.rule,
|
|
|
tone=r.tone, bucket=r.bucket.value)
|
|
|
```
|
|
|
|
|
|
`main.py` 등록 추가:
|
|
|
|
|
|
```python
|
|
|
from .routers import mail, notify
|
|
|
app.include_router(mail.router, prefix="/api", tags=["mail"])
|
|
|
app.include_router(notify.router, prefix="/api", tags=["notify"])
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
## 4. 상세 구현 — 메일 프론트엔드
|
|
|
|
|
|
### 4.1 라우트 & 데이터 훅
|
|
|
|
|
|
원본 `mail.jsx`는 localStorage 영속(`ariM.*`)으로 read/star/archive/added 상태를 들고 있다. 우리 구현은 **서버 상태**(GET `/api/mail`)를 정본으로 하되, 낙관적 토글(star/archive)은 클라이언트에서 즉시 반영 후 PATCH(없으면 로컬). 핵심 상호작용은 서버 왕복(extract→작업/일정, send→결재함).
|
|
|
|
|
|
```ts
|
|
|
// frontend/lib/hooks/useMail.ts
|
|
|
import useSWR from "swr";
|
|
|
import { api } from "@/lib/api";
|
|
|
|
|
|
export function useMail(account: string | null, folder: string) {
|
|
|
const qs = new URLSearchParams();
|
|
|
if (account) qs.set("account", account);
|
|
|
qs.set("folder", folder);
|
|
|
const { data: emails, mutate } = useSWR(`/api/mail?${qs}`, api.get);
|
|
|
const { data: accounts } = useSWR("/api/mail/accounts", api.get);
|
|
|
const { data: folders } = useSWR("/api/mail/folders", api.get);
|
|
|
return { emails, accounts, folders, mutate };
|
|
|
}
|
|
|
|
|
|
export const mailApi = {
|
|
|
detail: (id: string) => api.get(`/api/mail/${id}`),
|
|
|
extract: (id: string, kind: "task" | "event" | "file", index = 0) =>
|
|
|
api.post(`/api/mail/${id}/extract`, { kind, index }),
|
|
|
extractAll: (id: string) => api.post(`/api/mail/${id}/extract-all`, {}),
|
|
|
replyDraft: (id: string, reply_index: number | null) =>
|
|
|
api.post(`/api/mail/${id}/reply-draft`, { reply_index }),
|
|
|
send: (body: any) => api.post(`/api/mail/send`, body),
|
|
|
};
|
|
|
```
|
|
|
|
|
|
### 4.2 컴포넌트 트리 (원본 mail.jsx 1:1)
|
|
|
|
|
|
| 컴포넌트 | 원본 | 역할 |
|
|
|
|---|---|---|
|
|
|
| `MailSidebar` | `<aside className="subnav">` | 새 메일 버튼, 아리 정리(`sn-smart`), 폴더(`fl-row`), 계정(`acc-row`), 푸터(`sn-foot`) |
|
|
|
| `MailList` | `<div className="mlist">` | 헤더(제목/통수/동기화 `ml-sync`/검색 `ml-search`), 행 목록(`MailRow`) |
|
|
|
| `MailRow` | `MailRow` | 아바타(+acc-pip), 발신자/시간, 제목/프리뷰, **AI pill**(`중요`/`할 일 N`/`일정 N`/`답장 추천`), 별표/첨부 |
|
|
|
| `Reader` | `Reader` | 툴바(답장/전체답장/전달/별표/보관/삭제), 본문, 첨부, `AriPanel` |
|
|
|
| `AriPanel` | `AriPanel` | 분석 헤더(우선순위 pill), 요약, 할 일/일정 `Sug`, 답장 추천 `rep`, 프로젝트 정리 `ap-file`, 모두 적용 `ap-allbar` |
|
|
|
| `Sug` | `Sug` | 제안 항목(작업/일정) — 추가 버튼(추가됨 토글) |
|
|
|
| `Triage` | `Triage` | 아리 정리 히어로 + 3컬럼(할 일/일정/답장) — smart view & !sel 일 때 |
|
|
|
| `Compose` | `Compose` | 작성 슬라이드오버(보내는 계정 사이클, AI 작성 도우미 4버튼, 보내기) |
|
|
|
|
|
|
`AriPanel`의 추가 버튼은 원본의 로컬 `added` 맵 대신 **extract API** 호출:
|
|
|
|
|
|
```tsx
|
|
|
// frontend/components/mail/Sug.tsx
|
|
|
function Sug({ kind, text, meta, added, onAdd }: SugProps) {
|
|
|
return (
|
|
|
<div className={"sug " + kind + (added ? " added" : "")}>
|
|
|
<div className="sug-ico"><Icon name={kind === "task" ? "check" : "cal"} /></div>
|
|
|
<div className="sug-main">
|
|
|
<div className="sug-text">{text}</div>
|
|
|
<div className="sug-meta">{meta}</div>
|
|
|
</div>
|
|
|
<button className={"sug-add" + (added ? " done" : "")} onClick={onAdd}>
|
|
|
<Icon name={added ? "tick" : "plus"} w={added ? 3 : 1.9} />
|
|
|
{added ? "추가됨" : (kind === "task" ? "작업" : "일정")}
|
|
|
</button>
|
|
|
</div>
|
|
|
);
|
|
|
}
|
|
|
```
|
|
|
|
|
|
```tsx
|
|
|
// app/mail/page.tsx (발췌 — 추가 핸들러)
|
|
|
const onAdd = async (key: string, kind: "task" | "event" | "file", index: number, label: string) => {
|
|
|
if (added[key]) return;
|
|
|
setAdded((a) => ({ ...a, [key]: true })); // 낙관적
|
|
|
const res = await mailApi.extract(selId!, kind, index);
|
|
|
toast(res.message, kind === "event" ? "coral" : kind === "file" ? "violet" : "blue");
|
|
|
// 연합: 작업/일정 페이지의 SWR 캐시 무효화 (등장)
|
|
|
if (kind === "task") globalMutate("/api/tasks");
|
|
|
if (kind === "event") globalMutate("/api/calendar");
|
|
|
};
|
|
|
|
|
|
const addAll = async () => {
|
|
|
const res = await mailApi.extractAll(selId!);
|
|
|
setAddedAllFor(selId!);
|
|
|
toast(res.message, "green");
|
|
|
globalMutate("/api/tasks"); globalMutate("/api/calendar");
|
|
|
};
|
|
|
|
|
|
const onReply = async (id: string, replyIndex: number | null) => {
|
|
|
const d = await mailApi.replyDraft(id, replyIndex);
|
|
|
setCompose({ from: d.from_account, to: d.to, subject: d.subject, body: d.body, inReplyTo: id });
|
|
|
};
|
|
|
|
|
|
const onSend = async (c: ComposeState) => {
|
|
|
const res = await mailApi.send({
|
|
|
from_account: c.from, to: c.to, subject: c.subject, body: c.body, in_reply_to: c.inReplyTo,
|
|
|
});
|
|
|
setCompose(null);
|
|
|
toast(res.message, "violet"); // "결재함에서 확인하세요"
|
|
|
globalMutate("/api/approvals"); // 결재함 등장 (phase-7)
|
|
|
};
|
|
|
```
|
|
|
|
|
|
### 4.3 레이아웃 (원본 mail.css 그대로)
|
|
|
|
|
|
`.mwork { display:grid; grid-template-columns: 248px 384px minmax(0,1fr); gap:16px; height: calc(100vh - 96px); }`. 사이드바/목록/리더는 글래스 카드(`var(--glass)` + `var(--blur)` + `var(--glass-brd)` + `box-shadow: var(--shadow), var(--glass-hi)`, `border-radius: var(--radius)`=22px). 선택 행 `.mrow.on { background: var(--card); box-shadow: inset 3px 0 0 0 var(--coral); }`. 미읽음 점 `.mrow.unread::before { background: var(--blue); }`. 반응형: 1320px→사이드바 224/목록 340, 1080px→사이드바 숨김, 760px→`.mlist.has-sel { display:none }`(목록↔리더 토글).
|
|
|
|
|
|
---
|
|
|
|
|
|
## 5. 상세 구현 — 알림 트리아지 프론트엔드
|
|
|
|
|
|
### 5.1 라우트 & 데이터 훅
|
|
|
|
|
|
```ts
|
|
|
// frontend/lib/hooks/useNotify.ts
|
|
|
import useSWR from "swr";
|
|
|
import { api } from "@/lib/api";
|
|
|
|
|
|
export function useNotify() {
|
|
|
const { data: triage, mutate } = useSWR("/api/notifications", api.get);
|
|
|
const { data: guard, mutate: mGuard } = useSWR("/api/notifications/guard", api.get);
|
|
|
const { data: digests } = useSWR("/api/notifications/digests", api.get);
|
|
|
const { data: senders } = useSWR("/api/notifications/senders", api.get);
|
|
|
return { triage, guard, digests, senders, mutate, mGuard };
|
|
|
}
|
|
|
|
|
|
export const notifyApi = {
|
|
|
reclassify: (id: string, to_bucket: string) =>
|
|
|
api.post(`/api/notifications/${id}/reclassify`, { to_bucket }),
|
|
|
undo: (id: string) => api.post(`/api/notifications/${id}/undo`, {}),
|
|
|
setGuard: (on: boolean) => api.post(`/api/notifications/guard`, { on }),
|
|
|
};
|
|
|
```
|
|
|
|
|
|
### 5.2 페이지 구조 (원본 notify.jsx 1:1)
|
|
|
|
|
|
페이지 셸은 `dash.css`의 `.dash`/`.pagehead`/`.work`/`.board`/`.card`를 그대로 쓴다(원본 notify.jsx는 MVP 셸 `Topbar`/`SubRail`을 import). SubRail 3항목:
|
|
|
|
|
|
```tsx
|
|
|
const NT_RAIL = [
|
|
|
{ id: "triage", icon: "bell", label: "트리아지", dot: counts.now > 0 },
|
|
|
{ sep: true },
|
|
|
{ id: "guard", icon: "shield", label: "집중 보호 · 묶음" },
|
|
|
{ id: "senders", icon: "users", label: "발신자 규칙" },
|
|
|
];
|
|
|
const HEAD = {
|
|
|
triage: { title: "알림", em: "중요한 것만 울려요" },
|
|
|
guard: { title: "집중 보호", em: "조용히, 그래도 놓치지 않게" },
|
|
|
senders: { title: "발신자 규칙", em: "누구는 바로, 누구는 모아서" },
|
|
|
};
|
|
|
```
|
|
|
|
|
|
페이지헤드 eyebrow(원본 그대로): `오늘 {stats.total}건 도착 · 직접 본 건 {stats.seen}건 · {stats.filtered}는 아리가 걸렀어요`.
|
|
|
|
|
|
### 5.3 트리아지 스트림 (탭 + 이유 칩 + 액션)
|
|
|
|
|
|
```tsx
|
|
|
// frontend/components/notify/TriageStream.tsx
|
|
|
const NT_TABS = [
|
|
|
{ id: "now", label: "지금" },
|
|
|
{ id: "later", label: "나중에" },
|
|
|
{ id: "held", label: "아리가 처리" },
|
|
|
];
|
|
|
const NT_CAP = {
|
|
|
now: "직접 봐야 하는 알림만 남겼어요. 나머지는 아리가 들고 있을게요.",
|
|
|
later: "급하지 않은 알림은 묶어서 다이제스트 시간에 전달돼요.",
|
|
|
held: "아리가 이미 처리한 알림이에요. 전부 되돌릴 수 있어요.",
|
|
|
};
|
|
|
|
|
|
function TriageStream({ triage, tab, setTab, onLater, onUndo }: Props) {
|
|
|
const list = triage[tab];
|
|
|
const more = tab === "later" ? triage.counts.later - triage.later.length
|
|
|
: tab === "held" ? triage.counts.held - triage.held.length : 0;
|
|
|
return (
|
|
|
<section className="card sp2 r2">
|
|
|
<div className="ch">
|
|
|
<div className="ico"><Icon name="bell" /></div>
|
|
|
<div className="htext"><h3>트리아지</h3><div className="sub">아리가 분류한 오늘의 알림</div></div>
|
|
|
</div>
|
|
|
<div className="nt-tabs">
|
|
|
{NT_TABS.map((t) => (
|
|
|
<button key={t.id} className={"nt-tab" + (tab === t.id ? " on" : "")} onClick={() => setTab(t.id)}>
|
|
|
{t.label}<span className="n">{triage.counts[t.id]}</span>
|
|
|
</button>
|
|
|
))}
|
|
|
</div>
|
|
|
<div className="nt-cap">{NT_CAP[tab]}</div>
|
|
|
<div className="nt-list">
|
|
|
{list.map((it) => (
|
|
|
<div className="nt-row" key={it.id}>
|
|
|
<SrcIcon src={it.src} />
|
|
|
<div className="nt-body">
|
|
|
<div className="nt-app"><span>{it.app}</span><WhyChip why={it.why} tone={it.tone} /></div>
|
|
|
<div className="nt-title">{it.title}</div>
|
|
|
<div className="nt-sum">{it.sum}</div>
|
|
|
{tab === "now" && (
|
|
|
<div className="nt-acts">
|
|
|
{it.act && <button className="nt-act">{it.act}</button>}
|
|
|
<button className="nt-act ghost" onClick={() => onLater(it)}>나중에</button>
|
|
|
</div>
|
|
|
)}
|
|
|
{tab === "held" && (
|
|
|
<div className="nt-acts">
|
|
|
<button className="nt-act ghost" onClick={() => onUndo(it)}>되돌리기</button>
|
|
|
</div>
|
|
|
)}
|
|
|
</div>
|
|
|
<span className="nt-time">{it.time}</span>
|
|
|
</div>
|
|
|
))}
|
|
|
{list.length === 0 && (
|
|
|
<div className="nt-empty"><Icon name="tick" />비어 있어요. 오늘은 조용한 날이네요.</div>
|
|
|
)}
|
|
|
</div>
|
|
|
{more > 0 && <div className="nt-more">비슷한 알림 +{more}건 — 아리가 묶어뒀어요</div>}
|
|
|
</section>
|
|
|
);
|
|
|
}
|
|
|
```
|
|
|
|
|
|
`WhyChip`(이유 칩)과 `SrcIcon`(소스 아이콘)은 원본 그대로:
|
|
|
|
|
|
```tsx
|
|
|
function WhyChip({ why, tone }: { why: string; tone: string }) {
|
|
|
const t = tone === "muted" ? "var(--muted)" : `var(--${tone})`;
|
|
|
return (
|
|
|
<span className="nt-why" style={{ color: t, background: `color-mix(in oklab, ${t} 13%, transparent)` }}>
|
|
|
{why}
|
|
|
</span>
|
|
|
);
|
|
|
}
|
|
|
|
|
|
const NT_SRC = {
|
|
|
mail: { local: false, d: "mail", tone: "var(--blue)" },
|
|
|
cal: { local: false, d: "cal", tone: "var(--violet)" },
|
|
|
chat: { local: true, d: "msg", tone: "var(--violet)" }, // 페이지 전용 path
|
|
|
fin: { local: true, d: "card", tone: "var(--green)" },
|
|
|
app: { local: false, d: "grid", tone: "var(--ink-2)" },
|
|
|
};
|
|
|
```
|
|
|
|
|
|
> `msg`/`card`는 원본 `notify.jsx`의 페이지 전용 로컬 path(`NT_P`). 우리 구현은 `Icon` 중앙 맵에 `msg`/`card`를 추가하거나, `notify` 전용 `NI` 컴포넌트를 둔다(원본과 동일). path: `msg = "M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"`, `card = "M2 5h20v14H2z|M2 10h20"`.
|
|
|
|
|
|
### 5.4 집중 보호 · 발신자 규칙
|
|
|
|
|
|
`GuardPanel`(원본 `view === "guard"`): 딥 워크 보호 토글(`.sw`/`.sw.on`), 조용한 시간(`gd-quiet`), 묶음 배달 목록(`dg2-row`, done 이면 `opacity:0.5` + "— 전달됨"). `SenderRules`(원본 `view === "senders"`): `sd-row`(점·who·from·rule 칩) + 안내 노트(`sd-note`, "‘나중에’로 미룬 발신자가 쌓이면 아리가 새 규칙을 제안해요. 자동화 페이지에서 언제든 고칠 수 있어요." → phase-7 자동화 연계).
|
|
|
|
|
|
토글 동작:
|
|
|
|
|
|
```tsx
|
|
|
const onToggleGuard = async () => {
|
|
|
const next = !guard.on;
|
|
|
mGuard({ ...guard, on: next }, false); // 낙관적
|
|
|
await notifyApi.setGuard(next);
|
|
|
mGuard();
|
|
|
};
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
## 6. 데이터 / 타입 / API 계약
|
|
|
|
|
|
### 6.1 API 엔드포인트 표
|
|
|
|
|
|
| 메서드 | 경로 | 용도 | 요청 | 응답 |
|
|
|
|---|---|---|---|---|
|
|
|
| GET | `/api/mail/accounts` | 3계정 | — | `MailAccountOut[]` |
|
|
|
| GET | `/api/mail/folders` | 폴더 | — | `MailFolderOut[]` |
|
|
|
| GET | `/api/mail?account=&folder=` | 목록 | query | `EmailRowOut[]` |
|
|
|
| GET | `/api/mail/{id}` | 상세(+읽음) | — | `EmailDetailOut` |
|
|
|
| POST | `/api/mail/{id}/extract` | 할일/일정/정리 실체화 | `{kind,index}` | `ExtractResponse` |
|
|
|
| POST | `/api/mail/{id}/extract-all` | 모두 적용 | — | `ExtractAllResponse` |
|
|
|
| POST | `/api/mail/{id}/reply-draft` | 회신 초안 | `{reply_index?}` | `ReplyDraftOut` |
|
|
|
| POST | `/api/mail/send` | 보내기→결재함 | `SendRequest` | `SendResponse` |
|
|
|
| GET | `/api/notifications` | 트리아지(3버킷+요약) | — | `TriageOut` |
|
|
|
| POST | `/api/notifications/{id}/reclassify` | 재분류(now↔later↔held) | `{to_bucket}` | `NotificationOut` |
|
|
|
| POST | `/api/notifications/{id}/undo` | held→now 되돌리기 | — | `NotificationOut` |
|
|
|
| GET/POST | `/api/notifications/guard` | 집중 보호 조회/토글 | `{on}` | `GuardOut` |
|
|
|
| GET | `/api/notifications/digests` | 묶음 배달 | — | `DigestOut[]` |
|
|
|
| GET/POST | `/api/notifications/senders` | 발신자 규칙 조회/추가 | `SenderRuleCreate` | `SenderRuleOut[]`/`SenderRuleOut` |
|
|
|
|
|
|
### 6.2 대표 요청/응답 JSON
|
|
|
|
|
|
`GET /api/mail/m1` (상세 — `from`은 people 키, `ai`는 분석 묶음):
|
|
|
```json
|
|
|
{
|
|
|
"id": "m1", "account": "work", "from": "hyunwoo", "to": "나",
|
|
|
"subject": "온보딩 시안 v3 — 금요일까지 피드백 부탁드려요",
|
|
|
"time": "8분 전", "date": "오늘 09:12", "read": true, "starred": false, "has_attach": true,
|
|
|
"labels": ["디자인"],
|
|
|
"preview": "지우님, 리뷰에서 나온 코멘트 반영해서 v3 정리했어요…",
|
|
|
"body": ["지우님, 안녕하세요. 현우입니다.", "..."],
|
|
|
"attachments": [{"name":"온보딩_v3.fig","size":"4.2MB"},{"name":"변경사항_요약.pdf","size":"320KB"}],
|
|
|
"ai": {
|
|
|
"summary": "온보딩 시안 v3 도착 — 3개 화면(환영·권한요청·음성안내) 수정. 금요일 6/13까지 피드백 요청.",
|
|
|
"priority": "높음", "category": "검토 요청",
|
|
|
"tasks": [{"text":"온보딩 시안 v3 피드백 정리","project":"온보딩 리디자인 · 와이어프레임","due":"6/13","prio":"높음"}],
|
|
|
"events": [],
|
|
|
"replies": [{"tone":"수락 · 일정 약속","preview":"금요일까지 피드백 드릴게요","body":"현우님, 잘 받았어요! ..."}],
|
|
|
"file": {"project":"온보딩 리디자인","reason":"‘와이어프레임’ 작업과 직접 연결돼요"}
|
|
|
}
|
|
|
}
|
|
|
```
|
|
|
|
|
|
`POST /api/mail/m1/extract` 본문 `{"kind":"task","index":0}` → 응답:
|
|
|
```json
|
|
|
{ "kind":"task", "message":"작업에 추가했어요",
|
|
|
"task": {"id":"t-3a9f...","title":"온보딩 시안 v3 피드백 정리","project_id":"onb-wire",
|
|
|
"status":"todo","assignee_id":"jiwoo","prio":"높음",
|
|
|
"notes":"‘온보딩 시안 v3 — 금요일까지 피드백 부탁드려요’ 메일에서 아리가 추출한 작업이에요.","children":[]} }
|
|
|
```
|
|
|
|
|
|
`POST /api/mail/m2/extract` 본문 `{"kind":"event","index":0}` (m2 분기 전략 미팅) → 응답:
|
|
|
```json
|
|
|
{ "kind":"event", "event_id":"ev-7c1d...", "message":"일정에 추가했어요" }
|
|
|
```
|
|
|
|
|
|
`POST /api/mail/send` (회신 보내기) → 결재함 대기:
|
|
|
```json
|
|
|
{ "approval_id":"ap-8b2e...", "status":"pending", "message":"보낼 준비가 됐어요 — 결재함에서 확인하세요" }
|
|
|
```
|
|
|
|
|
|
`GET /api/notifications` (트리아지):
|
|
|
```json
|
|
|
{
|
|
|
"now": [ {"id":"n1","bucket":"now","src":"mail","app":"메일 · 회사","title":"수아님 — 분기 리포트 피드백",
|
|
|
"sum":"수치 2곳 수정 요청, 목요일까지 회신을 원해요.","time":"09:12","why":"VIP 발신자","tone":"coral","act":"답장 초안 보기"} ],
|
|
|
"later": [ {"id":"l1","bucket":"later","src":"mail","app":"메일 · 개인","title":"뉴스레터 5통 묶음","sum":"...","time":"오전","why":"다이제스트","tone":"violet","act":""} ],
|
|
|
"held": [ {"id":"h1","bucket":"held","src":"mail","app":"메일 · 전체","title":"프로모션·마케팅 18건 자동 보관","sum":"...","time":"오늘","why":"규칙: 자동 보관","tone":"muted","act":""} ],
|
|
|
"counts": { "now": 6, "later": 12, "held": 29 },
|
|
|
"stats": { "total": 47, "seen": 6, "filtered": "87%" }
|
|
|
}
|
|
|
```
|
|
|
> `counts.later = later.length(6) + later_more(6) = 12`, `counts.held = held.length(6) + held_more(23) = 29` (원본 `notify.jsx`의 `counts` 계산 그대로).
|
|
|
|
|
|
`POST /api/notifications/n1/reclassify` 본문 `{"to_bucket":"later"}` → `{"id":"n1","bucket":"later","why":"내가 미룸","tone":"muted","act":"", ...}`.
|
|
|
|
|
|
`POST /api/notifications/h1/undo` → `{"id":"h1","bucket":"now","why":"되돌림","tone":"amber", ...}`.
|
|
|
|
|
|
### 6.3 프론트 타입 (`lib/types.ts` 발췌)
|
|
|
|
|
|
```ts
|
|
|
export type AiTask = { text: string; project: string; due?: string; prio?: string };
|
|
|
export type AiEvent = { title: string; date?: string; day?: string; time?: string; dur?: string; place?: string };
|
|
|
export type AiReply = { tone: string; preview: string; body: string };
|
|
|
export type AiAnalysis = {
|
|
|
summary: string; priority: "높음" | "보통" | "낮음"; category: string;
|
|
|
tasks: AiTask[]; events: AiEvent[]; replies: AiReply[]; file: { project: string; reason: string } | null;
|
|
|
};
|
|
|
export type EmailRow = {
|
|
|
id: string; account: string; from: string; to: string; subject: string;
|
|
|
time: string; read: boolean; starred: boolean; has_attach: boolean;
|
|
|
labels: string[]; preview: string; ai: AiAnalysis | null;
|
|
|
};
|
|
|
export type Notification = {
|
|
|
id: string; bucket: "now" | "later" | "held"; src: "mail"|"cal"|"chat"|"fin"|"app";
|
|
|
app: string; title: string; sum: string; time: string; why: string; tone: string; act: string;
|
|
|
};
|
|
|
export type Triage = {
|
|
|
now: Notification[]; later: Notification[]; held: Notification[];
|
|
|
counts: { now: number; later: number; held: number };
|
|
|
stats: { total: number; seen: number; filtered: string };
|
|
|
};
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
## 7. 디자인 충실도 노트
|
|
|
|
|
|
### 7.1 토큰/테마 — 단일 출처 일치
|
|
|
|
|
|
원본 `mail.css`/`notify.css`는 학습용으로 자체 `:root`를 복제하지만(`--blue:#4f72e0` 등), 본 프로젝트는 **`styles/tokens.css`(dash.css `:root` 이식, overview §11.1) 단일 출처**를 따른다. 따라서 `mail.css`를 이식할 때 **상단 `:root`/`[data-theme="dark"]` 블록을 제거**하고 토큰 정의는 `tokens.css`에 위임한다(값은 동일: `--blue #4f72e0`, `--coral #df7256`, `--green #4e9b66`, `--violet #8b6fd4`, `--amber #e0a23c`, `--radius 22px`, `--radius-sm 14px`). `notify.css`는 `:root`가 없으므로 그대로 이식.
|
|
|
|
|
|
### 7.2 메일 — 인용 값
|
|
|
|
|
|
| 요소 | 원본 값 (mail.css) |
|
|
|
|---|---|
|
|
|
| 작업 영역 그리드 | `grid-template-columns: 248px 384px minmax(0,1fr); gap:16px; height: calc(100vh - 96px)` |
|
|
|
| 메일 행 선택 | `.mrow.on { background: var(--card); box-shadow: inset 3px 0 0 0 var(--coral) }` |
|
|
|
| 미읽음 점 | `.mrow.unread::before { width:7px; height:7px; background: var(--blue) }` |
|
|
|
| acc-pip | `.mr-ava .acc-pip { width:12px; height:12px; border:2px solid var(--card) }` (계정 색 `var(--${tone})`) |
|
|
|
| AI pill 색 | task=`--blue 12%`, event=`--coral 13%`, reply=`--green 13%`, prio=`--coral 13%` |
|
|
|
| 우선순위 pill | `.ap-prio.높음 { color:#fff; background: var(--coral) }`, `.보통 { color: var(--amber); background: --amber 16% }`, `.낮음 { color: var(--muted); background: var(--card-2) }` |
|
|
|
| 아리 분석 패널 | `radial-gradient(120% 90% at 0% 0%, --coral 14%, transparent 55%), var(--card)` + `border: --coral 24%` |
|
|
|
| 추가됨 상태 | `.sug.added { background: --green 9%; border: --green 30% }`, `.sug-add.done { color: var(--green) }` |
|
|
|
| 정리(파일) 추가 | `.ap-file .sug-add { background: var(--violet) }` (정리는 violet 톤) |
|
|
|
| 작성 슬라이드오버 | `.compose { width:620px; animation: slidein 0.24s; border-left: 1px solid var(--line) }` |
|
|
|
|
|
|
문구 인용(원본 그대로 유지): 빈 리더 `"읽을 메일을 선택하세요"` / `"메일을 열면 아리가 내용을 분석해 할 일·일정·답장을 미리 준비해 드려요."`, 분석 헤더 `"아리가 분석했어요"` + `"{category} · 새 메일을 읽고 필요한 일을 정리했어요"`, 요약 `"요약 — "` prefix(`<b>`), 모두 적용 바 `"아리가 준비한 {N}개 작업을 한 번에 적용할 수 있어요"`, 트리아지 히어로 `"새 메일 {fresh}통을 읽고 할 일을 정리해뒀어요"`, 작성 도우미 4버튼 `초안 작성 / 더 정중하게 / 짧게 / 다듬기`, 푸터 `"지우님 · Pro 플랜 · 3개 계정"`.
|
|
|
|
|
|
### 7.3 알림 — 인용 값
|
|
|
|
|
|
| 요소 | 원본 값 (notify.css) |
|
|
|
|---|---|
|
|
|
| 토글 스위치 | `.sw { width:42px; height:24px } .sw.on { background: var(--green) } .sw.on::after { transform: translateX(18px) }` |
|
|
|
| 탭 on | `.nt-tab.on { background: var(--fill); color: var(--on-fill) }`, `.n` = `font-mono` |
|
|
|
| 이유 칩 | `.nt-why { font-size:10px; font-weight:700; padding:1px 7px; border-radius:999px }` (색 = `color-mix(in oklab, var(--tone) 13%, transparent)`) |
|
|
|
| 소스 아이콘 | `.nt-ic { width:34px; height:34px; border-radius:10px; background: var(--glass-2) }` |
|
|
|
| 액션 | `.nt-act { background: var(--fill); color: var(--on-fill) }`, `.nt-act.ghost { background: var(--glass-2) }` |
|
|
|
| 더보기 | `.nt-more { border:1px dashed var(--line-2) }` "비슷한 알림 +N건 — 아리가 묶어뒀어요" |
|
|
|
| 집중 보호 카드 | `.gd-hold { background: linear-gradient(100deg, --coral 13%, transparent 70%), var(--glass-2); border: --coral 30% }`, off → `var(--glass-2)` |
|
|
|
| 요약 그리드 | `.nt-sumgrid { grid-template-columns:1fr 1fr }`, `.nt-sumcell.hi` = coral 강조 |
|
|
|
|
|
|
문구 인용: 탭 라벨 `지금 / 나중에 / 아리가 처리`, 캡션 3종(`NT_CAP`), 빈 상태 `"비어 있어요. 오늘은 조용한 날이네요."`, 가드 `"딥 워크 보호 중 — {until}까지"` / `"알림 {held}건 보류 중 · 끝나면 한 번에 전달해요"`, 조용한 시간 `"긴급·VIP만 통과"`, 발신자 안내 노트(§5.4), 오늘 요약 셀 `도착 / 지금 볼 것 / 아리가 처리 / 걸러냄`, 하단 노트 `"‘지금 볼 것’만 알림으로 울리고, 나머지는 묶음 배달 시간에 모아서 보여드려요."`.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 8. 상태 처리 · 엣지 케이스 · 오프라인
|
|
|
|
|
|
| 상황 | 처리 |
|
|
|
|---|---|
|
|
|
| 메일 미선택 | `Reader` 빈 상태(`rd-empty`, `mail` 아이콘 + 안내). smart view 이면 `Triage` 표시 |
|
|
|
| 폴더에 메일 없음 | `"메일이 없어요 / 이 보기에 표시할 메일이 없습니다."`(`rd-empty`, `inbox` 아이콘) |
|
|
|
| ai 비어있는 메일(m7) | AI pill 미표시(원본 조건 `priority==="높음" || aiCount>0`), AriPanel은 요약만, `tasks/events/replies` 섹션 생략, `ap-allbar` 미표시(keys≤1) |
|
|
|
| extract 중복 클릭 | 낙관적 `added[key]` 가드 → 서버도 멱등 아님이므로 프론트 가드 필수. 이미 추가된 항목은 `pointer-events:none`(`.sug-add.done`) |
|
|
|
| extract-all 부분 실패 | 각 extract 개별 try, 성공분만 토스트 카운트(현 구현은 전부 성공 가정 — 실패 시 재시도 토스트) |
|
|
|
| 회신 초안 LLM 미가용 | provider 실패 → 기본 문구(`지우 드림`) 폴백. 토스트 영향 없음 |
|
|
|
| send 후 결재함 | 메일 페이지는 토스트만, 실제 발송은 결재함 승인까지 **보류**(원본의 즉시 "메일을 보냈어요" 대신 "결재함에서 확인하세요"로 변경 — 철학 일치) |
|
|
|
| 알림 빈 버킷 | `nt-empty` "조용한 날" 표시 |
|
|
|
| reclassify/undo 낙관적 후 서버 실패 | `mutate()` 롤백(서버 재요청으로 정합) |
|
|
|
| 가드 ON 상태에서 알림 도착 | `services/triage.classify_notification` 이 `cal` 외 src 를 later 로 보류(이유 "집중 보호 중") |
|
|
|
| 오프라인(LLM 없음) | 시드 메일은 `ai_json` 그대로 사용(결정성). 신규 메일 분석은 `_heuristic_analyze` 폴백. 알림 분류는 규칙 기반(`triage.py`)이라 LLM 불필요 |
|
|
|
| `from` 발신자 person 미존재 | 메일 전용 person 시드(daepyo/bank/book/stripe/recruit) 보장. 누락 시 `from_key` 문자열 그대로 표시(가드) |
|
|
|
| HTML 본문 | 본문은 문단 배열(`body: string[]`)로 plain text — XSS 없음. 요약 `summary`만 `<b>` 포함 → `dangerouslySetInnerHTML`는 `"<b>요약 — </b>"` prefix + escape 된 summary (신뢰된 시드, overview §15 새니타이즈 원칙) |
|
|
|
|
|
|
---
|
|
|
|
|
|
## 9. 연합 이벤트 (발행/구독)
|
|
|
|
|
|
CONTRACT 연합 이벤트 모델(`event_bus`). 이 phase의 발행/구독:
|
|
|
|
|
|
### 9.1 발행 (publish)
|
|
|
|
|
|
| 이벤트 | 발행 시점 | 페이로드 | 구독자 |
|
|
|
|---|---|---|---|
|
|
|
| `mail.received` | 커넥터가 새 메일 적재(phase-13 real / mock 시드) | `{email_id, account, from}` | `mail_ai.analyze_email`(요약·추출), `triage.classify_notification`(메일 알림 생성) |
|
|
|
| `task.created` | `POST /api/mail/{id}/extract`(task) | `{task_id, source:"mail:{id}"}` | 작업 페이지(GET /api/tasks 등장), 리스크 레이더 재계산 |
|
|
|
| `event.created` | `POST /api/mail/{id}/extract`(event) | `{event_id, source:"mail:{id}"}` | 일정 페이지(phase-8) |
|
|
|
| `notification.triaged` | reclassify/undo | `{id, to, by:"user"|"undo"}` | suggester(패턴), 하루 마감 집계(phase-12) |
|
|
|
| `automation.suggested` | "나중에" 반복 ≥3 | `{app, count, offer}` | phase-7 suggester → `automation_suggestion` |
|
|
|
| `approval.enqueued` | `POST /api/mail/send`(high-risk) | `{approval_id, source:"mail"}` | 결재함(phase-7), 대시보드 badge |
|
|
|
|
|
|
### 9.2 구독 (subscribe)
|
|
|
|
|
|
| 이벤트 | 출처 | 이 phase의 반응 |
|
|
|
|---|---|---|
|
|
|
| `mail.received` | 커넥터/스케줄러(phase-14 worker) | 자동 분석 + 알림 생성(`src=mail`, 발신자 규칙 적용) |
|
|
|
| `approval.executed` | 결재함 승인(phase-7) — 메일 send 결재 승인 | `get_mail_connector().send(...)` 실제 발송 → `Sent` 행 → `approval.executed` 집계(하루 마감) |
|
|
|
| `meeting.ended` | phase-8 회의 도우미 | (간접) 회의 action items 가 메일 회신 초안으로 이어질 수 있음 — phase-10+ |
|
|
|
|
|
|
### 9.3 시나리오 (PROJECT-README §5 반영)
|
|
|
|
|
|
```
|
|
|
[현우님 메일 m1 도착]
|
|
|
→ mail.received 발행
|
|
|
→ analyze_email: 요약·할일 2건·답장 3종 준비 (이미 시드된 ai_json)
|
|
|
→ 사용자: 메일 열람 → AriPanel "온보딩 시안 v3 피드백 정리" 작업 추가
|
|
|
→ POST /api/mail/m1/extract {kind:task,index:0}
|
|
|
→ task 생성(project=onb-wire) → task.created 발행
|
|
|
→ 작업 페이지에 "다른 작업과 똑같이" 등장 (개인=프로젝트 철학)
|
|
|
→ 사용자: "수락 · 일정 약속" 답장 초안 선택 → Compose 채워짐 → 보내기
|
|
|
→ POST /api/mail/send → approval(source=mail, risk=high, status=pending)
|
|
|
→ 결재함에 "보내기 대기" 카드 등장 (이미 초안은 써뒀어요)
|
|
|
→ 결재함 승인 → connector.send → approval.executed → 하루 마감 "오늘 보낸 메일 1" 합산
|
|
|
```
|
|
|
|
|
|
```
|
|
|
[알림 트리아지 → 자동화 제안]
|
|
|
사용자가 '뉴스레터 5통 묶음'(l1)을 며칠째 '나중에'로 미룸
|
|
|
→ POST /api/notifications/l1/reclassify {to_bucket:later} (why="내가 미룸")
|
|
|
→ notification.triaged 발행 → detect_defer_pattern: '메일 · 개인' 3회 이상
|
|
|
→ automation.suggested 발행
|
|
|
→ phase-7 suggester: "‘메일 · 개인’ 뉴스레터를 항상 저녁 다이제스트로 묶을까요?" 제안 카드
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
## 10. 테스팅 & 검증
|
|
|
|
|
|
### 10.1 실행 명령
|
|
|
|
|
|
```bash
|
|
|
# 백엔드
|
|
|
cd backend && uv run python -m app.seed # 시드(메일/알림 포함) 적재
|
|
|
uv run uvicorn app.main:app --reload # API 기동
|
|
|
uv run pytest tests/test_api_mail.py tests/test_api_notify.py \
|
|
|
tests/test_mail_ai.py tests/test_triage.py -v
|
|
|
|
|
|
# 프론트
|
|
|
cd frontend && pnpm dev # http://localhost:3000/mail , /notifications
|
|
|
pnpm vitest run tests/mail tests/notify
|
|
|
pnpm playwright test playwright/mail.spec.ts playwright/notify.spec.ts
|
|
|
pnpm playwright test --grep @a11y # axe 접근성
|
|
|
```
|
|
|
|
|
|
### 10.2 백엔드 테스트 케이스 (pytest)
|
|
|
|
|
|
```python
|
|
|
# tests/test_api_mail.py
|
|
|
def test_list_accounts(client):
|
|
|
r = client.get("/api/mail/accounts")
|
|
|
assert [a["id"] for a in r.json()] == ["work", "personal", "side"]
|
|
|
assert r.json()[0]["tone"] == "coral" # 회사=coral
|
|
|
|
|
|
def test_email_detail_marks_read(client):
|
|
|
assert client.get("/api/mail/m1").json()["read"] is True # 열람=읽음
|
|
|
assert client.get("/api/mail/m1").json()["from"] == "hyunwoo" # alias 'from'
|
|
|
|
|
|
def test_extract_task_creates_task(client):
|
|
|
r = client.post("/api/mail/m1/extract", json={"kind": "task", "index": 0})
|
|
|
body = r.json()
|
|
|
assert body["kind"] == "task"
|
|
|
assert body["task"]["project_id"] == "onb-wire" # 라벨→project_id 매핑
|
|
|
assert body["task"]["prio"] == "높음"
|
|
|
# 연합: 작업 목록에 등장
|
|
|
titles = [t["title"] for t in client.get("/api/tasks").json()]
|
|
|
assert "온보딩 시안 v3 피드백 정리" in _flatten_titles(client.get("/api/tasks").json())
|
|
|
|
|
|
def test_extract_event_creates_event(client):
|
|
|
r = client.post("/api/mail/m2/extract", json={"kind": "event", "index": 0}) # 분기 전략 미팅
|
|
|
assert r.json()["kind"] == "event" and r.json()["event_id"]
|
|
|
|
|
|
def test_extract_all(client):
|
|
|
r = client.post("/api/mail/m1/extract-all", json={})
|
|
|
assert len(r.json()["created_task_ids"]) == 2 # m1 tasks 2건
|
|
|
assert r.json()["filed_project"] == "온보딩 리디자인"
|
|
|
|
|
|
def test_reply_draft_picks_seeded(client):
|
|
|
r = client.post("/api/mail/m1/reply-draft", json={"reply_index": 0})
|
|
|
assert r.json()["subject"].startswith("Re: ")
|
|
|
assert "금요일" in r.json()["body"]
|
|
|
|
|
|
def test_send_creates_high_risk_approval(client):
|
|
|
r = client.post("/api/mail/send", json={"from_account": "work", "to": "현우 <hyunwoo@lumi.co>",
|
|
|
"subject": "Re: 온보딩", "body": "확인했어요"})
|
|
|
assert r.json()["status"] == "pending"
|
|
|
appr = client.get(f"/api/approvals/{r.json()['approval_id']}").json() # phase-7
|
|
|
assert appr["risk"] == "high" and appr["source"] == "mail"
|
|
|
|
|
|
def test_m7_empty_ai(client):
|
|
|
ai = client.get("/api/mail/m7").json()["ai"]
|
|
|
assert ai["priority"] == "낮음" and ai["tasks"] == [] and ai["events"] == []
|
|
|
```
|
|
|
|
|
|
```python
|
|
|
# tests/test_api_notify.py
|
|
|
def test_triage_counts(client):
|
|
|
t = client.get("/api/notifications").json()
|
|
|
assert len(t["now"]) == 6
|
|
|
assert t["counts"]["later"] == 12 # 6 + later_more(6)
|
|
|
assert t["counts"]["held"] == 29 # 6 + held_more(23)
|
|
|
assert t["stats"] == {"total": 47, "seen": 6, "filtered": "87%"}
|
|
|
|
|
|
def test_now_has_why_chip(client):
|
|
|
n1 = next(n for n in client.get("/api/notifications").json()["now"] if n["id"] == "n1")
|
|
|
assert n1["why"] == "VIP 발신자" and n1["tone"] == "coral" and n1["act"] == "답장 초안 보기"
|
|
|
|
|
|
def test_reclassify_to_later(client):
|
|
|
r = client.post("/api/notifications/n1/reclassify", json={"to_bucket": "later"})
|
|
|
assert r.json()["bucket"] == "later" and r.json()["why"] == "내가 미룸" and r.json()["tone"] == "muted"
|
|
|
|
|
|
def test_undo_held_to_now(client):
|
|
|
r = client.post("/api/notifications/h2/undo", json={})
|
|
|
assert r.json()["bucket"] == "now" and r.json()["why"] == "되돌림" and r.json()["tone"] == "amber"
|
|
|
|
|
|
def test_guard_toggle(client):
|
|
|
assert client.post("/api/notifications/guard", json={"on": False}).json()["on"] is False
|
|
|
|
|
|
def test_senders_have_from_alias(client):
|
|
|
rows = client.get("/api/notifications/senders").json()
|
|
|
assert rows[0]["who"] == "수아님" and rows[0]["from"] == "메일 · 메신저" and rows[0]["rule"] == "항상 즉시"
|
|
|
|
|
|
def test_add_sender(client):
|
|
|
r = client.post("/api/notifications/senders",
|
|
|
json={"who": "뉴스레터 X", "from_label": "메일", "rule": "저녁 다이제스트", "bucket": "later"})
|
|
|
assert r.json()["bucket"] == "later"
|
|
|
```
|
|
|
|
|
|
```python
|
|
|
# tests/test_triage.py (서비스 단위)
|
|
|
def test_sender_rule_wins(session):
|
|
|
seed_notify(session)
|
|
|
out = classify_notification(session, src="mail", sender="수아님", title="...")
|
|
|
assert out["bucket"] == "now" # sr1 항상 즉시
|
|
|
def test_marketing_held(session):
|
|
|
seed_notify(session)
|
|
|
out = classify_notification(session, src="mail", sender="마케팅 · 프로모션", title="할인")
|
|
|
assert out["bucket"] == "held" and out["held_by_rule"] == "sr5"
|
|
|
def test_defer_pattern(session):
|
|
|
seed_notify(session)
|
|
|
# 같은 app 을 3회 '내가 미룸' 으로 → 후보 반환
|
|
|
for nid in ("l1", "l2"): # 셋업: 임의로 why='내가 미룸' 변경
|
|
|
...
|
|
|
cands = detect_defer_pattern(session, threshold=3)
|
|
|
assert all(c["count"] >= 3 for c in cands)
|
|
|
```
|
|
|
|
|
|
```python
|
|
|
# tests/test_mail_ai.py
|
|
|
def test_seed_email_uses_stored_ai(session, heuristic_provider):
|
|
|
seed_mail(session)
|
|
|
e = session.get(Email, "m1")
|
|
|
out = analyze_email(session, e, heuristic_provider)
|
|
|
assert out["summary"].startswith("온보딩 시안 v3") # 시드 ai_json 재사용(결정성)
|
|
|
def test_new_email_heuristic_fallback(session, heuristic_provider):
|
|
|
e = Email(id="mX", account="work", from_key="hyunwoo",
|
|
|
subject="[중요] 14시 미팅 부탁", body=["14시에 봬요"], ai_json={})
|
|
|
out = analyze_email(session, e, heuristic_provider)
|
|
|
assert out["priority"] == "높음" # [중요]
|
|
|
assert len(out["events"]) == 1 # 14시 → event 후보
|
|
|
```
|
|
|
|
|
|
### 10.3 프론트 테스트 (Vitest + RTL)
|
|
|
|
|
|
- `MailRow`: ai pill 렌더 — `priority="높음"`이면 `중요` pill, `tasks.length`이면 `할 일 N`, events→`일정 N`, replies→`답장 추천`. m7(빈 ai)은 pill 없음.
|
|
|
- `AriPanel`: `tasks/events/replies/file` 섹션 조건부 렌더, `keys>1`일 때만 `ap-allbar`, `added` 상태에서 버튼 `추가됨`/`done` 클래스.
|
|
|
- `TriageStream`: 탭 전환 시 `NT_CAP` 변경, `now` 탭에만 액션 버튼, `held` 탭에만 되돌리기, 빈 버킷 `nt-empty`, `more>0`이면 `nt-more`.
|
|
|
- `WhyChip`: `tone="muted"` → `var(--muted)`, 그 외 `var(--tone)`.
|
|
|
- `GuardPanel`: 토글 on/off 시 `.sw.on` 클래스 + 문구 전환("딥 워크 보호 중"↔"보호 꺼짐").
|
|
|
|
|
|
### 10.4 E2E (Playwright)
|
|
|
|
|
|
```ts
|
|
|
// playwright/mail.spec.ts
|
|
|
test("메일 ai 추출 → 작업 페이지 왕복", async ({ page }) => {
|
|
|
await page.goto("/mail");
|
|
|
await page.getByText("온보딩 시안 v3").click(); // m1 열기
|
|
|
await page.getByText("아리가 분석했어요").waitFor();
|
|
|
await page.getByRole("button", { name: /작업$/ }).first().click(); // Sug 작업 추가
|
|
|
await expect(page.getByText("작업에 추가했어요")).toBeVisible(); // 토스트
|
|
|
await page.goto("/tasks");
|
|
|
await expect(page.getByText("온보딩 시안 v3 피드백 정리")).toBeVisible(); // 등장
|
|
|
});
|
|
|
|
|
|
test("회신 초안 선택 → 결재함 대기", async ({ page }) => {
|
|
|
await page.goto("/mail");
|
|
|
await page.getByText("온보딩 시안 v3").click();
|
|
|
await page.getByText("금요일까지 피드백 드릴게요").click(); // 답장 추천 → Compose
|
|
|
await page.getByRole("button", { name: "보내기" }).click();
|
|
|
await expect(page.getByText("결재함에서 확인하세요")).toBeVisible();
|
|
|
});
|
|
|
|
|
|
// playwright/notify.spec.ts
|
|
|
test("트리아지 재분류 · 되돌리기", async ({ page }) => {
|
|
|
await page.goto("/notifications");
|
|
|
await page.getByRole("button", { name: /지금/ }).click();
|
|
|
await page.getByText("수아님 — 분기 리포트 피드백").locator("..").getByText("나중에").click();
|
|
|
await page.getByRole("button", { name: /나중에/ }).click();
|
|
|
await expect(page.getByText("내가 미룸")).toBeVisible(); // 이유 칩
|
|
|
await page.getByRole("button", { name: /아리가 처리/ }).click();
|
|
|
await page.getByText("회의 초대 자동 수락").locator("..").getByText("되돌리기").click();
|
|
|
await page.getByRole("button", { name: /지금/ }).click();
|
|
|
await expect(page.getByText("되돌림")).toBeVisible();
|
|
|
});
|
|
|
|
|
|
test("발신자 규칙 적용", async ({ page }) => {
|
|
|
await page.goto("/notifications");
|
|
|
await page.getByText("발신자 규칙").click();
|
|
|
await expect(page.getByText("수아님")).toBeVisible();
|
|
|
await expect(page.getByText("항상 즉시")).toBeVisible();
|
|
|
});
|
|
|
```
|
|
|
|
|
|
### 10.5 수동 QA 체크리스트
|
|
|
|
|
|
- [ ] `/mail` 3분할 레이아웃, 1320/1080/760px 반응형(목록↔리더 토글) 확인
|
|
|
- [ ] 전체 받은편지함 ↔ 계정별 필터(회사/개인/사이드) 전환, acc-pip 색 일치
|
|
|
- [ ] 아리 정리(smart) 히어로 — `새 메일 N통` 카운트, 3컬럼(할 일/일정/답장)
|
|
|
- [ ] m1 열람 → 읽음 처리(미읽음 점 사라짐), AriPanel 요약·할일 2·답장 3·정리 1 표시
|
|
|
- [ ] "작업" 추가 → 토스트 → `/tasks`에서 등장 / "일정" 추가(m2) → `/일정`에서 등장
|
|
|
- [ ] "모두 적용" → 모두 적용됨 상태, 토스트
|
|
|
- [ ] 답장 추천 클릭 → Compose 채워짐(Re: 제목, body) → 보내기 → "결재함에서 확인하세요" → `/결재함` 대기 카드
|
|
|
- [ ] m7(빈 ai) → pill 없음, AriPanel 요약만, 모두 적용 바 없음
|
|
|
- [ ] `/notifications` 트리아지 — 지금 6 / 나중에 12 / 아리가 처리 29, eyebrow `오늘 47건 도착 · 직접 본 건 6건 · 87%는 아리가 걸렀어요`
|
|
|
- [ ] 이유 칩 색(VIP 발신자=coral, 곧 시작=blue, 직접 멘션=violet, 보안=coral, 내 프로젝트=green)
|
|
|
- [ ] "나중에" 미루기 → 이유 "내가 미룸"(muted), "되돌리기" → "되돌림"(amber)
|
|
|
- [ ] 집중 보호 토글 on/off, 묶음 배달 3건(09:00/13:00/18:30, 저녁만 미전달)
|
|
|
- [ ] 발신자 규칙 5건 + 안내 노트(자동화 연계 문구)
|
|
|
- [ ] 라이트/다크 테마 토글 시 두 페이지 모두 토큰 정상 반영
|
|
|
- [ ] 오프라인(Ollama 중지) — 시드 메일 분석 그대로, 알림 분류 정상
|
|
|
|
|
|
### 10.6 통과 기준
|
|
|
|
|
|
1. 백엔드 pytest 전부 green(메일 8건/알림 18건 시드, extract→task/event 왕복, send→high-risk approval, 트리아지 counts, reclassify/undo 이유 칩, 발신자 규칙).
|
|
|
2. E2E: 메일→작업 등장 / 회신→결재함 / 알림 재분류·되돌리기 / 발신자 규칙 표시 통과.
|
|
|
3. axe: 두 페이지 0 violations(버튼 aria-label, 토글 `aria-label`, 대비).
|
|
|
4. 디자인: §7 인용 값(레이아웃 px, 색, 문구)과 픽셀/문구 일치.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 11. 완료 기준 (Definition of Done)
|
|
|
|
|
|
- [ ] `models.py` 10개 신규 테이블 + `Person.role` 추가, alembic 마이그레이션 왕복(`downgrade base && upgrade head`) 성공
|
|
|
- [ ] `seed_mail`/`seed_notify`가 `run_seed` 에 통합, 메일 8건(m1~m8)·알림 18건·계정 3·발신자 규칙 5·묶음 3 적재
|
|
|
- [ ] `connectors/mail.py`·`chat.py` base+Mock 구현, `CONNECTOR_MAIL`/`CONNECTOR_CHAT` env(기본 mock), phase-13 real 자리 확보
|
|
|
- [ ] `services/mail_ai.py`(LLM+heuristic 폴백, 시드 ai_json 재사용)·`services/triage.py`(규칙+가드+패턴 탐지)
|
|
|
- [ ] `routers/mail.py`·`notify.py` 전 엔드포인트(§6.1) 동작, prefix 규약 준수(라우터 내부 prefix 없음)
|
|
|
- [ ] 메일 extract → `task`/`event` 실체화(federation), 작업/일정 페이지 등장
|
|
|
- [ ] 메일 send → high-risk `approval`(source=mail) → 결재함 대기(phase-7)
|
|
|
- [ ] 알림 reclassify/undo 이유 칩 갱신, "나중에" 반복 → automation.suggested 발행
|
|
|
- [ ] 프론트 메일 3분할(사이드바/목록/리더+AriPanel/Compose/Triage) 원본 1:1, 알림 SubRail 3뷰 원본 1:1
|
|
|
- [ ] `mail.css`(:root 제거 후 tokens.css 사용)·`notify.css` 이식, 라이트/다크 정상
|
|
|
- [ ] 상태/엣지(§8) 처리, 오프라인 결정성 보장
|
|
|
- [ ] pytest/Vitest/Playwright/axe 전부 green, 수동 QA 체크리스트(§10.5) 통과
|
|
|
- [ ] 상호 참조 정확(overview/phase-2/7/8/10), CONTRACT 명명 일치(automation/connectors/event_bus/approval/autonomy)
|
|
|
|
|
|
---
|
|
|
|
|
|
## 12. 다음 단계
|
|
|
|
|
|
다음 문서: **`phase-10-research-travel.md`** — 리서치(에이전트 오케스트레이션 + RAG 지식베이스 + 웹검색)와 여행(여행 플래너 에이전트). 본 phase의 메일→작업/일정 federation, 커넥터 추상화(mock-first), LLM provider 폴백 패턴을 그대로 이어받아, `backend/app/agents/`(plan→act→observe→reflect)와 `backend/app/rag/`(ingest→chunk→embed→vector store)를 도입한다. 알림 "나중에" 패턴→자동화 제안은 phase-7 suggester 와 계속 연동되며, 리서치/여행 결과의 high-risk 동작(예약/결제)은 동일하게 결재함(phase-7)으로 흐른다.
|