# Phase 13 — 외부 연동 & 데이터 커넥터 (mock → real) > 한 줄 요약: phase 7~12에서 **mock-first** 로 만든 모든 페이지(라이프·메일·일정·알림·리서치·여행·결재함·자동화)를, 코드 한 줄 안 바꾸고 **실제 외부 데이터**로 전환한다. 커넥터 추상화(`backend/app/connectors/`) 뒤에 `MockConnector` 대신 `RealConnector` 를 꽂으면(`CONNECTOR_=real`), 동일 인터페이스(`fetch / sync / normalize / write`)를 통해 실데이터가 흘러들고 연합 이벤트(`mail.received`, `calendar.updated`, …)가 **실제로** 발생해 자동화·작업·알림이 진짜 데이터로 작동한다. > 이 문서는 **포스트-MVP 세트의 일부**입니다 — 먼저 `dev/overview.md` 와 `dev/post-mvp-overview.md` 를 읽으세요. > > 선행(필수): `phase-2-backend.md`(모델/스키마/라우터/시드/Provider 추상화 규약 — 모든 신규 테이블·엔드포인트는 이 패턴을 따른다), `phase-7-approvals-automation.md`(자율성 코어·event_bus·자동화 엔진·결재함), `phase-8-calendar-meetings.md`, `phase-9-mail-notifications.md`, `phase-10-research-travel.md`, `phase-11-life-care.md`, `phase-12-daily-narrative.md`(모두 mock-first 로 커넥터를 통해 데이터를 읽도록 구현되어 있어야 한다). > > 후속: `phase-14-proactive-agent.md`(능동형 에이전트 + 멀티모달 캡처 — 본 phase 의 RealConnector 가 공급하는 실데이터 이벤트 위에서 동작). --- ## 0. 목차 1. [개요 & 목표](#1-개요--목표) 2. [선행 조건 / 산출물](#2-선행-조건--산출물) 3. [상세 구현 — 커넥터 프레임워크](#3-상세-구현--커넥터-프레임워크) 4. [데이터/타입/API 계약](#4-데이터타입api-계약) 5. [디자인 충실도 노트](#5-디자인-충실도-노트) 6. [상태 처리 & 엣지 케이스](#6-상태-처리--엣지-케이스) 7. [연합 이벤트(발행/구독)](#7-연합-이벤트발행구독) 8. [테스팅 & 검증](#8-테스팅--검증) 9. [완료 기준 (Definition of Done)](#9-완료-기준-definition-of-done) 10. [다음 단계](#10-다음-단계) --- ## 1. 개요 & 목표 ### 1.1 이 phase가 끝나면 무엇이 동작하는가 phase 7~12 의 페이지들은 **이미** 동작한다 — 단, 시드(`life-data.js`/`mail-data.js`/`cal-data.js` 등에서 이식한 목업) 위에서. Phase 13 이 끝나면: - `backend/app/connectors/` 에 도메인별 **base 인터페이스**(`fetch / sync / normalize / write`) + 각 도메인의 `MockConnector`(시드 기반) + `RealConnector`(실제 제공자) 가 존재한다. - 환경변수 한 줄(`CONNECTOR_MAIL=real`, `CONNECTOR_CALENDAR=real`, …)만 바꾸면 **페이지/라우터 코드는 그대로** 두고 데이터 소스가 mock→real 로 전환된다(인터페이스 불변). - 6개 도메인 커넥터가 준비된다: **mail**(Gmail API / Google Workspace / HEY), **calendar**(Google Calendar API), **chat**(메신저), **finance**(카드/뱅크 아그리게이터 또는 수동 CSV 임포트), **health**(Apple Health/HealthKit export / Google Fit), **knowledge**(Notion API / Readwise / 웹 클리퍼). - 제공자별 OAuth2 플로우(또는 토큰/CSV 임포트 대안)와 **암호화된 토큰 저장**(`connector_account.token_enc`)·만료/갱신이 구현된다. - **증분 sync**(델타 토큰/`since` 커서) · **정규화**(provider 페이로드 → 내부 모델) · **충돌/중복 처리**(`external_id` UNIQUE upsert) · **레이트리밋**(지수 백오프 + per-connector 윈도우)가 동작한다. - 실데이터가 들어오면 `event_bus`(phase-7 정본)에 `mail.received` / `calendar.updated` 등 **실제 이벤트가 발행**되어, 자동화 엔진(`evaluator`)·작업 실체화·알림 트리아지가 **실데이터로** 동작한다. - **로컬 우선**: Ollama 로컬 LLM 철학과 정합 — 가능한 데이터는 로컬/수동 임포트(CSV·HealthKit export·`.ics` 파일·Markdown 노트)로도 들일 수 있고, 클라우드 OAuth 연동은 **선택**이다. - 라이프 페이지의 **연결된 데이터 소스 카드**(`life-data.js: sources` 의 `on`/`last` — "Apple Health · 방금 동기화", "우리카드 · 오늘 09:12", "Google Fit · 연결 안 됨")가 mock 의 정적 값이 아니라 **실제 커넥터 상태**(connected/disconnected/syncing/error/token_expired)를 비춘다. ### 1.2 핵심 설계 원칙 — "인터페이스 불변, 구현만 교체" ``` ┌─ MockConnector (시드 기반, 기본값) 페이지/라우터 ── ConnectorRegistry.get(domain) ──→ BaseConnector(추상) ─┤ (코드 불변) (CONNECTOR_ 로 선택) └─ RealConnector (OAuth/실제 API) │ normalize() ▼ 내부 모델(email/event/finance_tx/...) │ upsert(external_id UNIQUE) ▼ event_bus.publish(mail.received 등) │ ▼ 자동화/작업/알림 (실데이터로 동작) ``` CONTRACT 의 횡단 아키텍처 명명을 그대로 사용한다: 커넥터는 `backend/app/connectors/`, 도메인은 `mail/calendar/chat/finance/health/knowledge`, 설정은 `CONNECTOR_=mock|real`(기본 `mock`), 이벤트는 `event_bus`(phase-7), 임베딩/지식은 `backend/app/rag/`(phase-10) 와 공유한다. > **모델 비종속 = 제공자 비종속의 확장.** phase-2 가 LLM 을 `LLMProvider` 로 추상화해 "모델 강제 금지 + env 주입 + 폴백" 한 것과 동일한 정신으로, Phase 13 은 외부 데이터 제공자를 `BaseConnector` 로 추상화해 "**제공자 강제 금지 + env 주입 + mock 폴백**" 한다. Gmail/Google Calendar/Apple Health/Notion 등 구체 제공자는 **목표 예시이며 교체 가능**(CONTRACT 가정). --- ## 2. 선행 조건 / 산출물 ### 2.1 선행 조건 (의존 phase) | 의존 | 무엇을 가져오는가 | |---|---| | `phase-2-backend.md` | SQLModel/Pydantic/Alembic 패턴, 라우터 prefix 규약(라우터 내부 prefix 없음 → `main.py` 의 `include_router(prefix="/api")`), `config.py` Settings(env 주입), TEXT PK 규약, `run_seed(session=None, reset=True)`, `get_provider()`(LLM 추상화 — 폴백 패턴의 원형). | | `phase-7-approvals-automation.md` | **`event_bus`**(내부 이벤트 버스, 발행/구독 정본), `evaluator`(이벤트→규칙 매칭), `approval` 모델 + `autonomy_setting`(low+mixed 이상=자동실행, high=승인대기), `backend/app/worker/`(스케줄러 — 동기화 작업을 여기에 등록). | | `phase-8-calendar-meetings.md` | `calendar`/`event`/`focus_block`/`meeting` 모델. RealConnector(calendar) 의 정규화 타깃. | | `phase-9-mail-notifications.md` | `mail_account`/`email`/`notification` 모델 + 메일→작업/일정 추출(`email.ai.tasks/events`), 알림 트리아지. RealConnector(mail) 의 정규화 타깃. | | `phase-10-research-travel.md` | `backend/app/rag/`(ingest→chunk→embed→vector), `knowledge_qa`. knowledge 커넥터가 ingest 로 흘려보낸다. | | `phase-11-life-care.md` | `connector_source`(health/finance/knowledge on/last), `health`/`finance`/`knowledge_item` 모델. 라이프 페이지 connector 카드. | | `phase-12-daily-narrative.md` | 하루 마감 집계 — sync 결과(아낀 시간·자동 처리 건수)가 narrative 에 합산. | > **이 phase 가 mock 을 끄지 않는다.** mock 은 데모/오프라인/CI 의 결정적 기본값으로 **영구 유지**된다. real 은 사용자가 명시적으로 켤 때만(`CONNECTOR_=real` + 자격증명) 활성화. 단일 데모 사용자(지우)는 유지하고 멀티유저/per-user 토큰 스코프는 `phase-15-production.md` 에서 도입한다(본 phase 의 `connector_account.user_id` 는 `"jiwoo"` 고정 시드). ### 2.2 산출물 (Deliverables) ``` backend/ ├─ app/ │ ├─ config.py (+) CONNECTOR_, OAuth/자격증명 env, ARI_SECRET_KEY │ ├─ models.py (+) connector_account, connector_sync_state, external_link, oauth_state, connector_account_log │ ├─ schemas.py (+) ConnectorStatusOut, OAuthStartOut, SyncResultOut, ImportResultOut, ... │ ├─ crypto.py (+) Fernet 기반 토큰 암복호화 (ARI_SECRET_KEY 파생) │ ├─ connectors/ │ │ ├─ __init__.py │ │ ├─ base.py BaseConnector(추상): fetch/sync/normalize/write + 공통 upsert/레이트리밋 │ │ ├─ registry.py ConnectorRegistry.get(domain) — CONNECTOR_ 로 mock|real 선택 + 캐시 │ │ ├─ ratelimit.py 토큰 버킷 + 지수 백오프(429/Retry-After) │ │ ├─ oauth.py OAuth2 Authorization Code(+PKCE) 공통 헬퍼 (start/callback/refresh) │ │ ├─ mail/ base.py mock.py real_gmail.py real_imap.py normalize.py │ │ ├─ calendar/ base.py mock.py real_google.py ics_import.py normalize.py │ │ ├─ chat/ base.py mock.py real_webhook.py normalize.py │ │ ├─ finance/ base.py mock.py csv_import.py real_aggregator.py normalize.py │ │ ├─ health/ base.py mock.py healthkit_import.py real_fit.py normalize.py │ │ └─ knowledge/ base.py mock.py real_notion.py real_readwise.py clipper.py normalize.py │ ├─ routers/ │ │ └─ connectors.py GET/POST /api/connectors* (상태/연결/해제/동기화/임포트/OAuth 콜백) │ └─ worker/ │ └─ sync_jobs.py (+) 주기 sync 잡 등록(APScheduler) + 수동 트리거 진입점 ├─ migrations/versions/xxxx_connectors.py └─ tests/ ├─ test_connector_contract.py 모든 커넥터가 BaseConnector 계약 충족(인터페이스 계약 테스트) ├─ test_connector_swap.py CONNECTOR_ 스왑(mock↔real) — 라우터 응답 동일 shape ├─ test_oauth_mock.py OAuth 플로우 모킹(respx) — start→callback→token 저장(암호화) ├─ test_sync_idempotent.py 동기화 멱등성(같은 페이로드 2회 → 중복 0) ├─ test_normalize_golden.py 정규화 골든(provider 페이로드 → 내부 모델 고정 매핑) ├─ test_crypto.py 토큰 암복호화 왕복 └─ test_federation_real.py real(가짜) sync → event_bus 에 mail.received/calendar.updated 발행 frontend/ ├─ lib/types.ts (+) ConnectorStatus, OAuthStart, SyncResult, ImportResult ├─ components/connectors/ │ ├─ ConnectorCard.tsx 라이프 connector 카드 재사용 (on/last/상태) │ ├─ ConnectStateBadge.tsx 연결/해제/동기화 중/오류/권한 만료 배지 │ └─ ImportDropzone.tsx CSV/.ics/HealthKit export 수동 임포트 └─ playwright/connectors.spec.ts 연결→동기화→실데이터 반영 E2E (real=fake provider) ``` > 새 의존성(backend): `cryptography`(Fernet 토큰 암호화), `respx`(httpx OAuth 모킹 — dev). pip/uv 추가는 §8.1 에 명령 포함. **APScheduler 는 phase-7 worker 에서 이미 도입**했다고 가정(없으면 phase-7 에서 추가). --- ## 3. 상세 구현 — 커넥터 프레임워크 ### 3.0 설정 — `config.py` 확장 phase-2 `Settings` 에 커넥터/OAuth/암호화 키 필드를 더한다. pydantic-settings 가 필드명을 대문자 env 로 자동 매핑(`connector_mail` → `CONNECTOR_MAIL`). ```python # backend/app/config.py (phase-2 Settings 에 추가) from functools import lru_cache from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", extra="ignore") # ── (phase-2 에서 상속) ── database_url: str = "sqlite:///./ari.db" ollama_host: str = "http://localhost:11434" ollama_model: str = "llama3.1" llm_provider: str = "auto" llm_timeout: float = 20.0 risk_today: int = 8 frontend_origin: str = "http://localhost:3000" ari_allow_test_reset: bool = False # ── (phase-13 신규) 도메인별 커넥터 선택. 기본 mock. ── connector_mail: str = "mock" # mock | real connector_calendar: str = "mock" connector_chat: str = "mock" connector_finance: str = "mock" # mock | csv | real connector_health: str = "mock" # mock | healthkit | real connector_knowledge: str = "mock" # ── 토큰 암호화 키(필수 when real). Fernet 키 파생용. ── ari_secret_key: str = "dev-insecure-change-me-please-32bytes!" # ── OAuth2 자격증명(제공자별). 비어 있으면 해당 RealConnector 비활성. ── google_client_id: str = "" # Gmail + Google Calendar 공용 google_client_secret: str = "" google_redirect_uri: str = "http://localhost:8000/api/connectors/oauth/callback" notion_client_id: str = "" notion_client_secret: str = "" readwise_token: str = "" # Readwise 는 단순 토큰(OAuth 아님) # ── 동기화 정책 ── sync_interval_minutes: int = 15 # worker 주기 sync sync_page_size: int = 50 connector_http_timeout: float = 20.0 @lru_cache def get_settings() -> Settings: return Settings() ``` `.env` 예시(루트 또는 `backend/.env`) — **로컬 우선이라 전부 mock 이 기본**, real 은 선택: ```bash # (phase-0/2 에서 상속) DATABASE_URL=sqlite:///./ari.db OLLAMA_HOST=http://localhost:11434 OLLAMA_MODEL=qwen2.5:7b LLM_PROVIDER=auto FRONTEND_ORIGIN=http://localhost:3000 # ── 커넥터(phase-13) — 기본 전부 mock. 켤 도메인만 바꾼다. ── CONNECTOR_MAIL=mock # mock | real CONNECTOR_CALENDAR=mock # mock | real (real=Google Calendar) CONNECTOR_CHAT=mock CONNECTOR_FINANCE=mock # mock | csv (수동 CSV 임포트) | real CONNECTOR_HEALTH=mock # mock | healthkit (export 임포트) | real (Google Fit) CONNECTOR_KNOWLEDGE=mock # mock | real (Notion/Readwise/클리퍼) # ── 암호화 키(real 켤 때 필수, 32+ 바이트 권장) ── ARI_SECRET_KEY=please-generate-a-long-random-secret-key # ── OAuth (real 켤 때만) ── GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= GOOGLE_REDIRECT_URI=http://localhost:8000/api/connectors/oauth/callback NOTION_CLIENT_ID= NOTION_CLIENT_SECRET= READWISE_TOKEN= ``` --- ### 3.1 데이터 모델 — `models.py` 추가 (phase-2 패턴) phase-2 규약 그대로: 모든 PK 는 TEXT(str), enum 은 `str, Enum`, `now()` 헬퍼 재사용. 신규 5개 테이블. ```python # backend/app/models.py (phase-2 에 추가) from __future__ import annotations from datetime import datetime from enum import Enum from typing import Optional from sqlmodel import SQLModel, Field # from .models import now (phase-2 정의 재사용) class ConnectorDomain(str, Enum): mail = "mail" calendar = "calendar" chat = "chat" finance = "finance" health = "health" knowledge = "knowledge" class ConnectorMode(str, Enum): mock = "mock" csv = "csv" healthkit = "healthkit" ics = "ics" real = "real" class ConnState(str, Enum): connected = "connected" # 정상 연결 (라이프 카드 on:true) disconnected = "disconnected" # 미연결 (life-data.js: "연결 안 됨") syncing = "syncing" # 동기화 중 error = "error" # 마지막 sync 실패 token_expired = "token_expired" # 권한 만료 → 재인증 필요 class ConnectorAccount(SQLModel, table=True): """한 도메인의 한 제공자 계정. 예: mail/work(jiwoo@lumi.co), finance/woori.""" __tablename__ = "connector_account" id: str = Field(primary_key=True) # TEXT PK 예: "ca-mail-work" user_id: str = Field(default="jiwoo", foreign_key="person.id") # 단일 데모 사용자(phase-15 멀티유저) domain: ConnectorDomain mode: ConnectorMode = ConnectorMode.mock provider: str = "" # "gmail" | "google_calendar" | "notion" | "woori" | "apple_health" ... external_account_id: str = "" # 제공자 계정 식별자(이메일 주소 등) name: str = "" # 표시명 예: "회사" / "우리카드" kind: str = "" # life-data.js 의 kind 예: "Lumi Workspace" / "신용/체크" tone: str = "ink" # tone 집합: blue|violet|coral|green|amber|ink|faint state: ConnState = ConnState.disconnected last_synced_at: Optional[datetime] = None last_label: str = "연결 안 됨" # 라이프 카드 last 문구 (예: "방금 동기화" / "오늘 09:12") token_enc: str = "" # 암호화된 토큰 JSON (crypto.py). 평문 저장 금지. scopes: str = "" # 콤마구분 OAuth 스코프 error_detail: str = "" created_at: datetime = Field(default_factory=now) updated_at: datetime = Field(default_factory=now) class ConnectorSyncState(SQLModel, table=True): """증분 sync 커서. 제공자별 델타 토큰 또는 since 타임스탬프.""" __tablename__ = "connector_sync_state" id: str = Field(primary_key=True) # "cs-" account_id: str = Field(foreign_key="connector_account.id") cursor: str = "" # Gmail historyId / Google syncToken / ISO since page_token: str = "" # 진행 중 페이지네이션 토큰(중단 복구용) last_full_sync_at: Optional[datetime] = None last_delta_sync_at: Optional[datetime] = None items_seen: int = 0 items_upserted: int = 0 class ExternalLink(SQLModel, table=True): """외부 제공자 객체 ↔ 내부 엔티티 매핑(중복/충돌 처리의 핵심). (account_id, external_id) UNIQUE 로 멱등 upsert.""" __tablename__ = "external_link" id: str = Field(primary_key=True) # "xl-" account_id: str = Field(foreign_key="connector_account.id", index=True) external_id: str = Field(index=True) # 제공자측 고유 id (Gmail message id 등) entity_type: str # "email" | "event" | "finance_tx" | "health_sample" | "knowledge_item" | "chat_message" entity_id: str # 내부 엔티티 PK (예: email.id) etag: str = "" # 변경 감지용(있으면 비교 후 skip) external_updated_at: Optional[datetime] = None created_at: datetime = Field(default_factory=now) class OAuthState(SQLModel, table=True): """OAuth Authorization Code 플로우의 CSRF state + PKCE verifier 임시 저장.""" __tablename__ = "oauth_state" id: str = Field(primary_key=True) # state nonce 자체를 PK 로 domain: ConnectorDomain provider: str code_verifier: str = "" # PKCE redirect_after: str = "/life" # 콜백 후 프론트 복귀 경로 created_at: datetime = Field(default_factory=now) class ConnectorAccountLog(SQLModel, table=True): """연결/해제/동기화/오류 활동 로그(결재함 activity log 와 동일 톤).""" __tablename__ = "connector_account_log" id: str = Field(primary_key=True) # "cl1", ... account_id: str = Field(foreign_key="connector_account.id") action: str # "connected" | "disconnected" | "sync" | "error" | "token_refreshed" | "import" detail: str = "" items: int = 0 created_at: datetime = Field(default_factory=now) ``` > `external_link` 의 `(account_id, external_id)` 는 **복합 UNIQUE 인덱스**로 만든다(Alembic 에서 `op.create_index(..., unique=True)`). 이것이 "같은 메일/일정을 두 번 받아도 한 번만 저장"(멱등성)을 보장하는 단일 지점이다. #### Alembic 마이그레이션 ```bash cd /Users/kim1634/mycloud/personal/workspace/ari_assistant/workspace/backend uv run alembic revision --autogenerate -m "phase13 connectors" # 생성 파일에 external_link 복합 UNIQUE 인덱스 수동 보강: # op.create_index("ux_extlink_acct_ext", "external_link", ["account_id", "external_id"], unique=True) uv run alembic upgrade head # 왕복: uv run alembic downgrade -1 && uv run alembic upgrade head ``` #### 시드 — mock 계정(라이프/메일 카드 값 그대로) `run_seed()` 의 하위 헬퍼 `_seed_connectors(session)` 를 추가한다(phase-2 의 `_seed_dashboard` 와 동일 패턴 — `run_seed` 내부에서만 호출). 값은 **원본 `life-data.js: sources` 와 `mail-data.js: accounts` 그대로** 이식한다. ```python # backend/app/seed.py (run_seed 안에서 _seed_connectors(s) 호출) # (domain, id, provider, name, kind, tone, on, last) ← life-data.js sources / mail-data.js accounts 그대로 CONNECTORS = [ # mail (mail-data.js accounts) — 회사/개인/사이드 3계정 ("mail", "ca-mail-work", "google_workspace", "회사", "Lumi Workspace", "coral", True, "방금 동기화"), ("mail", "ca-mail-personal", "gmail", "개인", "Google", "blue", True, "방금 동기화"), ("mail", "ca-mail-side", "hey", "사이드","HEY", "violet", True, "방금 동기화"), # calendar (cal-data.js cals 는 카테고리지만 연동 단위는 google_calendar 1계정으로 묶음) ("calendar", "ca-cal-google", "google_calendar", "Google 캘린더", "캘린더", "blue", True, "방금 동기화"), # health (life-data.js sources.health) ("health", "ca-health-apple", "apple_health", "Apple Health", "건강 앱", "coral", True, "방금 동기화"), ("health", "ca-health-watch", "apple_watch", "Apple Watch", "웨어러블", "violet", True, "1분 전"), ("health", "ca-health-fit", "google_fit", "Google Fit", "활동 추적","green", False, "연결 안 됨"), # finance (life-data.js sources.finance) ("finance", "ca-fin-woori", "woori", "우리카드", "신용/체크", "blue", True, "오늘 09:12"), ("finance", "ca-fin-toss", "toss", "토스", "계좌 통합", "coral", True, "오늘 08:40"), ("finance", "ca-fin-kakao", "kakaobank", "카카오뱅크", "입출금", "amber", True, "어제"), ("finance", "ca-fin-kb", "kb_sec", "KB증권", "투자", "green", False, "연결 안 됨"), # knowledge (life-data.js sources.knowledge) ("knowledge", "ca-kn-notion", "notion", "Notion", "메모·문서", "violet", True, "오늘"), ("knowledge", "ca-kn-web", "clipper", "웹 클리퍼", "아티클 스크랩", "blue", True, "2시간 전"), ("knowledge", "ca-kn-readwise","readwise", "Readwise", "하이라이트", "coral", True, "어제"), ] def _seed_connectors(s): from .models import ConnectorAccount, ConnState, ConnectorMode for domain, cid, provider, name, kind, tone, on, last in CONNECTORS: s.add(ConnectorAccount( id=cid, user_id="jiwoo", domain=domain, mode=ConnectorMode.mock, provider=provider, name=name, kind=kind, tone=tone, external_account_id=name, last_label=last, state=ConnState.connected if on else ConnState.disconnected, )) ``` > 시드는 **mock 모드** 의 계정이다. `on:false` 인 Google Fit·KB증권은 `disconnected` 로 — 원본 `life-data.js` 의 `"연결 안 됨"` 문구(`last`)를 `last_label` 에 그대로 보존한다. 이메일 계정은 `mail-data.js` 의 `id`(work/personal/side)·`name`(회사/개인/사이드)·`kind`(Lumi Workspace/Google/HEY)·`tone`(coral/blue/violet) 을 1바이트도 안 바꾸고 옮긴다. --- ### 3.2 토큰 암호화 — `crypto.py` 평문 토큰은 절대 DB 에 저장하지 않는다. `ARI_SECRET_KEY` 에서 Fernet 키를 파생해 토큰 JSON 을 대칭 암호화한다. ```python # backend/app/crypto.py import base64 import hashlib import json from cryptography.fernet import Fernet, InvalidToken from .config import get_settings def _fernet() -> Fernet: # ARI_SECRET_KEY -> 32바이트 -> urlsafe base64 -> Fernet 키 raw = get_settings().ari_secret_key.encode("utf-8") key = base64.urlsafe_b64encode(hashlib.sha256(raw).digest()) return Fernet(key) def encrypt_token(token_dict: dict) -> str: """{'access_token':..., 'refresh_token':..., 'expires_at':...} -> 암호문 str.""" return _fernet().encrypt(json.dumps(token_dict).encode("utf-8")).decode("utf-8") def decrypt_token(enc: str) -> dict: if not enc: return {} try: return json.loads(_fernet().decrypt(enc.encode("utf-8")).decode("utf-8")) except (InvalidToken, ValueError): return {} # 키 회전/손상 → 빈 dict (호출측은 token_expired 처리) ``` > 운영(phase-15)에서는 `ARI_SECRET_KEY` 를 시크릿 매니저에서 주입하고 키 회전 정책을 둔다. 본 phase 는 env 주입 + 손상 시 안전 폴백(`{}` → 재인증)만 보장. --- ### 3.3 Base 인터페이스 — `connectors/base.py` CONTRACT 의 4개 메서드 `fetch / sync / normalize / write` 를 추상화한다. LLM `LLMProvider`(ABC) 패턴을 그대로 따른다. ```python # backend/app/connectors/base.py from __future__ import annotations from abc import ABC, abstractmethod from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Any, Iterable from sqlmodel import Session, select from ..models import ConnectorAccount, ConnectorSyncState, ExternalLink, ConnState import hashlib def _now(): return datetime.now(timezone.utc) @dataclass class RawRecord: """제공자에서 fetch 한 1건의 원시 레코드(provider 페이로드).""" external_id: str payload: dict etag: str = "" external_updated_at: datetime | None = None @dataclass class NormalizedRecord: """normalize() 산출물 — 내부 모델로 write 가능한 정규화 dict.""" entity_type: str # "email" | "event" | "finance_tx" | ... external_id: str fields: dict[str, Any] = field(default_factory=dict) etag: str = "" external_updated_at: datetime | None = None @dataclass class SyncResult: domain: str account_id: str seen: int = 0 upserted: int = 0 skipped: int = 0 errors: int = 0 events_published: list[str] = field(default_factory=list) detail: str = "" class BaseConnector(ABC): """모든 도메인 커넥터의 공통 계약. Mock/Real 이 동일하게 구현한다(인터페이스 불변).""" domain: str = "base" entity_type: str = "record" def __init__(self, account: ConnectorAccount): self.account = account # ── (1) fetch: 제공자에서 원시 레코드를 가져온다(증분 since/cursor) ── @abstractmethod def fetch(self, session: Session, *, full: bool = False) -> Iterable[RawRecord]: ... # ── (2) normalize: provider 페이로드 → 내부 모델 dict ── @abstractmethod def normalize(self, raw: RawRecord) -> NormalizedRecord: ... # ── (3) write: 정규화 레코드를 내부 테이블에 upsert(external_link 멱등) ── @abstractmethod def write(self, session: Session, norm: NormalizedRecord) -> tuple[str, bool]: """return (entity_id, created) — created=False 면 update/skip.""" ... # ── (4) sync: fetch→normalize→write 오케스트레이션 + event_bus 발행 ── def sync(self, session: Session, *, full: bool = False) -> SyncResult: from ..automation.event_bus import bus # phase-7 정본 (지연 import 로 순환 방지) res = SyncResult(domain=self.domain, account_id=self.account.id) self._set_state(session, ConnState.syncing) try: for raw in self.fetch(session, full=full): res.seen += 1 if self._is_duplicate(session, raw): # etag 동일 → skip(멱등) res.skipped += 1 continue norm = self.normalize(raw) entity_id, created = self.write(session, norm) self._upsert_link(session, raw, norm, entity_id) if created: res.upserted += 1 evt = self.event_for(norm, entity_id) # 예: "mail.received" if evt: bus.publish(evt, {"account_id": self.account.id, "entity_type": norm.entity_type, "entity_id": entity_id}) res.events_published.append(evt) session.commit() self._finish_ok(session, res) except Exception as e: # 부분 실패도 서버 죽지 않음(오프라인 폴백 철학) session.rollback() res.errors += 1 res.detail = f"{type(e).__name__}: {e}" self._finish_error(session, res.detail) return res # ── 도메인이 발행할 연합 이벤트 타입(없으면 None) ── def event_for(self, norm: NormalizedRecord, entity_id: str) -> str | None: return None # ── 공통: 중복/멱등 ── def _is_duplicate(self, session: Session, raw: RawRecord) -> bool: link = session.exec( select(ExternalLink).where(ExternalLink.account_id == self.account.id, ExternalLink.external_id == raw.external_id)).first() return bool(link and raw.etag and link.etag == raw.etag) def _upsert_link(self, session, raw, norm, entity_id): link = session.exec( select(ExternalLink).where(ExternalLink.account_id == self.account.id, ExternalLink.external_id == raw.external_id)).first() if link: link.etag = norm.etag; link.entity_id = entity_id link.external_updated_at = norm.external_updated_at else: xlid = "xl-" + hashlib.sha1(f"{self.account.id}:{raw.external_id}".encode()).hexdigest()[:12] session.add(ExternalLink(id=xlid, account_id=self.account.id, external_id=raw.external_id, entity_type=norm.entity_type, entity_id=entity_id, etag=norm.etag, external_updated_at=norm.external_updated_at)) # ── 상태/커서/로그 ── def _state_row(self, session) -> ConnectorSyncState: st = session.get(ConnectorSyncState, f"cs-{self.account.id}") if not st: st = ConnectorSyncState(id=f"cs-{self.account.id}", account_id=self.account.id) session.add(st) return st def _set_state(self, session, state: ConnState): self.account.state = state; self.account.updated_at = _now() session.add(self.account); session.commit() def _finish_ok(self, session, res: SyncResult): self.account.state = ConnState.connected self.account.last_synced_at = _now() self.account.last_label = "방금 동기화" self.account.error_detail = "" st = self._state_row(session) st.items_seen += res.seen; st.items_upserted += res.upserted st.last_delta_sync_at = _now() session.add_all([self.account, st]); session.commit() self._log(session, "sync", f"upserted={res.upserted} skipped={res.skipped}", res.upserted) def _finish_error(self, session, detail: str): self.account.state = ConnState.error self.account.error_detail = detail session.add(self.account); session.commit() self._log(session, "error", detail) def _log(self, session, action: str, detail: str = "", items: int = 0): from ..models import ConnectorAccountLog import uuid session.add(ConnectorAccountLog(id="cl-" + uuid.uuid4().hex[:8], account_id=self.account.id, action=action, detail=detail, items=items)) session.commit() ``` > `write()` 는 `(entity_id, created)` 를 반환한다 — `created=True` 일 때만 이벤트를 발행해 **재동기화로 같은 메일이 다시 알림을 만드는 일**을 막는다. `_is_duplicate` 는 etag 동일 시 skip — 멱등성의 두 번째 방어선(첫 번째는 `external_link` UNIQUE). --- ### 3.4 레지스트리 — `connectors/registry.py` `CONNECTOR_` 로 mock/real 을 고르는 단일 지점. 페이지/라우터는 **항상 레지스트리를 통해서만** 커넥터를 얻는다(코드 불변의 핵심). ```python # backend/app/connectors/registry.py from __future__ import annotations from sqlmodel import Session, select from ..config import get_settings from ..models import ConnectorAccount, ConnectorDomain from .base import BaseConnector # 도메인 × 모드 → 구현 클래스 (lazy import 로 의존성 격리) def _impl(domain: str, mode: str): if domain == "mail": from .mail.mock import MockMailConnector from .mail.real_gmail import GmailConnector from .mail.real_imap import ImapConnector return {"mock": MockMailConnector, "real": GmailConnector, "imap": ImapConnector}.get(mode, MockMailConnector) if domain == "calendar": from .calendar.mock import MockCalendarConnector from .calendar.real_google import GoogleCalendarConnector from .calendar.ics_import import IcsConnector return {"mock": MockCalendarConnector, "real": GoogleCalendarConnector, "ics": IcsConnector}.get(mode, MockCalendarConnector) if domain == "finance": from .finance.mock import MockFinanceConnector from .finance.csv_import import CsvFinanceConnector from .finance.real_aggregator import AggregatorConnector return {"mock": MockFinanceConnector, "csv": CsvFinanceConnector, "real": AggregatorConnector}.get(mode, MockFinanceConnector) if domain == "health": from .health.mock import MockHealthConnector from .health.healthkit_import import HealthKitConnector from .health.real_fit import GoogleFitConnector return {"mock": MockHealthConnector, "healthkit": HealthKitConnector, "real": GoogleFitConnector}.get(mode, MockHealthConnector) if domain == "knowledge": from .knowledge.mock import MockKnowledgeConnector from .knowledge.real_notion import NotionConnector return {"mock": MockKnowledgeConnector, "real": NotionConnector}.get(mode, MockKnowledgeConnector) if domain == "chat": from .chat.mock import MockChatConnector from .chat.real_webhook import WebhookChatConnector return {"mock": MockChatConnector, "real": WebhookChatConnector}.get(mode, MockChatConnector) raise ValueError(f"unknown domain {domain}") class ConnectorRegistry: @staticmethod def mode_for(domain: str) -> str: return getattr(get_settings(), f"connector_{domain}", "mock") @staticmethod def get(session: Session, account: ConnectorAccount) -> BaseConnector: """계정별 커넥터 인스턴스. 모드는 env(CONNECTOR_) > 계정.mode 순. 단, mock 시드 계정은 env 와 무관하게 mock 으로(데모 결정성).""" env_mode = ConnectorRegistry.mode_for(account.domain) mode = env_mode if account.mode != "mock" or env_mode != "real" else "mock" # 정책: env=real 이면 real 계정만 real 로. 시드 mock 계정은 그대로 mock. mode = env_mode if account.mode.value != "mock" else ( "mock" if env_mode == "real" else env_mode) Impl = _impl(account.domain, account.mode.value if account.mode.value != "mock" else env_mode) return Impl(account) @staticmethod def accounts(session: Session, domain: str | None = None) -> list[ConnectorAccount]: q = select(ConnectorAccount) if domain: q = q.where(ConnectorAccount.domain == domain) return session.exec(q.order_by(ConnectorAccount.id)).all() ``` > **모드 결정 정본**: 계정 row 의 `mode` 가 우선(연결 시 real 로 승격된 계정은 `mode=real`). env `CONNECTOR_=real` 은 "이 도메인에서 real 연동을 허용한다"는 게이트. 시드 mock 계정(`mode=mock`)은 env 가 real 이어도 **mock 으로 남아** 데모/CI 결정성을 지킨다 — real 데이터는 OAuth 로 새로 연결한 계정(`mode=real`)에서만 들어온다. 따라서 **mock 과 real 이 공존**할 수 있다(예: 메일은 real, 금융은 mock). --- ### 3.5 도메인 구현 — mail (대표 사례, 가장 상세) #### 3.5.1 `mail/mock.py` — 시드 기반 (기본값) `mail-data.js: emails` 를 그대로 fetch 한다. 이미 phase-9 가 `email` 테이블에 시드했다면 mock 은 "no-op sync"(이미 있는 데이터)로 동작하되, 데모용 "새 메일 1건 도착" 시뮬레이션을 위한 `simulate_new()` 진입점을 둔다. ```python # backend/app/connectors/mail/mock.py from datetime import datetime, timezone from sqlmodel import Session from ..base import BaseConnector, RawRecord, NormalizedRecord from .normalize import normalize_email # mail-data.js emails 에서 가져온 "새로 도착할" 시뮬레이션 메일(연합 데모용) _SIM = [{ "id": "sim-m1", "account": "work", "from": "hyunwoo", "to": "나", "subject": "온보딩 시안 v3 — 금요일까지 피드백 부탁드려요", "date": "오늘 09:12", "read": False, "starred": False, "preview": "지우님, 리뷰에서 나온 코멘트 반영해서 v3 정리했어요…", "labels": ["디자인"], }] class MockMailConnector(BaseConnector): domain = "mail" entity_type = "email" def fetch(self, session: Session, *, full: bool = False): # mock: 기본은 빈 스트림(시드는 이미 적재됨). 데모 시 simulate 로 1건 흘림. src = getattr(self, "_sim_payloads", []) for p in src: yield RawRecord(external_id=p["id"], payload=p, etag=p.get("date", ""), external_updated_at=datetime.now(timezone.utc)) def simulate_new(self): """데모: '새 메일 도착' 1건을 다음 sync 에 흘린다(연합 트리거).""" self._sim_payloads = _SIM return self def normalize(self, raw: RawRecord) -> NormalizedRecord: return normalize_email(self.account, raw) # provider-무관 정규화 공유 def write(self, session: Session, norm: NormalizedRecord): return _upsert_email(session, norm) def event_for(self, norm, entity_id): return "mail.received" ``` #### 3.5.2 `mail/real_gmail.py` — Gmail API OAuth2 액세스 토큰으로 Gmail REST(`users.messages.list` + `users.messages.get`)를 호출하고, 증분은 `historyId`(델타) 또는 `q=after:` 로 처리한다. ```python # backend/app/connectors/mail/real_gmail.py import base64 import httpx from datetime import datetime, timezone from sqlmodel import Session from ..base import BaseConnector, RawRecord, NormalizedRecord from ..oauth import valid_access_token # 만료 시 자동 refresh from ..ratelimit import RateLimiter from .normalize import normalize_email from ...config import get_settings API = "https://gmail.googleapis.com/gmail/v1" class GmailConnector(BaseConnector): domain = "mail" entity_type = "email" _rl = RateLimiter(rate=4, per=1.0) # Gmail per-user QPS 보수적 제한 def fetch(self, session: Session, *, full: bool = False): st = self._state_row(session) token = valid_access_token(session, self.account) # token_expired 면 예외 → sync 가 error 처리 headers = {"Authorization": f"Bearer {token}"} timeout = get_settings().connector_http_timeout page_size = get_settings().sync_page_size params = {"maxResults": page_size, "q": "" if full else "newer_than:7d"} if st.cursor and not full: params["q"] = f"after:{st.cursor}" # epoch seconds 커서 with httpx.Client(timeout=timeout) as c: self._rl.acquire() r = c.get(f"{API}/users/me/messages", headers=headers, params=params) self._rl.handle_response(r) # 429 → 백오프 r.raise_for_status() for m in r.json().get("messages", []): self._rl.acquire() d = c.get(f"{API}/users/me/messages/{m['id']}", headers=headers, params={"format": "full"}) self._rl.handle_response(d); d.raise_for_status() msg = d.json() yield RawRecord(external_id=msg["id"], payload=msg, etag=msg.get("historyId", ""), external_updated_at=_epoch_to_dt(msg.get("internalDate"))) st.cursor = str(int(datetime.now(timezone.utc).timestamp())) session.add(st); session.commit() def normalize(self, raw: RawRecord) -> NormalizedRecord: return normalize_email(self.account, raw, provider="gmail") def write(self, session: Session, norm: NormalizedRecord): return _upsert_email(session, norm) def event_for(self, norm, entity_id): return "mail.received" def _epoch_to_dt(ms: str | None): return datetime.fromtimestamp(int(ms) / 1000, tz=timezone.utc) if ms else None ``` `mail/real_imap.py`(HEY/일반 IMAP)는 `imaplib` 로 `UID SEARCH SINCE` 증분을 구현하되 동일 `RawRecord`/`normalize_email` 로 귀결한다(로컬 우선 대안 — OAuth 불필요). #### 3.5.3 `mail/normalize.py` — provider → 내부 `email` 모델 (정규화 골든) **provider 가 무엇이든** 동일한 내부 `email` 필드로 매핑한다. phase-9 의 `email` 모델 필드(`account/from/to/subject/body/labels/read/starred/attachments + ai{...}`)를 타깃으로 한다. 내부 `ai` 묶음(summary/priority/category/tasks/events/replies/file)은 **로컬 LLM(phase-9 의 메일 분석)** 이 채운다 — provider 는 raw 만 준다. ```python # backend/app/connectors/mail/normalize.py from datetime import datetime, timezone from ..base import RawRecord, NormalizedRecord from ...models import ConnectorAccount # mail-data.js account id ↔ connector_account 매핑(시드 정합) _ACCOUNT_KEY = {"ca-mail-work": "work", "ca-mail-personal": "personal", "ca-mail-side": "side"} def normalize_email(account: ConnectorAccount, raw: RawRecord, provider: str = "mock") -> NormalizedRecord: p = raw.payload if provider == "gmail": headers = {h["name"].lower(): h["value"] for h in p.get("payload", {}).get("headers", [])} fields = { "account": _ACCOUNT_KEY.get(account.id, "work"), "from_addr": headers.get("from", ""), "to_addr": headers.get("to", "나"), "subject": headers.get("subject", "(제목 없음)"), "body": _gmail_body(p), "labels": _gmail_labels(p.get("labelIds", [])), "read": "UNREAD" not in p.get("labelIds", []), "starred": "STARRED" in p.get("labelIds", []), "has_attach": _gmail_has_attach(p), "received_at": _internal_dt(p.get("internalDate")), "ai": None, # phase-9 메일 분석(로컬 LLM)이 후처리로 채움 } else: # mock(mail-data.js shape) / imap 등은 이미 내부 키와 유사 fields = { "account": p.get("account", "work"), "from_addr": p.get("from", ""), "to_addr": p.get("to", "나"), "subject": p.get("subject", ""), "body": "\n".join(p.get("body", [])) if isinstance(p.get("body"), list) else p.get("body", ""), "labels": p.get("labels", []), "read": p.get("read", False), "starred": p.get("starred", False), "has_attach": p.get("hasAttach", False), "received_at": datetime.now(timezone.utc), "ai": p.get("ai"), } return NormalizedRecord(entity_type="email", external_id=raw.external_id, fields=fields, etag=raw.etag, external_updated_at=raw.external_updated_at) def _internal_dt(ms): return datetime.fromtimestamp(int(ms) / 1000, tz=timezone.utc) if ms else datetime.now(timezone.utc) # _gmail_body / _gmail_labels / _gmail_has_attach: MIME 파트 파싱(생략 — base64url 디코딩, 라벨 한국어 매핑) ``` > **정규화 골든의 의미**: `test_normalize_golden.py` 가 "Gmail 페이로드 픽스처 → 위 `fields` dict" 의 매핑을 **고정**한다. provider 가 바뀌어도(IMAP/HEY) 동일 `fields` 로 귀결되어야 페이지(메일/알림/작업)가 코드 변경 없이 동작한다. 라벨은 Gmail `CATEGORY_PROMOTIONS` 등 → 내부 한국어 라벨(`["디자인"]`, `["금융"]` 등 `mail-data.js` 톤)로 매핑한다. #### 3.5.4 공통 upsert — `email` 멱등 저장 ```python # backend/app/connectors/mail/_upsert.py (또는 normalize.py 하단) import uuid from sqlmodel import Session, select from ...models import Email, ExternalLink # Email = phase-9 모델 def _upsert_email(session: Session, norm) -> tuple[str, bool]: link = session.exec(select(ExternalLink).where( ExternalLink.external_id == norm.external_id, ExternalLink.entity_type == "email")).first() if link: # 이미 존재 → update(멱등) e = session.get(Email, link.entity_id) if e: for k, v in norm.fields.items(): if v is not None and hasattr(e, k): setattr(e, k, v) session.add(e) return e.id, False eid = "m-" + uuid.uuid4().hex[:8] e = Email(id=eid, **{k: v for k, v in norm.fields.items() if v is not None}) session.add(e) return eid, True ``` > 다른 도메인(calendar/finance/health/knowledge/chat)도 **정확히 같은 4단 구조**(mock.py / real_*.py / normalize.py / _upsert)를 따른다. 표는 §3.6. --- ### 3.6 도메인별 매핑 요약표 | 도메인 | mock 출처(시드) | real 제공자 | 로컬 우선 대안 | 정규화 타깃(내부 모델, phase) | 발행 이벤트 | |---|---|---|---|---|---| | **mail** | `mail-data.js: emails/accounts` | Gmail API, Google Workspace | IMAP(HEY/일반) | `email`(phase-9) | `mail.received` | | **calendar** | `cal-data.js: events/focusBlocks` | Google Calendar API | `.ics` 파일 임포트 | `event`/`focus_block`/`meeting`(phase-8) | `calendar.updated` | | **chat** | (phase-9 알림 시드) | 메신저 Webhook/API | — | `notification`/`chat_message`(phase-9) | `chat.message` | | **finance** | `life-data.js: finance` | 카드/뱅크 아그리게이터 | **CSV 임포트**(카드 명세) | `finance`/`finance_tx`(phase-11) | `finance.transaction` | | **health** | `life-data.js: health` | Google Fit API | **HealthKit export(.zip/.xml)** | `health`(rings/vitals/sleep)(phase-11) | `health.sample` | | **knowledge** | `life-data.js: knowledge` | Notion API, Readwise | 웹 클리퍼, Markdown 노트 | `knowledge_item` + RAG ingest(phase-10/11) | `knowledge.ingested` | 도메인 구현 디테일 메모: - **calendar**: Google Calendar 증분은 `syncToken`(델타) — `events.list?syncToken=...`, 410 GONE 시 full resync 후 새 토큰 획득. `.ics` 임포트(`ics_import.py`)는 `icalendar` 파싱 → 동일 `event` 정규화(로컬 우선, OAuth 불필요). 정규화는 `cal-data.js` 의 `{day,start,end,title,cal,loc,people,note,soon}` 키로 귀결. - **finance**: 한국 금융 OAuth 아그리게이터는 불안정/지역 제약 → **CSV 임포트를 1급 시민**으로(`csv_import.py`). 우리카드/토스 CSV 헤더 → `finance_tx{date, merchant, amount, category, account}`. 카테고리는 `life-data.js: finance.cats`(식비/쇼핑/교통/카페·간식/구독/문화·여가) 로 매핑. 금액 단위는 원본대로 정수 원화. - **health**: Apple Health 는 공식 API 가 없어 **HealthKit export(.xml)** 임포트가 정본(`healthkit_import.py`) — `` 파싱 → `health.rings`(걸음/운동/수면). Google Fit 은 OAuth real. 원본 `life-data.js` 의 단위·문구("7,240보", "7h 12m", "안정 심박 62 bpm")를 표시 포맷으로 보존. - **knowledge**: Notion/Readwise/클리퍼 → `knowledge_item{type: article|note|idea|highlight, title, src, excerpt, tags, ai}` (life-data.js shape) + **RAG ingest 트리거**(phase-10 `backend/app/rag/ingest`). knowledge_item 은 리서치(phase-10)와 라이프(phase-11)가 공유. - **chat**: 인바운드 Webhook(`real_webhook.py`)으로 메신저 메시지 수신 → `notification` 트리아지(phase-9)로 흘림. OAuth 대신 서명 검증. --- ### 3.7 OAuth2 공통 헬퍼 — `connectors/oauth.py` Authorization Code + PKCE. start(인증 URL 생성·state 저장) → callback(code→token 교환·암호화 저장) → refresh(만료 시 자동). ```python # backend/app/connectors/oauth.py import base64, hashlib, os, secrets, time import httpx from sqlmodel import Session from ..config import get_settings from ..crypto import encrypt_token, decrypt_token from ..models import ConnectorAccount, OAuthState, ConnState PROVIDERS = { "google": { # Gmail + Google Calendar "auth": "https://accounts.google.com/o/oauth2/v2/auth", "token": "https://oauth2.googleapis.com/token", "scopes": {"gmail": ["https://www.googleapis.com/auth/gmail.readonly"], "google_calendar": ["https://www.googleapis.com/auth/calendar.events"]}, }, "notion": {"auth": "https://api.notion.com/v1/oauth/authorize", "token": "https://api.notion.com/v1/oauth/token", "scopes": {"notion": []}}, } def start_oauth(session: Session, domain: str, provider: str, redirect_after: str = "/life") -> str: st = get_settings() fam = "google" if provider in ("gmail", "google_calendar") else provider conf = PROVIDERS[fam] state = secrets.token_urlsafe(24) verifier = base64.urlsafe_b64encode(os.urandom(40)).decode().rstrip("=") challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=") session.add(OAuthState(id=state, domain=domain, provider=provider, code_verifier=verifier, redirect_after=redirect_after)) session.commit() scopes = " ".join(conf["scopes"].get(provider, [])) params = {"client_id": st.google_client_id if fam == "google" else st.notion_client_id, "redirect_uri": st.google_redirect_uri, "response_type": "code", "scope": scopes, "state": state, "access_type": "offline", "prompt": "consent", "code_challenge": challenge, "code_challenge_method": "S256"} return f"{conf['auth']}?" + "&".join(f"{k}={httpx.QueryParams({k:v})[k]}" for k, v in params.items()) def finish_oauth(session: Session, code: str, state: str) -> ConnectorAccount: os_row = session.get(OAuthState, state) if not os_row: raise ValueError("invalid oauth state") st = get_settings() fam = "google" if os_row.provider in ("gmail", "google_calendar") else os_row.provider conf = PROVIDERS[fam] data = {"code": code, "grant_type": "authorization_code", "redirect_uri": st.google_redirect_uri, "code_verifier": os_row.code_verifier, "client_id": st.google_client_id if fam == "google" else st.notion_client_id, "client_secret": st.google_client_secret if fam == "google" else st.notion_client_secret} r = httpx.post(conf["token"], data=data, timeout=st.connector_http_timeout) r.raise_for_status() tok = r.json() tok["expires_at"] = int(time.time()) + int(tok.get("expires_in", 3600)) # 새 real 계정 생성(또는 기존 계정 승격) → mode=real acct = _ensure_account(session, os_row.domain, os_row.provider) acct.mode = "real"; acct.state = ConnState.connected acct.token_enc = encrypt_token(tok); acct.scopes = tok.get("scope", "") acct.last_label = "방금 연결됨" session.add(acct); session.delete(os_row); session.commit() return acct def valid_access_token(session: Session, account: ConnectorAccount) -> str: """만료되면 refresh_token 으로 갱신. 갱신 불가면 token_expired 로 표시 후 예외.""" tok = decrypt_token(account.token_enc) if not tok: account.state = ConnState.token_expired; session.add(account); session.commit() raise PermissionError("token missing/corrupt") if tok.get("expires_at", 0) > int(time.time()) + 60: return tok["access_token"] # refresh st = get_settings() fam = "google" if account.provider in ("gmail", "google_calendar") else account.provider conf = PROVIDERS[fam] rt = tok.get("refresh_token") if not rt: account.state = ConnState.token_expired; session.add(account); session.commit() raise PermissionError("no refresh_token") r = httpx.post(conf["token"], data={ "grant_type": "refresh_token", "refresh_token": rt, "client_id": st.google_client_id if fam == "google" else st.notion_client_id, "client_secret": st.google_client_secret if fam == "google" else st.notion_client_secret}, timeout=st.connector_http_timeout) if r.status_code != 200: account.state = ConnState.token_expired; session.add(account); session.commit() raise PermissionError("refresh failed") new = r.json(); new["refresh_token"] = rt new["expires_at"] = int(time.time()) + int(new.get("expires_in", 3600)) account.token_enc = encrypt_token(new); session.add(account); session.commit() return new["access_token"] ``` --- ### 3.8 레이트리밋 — `connectors/ratelimit.py` ```python # backend/app/connectors/ratelimit.py import threading, time import httpx class RateLimiter: """간단 토큰 버킷 + 429/Retry-After 지수 백오프.""" def __init__(self, rate: float = 4, per: float = 1.0, max_backoff: float = 32.0): self.rate, self.per = rate, per self.allowance = rate; self.last = time.monotonic() self.max_backoff = max_backoff; self._lock = threading.Lock() def acquire(self): with self._lock: now = time.monotonic() self.allowance += (now - self.last) * (self.rate / self.per); self.last = now if self.allowance > self.rate: self.allowance = self.rate if self.allowance < 1.0: time.sleep((1.0 - self.allowance) * (self.per / self.rate)); self.allowance = 0.0 else: self.allowance -= 1.0 def handle_response(self, r: httpx.Response, attempt: int = 0): if r.status_code == 429: retry = float(r.headers.get("Retry-After", min(2 ** attempt, self.max_backoff))) time.sleep(min(retry, self.max_backoff)) return True # 호출측이 재시도 return False ``` --- ### 3.9 라우터 — `routers/connectors.py` phase-2 규약: 라우터 내부 prefix 없음, `main.py` 에서 `include_router(connectors.router, prefix="/api", tags=["connectors"])`. ```python # backend/app/routers/connectors.py from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi.responses import RedirectResponse from sqlmodel import Session, select from ..db import get_session from ..models import ConnectorAccount, ConnectorAccountLog, ConnState from ..connectors.registry import ConnectorRegistry from ..connectors import oauth as oauthlib from ..schemas import (ConnectorStatusOut, OAuthStartOut, SyncResultOut, ImportResultOut) from ..config import get_settings router = APIRouter() # prefix 없음. main.py 에서 prefix="/api". @router.get("/connectors", response_model=list[ConnectorStatusOut]) def list_connectors(domain: str | None = None, s: Session = Depends(get_session)): rows = ConnectorRegistry.accounts(s, domain) return [ConnectorStatusOut.from_account(a, ConnectorRegistry.mode_for(a.domain)) for a in rows] @router.post("/connectors/{account_id}/sync", response_model=SyncResultOut) def sync_one(account_id: str, full: bool = False, s: Session = Depends(get_session)): a = s.get(ConnectorAccount, account_id) if not a: raise HTTPException(404, "connector account not found") conn = ConnectorRegistry.get(s, a) res = conn.sync(s, full=full) return SyncResultOut(**res.__dict__) @router.post("/connectors/sync-all", response_model=list[SyncResultOut]) def sync_all(s: Session = Depends(get_session)): out = [] for a in ConnectorRegistry.accounts(s): if a.state == ConnState.disconnected: # 연결 안 된 계정은 건너뜀 continue out.append(SyncResultOut(**ConnectorRegistry.get(s, a).sync(s).__dict__)) return out # ── OAuth ── @router.get("/connectors/oauth/start", response_model=OAuthStartOut) def oauth_start(domain: str, provider: str, redirect_after: str = "/life", s: Session = Depends(get_session)): if not get_settings().google_client_id and provider in ("gmail", "google_calendar"): raise HTTPException(400, "google_client_id 미설정 — real 연동 비활성") url = oauthlib.start_oauth(s, domain, provider, redirect_after) return OAuthStartOut(authorize_url=url) @router.get("/connectors/oauth/callback") def oauth_callback(code: str, state: str, s: Session = Depends(get_session)): try: acct = oauthlib.finish_oauth(s, code, state) except Exception as e: return RedirectResponse(url=f"{get_settings().frontend_origin}/life?connect=error") # 연결 직후 1회 초기 sync ConnectorRegistry.get(s, acct).sync(s, full=True) redirect = s and "/life" return RedirectResponse(url=f"{get_settings().frontend_origin}/life?connect=ok&domain={acct.domain}") @router.post("/connectors/{account_id}/disconnect", response_model=ConnectorStatusOut) def disconnect(account_id: str, s: Session = Depends(get_session)): a = s.get(ConnectorAccount, account_id) if not a: raise HTTPException(404, "not found") a.state = ConnState.disconnected; a.token_enc = ""; a.last_label = "연결 안 됨" import uuid s.add(a); s.add(ConnectorAccountLog(id="cl-"+uuid.uuid4().hex[:8], account_id=a.id, action="disconnected", detail="사용자 해제")) s.commit() return ConnectorStatusOut.from_account(a, ConnectorRegistry.mode_for(a.domain)) # ── 로컬 우선: 수동 임포트(CSV / .ics / HealthKit export) ── @router.post("/connectors/{account_id}/import", response_model=ImportResultOut) async def import_file(account_id: str, file: UploadFile = File(...), s: Session = Depends(get_session)): a = s.get(ConnectorAccount, account_id) if not a: raise HTTPException(404, "not found") content = await file.read() conn = ConnectorRegistry.get(s, a) # csv/ics/healthkit 모드 커넥터 if not hasattr(conn, "import_bytes"): raise HTTPException(400, "이 커넥터는 파일 임포트를 지원하지 않아요") res = conn.import_bytes(s, content, filename=file.filename) # 내부에서 fetch→normalize→write return ImportResultOut(**res) ``` > **연결 흐름(real)**: 프론트가 `GET /api/connectors/oauth/start?domain=mail&provider=gmail` 으로 `authorize_url` 을 받아 새 창으로 이동 → 사용자 동의 → 제공자가 `GET /api/connectors/oauth/callback?code=&state=` 호출 → 토큰 교환·암호화 저장·초기 full sync → `frontend_origin/life?connect=ok` 로 리다이렉트. **로컬 우선 흐름(import)**: `POST /api/connectors/{id}/import` 멀티파트 업로드(CSV/.ics/HealthKit export) → OAuth 없이 즉시 정규화·저장. --- ### 3.10 능동 레이어 — `worker/sync_jobs.py` (주기 sync) phase-7 의 `backend/app/worker/`(APScheduler)에 주기 동기화 잡을 등록한다. 프로토타입은 수동 트리거(`/api/connectors/sync-all`)도 제공(데모 결정성). ```python # backend/app/worker/sync_jobs.py from ..config import get_settings from ..db import engine from sqlmodel import Session from ..connectors.registry import ConnectorRegistry from ..models import ConnState def run_periodic_sync(): """worker 스케줄러가 sync_interval_minutes 마다 호출. real/연결된 계정만 sync.""" with Session(engine) as s: for a in ConnectorRegistry.accounts(s): if a.state in (ConnState.connected, ConnState.error) and a.mode.value != "mock": ConnectorRegistry.get(s, a).sync(s) def register(scheduler): """phase-7 worker.main 에서 호출.""" mins = get_settings().sync_interval_minutes scheduler.add_job(run_periodic_sync, "interval", minutes=mins, id="connector_sync", replace_existing=True) ``` > mock 계정은 주기 sync 대상이 아니다(데이터가 변하지 않음). real/연결 계정만 `sync_interval_minutes`(기본 15분) 간격으로 증분 sync → 새 객체에서만 이벤트 발행 → 자동화/알림 작동. --- ## 4. 데이터/타입/API 계약 ### 4.1 신규 엔드포인트 (prefix `/api`) | 메서드 | 경로 | 용도 | |---|---|---| | GET | `/api/connectors?domain=` | 커넥터 계정 상태 목록(`ConnectorStatusOut[]`) — 라이프 카드/설정에서 사용 | | POST | `/api/connectors/{account_id}/sync?full=` | 단일 계정 수동 동기화 → `SyncResultOut` | | POST | `/api/connectors/sync-all` | 연결된 전 계정 동기화 → `SyncResultOut[]` | | GET | `/api/connectors/oauth/start?domain=&provider=&redirect_after=` | OAuth 인증 URL 발급 → `OAuthStartOut` | | GET | `/api/connectors/oauth/callback?code=&state=` | OAuth 콜백(토큰 교환·저장·초기 sync) → 프론트 리다이렉트 | | POST | `/api/connectors/{account_id}/disconnect` | 연결 해제(토큰 폐기) → `ConnectorStatusOut` | | POST | `/api/connectors/{account_id}/import` (multipart) | CSV/.ics/HealthKit export 수동 임포트 → `ImportResultOut` | ### 4.2 Pydantic 스키마 (`schemas.py` 추가, `types.ts` 와 1:1) ```python # backend/app/schemas.py (추가) from datetime import datetime from typing import Optional from pydantic import BaseModel class ConnectorStatusOut(BaseModel): id: str domain: str provider: str name: str # "회사" / "우리카드" / "Apple Health" kind: str # "Lumi Workspace" / "신용/체크" / "건강 앱" tone: str # blue|violet|coral|green|amber|ink|faint mode: str # mock | csv | healthkit | real env_mode: str # CONNECTOR_ 값(real 허용 여부 표시) state: str # connected|disconnected|syncing|error|token_expired on: bool # state==connected (라이프 카드 on 호환) last: str # last_label ("방금 동기화"/"오늘 09:12"/"연결 안 됨") last_synced_at: Optional[datetime] = None error_detail: str = "" @classmethod def from_account(cls, a, env_mode: str) -> "ConnectorStatusOut": return cls(id=a.id, domain=a.domain, provider=a.provider, name=a.name, kind=a.kind, tone=a.tone, mode=a.mode, env_mode=env_mode, state=a.state, on=(a.state == "connected"), last=a.last_label, last_synced_at=a.last_synced_at, error_detail=a.error_detail) class OAuthStartOut(BaseModel): authorize_url: str class SyncResultOut(BaseModel): domain: str account_id: str seen: int = 0 upserted: int = 0 skipped: int = 0 errors: int = 0 events_published: list[str] = [] detail: str = "" class ImportResultOut(BaseModel): entity_type: str imported: int = 0 skipped: int = 0 errors: int = 0 detail: str = "" ``` ```ts // frontend/lib/types.ts (추가 — 백엔드 키 snake_case 유지) export type ConnState = | "connected" | "disconnected" | "syncing" | "error" | "token_expired"; export interface ConnectorStatus { id: string; domain: "mail" | "calendar" | "chat" | "finance" | "health" | "knowledge"; provider: string; name: string; kind: string; tone: "blue" | "violet" | "coral" | "green" | "amber" | "ink" | "faint"; mode: "mock" | "csv" | "healthkit" | "real"; env_mode: string; state: ConnState; on: boolean; last: string; last_synced_at: string | null; error_detail: string; } export interface OAuthStart { authorize_url: string; } export interface SyncResult { domain: string; account_id: string; seen: number; upserted: number; skipped: number; errors: number; events_published: string[]; detail: string; } export interface ImportResult { entity_type: string; imported: number; skipped: number; errors: number; detail: string; } ``` ### 4.3 요청/응답 JSON 예시 `GET /api/connectors?domain=health` (시드 mock — `life-data.js: sources.health` 그대로): ```json [ {"id":"ca-health-apple","domain":"health","provider":"apple_health","name":"Apple Health", "kind":"건강 앱","tone":"coral","mode":"mock","env_mode":"mock","state":"connected", "on":true,"last":"방금 동기화","last_synced_at":null,"error_detail":""}, {"id":"ca-health-watch","domain":"health","provider":"apple_watch","name":"Apple Watch", "kind":"웨어러블","tone":"violet","mode":"mock","env_mode":"mock","state":"connected", "on":true,"last":"1분 전","last_synced_at":null,"error_detail":""}, {"id":"ca-health-fit","domain":"health","provider":"google_fit","name":"Google Fit", "kind":"활동 추적","tone":"green","mode":"mock","env_mode":"mock","state":"disconnected", "on":false,"last":"연결 안 됨","last_synced_at":null,"error_detail":""} ] ``` `POST /api/connectors/ca-mail-personal/sync` (Gmail real, 새 메일 2건): ```json { "domain": "mail", "account_id": "ca-mail-personal", "seen": 12, "upserted": 2, "skipped": 10, "errors": 0, "events_published": ["mail.received", "mail.received"], "detail": "" } ``` `GET /api/connectors/oauth/start?domain=calendar&provider=google_calendar`: ```json { "authorize_url": "https://accounts.google.com/o/oauth2/v2/auth?client_id=...&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar.events&state=...&code_challenge=...&code_challenge_method=S256" } ``` `POST /api/connectors/ca-fin-woori/import` (우리카드 CSV 업로드, multipart): ```json { "entity_type": "finance_tx", "imported": 47, "skipped": 3, "errors": 0, "detail": "우리카드 6월 명세 · 식비 412,000 · 쇼핑 298,000 정규화 완료" } ``` --- ## 5. 디자인 충실도 노트 기준 파일(REF): `design-reference/assets/life-data.js`, `mail-data.js`, `cal-data.js`, `life.css`, `dash.css`(토큰). ### 5.1 라이프 connector 카드 — `sources` 의 on/last 그대로 원본 `life-data.js: sources` 의 각 항목은 `{id, name, kind, tone, on, last}` 다. 이 carded UI 를 Phase 13 이 **실제 커넥터 상태로** 채운다(값은 mock 일 때 원본과 1바이트 동일). ``` 원본 life-data.js: { id:"apple", name:"Apple Health", kind:"건강 앱", tone:"coral", on:true, last:"방금 동기화" } { id:"toss", name:"토스", kind:"계좌 통합", tone:"coral", on:true, last:"오늘 08:40" } { id:"fit", name:"Google Fit", kind:"활동 추적", tone:"green", on:false, last:"연결 안 됨" } → ConnectorStatusOut: name/kind/tone 동일, on=state=="connected", last=last_label ``` `ConnectorCard.tsx` 는 phase-11 의 라이프 카드를 재사용한다. 톤은 **키**(`"coral"` 등)로 받아 `var(--coral)`(=`#df7256`, dash.css) 로 변환(CONTRACT: tone 은 백엔드 키 저장, 프론트 `var(--tone)` 변환). `on:false` 카드는 흐리게(`--faint #b6b3ad`) + "연결 안 됨" + [연결] 버튼. ### 5.2 메일 계정 톤·라벨 보존 `mail-data.js: accounts` 의 3계정 톤(`work=coral`, `personal=blue`, `side=violet`)·`kind`(Lumi Workspace/Google/HEY)·`unread` 배지를 그대로 유지. 정규화 라벨은 원본 메일 `labels`(`["디자인"]`, `["경영진"]`, `["금융"]`, `["채용"]`, `["영수증"]` …)와 동일 한국어 라벨로 매핑. ### 5.3 상태 배지 색 (dash.css 액센트 그대로) | 상태 | 라벨 | tone/색 | |---|---|---| | connected | "방금 동기화" / "오늘 09:12" / "1분 전" | `--green #4e9b66` 점 | | syncing | "동기화 중…" | `--blue #4f72e0` (스피너) | | disconnected | "연결 안 됨" | `--faint #b6b3ad` | | error | "동기화 실패 · 다시 시도" | `--coral #df7256` | | token_expired | "권한 만료 · 다시 연결" | `--amber #e0a23c` | > 글래스 카드는 `background: var(--glass)` + `backdrop-filter: var(--blur)`(blur(26px) saturate(190%)) + `border: 1px solid var(--glass-brd)`(dash.css §11.4). 라운드 `--radius 22px`. 라이트/다크는 `data-theme`(next-themes) 그대로. ### 5.4 캘린더/건강/금융 표시 포맷 보존 - 일정: `cal-data.js` 의 `start/end`("09:30"), `loc`("Zoom"/"대회의실"), `soon:true`(분기 전략 미팅) 보존. real 동기화 시에도 동일 키. - 건강: "7,240보 / 10,000보 / 72%", "수면 7h 12m", "안정 심박 62 bpm" 등 `.mono`(tabular-nums) 표시. - 금융: 금액 정수 원화(`1,284,000`), 카테고리 6종, 구독 spark — `life-data.js: finance` 포맷 그대로. --- ## 6. 상태 처리 & 엣지 케이스 | 상황 | 기대 동작 | 구현 포인트 | |---|---|---| | **로딩** | 커넥터 목록/sync 중 스켈레톤 + "동기화 중…" 배지 | `state=syncing`, 프론트 낙관적 표시 | | **빈 상태** | 도메인에 연결 계정 0개 | "아직 연결된 OO이 없어요 · [연결하기]" 빈-상태 카드 | | **연결(connect)** | OAuth 동의 후 `connect=ok` 리다이렉트 + 초기 full sync | `oauth_callback` 에서 1회 full sync | | **해제(disconnect)** | 토큰 폐기(`token_enc=""`), state→disconnected, last→"연결 안 됨" | `/disconnect`, 데이터는 보존(이미 받은 메일 유지) | | **오류(error)** | sync 예외 → state=error, error_detail 저장, **서버 안 죽음** | `BaseConnector.sync` 의 `except` rollback + `_finish_error` | | **동기화 중(syncing)** | 재진입 sync 방지 권장(같은 계정 동시 sync) | `state==syncing` 이면 409 또는 skip | | **권한 만료(token_expired)** | refresh 실패 → state=token_expired, "다시 연결" CTA | `valid_access_token` 의 PermissionError → sync error → UI 재인증 | | **레이트리밋(429)** | Retry-After 만큼 백오프 후 재시도, 초과 시 부분 결과 commit | `RateLimiter.handle_response` | | **중복/충돌** | 같은 external_id 재수신 → upsert(중복 0). etag 동일 → skip | `external_link` UNIQUE + `_is_duplicate` | | **오프라인(Ollama/네트워크 끊김)** | mock 은 정상, real 은 fetch 실패→error 로 표시(데모는 mock 폴백) | env 기본 mock, real 실패가 앱을 멈추지 않음 | | **CSV 형식 오류** | 파싱 실패 행 skip + `errors` 카운트, 정상 행만 import | `csv_import.import_bytes` 행 단위 try/except | | **암호화 키 손상/회전** | `decrypt_token` → `{}` → token_expired (재연결 유도) | `crypto.decrypt_token` InvalidToken 폴백 | | **mock↔real 공존** | 메일=real, 금융=mock 동시 가능 | 계정별 `mode`, env 는 도메인 게이트 | | **시드 mock 보호** | env=real 이어도 시드 mock 계정은 mock 유지(데모 결정성) | `ConnectorRegistry.get` 모드 결정 정본 | 엣지 메모: - `GOOGLE_CLIENT_ID` 미설정인데 `/oauth/start` 호출 → 400 "real 연동 비활성"(앱 안 죽음). - `.ics`/CSV 임포트는 OAuth 불필요 → 클라이언트 비밀 없이도 **로컬 우선** 데이터 유입 보장. - 재동기화로 같은 메일이 들어와도 `created=False` → `mail.received` **재발행 안 함**(중복 알림 방지). --- ## 7. 연합 이벤트 (발행/구독) CONTRACT 의 `event_bus`(phase-7 정본) 이벤트 모델을 사용한다. **이 phase 의 핵심 가치**: mock 에서는 시뮬레이션이던 이벤트가 real 에서 **실제 외부 데이터로 발행**되어, phase 7~12 의 구독자가 진짜 데이터로 동작한다. ### 7.1 이 phase가 발행하는 이벤트 | 이벤트 | 발행 시점 | 페이로드 | 주 구독자 | |---|---|---|---| | `mail.received` | mail sync 가 새 email upsert(`created=True`) | `{account_id, entity_type:"email", entity_id}` | phase-9(메일 분석 → `email.ai.tasks/events`), 자동화 `evaluator`(영수증/뉴스레터 정리 규칙), 알림 트리아지 | | `calendar.updated` | calendar sync 가 새/변경 event upsert | `{account_id, entity_type:"event", entity_id}` | phase-8(일정 표시·회의 도우미), 집중 모드 빈 블록 재계산, 리스크 레이더 | | `finance.transaction` | finance sync/CSV import 가 새 거래 | `{account_id, entity_type:"finance_tx", entity_id}` | phase-11(예산/카테고리 갱신), 자동화(예산 초과·잠자는 구독 제안 → 결재함) | | `health.sample` | health sync/HealthKit import 가 새 샘플 | `{account_id, entity_type:"health_sample", entity_id}` | phase-11(rings/sleep 갱신, 코치 리마인더) | | `knowledge.ingested` | knowledge sync 가 새 knowledge_item | `{account_id, entity_type:"knowledge_item", entity_id}` | phase-10 RAG ingest(chunk→embed), 리서치 Q&A·라이프 지식 | | `chat.message` | chat webhook 수신 | `{account_id, entity_type:"chat_message", entity_id}` | phase-9 알림 트리아지 | | `connector.synced` | 모든 도메인 sync 완료 | `{account_id, upserted, skipped, errors}` | phase-12 하루 마감(동기화 요약), 라이프 카드 last 갱신 | | `connector.error` | sync 실패/토큰 만료 | `{account_id, detail, state}` | 알림(재연결 안내), 라이프 카드 error 배지 | ### 7.2 이 phase가 구독하는 이벤트 | 구독 이벤트 | 출처 | 처리 | |---|---|---| | `automation.matched` (mail/calendar 동작) | phase-7 evaluator | `BaseConnector.write()`(예: calendar_write tool → real 캘린더에 이벤트 쓰기) — 양방향(write 메서드) | | `worker.tick` (스케줄러) | phase-7 worker | `run_periodic_sync()` 실행(증분 sync) | ### 7.3 연합 시나리오 (PROJECT-README §5 — 실데이터 버전) ``` [real Gmail 연결됨] 현우님 "온보딩 시안 v3" 메일 도착 → GmailConnector.sync(): fetch → normalize_email → write(email) created=True → event_bus.publish("mail.received", {entity_id:"m-xxxx"}) → phase-9 메일 분석(로컬 LLM): ai.tasks=[{온보딩 시안 v3 피드백 정리, due 6/13, 높음}] ai.events=[], ai.replies=[{수락·일정 약속, ...}] (mail-data.js m1 의 ai 묶음과 동일 형태) → 자동화 evaluator: "검토 요청 메일 → 작업 초안" 규칙 매칭 → autonomy_setting=mixed, risk=low → 작업 자동 생성(되돌리기) → 결재함 a 항목 → 작업 페이지 GET /api/tasks 에 등장 (federation, 개인=프로젝트 원칙) → 하루 마감: "아리가 처리" 카운트 +1, 아낀 시간 합산 ``` > 이 흐름은 mock(`MockMailConnector.simulate_new()`)에서도 **동일 코드**로 재현된다 — 그래서 데모/테스트가 결정적이다. real 은 같은 이벤트를 진짜 메일에서 만들 뿐이다(인터페이스 불변의 증거). --- ## 8. 테스팅 & 검증 ### 8.1 실행 명령 ```bash # 의존성 추가(uv) cd /Users/kim1634/mycloud/personal/workspace/ari_assistant/workspace/backend uv add cryptography uv add --dev respx # (pip 폴백: pip install cryptography && pip install respx) # 마이그레이션 + 시드(커넥터 mock 계정 포함) uv run alembic upgrade head uv run python -m app.seed # run_seed() → _seed_connectors() 포함 # 백엔드 테스트 uv run pytest tests/test_connector_contract.py tests/test_connector_swap.py \ tests/test_oauth_mock.py tests/test_sync_idempotent.py \ tests/test_normalize_golden.py tests/test_crypto.py \ tests/test_federation_real.py -q uv run pytest -q # 전체(기존 phase 회귀 포함) # 수동 스모크 curl -s http://localhost:8000/api/connectors?domain=health | python3 -m json.tool curl -s -X POST http://localhost:8000/api/connectors/ca-mail-work/sync | python3 -m json.tool # 프론트 cd ../frontend && pnpm test pnpm exec playwright test playwright/connectors.spec.ts ``` ### 8.2 테스트 케이스 목록 | # | 케이스 | 방법 | 통과 기준 | |---|---|---|---| | C1 | **인터페이스 계약**: 모든 도메인 mock/real 이 `fetch/normalize/write/sync` 구현 | `test_connector_contract.py` — 각 클래스가 `BaseConnector` 서브클래스 + 메서드 존재 | 6개 도메인 × (mock + real/alt) 전부 통과, 추상 미구현 0 | | C2 | **Mock↔Real 스왑 동일 shape**: `CONNECTOR_MAIL=mock` vs `real`(fake) 에서 `/api/connectors` 응답 키 동일 | `test_connector_swap.py` — env monkeypatch + 응답 스키마 비교 | `ConnectorStatusOut` 키/타입 동일, 페이지 코드 변경 0 | | C3 | **OAuth 모킹**: start→callback→token 저장(암호화) | `test_oauth_mock.py` — `respx` 로 token 엔드포인트 모킹 | `connector_account.token_enc` 비어있지 않고 평문 access_token **미포함**, state row 소거 | | C4 | **동기화 멱등성**: 같은 페이로드 2회 sync → 중복 0 | `test_sync_idempotent.py` — fake fetch 동일 레코드 2회 | 1회차 upserted=N, 2회차 upserted=0 & skipped=N, `external_link` row N개 | | C5 | **정규화 골든**: Gmail 픽스처 → 고정 `email.fields` | `test_normalize_golden.py` — 픽스처 vs 기대 dict | 필드별 정확 매핑(subject/from/labels/read/starred) | | C6 | **토큰 암복호화 왕복** | `test_crypto.py` | `decrypt(encrypt(x))==x`, 잘못된 키 → `{}` (예외 누수 없음) | | C7 | **연합(real fake) → 이벤트 발행** | `test_federation_real.py` — fake GmailConnector sync → event_bus 스파이 | 새 메일당 `mail.received` 1회, 재sync 시 0회 | | C8 | **토큰 만료 → token_expired** | refresh 401 모킹 | state=token_expired, sync errors≥1, 서버 200(앱 안 죽음) | | C9 | **레이트리밋 백오프**: 429 → Retry-After 후 재시도 | `RateLimiter` 단위 + 429 모킹 | 백오프 호출 발생, 최종 성공 | | C10 | **CSV 임포트(로컬 우선)**: 우리카드 CSV → finance_tx | `import_bytes` 단위 + 오류 행 skip | imported>0, 오류 행 skip+errors 카운트 | | C11 | **.ics 임포트**: 이벤트 → `event` 정규화 | `IcsConnector.import_bytes` | `cal-data.js` 키로 귀결 | | C12 | **disconnect 토큰 폐기** | `/disconnect` 호출 | `token_enc==""`, state=disconnected, 기존 데이터 보존 | | C13 | **mock 시드 보호**: env=real 인데 시드 mock 계정 | registry 모드 결정 | 시드 계정 mock 유지(데모 결정성) | | C14 | **회귀**: phase 2~12 기존 테스트 | `uv run pytest -q` | 전부 green(인터페이스 불변 증명) | ### 8.3 대표 테스트 코드 ```python # backend/tests/test_sync_idempotent.py from app.connectors.mail.mock import MockMailConnector from app.models import ConnectorAccount, ExternalLink, Email from sqlmodel import select def test_sync_is_idempotent(session, seed): acct = session.get(ConnectorAccount, "ca-mail-work") conn = MockMailConnector(acct).simulate_new() # 새 메일 1건 흘림 r1 = conn.sync(session) conn2 = MockMailConnector(acct).simulate_new() # 같은 external_id 재공급 r2 = conn2.sync(session) assert r1.upserted == 1 and r2.upserted == 0 and r2.skipped == 1 links = session.exec(select(ExternalLink).where( ExternalLink.account_id == "ca-mail-work")).all() assert len([l for l in links if l.external_id == "sim-m1"]) == 1 # 중복 0 ``` ```python # backend/tests/test_oauth_mock.py import respx, httpx from app.connectors import oauth from app.crypto import decrypt_token @respx.mock def test_oauth_callback_stores_encrypted_token(session, monkeypatch): monkeypatch.setenv("GOOGLE_CLIENT_ID", "cid"); monkeypatch.setenv("GOOGLE_CLIENT_SECRET", "sec") url = oauth.start_oauth(session, "mail", "gmail") state = url.split("state=")[1].split("&")[0] respx.post("https://oauth2.googleapis.com/token").mock( return_value=httpx.Response(200, json={ "access_token": "AT", "refresh_token": "RT", "expires_in": 3600, "scope": "...readonly"})) acct = oauth.finish_oauth(session, code="CODE", state=state) assert acct.mode == "real" and acct.state == "connected" assert "AT" not in acct.token_enc # 평문 노출 금지 assert decrypt_token(acct.token_enc)["access_token"] == "AT" ``` ```python # backend/tests/test_connector_swap.py def test_status_shape_same_mock_and_real(client, monkeypatch): monkeypatch.setenv("CONNECTOR_MAIL", "mock") mock_keys = set(client.get("/api/connectors?domain=mail").json()[0].keys()) monkeypatch.setenv("CONNECTOR_MAIL", "real") real_keys = set(client.get("/api/connectors?domain=mail").json()[0].keys()) assert mock_keys == real_keys # 페이지가 보는 shape 불변 ``` ### 8.4 수동 QA 체크리스트 - [ ] `python -m app.seed` 후 `/api/connectors` 가 mail 3 + calendar 1 + health 3 + finance 4 + knowledge 3 = **14 계정** 반환, 값이 `mail-data.js`/`life-data.js` 와 동일. - [ ] Google Fit·KB증권은 `on:false` + `last:"연결 안 됨"`. - [ ] 라이프 페이지 connector 카드가 `/api/connectors` 값으로 렌더(톤·last 문구 원본 일치). - [ ] `CONNECTOR_*` 전부 mock(기본)에서 모든 페이지가 phase 7~12 와 **동일하게** 동작(회귀 없음). - [ ] `GOOGLE_CLIENT_ID` 미설정 시 `/oauth/start?provider=gmail` → 400(앱 안 죽음), mock 페이지 정상. - [ ] (자격증명 있을 때) OAuth start → 동의 → callback → `connect=ok` 리다이렉트 + 초기 sync → 메일 등장. - [ ] 같은 계정 sync 2회 → 두 번째 `upserted:0`(중복 없음). - [ ] CSV(우리카드 명세) 임포트 → 금융 거래/카테고리 갱신, OAuth 불필요. - [ ] `.ics` 임포트 → 일정에 이벤트 등장, OAuth 불필요. - [ ] sync 실패(네트워크 끊김) → 카드 error 배지 + "다시 시도", **서버 200·앱 살아있음**. - [ ] 토큰 만료 시 "권한 만료 · 다시 연결"(amber) 배지. - [ ] DB 의 `connector_account.token_enc` 에 **평문 토큰이 보이지 않음**(grep 으로 access_token 미검출). - [ ] disconnect 후 토큰 폐기되지만 이미 받은 메일/일정은 유지. ### 8.5 통과 기준 (요약) C1~C14 전부 green + 수동 체크리스트 전 항목. **핵심 합격선**: (1) `CONNECTOR_*=mock` 기본에서 phase 7~12 회귀 0(인터페이스 불변), (2) real(fake) sync 가 동일 코드 경로로 `mail.received`/`calendar.updated` 를 발행, (3) 동기화 멱등(중복 0), (4) 토큰 평문 미저장, (5) 모든 외부 실패가 앱을 멈추지 않음(mock/로컬 폴백). --- ## 9. 완료 기준 (Definition of Done) - [ ] `backend/app/connectors/`: `base.py`(fetch/sync/normalize/write 추상 + 공통 upsert/멱등/이벤트), `registry.py`(CONNECTOR_ 선택), `oauth.py`(Auth Code+PKCE start/callback/refresh), `ratelimit.py`, `crypto.py` 존재. - [ ] 6개 도메인(mail/calendar/chat/finance/health/knowledge) 각각 `mock.py` + `real_*.py`(또는 csv/ics/healthkit alt) + `normalize.py` 존재, 모두 `BaseConnector` 계약 충족. - [ ] 신규 5개 테이블(`connector_account`, `connector_sync_state`, `external_link`(복합 UNIQUE), `oauth_state`, `connector_account_log`) Alembic 마이그레이션 + 왕복 OK. - [ ] `_seed_connectors()` 가 `run_seed()` 에 통합되어 14개 mock 계정 시드(값 = `mail-data.js`/`life-data.js`/`cal-data.js` 원본). - [ ] `config.py` 에 `CONNECTOR_`(기본 mock) + OAuth/암호화 env, `.env` 예시 갱신. - [ ] 라우터 `/api/connectors`(상태/sync/sync-all/oauth start·callback/disconnect/import) + `main.py` `include_router(prefix="/api")`. - [ ] **mock 기본에서 phase 7~12 전부 회귀 0**(코드 변경 없이 동작) — 인터페이스 불변 증명. - [ ] real(fake) sync → `event_bus` 에 `mail.received`/`calendar.updated`/… 발행 → 자동화/작업/알림이 실데이터로 동작. - [ ] 증분 sync(cursor) + 정규화(provider→내부) + 멱등 upsert(external_link UNIQUE) + 레이트리밋(429 백오프) 동작. - [ ] 로컬 우선: CSV/.ics/HealthKit export 수동 임포트가 OAuth 없이 동작. - [ ] 토큰 암호화 저장(평문 미저장) + 만료 자동 refresh + 실패 시 token_expired. - [ ] 라이프 connector 카드 on/last/상태(connected/disconnected/syncing/error/token_expired) 디자인 충실 렌더(tone=키→`var(--tone)`). - [ ] C1~C14 테스트 green + 수동 QA 체크리스트 전 항목. --- ## 10. 다음 단계 - **다음 문서: `phase-14-proactive-agent.md`** — 능동형 에이전트(심부름 대행) + 멀티모달 캡처(음성 STT/이미지 Vision 실구현) + 능동 알림. Phase 13 의 RealConnector 가 공급하는 **실데이터 이벤트 스트림**(`mail.received`/`calendar.updated`/`finance.transaction`/`health.sample`) 위에서, `backend/app/agents/`(plan→act→observe→reflect) 와 `backend/app/worker/`(다이제스트/브리핑/패턴 탐지)가 동작한다. 에이전트 tool(`calendar_write`/`task_create`/`form_fill`)은 본 phase 의 `BaseConnector.write()` 를 호출해 외부에 **쓰기**까지 연합한다. - 이후 `phase-15-production.md` — 인증/멀티유저(per-user `connector_account.user_id` 스코프), 토큰 시크릿 매니저·키 회전, 배포/관측성(sync 지표·이벤트 추적)/성능/보안. > 빌드 순서(CONTRACT): 7 → 8 → 9 → 10 → 11 → 12 → **13** → 14 → 15. 페이지는 mock-first 로 만들고, **13(이 문서)에서 real 로 전환**한다.