85 KiB
Phase 13 — 외부 연동 & 데이터 커넥터 (mock → real)
한 줄 요약: phase 7~12에서 mock-first 로 만든 모든 페이지(라이프·메일·일정·알림·리서치·여행·결재함·자동화)를, 코드 한 줄 안 바꾸고 실제 외부 데이터로 전환한다. 커넥터 추상화(
backend/app/connectors/) 뒤에MockConnector대신RealConnector를 꽂으면(CONNECTOR_<DOMAIN>=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. 목차
- 개요 & 목표
- 선행 조건 / 산출물
- 상세 구현 — 커넥터 프레임워크
- 데이터/타입/API 계약
- 디자인 충실도 노트
- 상태 처리 & 엣지 케이스
- 연합 이벤트(발행/구독)
- 테스팅 & 검증
- 완료 기준 (Definition of Done)
- 다음 단계
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_idUNIQUE 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_<DOMAIN> 로 선택) └─ 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_<DOMAIN>=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_<DOMAIN>=real+ 자격증명) 활성화. 단일 데모 사용자(지우)는 유지하고 멀티유저/per-user 토큰 스코프는phase-15-production.md에서 도입한다(본 phase 의connector_account.user_id는"jiwoo"고정 시드).
2.2 산출물 (Deliverables)
backend/
├─ app/
│ ├─ config.py (+) CONNECTOR_<DOMAIN>, 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_<DOMAIN> 로 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_<DOMAIN> 스왑(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).
# 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 은 선택:
# (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개 테이블.
# 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>"
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-<hash>"
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 마이그레이션
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 그대로 이식한다.
# 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 을 대칭 암호화한다.
# 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) 패턴을 그대로 따른다.
# 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_linkUNIQUE).
3.4 레지스트리 — connectors/registry.py
CONNECTOR_<DOMAIN> 로 mock/real 을 고르는 단일 지점. 페이지/라우터는 항상 레지스트리를 통해서만 커넥터를 얻는다(코드 불변의 핵심).
# 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_<DOMAIN>) > 계정.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). envCONNECTOR_<DOMAIN>=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() 진입점을 둔다.
# 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:<epoch> 로 처리한다.
# 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 만 준다.
# 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 페이로드 픽스처 → 위fieldsdict" 의 매핑을 고정한다. provider 가 바뀌어도(IMAP/HEY) 동일fields로 귀결되어야 페이지(메일/알림/작업)가 코드 변경 없이 동작한다. 라벨은 GmailCATEGORY_PROMOTIONS등 → 내부 한국어 라벨(["디자인"],["금융"]등mail-data.js톤)로 매핑한다.
3.5.4 공통 upsert — email 멱등 저장
# 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-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) —<Record type="HKQuantityTypeIdentifierStepCount" .../>파싱 →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-10backend/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(만료 시 자동).
# 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
# 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"]).
# 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)도 제공(데모 결정성).
# 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)
# 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_<DOMAIN> 값(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 = ""
// 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 그대로):
[
{"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건):
{
"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:
{ "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):
{ "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 실행 명령
# 의존성 추가(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 대표 테스트 코드
# 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
# 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"
# 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_<DOMAIN>(기본 mock) + OAuth/암호화 env,.env예시 갱신.- 라우터
/api/connectors(상태/sync/sync-all/oauth start·callback/disconnect/import) +main.pyinclude_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-userconnector_account.user_id스코프), 토큰 시크릿 매니저·키 회전, 배포/관측성(sync 지표·이벤트 추적)/성능/보안.
빌드 순서(CONTRACT): 7 → 8 → 9 → 10 → 11 → 12 → 13 → 14 → 15. 페이지는 mock-first 로 만들고, 13(이 문서)에서 real 로 전환한다.