# Phase 11 — 라이프 케어 (건강·금융·지식)

> 한 줄 요약: 일 밖의 삶을 코칭하는 **라이프(Life Care)** 페이지 — 건강·금융·지식(제2의 뇌) 3영역 + 연결된 데이터 소스(커넥터) + 아리의 코칭/인사이트/리마인더를 `life.jsx`/`life-data.js`/`life.css` 그대로 픽셀 충실 재현하고, 백엔드(`connector_source`/`health`/`finance`/`knowledge_item`)·federation(금융 인사이트→결재함, 습관→자동화, 지식 ai 제안→작업/캘린더)·mock-first 커넥터를 완성한다.

> 이 문서는 **포스트-MVP 세트의 일부**입니다 — 먼저 `dev/overview.md` 와 `dev/post-mvp-overview.md` 를 읽으세요.
> 선행: `phase-7-approvals-automation.md`(결재함 + 자동화 엔진), `phase-10-research-travel.md`(RAG·지식베이스·에이전트). 후속: `phase-12-daily-narrative.md`(여정 + 하루 마감), 실연동 전환은 `phase-13-integrations.md`.
> 상속 규약(스택/토큰/데이터모델/API/환경변수/시드/명명)은 `overview.md` §6~§14, `phase-2-backend.md` §3 을 **그대로** 따른다(임의 변경 금지).

---

## 1. 개요 & 목표

### 1.1 이 phase가 끝나면 무엇이 동작하는가

라이프(Life Care, 원본 `라이프.html`, 상단 내비 11번째 항목 `life`/아이콘 `heart`)가 동작한다:

- **3영역 + 통합 "오늘" 탭**: 좌측 모듈 레일(`오늘의 라이프 · 건강 & 웰니스 · 금융 & 소비 · 지식 & 학습`)로 4개 모듈을 전환하며, 각 모듈은 `life.jsx` 의 컴포넌트(`TodayModule`/`HealthModule`/`FinanceModule`/`KnowledgeModule`)를 픽셀 충실 재현한다.
- **건강**: 액티비티 링(걸음/운동/수면), 바이탈, 수면 분석(점수·단계 막대·7일 추이·노트), 아리 건강 코칭, 건강한 습관(스트릭·진행도·리마인더·기록 버튼).
- **금융**: 이달 예산 게이지, 자동 분류된 지출 카테고리, 구독 결제 예정(D-카운트·일시정지), 아리 소비 코칭, 인사이트(쇼핑 예산 초과 임박·잠자는 구독 발견·저축 페이스).
- **지식(제2의 뇌)**: stats(저장 수·이번 주·컬렉션), 자연어 검색(→AI 답변 카드 + 근거 칩), 컬렉션, 스크랩 카드(article/note/idea/highlight + 아리 ai 제안 + 자동 태깅).
- **연결된 데이터 소스(커넥터)**: 좌측 레일 하단에 health/finance/knowledge 소스 목록(연결됨/연결 점, 마지막 동기화). mock-first — `phase-13-integrations.md` 가 같은 인터페이스로 real 을 끼운다.
- **federation 발행**: 금융 인사이트(잠자는 구독 일시정지)→**결재함**(`phase-7`, `risk=high`), 습관 리마인더→**자동화 규칙**(`phase-7`), 지식 아이템 ai 제안→**작업/캘린더**("딥워크 블록 잡아드릴까요").
- **백엔드**: `connector_source`/`health`/`finance`/`knowledge_item` 모델·시드(`life-data.js` 값 그대로) + 영역별 조회/인사이트/습관 토글/구독 일시정지/지식 Q&A API.
- **테스트**: pytest(모델/시드/API/federation) + Vitest+RTL(모듈/습관 토글/구독 일시정지/검색) + Playwright(모듈 전환·인사이트→결재 흐름) + axe.

### 1.2 목표(제품)

라이프는 *"일 밖의 삶 코칭"* 이다. 다른 페이지(작업·메일·일정)가 "일"이라면, 라이프는 **건강·돈·배움**이라는 개인의 세 축을 아리가 읽고 분석해 **"이미 챙겨뒀어요"** 로 보여준다(제품 철학 §3.2 상속). 핵심 톤:

- 사용자는 데이터를 **분류·해석하지 않는다** — 아리가 거래내역을 카테고리로 묶고("자동 분류된 지출"), 스크랩을 자동 태깅하며, 수면을 분석해 코칭 문구를 미리 써둔다.
- 아리는 묻지 않고 **미리 처리** 한다 — 물 섭취 리마인더를 *이미* 오후 3시에 걸어뒀고("리마인더 자동 설정됨"), 잠자는 구독은 *정지 절차를 준비해뒀다*. 사용자는 결재함에서 승인/되돌리기만.
- **개인=프로젝트 원칙의 확장**: 라이프는 별도 비밀 공간이 아니라 그냥 또 하나의 "삶의 영역"이며, 작업·일정·결재함과 같은 데이터/인물/철학을 공유한다.

> **레일 승격 노트(README §6.3)**: 라이프는 현재 메인 13페이지의 정식 항목(`life`)이다. PROJECT-README §⑨ 의 "세 영역 탭" 표현은 이 페이지의 좌측 모듈 레일을 가리킨다. 본 phase 는 라이프를 정식 페이지로 구현하며, 향후 "건강만 빠른 위젯으로 승격" 같은 옵션은 `phase-12`(하루 마감 집계)·대시보드 위젯에서 재사용 가능하다.

---

## 2. 선행 조건 / 산출물

### 2.1 선행 조건 (의존 phase)

| 의존 | 이유 |
|---|---|
| `overview.md` | 스택·토큰·데이터모델·API·federation·페르소나 정본. |
| `phase-1-design-system.md` | `Icon`(중앙 paths 맵)·`Topbar`(13항목)·토큰(`tokens.css`)·테마 토글. 라이프 전용 아이콘(`steps/flame/water/stretch/pill/feather/food/bag/car/coffee/repeat/ticket/play/music/wallet/heart/brain/article/note/idea/highlight/folder/layers/...`)을 paths 맵에 추가. |
| `phase-2-backend.md` | 모델/스키마/라우터/시드/LLM Provider 패턴. 신규 테이블·엔드포인트는 이 패턴을 그대로 따른다. |
| `phase-7-approvals-automation.md` | **결재함**(`approval`/`autonomy_setting`/`approval_log`) + **자동화 엔진**(`automation_rule`/`event_bus`/`evaluator`/`nl_parser`/`suggester`). 라이프의 인사이트→결재, 습관→규칙 federation 의 수신처. |
| `phase-10-research-travel.md` | **RAG/지식베이스**(`backend/app/rag/`: ingest→chunk→embed→vector store) + `research_collection`/`research_source`/`knowledge_qa`. 라이프 지식 모듈의 `knowledge_item` 은 리서치와 **공유**(아래 §4.4) — 지식 Q&A 는 `rag.query()` 를 재사용. |

> 선행 phase 문서가 아직 없으면, 본 문서는 해당 명명(테이블/엔드포인트/이벤트)을 **계약으로 가정**하고 구현하며, 실제 결선은 `phase-7`/`phase-10` 완료 시 자동으로 맞물린다(CONTRACT 횡단 아키텍처 명명 고정).

### 2.2 산출물 (Deliverables)

```
backend/
├─ app/
│  ├─ models.py                      (+) ConnectorSource, HealthRing, HealthVital, SleepNight,
│  │                                     HealthHabit, FinanceBudget, FinanceCategory, FinanceSub,
│  │                                     FinanceInsight, KnowledgeItem, KnowledgeCollection
│  ├─ schemas.py                     (+) Life* I/O 스키마 (types.ts 와 1:1)
│  ├─ routers/
│  │  └─ life.py                     (+) GET /life/* , 습관 토글, 구독 일시정지, 인사이트 실행, 지식 Q&A
│  ├─ connectors/                    (횡단 — phase-7/13 공유) mock-first
│  │  ├─ base.py                     ConnectorBase 인터페이스
│  │  ├─ health.py                   HealthConnector(Mock|Real)  ← Apple Health/Watch/Google Fit
│  │  ├─ finance.py                  FinanceConnector(Mock|Real) ← 우리카드/토스/카카오뱅크
│  │  ├─ knowledge.py                KnowledgeConnector(Mock|Real) ← Notion/Readwise/웹클리퍼
│  │  └─ registry.py                 get_connector(domain) — CONNECTOR_<DOMAIN>=mock|real
│  ├─ services/
│  │  └─ life_insights.py            (+) 인사이트 → 결재함 enqueue, 습관 → 자동화 규칙 제안
│  └─ seed_life.py                   (+) life-data.js 시드 (run_seed 에서 호출)
└─ tests/
   ├─ test_api_life.py               (+)
   ├─ test_life_seed.py              (+)
   └─ test_life_federation.py        (+)

frontend/
├─ app/life/page.tsx                 (+) 라이프 페이지(모듈 레일 + 본문)
├─ components/life/
│  ├─ ModuleRail.tsx                 좌측 모듈 + 연결 소스
│  ├─ Ring.tsx                       액티비티 링(conic-gradient)
│  ├─ HealthModule.tsx
│  ├─ FinanceModule.tsx
│  ├─ KnowledgeModule.tsx
│  ├─ TodayModule.tsx
│  ├─ HabitCard.tsx  SubRow.tsx  CatRow.tsx  InsightCard.tsx  ScrapCard.tsx
│  └─ KnowledgeSearch.tsx
├─ lib/hooks/useLife.ts              영역별 조회·토글·일시정지·Q&A
├─ lib/types.ts                      (+) Life* 타입
└─ styles/life.css                   life.css 이식 (토큰은 tokens.css 재사용)
```

---

## 3. 상세 구현

### 3.0 라우팅 & 모듈 상태

원본은 SPA 한 페이지 안에서 `mod` 상태(`today|health|finance|knowledge`)로 본문을 전환한다(`life.jsx` `App`). Next.js App Router 로는 **단일 라우트 `/life`** 에 클라이언트 모듈 상태로 구현한다(검색엔진 인덱싱 불필요한 개인 데이터이므로 쿼리 동기화는 선택).

```tsx
// frontend/app/life/page.tsx
"use client";
import { useState, useEffect } from "react";
import { ModuleRail } from "@/components/life/ModuleRail";
import { TodayModule } from "@/components/life/TodayModule";
import { HealthModule } from "@/components/life/HealthModule";
import { FinanceModule } from "@/components/life/FinanceModule";
import { KnowledgeModule } from "@/components/life/KnowledgeModule";
import { useLife } from "@/lib/hooks/useLife";

type Mod = "today" | "health" | "finance" | "knowledge";

export default function LifePage() {
  // 원본 load("mod","today") 와 동일: localStorage 키 "ariL.mod"
  const [mod, setMod] = useState<Mod>("today");
  useEffect(() => {
    const v = localStorage.getItem("ariL.mod");
    if (v) setMod(JSON.parse(v) as Mod);
  }, []);
  useEffect(() => { localStorage.setItem("ariL.mod", JSON.stringify(mod)); }, [mod]);

  const life = useLife();  // SWR: /api/life/overview + 영역별 lazy

  return (
    <div className="lwork">
      <ModuleRail mod={mod} onPick={setMod} sources={life.sources} onToast={life.toast} />
      <main className="lmain">
        {mod === "today" && <TodayModule go={setMod} {...life} />}
        {mod === "health" && <HealthModule {...life} />}
        {mod === "finance" && <FinanceModule {...life} />}
        {mod === "knowledge" && <KnowledgeModule {...life} />}
      </main>
    </div>
  );
}
```

> 원본 localStorage 네임스페이스는 `ariL.*`(`life.jsx` `load`/`save`): `ariL.theme`(테마는 next-themes 가 대체) · `ariL.mod` · `ariL.habits` · `ariL.paused`. 습관/일시정지는 **서버 상태**로 승격(아래 §3.5/§3.6)하되, 낙관적 UI 를 위해 로컬 미러는 유지 가능.

좌측 레일 + 본문 2단 그리드는 `life.css` `.lwork` 그대로:

```css
/* styles/life.css — 원본 158~163행 그대로 */
.lwork {
  display: grid;
  grid-template-columns: 256px minmax(0, 1fr);
  gap: 16px; align-items: start; padding-top: 12px;
}
@media (max-width: 1080px) { .lwork { grid-template-columns: 1fr; } .subnav { position: static; flex-direction: column; } }
```

---

### 3.1 `models.py` — 신규 테이블 (phase-2 패턴)

CONTRACT: 모든 PK 는 TEXT(str), tone 집합 `blue|violet|coral|green|amber|ink|faint`, 한국어 UI 문구는 원본 그대로. 단일 데모 사용자(지우)이므로 `person_id` 스코프는 생략(멀티유저는 `phase-15`).

```python
# backend/app/models.py  (phase-11 추가분)
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 의 now()

# ---------- enums ----------
class ConnectorDomain(str, Enum):
    health = "health"
    finance = "finance"
    knowledge = "knowledge"

class KnowledgeType(str, Enum):
    article = "article"
    note = "note"
    idea = "idea"
    highlight = "highlight"

class InsightKind(str, Enum):
    alert = "alert"     # 쇼핑 예산 초과 임박 (coral)
    save = "save"       # 잠자는 구독 발견 (green)
    info = "info"       # 저축 페이스 양호 (blue)

# ---------- 연결된 데이터 소스(커넥터) ----------
class ConnectorSource(SQLModel, table=True):
    """좌측 레일 '연결된 데이터'. life-data.js sources.{health|finance|knowledge}[] 그대로."""
    __tablename__ = "connector_source"
    id: str = Field(primary_key=True)            # "apple","watch","fit","woori","toss",...
    domain: ConnectorDomain
    name: str                                    # "Apple Health" / "우리카드" / "Notion"
    kind: str                                    # "건강 앱" / "신용/체크" / "메모·문서"
    tone: str = "ink"
    on: bool = True                              # 연결됨 여부
    last: str = ""                               # "방금 동기화" / "오늘 09:12" / "연결 안 됨"
    sort_order: int = 0

# ---------- 건강 ----------
class HealthRing(SQLModel, table=True):
    __tablename__ = "health_ring"
    id: str = Field(primary_key=True)            # "ring-steps","ring-exercise","ring-sleep"
    label: str                                   # "걸음" / "운동" / "수면"
    val: str                                     # "7,240" / "38" / "7h 12m"  (표시 문자열 그대로)
    unit: str = ""                               # "보" / "분" / ""
    goal: str                                    # "10,000보" / "45분" / "7h 30m"
    pct: int = 0                                 # 링 채움 % (conic-gradient --p)
    icon: str                                    # "steps" / "flame" / "moon"
    tone: str = "coral"
    sort_order: int = 0

class HealthVital(SQLModel, table=True):
    __tablename__ = "health_vital"
    id: str = Field(primary_key=True)            # "vital-hr","vital-stress","vital-workout"
    label: str                                   # "안정 심박"
    val: str                                     # "62" / "보통" / "3 / 4"
    unit: str = ""                               # "bpm" / "" / "회"
    trend: str = "flat"                          # "up" | "down" | "flat"
    note: str = ""                               # "지난주 –3"
    tone: str = "coral"
    sort_order: int = 0

class SleepNight(SQLModel, table=True):
    """단일 row(id=1). 어젯밤 수면 + 7일 추이(JSON)."""
    __tablename__ = "sleep_night"
    id: Optional[int] = Field(default=None, primary_key=True)
    total: str = ""                              # "7시간 12분"
    score: int = 0                               # 86
    stages_json: str = "[]"                      # [{"label":"깊은 수면","pct":22,"tone":"violet"},...]
    week_json: str = "[]"                        # [72,65,88,60,90,78,86]
    note: str = ""                               # "수요일과 어젯밤은 잘 주무셨어요. ..."

class HealthHabit(SQLModel, table=True):
    __tablename__ = "health_habit"
    id: str = Field(primary_key=True)            # "water","stretch","vitamin","meditate"
    title: str                                   # "물 마시기"
    goal: str                                    # "하루 8잔"
    done: int = 0
    total: int = 1
    streak: int = 0                              # 연속 일수 (flame)
    icon: str                                    # "water"
    tone: str = "blue"
    at: str = ""                                 # "오후 3:00 리마인더"
    automation_rule_id: Optional[str] = Field(default=None, foreign_key="automation_rule.id")  # 습관→규칙 연결(phase-7)
    sort_order: int = 0

# ---------- 금융 ----------
class FinanceBudget(SQLModel, table=True):
    """단일 row(id=1). 이달 예산."""
    __tablename__ = "finance_budget"
    id: Optional[int] = Field(default=None, primary_key=True)
    spent: int = 0                               # 1284000 (원 단위 정수)
    limit: int = 0                               # 2000000
    pct: int = 0                                 # 64
    delta_pct: int = 0                           # -8 (지난달 대비)
    days_left: int = 0                           # 23

class FinanceCategory(SQLModel, table=True):
    __tablename__ = "finance_category"
    id: str = Field(primary_key=True)            # "cat-food","cat-shop",...
    name: str                                    # "식비"
    amt: int = 0                                 # 412000
    pct: int = 0                                 # 32 (전체 대비 %)
    icon: str                                    # "food"
    tone: str = "coral"
    over: bool = False                           # 예산 빠름 태그
    sort_order: int = 0

class FinanceSub(SQLModel, table=True):
    __tablename__ = "finance_sub"
    id: str = Field(primary_key=True)            # "sub-netflix",...
    name: str                                    # "Netflix"
    date: str = ""                               # "6월 9일" (표시 문구 그대로)
    in_days: int = 0                             # 2 (D-카운트)
    amt: int = 0                                 # 17000
    icon: str                                    # "play"
    tone: str = "coral"
    note: str = ""                               # "프리미엄"
    paused: bool = False                         # 일시정지 상태 (federation 대상)
    sort_order: int = 0

class FinanceInsight(SQLModel, table=True):
    __tablename__ = "finance_insight"
    id: str = Field(primary_key=True)            # "ins-shop","ins-netflix","ins-save"
    kind: InsightKind                            # alert | save | info
    icon: str                                    # "alert" | "repeat" | "trending"
    title: str                                   # "잠자는 구독 발견"
    body: str                                    # 한국어 본문 그대로
    # federation: save/alert 인사이트가 결재함으로 가는 액션 메타
    action: str = ""                             # "pause_sub" | "raise_budget" | "auto_transfer" | ""
    action_ref: str = ""                         # 예: pause_sub → "sub-netflix"
    sort_order: int = 0

# ---------- 지식 (제2의 뇌) — 리서치(phase-10)와 공유 ----------
class KnowledgeCollection(SQLModel, table=True):
    __tablename__ = "knowledge_collection"
    id: str = Field(primary_key=True)            # "coll-product",...
    name: str                                    # "프로덕트 전략"
    count: int = 0                               # 84
    tone: str = "blue"
    sort_order: int = 0

class KnowledgeItem(SQLModel, table=True):
    """제2의 뇌 스크랩. phase-10 research_source 와 공유 가능(아래 §4.4)."""
    __tablename__ = "knowledge_item"
    id: str = Field(primary_key=True)            # "k1".."k5" (원본 life-data items[].id)
    type: KnowledgeType                          # article|note|idea|highlight
    title: str
    src: str = ""                                # "anthropic.com" / "내 노트" / "Readwise"
    time: str = ""                               # "2시간 전" (상대시간 표시 문구)
    tone: str = "blue"
    icon: str                                    # "article"|"note"|"idea"|"highlight"
    tags_json: str = "[]"                        # ["AI·에이전트","리서치","설계"] (tags[0]=auto)
    excerpt: str = ""
    ai: str = ""                                 # 아리 제안 문구(HTML 허용)
    collection_id: Optional[str] = Field(default=None, foreign_key="knowledge_collection.id")
    connector_id: Optional[str] = Field(default=None, foreign_key="connector_source.id")  # 출처 커넥터
    created_at: datetime = Field(default_factory=now)
    sort_order: int = 0
```

> **PK 규약**: 시드 값이 자연 id 를 가지면(`apple`, `k1`) 그대로 쓰고, 없으면 `<entity>-<slug>`(예 `ring-steps`, `cat-food`, `sub-netflix`)로 부여한다. 단일 row 집계 테이블(`SleepNight`/`FinanceBudget`)은 phase-2 `Briefing` 패턴대로 int `id=1`.

> **JSON 컬럼 주석**: `stages_json`/`week_json`/`tags_json` 은 phase-2 가 채택한 "표시용 배열은 TEXT(JSON) 직렬화" 패턴을 따른다(SQLite 단순성). 스키마 레이어에서 `list[...]` 로 역직렬화해 내려준다.

---

### 3.2 `schemas.py` — Life I/O (types.ts 와 1:1)

```python
# backend/app/schemas.py  (phase-11 추가분)
from __future__ import annotations
from typing import Optional
from pydantic import BaseModel
from .models import ConnectorDomain, KnowledgeType, InsightKind

# ---------- 커넥터 ----------
class ConnectorSourceOut(BaseModel):
    id: str; domain: ConnectorDomain; name: str; kind: str
    tone: str; on: bool; last: str

# ---------- 건강 ----------
class RingOut(BaseModel):
    label: str; val: str; unit: str; goal: str; pct: int; icon: str; tone: str

class VitalOut(BaseModel):
    label: str; val: str; unit: str; trend: str; note: str; tone: str

class SleepStageOut(BaseModel):
    label: str; pct: int; tone: str          # tone="muted" 는 프론트가 var(--card-3)/var(--faint) 처리

class SleepOut(BaseModel):
    total: str; score: int
    stages: list[SleepStageOut]
    week: list[int]                          # [72,65,88,60,90,78,86]
    note: str

class HabitOut(BaseModel):
    id: str; title: str; goal: str; done: int; total: int
    streak: int; icon: str; tone: str; at: str
    automation_rule_id: Optional[str] = None

class HealthOut(BaseModel):
    rings: list[RingOut]
    vitals: list[VitalOut]
    sleep: SleepOut
    coach: str                               # HTML 허용
    habits: list[HabitOut]

# ---------- 금융 ----------
class BudgetOut(BaseModel):
    spent: int; limit: int; pct: int; delta_pct: int; days_left: int

class CategoryOut(BaseModel):
    name: str; amt: int; pct: int; icon: str; tone: str; over: bool

class SubOut(BaseModel):
    id: str; name: str; date: str; in_days: int; amt: int
    icon: str; tone: str; note: str; paused: bool

class InsightOut(BaseModel):
    id: str; kind: InsightKind; icon: str; title: str; body: str
    action: str; action_ref: str

class FinanceOut(BaseModel):
    budget: BudgetOut
    cats: list[CategoryOut]
    subs: list[SubOut]
    coach: str                               # HTML 허용
    insights: list[InsightOut]

# ---------- 지식 ----------
class CollectionOut(BaseModel):
    name: str; count: int; tone: str

class KnowledgeItemOut(BaseModel):
    id: str; type: KnowledgeType; title: str; src: str; time: str
    tone: str; icon: str; tags: list[str]; excerpt: str; ai: str

class KnowledgeStatsOut(BaseModel):
    items: int; this_week: int; collections: int   # 1248 / 23 / 9

class KnowledgeOut(BaseModel):
    stats: KnowledgeStatsOut
    suggested: list[str]
    collections: list[CollectionOut]
    items: list[KnowledgeItemOut]

# ---------- 통합 overview (좌측 레일 + 오늘 탭이 한 번에 소비) ----------
class LifeOverviewOut(BaseModel):
    sources: dict[str, list[ConnectorSourceOut]]   # {"health":[...],"finance":[...],"knowledge":[...]}
    health: HealthOut
    finance: FinanceOut
    knowledge: KnowledgeOut

# ---------- 액션 요청/응답 ----------
class HabitTickResponse(BaseModel):
    habit: HabitOut

class SubPauseRequest(BaseModel):
    paused: bool                              # 토글 결과를 명시(낙관적 UI 일관)

class SubPauseResponse(BaseModel):
    sub: SubOut
    approval_id: Optional[str] = None         # 일시정지 실행 시 결재함 항목 id(phase-7)

class InsightActResponse(BaseModel):
    approval_id: Optional[str] = None         # 결재함으로 보낸 항목
    task_id: Optional[str] = None             # 작업으로 보낸 경우(저축 자동 이체 등)
    message: str

class KnowledgeAskRequest(BaseModel):
    q: str

class KnowledgeAskResponse(BaseModel):
    q: str
    body: str                                 # HTML 허용 (RAG 답변)
    sources: list[str]                        # 근거 자료 제목들

class KnowledgeSuggestActRequest(BaseModel):
    item_id: str
    target: str                               # "task" | "calendar"

class KnowledgeSuggestActResponse(BaseModel):
    task_id: Optional[str] = None
    event_id: Optional[str] = None
    message: str
```

> **types.ts 대응**: `frontend/lib/types.ts` 에 동일 키/타입을 추가. `delta_pct`/`in_days`/`this_week` 등 snake_case 유지(phase-2 §3.2 규약). `tone` 은 키(`"coral"`)로 내려오고 프론트가 `var(--tone)` 로 변환(단 sleep stage `tone:"muted"` 는 예외: `var(--card-3)`/`var(--faint)`). 금액은 정수로 내려오고 프론트가 `won()`(`"₩"+n.toLocaleString("ko-KR")`)로 포맷(원본 `life.jsx` 92행 그대로).

---

### 3.3 `routers/life.py` — API (prefix 없이 정의 → main.py 에서 /api 부착)

phase-2 규약: 라우터는 내부 prefix 없이 정의하고, `main.py` 의 `include_router(prefix="/api", tags=["life"])` 에서만 `/api` 부착.

```python
# backend/app/routers/life.py
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from ..db import get_session
from ..models import (
    ConnectorSource, HealthRing, HealthVital, SleepNight, HealthHabit,
    FinanceBudget, FinanceCategory, FinanceSub, FinanceInsight,
    KnowledgeCollection, KnowledgeItem,
)
from ..schemas import (
    LifeOverviewOut, HealthOut, FinanceOut, KnowledgeOut,
    HabitTickResponse, SubPauseRequest, SubPauseResponse,
    InsightActResponse, KnowledgeAskRequest, KnowledgeAskResponse,
    KnowledgeSuggestActRequest, KnowledgeSuggestActResponse,
)
from ..services import life_insights, life_read   # life_read: 모델→Out 조립 헬퍼

router = APIRouter()  # ← 내부 prefix 없음 (main.py 에서 /api 부착)

@router.get("/life/overview", response_model=LifeOverviewOut)
def life_overview(s: Session = Depends(get_session)):
    """좌측 레일 + 오늘 탭이 한 번에 소비하는 통합 응답."""
    return life_read.build_overview(s)

@router.get("/life/health", response_model=HealthOut)
def life_health(s: Session = Depends(get_session)):
    return life_read.build_health(s)

@router.get("/life/finance", response_model=FinanceOut)
def life_finance(s: Session = Depends(get_session)):
    return life_read.build_finance(s)

@router.get("/life/knowledge", response_model=KnowledgeOut)
def life_knowledge(s: Session = Depends(get_session), q: str = "", type: str = ""):
    """q 가 있으면 제목/발췌/태그 부분일치 필터(원본 life.jsx filtered 로직 이식)."""
    return life_read.build_knowledge(s, q=q, type_=type)

# ----- 액션 -----
@router.post("/life/habits/{habit_id}/tick", response_model=HabitTickResponse)
def tick_habit(habit_id: str, s: Session = Depends(get_session)):
    h = s.get(HealthHabit, habit_id)
    if not h:
        raise HTTPException(404, "habit not found")
    if h.done < h.total:                       # 원본: done < total 일 때만 증가
        h.done += 1
        s.add(h); s.commit(); s.refresh(h)
    return {"habit": life_read.habit_out(h)}

@router.post("/life/subs/{sub_id}/pause", response_model=SubPauseResponse)
def pause_sub(sub_id: str, body: SubPauseRequest, s: Session = Depends(get_session)):
    sub = s.get(FinanceSub, sub_id)
    if not sub:
        raise HTTPException(404, "sub not found")
    sub.paused = body.paused
    s.add(sub); s.commit(); s.refresh(sub)
    # federation: 일시정지 '실행'은 결제 영향 → high-risk 결재함 항목 생성(phase-7)
    approval_id = None
    if body.paused:
        approval_id = life_insights.enqueue_sub_pause(s, sub)
    return {"sub": life_read.sub_out(sub), "approval_id": approval_id}

@router.post("/life/insights/{insight_id}/act", response_model=InsightActResponse)
def act_insight(insight_id: str, s: Session = Depends(get_session)):
    ins = s.get(FinanceInsight, insight_id)
    if not ins:
        raise HTTPException(404, "insight not found")
    return life_insights.act(s, ins)           # 결재함 또는 작업으로 라우팅

@router.post("/life/knowledge/ask", response_model=KnowledgeAskResponse)
def knowledge_ask(body: KnowledgeAskRequest, s: Session = Depends(get_session)):
    """제2의 뇌 자연어 Q&A. phase-10 rag.query() 재사용, 미가용 시 scripted 폴백."""
    return life_insights.knowledge_ask(s, body.q)

@router.post("/life/knowledge/suggest/act", response_model=KnowledgeSuggestActResponse)
def knowledge_suggest_act(body: KnowledgeSuggestActRequest, s: Session = Depends(get_session)):
    """지식 아이템의 ai 제안 실행 → 작업/캘린더 federation('딥워크 블록 잡아드릴까요')."""
    return life_insights.act_suggestion(s, body.item_id, body.target)
```

`main.py` 등록(phase-2 패턴):

```python
# backend/app/main.py (추가)
from .routers import life
app.include_router(life.router, prefix="/api", tags=["life"])
```

`life_read.py`(모델→Out 조립) 핵심 — `build_knowledge` 검색 필터는 원본 `life.jsx` 357~361행 이식:

```python
# backend/app/services/life_read.py (발췌)
import json
from sqlmodel import Session, select
from ..models import KnowledgeItem, KnowledgeCollection, KnowledgeType
from ..schemas import KnowledgeOut, KnowledgeItemOut, KnowledgeStatsOut, CollectionOut

def _item_out(it: KnowledgeItem) -> KnowledgeItemOut:
    return KnowledgeItemOut(
        id=it.id, type=it.type, title=it.title, src=it.src, time=it.time,
        tone=it.tone, icon=it.icon, tags=json.loads(it.tags_json),
        excerpt=it.excerpt, ai=it.ai,
    )

def build_knowledge(s: Session, q: str = "", type_: str = "") -> KnowledgeOut:
    items = s.exec(select(KnowledgeItem).order_by(KnowledgeItem.sort_order)).all()
    needle = q.strip().lower()
    if needle:
        # 원본: (title + excerpt + tags.join(" ")).toLowerCase().includes(s)
        items = [it for it in items
                 if needle in (it.title + it.excerpt + " ".join(json.loads(it.tags_json))).lower()]
    if type_:
        items = [it for it in items if it.type.value == type_]
    colls = s.exec(select(KnowledgeCollection).order_by(KnowledgeCollection.sort_order)).all()
    # stats.items 는 시드 상수(1248) — 표시용 누적치이므로 별도 settings row 또는 시드값 사용
    return KnowledgeOut(
        stats=KnowledgeStatsOut(items=1248, this_week=23, collections=9),
        suggested=["AI 에이전트 설계에서 배운 것 요약해줘", "이번 주 저장한 글 핵심만", "리텐션 관련 메모 모아줘"],
        collections=[CollectionOut(name=c.name, count=c.count, tone=c.tone) for c in colls],
        items=[_item_out(it) for it in items],
    )
```

> `stats.items=1248`/`this_week=23`/`collections=9` 는 원본 `knowledge.stats` 고정값(`life-data.js` 88행). 컬렉션 실제 4건과 표시 `collections:9` 가 다른 것은 원본 그대로(데모 누적 수치) — **임의 보정 금지**.

API 표:

| 메서드 | 경로 | 용도 |
|---|---|---|
| GET | `/api/life/overview` | 통합(`sources`+`health`+`finance`+`knowledge`) — 레일/오늘 탭 |
| GET | `/api/life/health` | 건강 모듈 |
| GET | `/api/life/finance` | 금융 모듈 |
| GET | `/api/life/knowledge?q=&type=` | 지식 모듈(검색 필터) |
| POST | `/api/life/habits/{id}/tick` | 습관 1회 기록(done+1) |
| POST | `/api/life/subs/{id}/pause` | 구독 일시정지/해제 토글 → (정지 시) 결재함 enqueue |
| POST | `/api/life/insights/{id}/act` | 인사이트 실행 → 결재함/작업 라우팅 |
| POST | `/api/life/knowledge/ask` | 제2의 뇌 자연어 Q&A(RAG) |
| POST | `/api/life/knowledge/suggest/act` | 지식 ai 제안 실행 → 작업/캘린더 |

---

### 3.4 커넥터 추상화 (`backend/app/connectors/`) — mock-first

CONTRACT 횡단: 도메인별 base 인터페이스 + `MockConnector`(시드 기반) + `RealConnector`(phase-13). 설정 `CONNECTOR_<DOMAIN>=mock|real`(기본 mock).

```python
# backend/app/config.py (추가 — phase-2 Settings 확장)
class Settings(BaseSettings):
    # ... (phase-2 기존 필드) ...
    connector_health: str = "mock"      # CONNECTOR_HEALTH
    connector_finance: str = "mock"     # CONNECTOR_FINANCE
    connector_knowledge: str = "mock"   # CONNECTOR_KNOWLEDGE
```

```python
# backend/app/connectors/base.py
from abc import ABC, abstractmethod

class ConnectorBase(ABC):
    domain: str
    @abstractmethod
    def list_sources(self) -> list[dict]: ...      # connector_source 행 형태
    @abstractmethod
    def sync(self, session) -> dict: ...            # 도메인 데이터를 DB에 적재/갱신 (mock=시드 재사용)
    @abstractmethod
    def health(self) -> dict: ...                   # {reachable, detail}
```

```python
# backend/app/connectors/finance.py
from .base import ConnectorBase
from ..seed_life import FINANCE_SOURCES, FINANCE_BUDGET, FINANCE_CATS, FINANCE_SUBS, FINANCE_INSIGHTS

class MockFinanceConnector(ConnectorBase):
    domain = "finance"
    def list_sources(self): return FINANCE_SOURCES   # 우리카드/토스/카카오뱅크/KB증권
    def sync(self, session):
        # mock: 시드 데이터를 그대로 반환(이미 DB에 존재). 실연동은 phase-13 RealFinanceConnector.
        return {"ok": True, "provider": "mock", "rows": len(FINANCE_SUBS)}
    def health(self): return {"reachable": True, "detail": "mock finance connector"}

class RealFinanceConnector(ConnectorBase):
    domain = "finance"
    # phase-13: 카드/토스/뱅크 아그리게이터(오픈뱅킹·토스 API 등) 실연동.
    def list_sources(self): raise NotImplementedError("phase-13")
    def sync(self, session): raise NotImplementedError("phase-13")
    def health(self): return {"reachable": False, "detail": "real finance not configured"}
```

```python
# backend/app/connectors/registry.py
from ..config import get_settings
from .health import MockHealthConnector, RealHealthConnector
from .finance import MockFinanceConnector, RealFinanceConnector
from .knowledge import MockKnowledgeConnector, RealKnowledgeConnector

_MAP = {
    ("health", "mock"): MockHealthConnector, ("health", "real"): RealHealthConnector,
    ("finance", "mock"): MockFinanceConnector, ("finance", "real"): RealFinanceConnector,
    ("knowledge", "mock"): MockKnowledgeConnector, ("knowledge", "real"): RealKnowledgeConnector,
}

def get_connector(domain: str):
    s = get_settings()
    mode = {"health": s.connector_health, "finance": s.connector_finance,
            "knowledge": s.connector_knowledge}[domain]
    return _MAP[(domain, mode)]()
```

| 도메인 | 가정된 실제 제공자(phase-13 목표 예시) | 원본 소스(`life-data.js sources`) |
|---|---|---|
| health | Apple Health · Apple Watch · Google Fit | `apple`(건강 앱, on) · `watch`(웨어러블, on) · `fit`(활동 추적, **off**) |
| finance | 우리카드 · 토스 · 카카오뱅크 · KB증권 (카드/토스/뱅크 아그리게이터) | `woori`(신용/체크, on) · `toss`(계좌 통합, on) · `kakao`(입출금, on) · `kb`(투자, **off**) |
| knowledge | Notion · Readwise · 웹 클리퍼 | `notion`(메모·문서, on) · `web`(아티클 스크랩, on) · `readwise`(하이라이트, on) |

> `RealConnector` 들은 본 phase 에선 `NotImplementedError`/`reachable:false` 스텁. **인터페이스가 같으므로** `phase-13` 이 `MockConnector` 를 떼고 같은 메서드로 갈아끼운다. 페이지·API·시드는 변경 없음(mock-first 원칙).

---

### 3.5 건강 모듈 (`HealthModule.tsx`) — 픽셀 충실 재현

원본 `life.jsx` `HealthModule`(110~237행) + `Ring`(94~108행) 이식. 링은 `conic-gradient` CSS 변수(`--p`/`--c`).

```tsx
// frontend/components/life/Ring.tsx (원본 94~108 + life.css .ring 322~337)
export function Ring({ r }: { r: RingOut }) {
  return (
    <div className="ring-card">
      <div className="ring" style={{ "--p": r.pct, "--c": `var(--${r.tone})` } as React.CSSProperties}>
        <div className="r-in">
          <Icon name={r.icon} />
          <b>{r.val}</b>
        </div>
      </div>
      <div className="rc-label">{r.label}{r.unit && <span className="t-muted"> {r.unit}</span>}</div>
      <div className="rc-goal">목표 {r.goal}</div>
    </div>
  );
}
```

```tsx
// frontend/components/life/HabitCard.tsx (원본 209~229)
export function HabitCard({ hb, onTick }: { hb: HabitOut; onTick: (id: string) => void }) {
  const done = hb.done >= hb.total;
  return (
    <div className={"habit" + (done ? " done" : "")}>
      <div className="habit-top">
        <div className={"h-ic bg-" + hb.tone}><Icon name={hb.icon} /></div>
        <div className="h-tx"><b>{hb.title}</b><span>{hb.goal}</span></div>
        <span className="habit-streak"><Icon name="flame" />{hb.streak}일</span>
      </div>
      <div className="habit-prog">
        <div className="hp-bar">
          <i style={{ width: Math.min(100, (hb.done / hb.total) * 100) + "%",
                      background: done ? "var(--green)" : `var(--${hb.tone})` }} />
        </div>
        <span className="hp-n">{hb.done}/{hb.total}</span>
      </div>
      <div className="habit-foot">
        <span className="habit-at"><Icon name="bell" />{hb.at}</span>
        <button className={"habit-btn" + (done ? " done" : "")} onClick={() => onTick(hb.id)} disabled={done}>
          <Icon name={done ? "tick" : "plus"} w={done ? 3 : 1.9} />{done ? "완료" : "기록"}
        </button>
      </div>
    </div>
  );
}
```

**디자인 충실도 핵심값**(`life.css` 그대로):

| 요소 | 값 (REF `life.css`) |
|---|---|
| 링 크기 | `.ring { width:92px; height:92px }` · 안쪽 hole `radial-gradient(closest-side, var(--card) 73%, transparent 74%)` |
| 수면 점수 링 | `.sleep-score { width:74px; height:74px }` · `conic-gradient(var(--violet) calc(--p*1%), var(--card-3) 0)` |
| 7일 막대 | `.sleep-week { height:56px }` · `.sw.peak { background: color-mix(in oklab, var(--violet) 55%, transparent) }` (원본: `v>=86`이면 peak) |
| 습관 그리드 | `.habits { grid-template-columns: repeat(2,1fr) }` · 완료 시 `.habit.done` green tint |
| 습관 스트릭 | `.habit-streak { color: var(--amber) }` + flame 아이콘 |
| 코칭 카드 | `.card.glassy` + `borderColor: color-mix(in oklab, var(--coral) 22%, var(--line-2))` |

**문구 인용**(원본 `life-data.js` 그대로 시드):
- 코칭: `"이번 주 활동량은 <b>지난주보다 12% 늘었어요</b>. 다만 어제 물 섭취가 권장량의 절반에 그쳤고, 오후 3시 이후 앉아 있는 시간이 길었어요. 가벼운 산책과 수분 보충 리마인더를 오후에 넣어둘게요."`
- 수면 노트: `"수요일과 어젯밤은 잘 주무셨어요. 하지만 평일 취침 시각이 들쭉날쭉해요 — 자정 전 취침을 목표로 잡아볼까요?"`
- 헤더 sub: `"Apple Health · Apple Watch 연동 · 오늘 {N}시 기준"`(원본 `new Date().getHours()` — SSR/CSR 일관 위해 클라이언트에서만 계산하거나 시드 고정값 사용).
- 수면 라벨: `"권장보다 18분 부족"`(원본 174행 하드코딩).

> **링 안쪽 아이콘 색**: `.ring .r-in .ic { color: var(--c) }` — 링 tone 과 동일. `Ring` 의 `--c` 가 `var(--${r.tone})` 로 주입되어야 한다(누락 시 검정으로 보임).

---

### 3.6 금융 모듈 (`FinanceModule.tsx`)

원본 `FinanceModule`(239~341행). 예산 게이지·카테고리 막대·구독 행·인사이트.

```tsx
// frontend/components/life/SubRow.tsx (원본 318~333)
export function SubRow({ s, onPause }: { s: SubOut; onPause: (id: string, next: boolean) => void }) {
  const soon = s.in_days <= 3 && !s.paused;     // 원본: inDays<=3 && !isPaused → .soon
  return (
    <div className={"sub-row" + (soon ? " soon" : "")} style={s.paused ? { opacity: 0.55 } : undefined}>
      <div className={"su-ic bg-" + s.tone}><Icon name={s.icon} /></div>
      <div className="su-main"><b>{s.name}</b><span>{s.note}{s.paused ? " · 일시정지됨" : ""}</span></div>
      <div className="su-when">
        <div className="sw-date">{s.date}</div>
        <div className={"sw-in" + (s.in_days > 3 ? " far" : "")}>{s.paused ? "정지" : s.in_days + "일 뒤"}</div>
      </div>
      <div className="su-amt">{won(s.amt)}</div>
      <button className="c-act" onClick={() => onPause(s.id, !s.paused)} aria-label="일시정지">
        <Icon name={s.paused ? "refresh" : "more"} />
      </button>
    </div>
  );
}
```

```tsx
// frontend/components/life/InsightCard.tsx (원본 280~286 / life.css .insight 451~467)
export function InsightCard({ ins, onAct }: { ins: InsightOut; onAct?: (id: string) => void }) {
  return (
    <div className={"insight " + ins.kind}>     {/* kind: alert|save|info */}
      <div className="in-ic"><Icon name={ins.icon} /></div>
      <div className="in-main"><b>{ins.title}</b><p>{ins.body}</p></div>
      {ins.action && onAct && (
        <button className="in-act" onClick={() => onAct(ins.id)}>
          {ins.action === "pause_sub" ? "일시정지" : ins.action === "raise_budget" ? "한도 조정" : "자동 이체"}
        </button>
      )}
    </div>
  );
}
```

**디자인 충실도**(`life.css`):
- 예산: `.bt-spent { font-family: var(--font-disp); font-size:30px; font-weight:800 }`, 게이지 `.budget-gauge>i { background: linear-gradient(90deg, var(--green), var(--blue)) }`.
- 카테고리 막대 폭: 원본 `width: c.pct * 2.4 + "%"`(스케일 배수 2.4), `over` 면 `background: var(--coral)` + `.over-tag`("예산 빠름").
- 구독 soon: `.sub-row.soon { border-color: color-mix(in oklab, var(--coral) 30%, var(--line)); background: color-mix(in oklab, var(--coral) 6%, var(--card-2)) }`.
- 인사이트: `.insight.alert`(coral)/`.insight.save`(green)/`.insight.info`(blue) — 좌측 아이콘 박스 색 분기.
- 금액 포맷: `won(n) = "₩" + n.toLocaleString("ko-KR")`(원본 92행) → 예 `won(1284000)="₩1,284,000"`.

**시드 문구 인용**(`life-data.js`):
- 코칭: `"이번 달은 <b>지난달보다 8% 적게</b> 쓰고 계세요. 다만 <b>쇼핑</b>이 예산의 23%로 평소보다 빠르게 늘고 있어요. 또 <b>이틀 뒤 Netflix·Spotify 결제</b>가 같은 날 빠져나가요 — 최근 두 달간 안 본 Netflix는 일시정지를 추천드려요."`
- 인사이트 3건: `"쇼핑 예산 초과 임박"`(alert) / `"잠자는 구독 발견"`(save, body `"Netflix를 최근 58일간 시청하지 않았어요. 일시정지 시 월 1.7만 원을 아껴요."`) / `"이달 저축 페이스 양호"`(info).
- 헤더 sub: `"우리카드 · 토스 · 카카오뱅크 연동 · 6월 예산"`.

> **금액 단위 주의**: DB 는 원 단위 **정수**(`spent=1284000`). 표시 문구의 "29.8만 원"/"1.7만 원"은 원본 인사이트 `body` 안의 한국어 문구로 그대로 시드(계산 결과가 아니라 카피). 표시 숫자(`won`)와 카피 숫자를 혼동해 보정하지 말 것.

---

### 3.7 지식 모듈 (`KnowledgeModule.tsx`) — 자연어 Q&A + 스크랩

원본 `KnowledgeModule`(343~458행). 검색 → AI 답변 카드, 컬렉션, 스크랩 카드(자동 태깅 + 아리 ai 제안).

```tsx
// frontend/components/life/KnowledgeModule.tsx (발췌 — 원본 검색/답변/필터 로직)
export function KnowledgeModule(props: LifeProps) {
  const [q, setQ] = useState("");
  const [answer, setAnswer] = useState<KnowledgeAskResponse | null>(null);
  const k = props.knowledge;

  const ask = async (text: string) => {
    if (!text.trim()) return;
    setQ(text);
    const res = await api.post("/life/knowledge/ask", { q: text });  // RAG(phase-10) 또는 폴백
    setAnswer(res);
  };

  // 원본 357~361: answer 가 있으면 전체, 없으면 부분일치 필터
  const filtered = useMemo(() => {
    const s = q.trim().toLowerCase();
    if (!s || answer) return k.items;
    return k.items.filter((it) =>
      (it.title + it.excerpt + it.tags.join(" ")).toLowerCase().includes(s));
  }, [q, answer, k.items]);

  return (
    <>
      {/* 헤더: stats — "1,248개 저장 · 이번 주 +23 · 9개 컬렉션" */}
      <div className="kn-search">
        <Icon name="spark" />
        <input value={q}
          onChange={(e) => { setQ(e.target.value); if (answer) setAnswer(null); }}
          onKeyDown={(e) => e.key === "Enter" && ask(q)}
          placeholder="저장한 모든 것에 자연어로 물어보세요…" />
        <button className="ks-go" onClick={() => ask(q)} aria-label="검색"><Icon name="arrow" /></button>
      </div>
      <div className="kn-suggest">
        {k.suggested.map((s, i) => (
          <button className="kn-chip" key={i} onClick={() => ask(s)}><Icon name="spark" />{s}</button>
        ))}
      </div>

      {answer && (
        <div className="kn-answer">
          <div className="ka-ava"><Icon name="spark" /></div>
          <div className="ka-main">
            <div className="ka-q">"{answer.q}"</div>
            <div className="ka-body" dangerouslySetInnerHTML={{ __html: answer.body }} />
            <div className="ka-src">
              {answer.sources.map((s, i) => <span className="src-chip" key={i}><Icon name="article" />{s}</span>)}
            </div>
          </div>
        </div>
      )}
      {/* 스크랩 + 컬렉션 col-8/col-4 그리드 ... ScrapCard 매핑 */}
    </>
  );
}
```

```tsx
// frontend/components/life/ScrapCard.tsx (원본 412~433)
const TYPE_LABEL = { article: "아티클", note: "메모", idea: "아이디어", highlight: "하이라이트" };
export function ScrapCard({ it, onSuggest, onToast }: ...) {
  return (
    <div className="scrap">
      <div className="scrap-top">
        <div className={"sc-ic bg-" + it.tone}><Icon name={it.icon} /></div>
        <div className="sc-h">
          <b>{it.title}</b>
          <div className="sc-meta">{it.src}<span className="dotsep" />{it.time}</div>
        </div>
        <span className={"scrap-type bg-" + it.tone}>{TYPE_LABEL[it.type]}</span>
      </div>
      <p className="scrap-excerpt">{it.excerpt}</p>
      <div className="scrap-ai">
        <div className="sa-ic"><Icon name="spark" /></div>
        <p dangerouslySetInnerHTML={{ __html: "<b>아리 — </b>" + it.ai }} />
      </div>
      <div className="scrap-foot">
        {it.tags.map((t, i) => <span className={"tag" + (i === 0 ? " auto" : "")} key={i}>{t}</span>)}
        <span className="sf-spacer" />
        <button className="sf-act" onClick={() => onToast("요약을 정리했어요", "violet")}><Icon name="layers" />요약</button>
        <button className="sf-act" onClick={() => onToast("열어보는 중…", "violet")}><Icon name="link" />열기</button>
      </div>
    </div>
  );
}
```

**디자인 충실도**:
- 검색바: `.kn-search { border:1.5px solid var(--line-2) }`, focus `box-shadow: 0 0 0 4px color-mix(in oklab, var(--violet) 14%, transparent)`, go 버튼 `.ks-go { background: var(--violet) }`.
- AI 답변 카드: `.kn-answer` violet radial-gradient + `animation: rise 0.35s`.
- 스크랩 ai 박스: `.scrap-ai { background: color-mix(in oklab, var(--violet) 7%, var(--card-2)) }`, `<b>아리 — </b>` prefix.
- 자동 태그: `.tag.auto`(첫 태그 = violet) — 원본 `i===0 ? " auto"`(자동 태깅 강조). `.tag::before { content:"#" }`.
- 빈 상태: `.kn-empty` — `"'{q}'에 해당하는 자료가 없어요. 다르게 물어보거나 새로 스크랩해 보세요."`(원본 435행).

**스크랩 ai 제안 문구 인용**(`life-data.js` items[].ai — federation 트리거):
- `k4`(딥 워크 하이라이트): `"주 4회 ‘딥 워크’ 블록을 캘린더에 잡아드릴까요? 오전 시간이 비어 있어요."` → **캘린더 federation**.
- `k2`(분기 회고 메모): `"‘분기 전략 미팅’ 안건과 연결돼요. 발표 슬라이드에 이 수치를 넣어드릴까요?"` → **작업 federation**.
- `k3`(위치 기반 리마인더 아이디어): `"자동화 규칙으로 만들 수 있어요. ‘위치 도착 → 리마인더’ 레시피를 제안해드릴게요."` → **자동화 federation**.
- `k5`(재무 자동화 글): `"금융 모듈의 ‘저축 자동 이체’ 제안과 맞닿아 있어요. 함께 설정해볼까요?"` → 금융 인사이트와 교차.

---

### 3.8 좌측 모듈 레일 + 연결 소스 (`ModuleRail.tsx`)

원본 `App` 의 `<aside className="subnav">`(626~652행). 모듈 4개 + 연결된 데이터 소스 + 푸터(지우님 · Pro 플랜 · 9개 소스).

```tsx
// frontend/components/life/ModuleRail.tsx (발췌)
const MODULES = [
  { id: "today", label: "오늘의 라이프", sub: "통합 코칭", icon: "spark", tone: "coral" },
  { id: "health", label: "건강 & 웰니스", sub: "활동·수면·습관", icon: "heart", tone: "coral" },
  { id: "finance", label: "금융 & 소비", sub: "예산·구독·자산", icon: "wallet", tone: "blue" },
  { id: "knowledge", label: "지식 & 학습", sub: "제2의 뇌", icon: "brain", tone: "violet" },
] as const;

export function ModuleRail({ mod, onPick, sources, onToast }: ...) {
  // 원본 638행: health 전체 + finance 앞2 + knowledge 앞2 만 레일에 표시
  const railSources = [...sources.health, ...sources.finance.slice(0, 2), ...sources.knowledge.slice(0, 2)];
  return (
    <aside className="subnav">
      {MODULES.map((m) => (
        <button key={m.id} className={"mod-row t-" + m.tone + (mod === m.id ? " on" : "")} onClick={() => onPick(m.id)}>
          <div className="m-ic"><Icon name={m.icon} /></div>
          <div className="m-txt"><b>{m.label}</b><span>{m.sub}</span></div>
        </button>
      ))}
      <div className="sn-label">연결된 데이터</div>
      <div className="src-list">
        {railSources.map((s) => (
          <button className="src-row" key={s.id}
            onClick={() => onToast(s.on ? `${s.name} 동기화 중…` : `${s.name} 연결 화면을 여는 중…`, s.tone)}>
            <span className={"src-dot" + (s.on ? "" : " off")} style={{ background: `var(--${s.tone})` }} />
            <div className="src-info"><b>{s.name}</b><span>{s.kind}</span></div>
            <span className={"src-stat" + (s.on ? "" : " off")}>{s.on ? "연결됨" : "연결"}</span>
          </button>
        ))}
        <button className="src-add" onClick={() => onToast("데이터 소스 연결 화면을 여는 중…", "blue")}>
          <Icon name="plus" />소스 연결하기
        </button>
      </div>
      <div className="sn-foot">
        <div className="av-lg">지</div>
        <div className="txt"><b>지우님</b><span>Pro 플랜 · 9개 소스</span></div>
      </div>
    </aside>
  );
}
```

**디자인 충실도**: `.mod-row.on.t-coral .m-ic { background: var(--coral); color:#fff }`(tone별 활성 아이콘), `.mod-row.on { background: var(--fill) }`. 소스 점 `.src-dot.off { background: var(--faint) }`, 상태 `.src-stat { color: var(--green) }`/`.off { color: var(--faint) }`. 푸터 아바타 `linear-gradient(135deg, var(--coral), var(--violet))`.

> "9개 소스" 라벨은 원본 하드코딩(`sn-foot`). 시드 소스 합계는 health 3 + finance 4 + knowledge 3 = 10 (off 2 포함). 표시 문구는 원본 그대로 `"Pro 플랜 · 9개 소스"`(연결된 8개 + 1? — 원본 카피이므로 그대로 유지, 보정 금지).

---

### 3.9 `seed_life.py` — life-data.js 시드 (run_seed 에서 호출)

phase-2 `run_seed(session=None, reset=True)` 패턴 상속. `seed_life.py` 가 `_seed_life(session)` 헬퍼를 제공하고 `run_seed` 내부에서 호출(phase-2 의 `_seed_dashboard` 와 동일 구조).

```python
# backend/app/seed_life.py
import json
from sqlmodel import Session, select
from .models import (
    ConnectorSource, HealthRing, HealthVital, SleepNight, HealthHabit,
    FinanceBudget, FinanceCategory, FinanceSub, FinanceInsight,
    KnowledgeCollection, KnowledgeItem,
    ConnectorDomain, KnowledgeType, InsightKind,
)

# ---- 연결 소스 (life-data.js sources) ----
HEALTH_SOURCES = [
    ("apple", "Apple Health", "건강 앱", "coral", True, "방금 동기화"),
    ("watch", "Apple Watch", "웨어러블", "violet", True, "1분 전"),
    ("fit", "Google Fit", "활동 추적", "green", False, "연결 안 됨"),
]
FINANCE_SOURCES = [
    ("woori", "우리카드", "신용/체크", "blue", True, "오늘 09:12"),
    ("toss", "토스", "계좌 통합", "coral", True, "오늘 08:40"),
    ("kakao", "카카오뱅크", "입출금", "amber", True, "어제"),
    ("kb", "KB증권", "투자", "green", False, "연결 안 됨"),
]
KNOWLEDGE_SOURCES = [
    ("notion", "Notion", "메모·문서", "violet", True, "오늘"),
    ("web", "웹 클리퍼", "아티클 스크랩", "blue", True, "2시간 전"),
    ("readwise", "Readwise", "하이라이트", "coral", True, "어제"),
]

# ---- 건강 ----
RINGS = [
    ("ring-steps", "걸음", "7,240", "보", "10,000보", 72, "steps", "coral"),
    ("ring-exercise", "운동", "38", "분", "45분", 84, "flame", "blue"),
    ("ring-sleep", "수면", "7h 12m", "", "7h 30m", 90, "moon", "violet"),
]
VITALS = [
    ("vital-hr", "안정 심박", "62", "bpm", "down", "지난주 –3", "coral"),
    ("vital-stress", "평균 스트레스", "보통", "", "flat", "오후에 상승", "amber"),
    ("vital-workout", "주간 운동", "3 / 4", "회", "up", "목표 근접", "blue"),
]
SLEEP = dict(
    total="7시간 12분", score=86,
    stages_json=json.dumps([
        {"label": "깊은 수면", "pct": 22, "tone": "violet"},
        {"label": "렘 수면", "pct": 24, "tone": "blue"},
        {"label": "얕은 수면", "pct": 48, "tone": "coral"},
        {"label": "깸", "pct": 6, "tone": "muted"},
    ], ensure_ascii=False),
    week_json=json.dumps([72, 65, 88, 60, 90, 78, 86]),
    note="수요일과 어젯밤은 잘 주무셨어요. 하지만 평일 취침 시각이 들쭉날쭉해요 — 자정 전 취침을 목표로 잡아볼까요?",
)
HEALTH_COACH = ("이번 주 활동량은 <b>지난주보다 12% 늘었어요</b>. 다만 어제 물 섭취가 권장량의 절반에 "
                "그쳤고, 오후 3시 이후 앉아 있는 시간이 길었어요. 가벼운 산책과 수분 보충 리마인더를 오후에 넣어둘게요.")
HABITS = [
    ("water", "물 마시기", "하루 8잔", 5, 8, 4, "water", "blue", "오후 3:00 리마인더"),
    ("stretch", "스트레칭", "오후 휴식 5분", 1, 2, 9, "stretch", "coral", "오후 3:30 리마인더"),
    ("vitamin", "비타민 D", "아침 1정", 1, 1, 21, "pill", "amber", "오전 8:00 완료"),
    ("meditate", "명상", "취침 전 10분", 0, 1, 2, "feather", "violet", "오후 10:30 예정"),
]

# ---- 금융 ----
FINANCE_BUDGET = dict(spent=1284000, limit=2000000, pct=64, delta_pct=-8, days_left=23)
FINANCE_CATS = [
    ("cat-food", "식비", 412000, 32, "food", "coral", False),
    ("cat-shop", "쇼핑", 298000, 23, "bag", "violet", True),
    ("cat-transit", "교통", 164000, 13, "car", "blue", False),
    ("cat-cafe", "카페·간식", 138000, 11, "coffee", "amber", False),
    ("cat-sub", "구독", 92000, 7, "repeat", "green", False),
    ("cat-culture", "문화·여가", 180000, 14, "ticket", "blue", False),
]
FINANCE_SUBS = [
    ("sub-netflix", "Netflix", "6월 9일", 2, 17000, "play", "coral", "프리미엄"),
    ("sub-spotify", "Spotify", "6월 9일", 2, 10900, "music", "green", "개인"),
    ("sub-chatgpt", "ChatGPT Plus", "6월 12일", 5, 29000, "spark", "violet", "월 구독"),
    ("sub-coupang", "쿠팡 와우", "6월 15일", 8, 7890, "bag", "blue", "멤버십"),
    ("sub-youtube", "유튜브 프리미엄", "6월 22일", 15, 14900, "play", "coral", "가족 요금제"),
]
FINANCE_COACH = ("이번 달은 <b>지난달보다 8% 적게</b> 쓰고 계세요. 다만 <b>쇼핑</b>이 예산의 23%로 "
                 "평소보다 빠르게 늘고 있어요. 또 <b>이틀 뒤 Netflix·Spotify 결제</b>가 같은 날 빠져나가요 — "
                 "최근 두 달간 안 본 Netflix는 일시정지를 추천드려요.")
FINANCE_INSIGHTS = [
    ("ins-shop", "alert", "alert", "쇼핑 예산 초과 임박",
     "이번 달 쇼핑 29.8만 원, 한도의 99%예요. 남은 23일을 위해 한도를 올리거나 알림을 받을까요?",
     "raise_budget", "cat-shop"),
    ("ins-netflix", "save", "repeat", "잠자는 구독 발견",
     "Netflix를 최근 58일간 시청하지 않았어요. 일시정지 시 월 1.7만 원을 아껴요.",
     "pause_sub", "sub-netflix"),
    ("ins-save", "info", "trending", "이달 저축 페이스 양호",
     "현재 추세라면 이번 달 약 26만 원을 남길 수 있어요. 적금에 자동 이체할까요?",
     "auto_transfer", ""),
]

# ---- 지식 ----
COLLECTIONS = [
    ("coll-product", "프로덕트 전략", 84, "blue"),
    ("coll-ai", "AI · 에이전트", 132, "violet"),
    ("coll-design", "디자인 시스템", 57, "coral"),
    ("coll-growth", "개인 성장", 41, "green"),
]
KITEMS = [
    ("k1", "article", "효과적인 AI 에이전트를 설계하는 6가지 패턴", "anthropic.com", "2시간 전", "blue", "article",
     ["AI·에이전트", "리서치", "설계"],
     "도구 사용, 계획 수립, 반성(reflection)을 분리하면 에이전트의 신뢰성이 크게 올라간다. 핵심은 작은 단위로 검증 가능한 단계로 쪼개는 것.",
     "오케스트레이터-워커 패턴과 평가-최적화 루프가 우리 온보딩 자동화에 바로 적용 가능해 보여요.", "coll-ai", "web"),
    ("k2", "note", "분기 회고 — 리텐션 실험 메모", "내 노트", "어제", "coral", "note",
     ["프로덕트 전략", "업무"],
     "온보딩 3단계 축소 후 D7 리텐션 +6%p. 다음 분기엔 알림 타이밍 개인화 A/B 테스트 진행.",
     "‘분기 전략 미팅’ 안건과 연결돼요. 발표 슬라이드에 이 수치를 넣어드릴까요?", "coll-product", "notion"),
    ("k3", "idea", "아이디어 — 습관 리마인더를 위치 기반으로", "빠른 메모", "어제", "violet", "idea",
     ["아이디어", "개인 성장"],
     "헬스장 근처를 지나면 운동 리마인더, 집 도착 시 명상 알림. 라이프 케어 모듈과 자연스럽게 이어짐.",
     "자동화 규칙으로 만들 수 있어요. ‘위치 도착 → 리마인더’ 레시피를 제안해드릴게요.", "coll-growth", "notion"),
    ("k4", "highlight", "‘딥 워크’ — 몰입의 4가지 규칙 하이라이트", "Readwise", "3일 전", "amber", "highlight",
     ["개인 성장", "독서"],
     "“집중은 근육과 같다. 산만함을 견디는 훈련을 반복할수록 더 깊이 몰입할 수 있다.”",
     "주 4회 ‘딥 워크’ 블록을 캘린더에 잡아드릴까요? 오전 시간이 비어 있어요.", "coll-growth", "readwise"),
    ("k5", "article", "개인 재무를 자동화하는 시스템 만들기", "newsletter", "5일 전", "green", "article",
     ["개인 성장", "금융"],
     "수입이 들어오면 자동으로 비율에 따라 분배(저축·투자·소비)하는 구조를 한 번 세팅해두면 의지력이 필요 없다.",
     "금융 모듈의 ‘저축 자동 이체’ 제안과 맞닿아 있어요. 함께 설정해볼까요?", "coll-growth", "web"),
]

def _seed_life(s: Session, reset: bool = True) -> None:
    if reset:
        for tbl in (KnowledgeItem, KnowledgeCollection, FinanceInsight, FinanceSub,
                    FinanceCategory, FinanceBudget, HealthHabit, SleepNight,
                    HealthVital, HealthRing, ConnectorSource):
            for row in s.exec(select(tbl)).all():
                s.delete(row)
        s.commit()

    order = 0
    for dom, rows in (("health", HEALTH_SOURCES), ("finance", FINANCE_SOURCES), ("knowledge", KNOWLEDGE_SOURCES)):
        for i, (sid, name, kind, tone, on, last) in enumerate(rows):
            s.add(ConnectorSource(id=sid, domain=ConnectorDomain(dom), name=name, kind=kind,
                                  tone=tone, on=on, last=last, sort_order=i))
    for i, (rid, label, val, unit, goal, pct, icon, tone) in enumerate(RINGS):
        s.add(HealthRing(id=rid, label=label, val=val, unit=unit, goal=goal, pct=pct, icon=icon, tone=tone, sort_order=i))
    for i, (vid, label, val, unit, trend, note, tone) in enumerate(VITALS):
        s.add(HealthVital(id=vid, label=label, val=val, unit=unit, trend=trend, note=note, tone=tone, sort_order=i))
    s.add(SleepNight(id=1, **SLEEP))
    for i, (hid, title, goal, done, total, streak, icon, tone, at) in enumerate(HABITS):
        s.add(HealthHabit(id=hid, title=title, goal=goal, done=done, total=total,
                          streak=streak, icon=icon, tone=tone, at=at, sort_order=i))
    s.add(FinanceBudget(id=1, **FINANCE_BUDGET))
    for i, (cid, name, amt, pct, icon, tone, over) in enumerate(FINANCE_CATS):
        s.add(FinanceCategory(id=cid, name=name, amt=amt, pct=pct, icon=icon, tone=tone, over=over, sort_order=i))
    for i, (sid, name, date, ind, amt, icon, tone, note) in enumerate(FINANCE_SUBS):
        s.add(FinanceSub(id=sid, name=name, date=date, in_days=ind, amt=amt, icon=icon, tone=tone, note=note, sort_order=i))
    for i, (iid, kind, icon, title, body, action, ref) in enumerate(FINANCE_INSIGHTS):
        s.add(FinanceInsight(id=iid, kind=InsightKind(kind), icon=icon, title=title, body=body,
                             action=action, action_ref=ref, sort_order=i))
    for i, (cid, name, count, tone) in enumerate(COLLECTIONS):
        s.add(KnowledgeCollection(id=cid, name=name, count=count, tone=tone, sort_order=i))
    for i, row in enumerate(KITEMS):
        kid, ktype, title, src, time, tone, icon, tags, excerpt, ai, coll, conn = row
        s.add(KnowledgeItem(id=kid, type=KnowledgeType(ktype), title=title, src=src, time=time,
                            tone=tone, icon=icon, tags_json=json.dumps(tags, ensure_ascii=False),
                            excerpt=excerpt, ai=ai, collection_id=coll, connector_id=conn, sort_order=i))
    s.commit()
```

`seed.py` 통합(phase-2 `run_seed` 의 `_run` 끝부분에 1줄 추가):

```python
# backend/app/seed.py  _run(...) 끝부분
    from .seed_life import _seed_life
    _seed_life(s, reset=reset)                  # 라이프 케어 시드 통합
    s.commit()
```

> **knowledge_item ↔ research_source 공유(phase-10)**: `KnowledgeItem.id` 는 `k1`..`k5`. 만약 `phase-10` 리서치가 같은 자료를 `research_source` 로 ingest 하면, 본 phase 는 `KnowledgeItem` 을 *제2의 뇌 표시 레이어*로 유지하고 RAG ingest 대상으로 등록(아래 §4.4). 시드 단계에선 두 테이블에 독립 적재하되, `connector_id` 로 출처를 일치시킨다.

---

### 3.10 `life_insights.py` — federation 서비스

금융 인사이트→결재함, 구독 일시정지→결재함, 지식 ai 제안→작업/캘린더, 지식 Q&A→RAG.

```python
# backend/app/services/life_insights.py
from sqlmodel import Session
from ..models import FinanceSub, FinanceInsight, KnowledgeItem
# phase-7 결재함/자동화 모델 (계약 가정)
from ..models import Approval                  # phase-7 의 approval 테이블
from ..schemas import InsightActResponse, KnowledgeAskResponse, KnowledgeSuggestActResponse
from ..automation.event_bus import publish      # 횡단 event_bus (phase-7)

def enqueue_sub_pause(s: Session, sub: FinanceSub) -> str:
    """구독 일시정지는 결제 영향 → high-risk 결재함 항목(되돌리기 가능)."""
    aid = f"ap-life-sub-{sub.id}"
    s.add(Approval(
        id=aid, icon="wallet", tone="amber", risk="high",
        source="life", status="pending",
        time="확인 필요",
        title=f"{sub.name} 일시정지를 추천해요",
        detail=f"최근 시청/사용 기록 없음 · {sub.date} {sub.amt:,}원 결제 예정 — 정지 절차는 준비해뒀어요",
        cta="일시정지", alt="유지", undo_label="",
    ))
    s.commit()
    publish("approval.enqueued", {"approval_id": aid, "source": "life", "ref": sub.id})
    return aid

def act(s: Session, ins: FinanceInsight) -> InsightActResponse:
    """인사이트 실행 → action 종류별 라우팅."""
    if ins.action == "pause_sub":
        sub = s.get(FinanceSub, ins.action_ref)
        sub.paused = True; s.add(sub); s.commit()
        aid = enqueue_sub_pause(s, sub)
        return InsightActResponse(approval_id=aid, message="잠자는 구독 일시정지를 결재함으로 보냈어요")
    if ins.action == "raise_budget":
        aid = f"ap-life-budget-{ins.action_ref}"
        s.add(Approval(id=aid, icon="alert", tone="coral", risk="high", source="life",
                       status="pending", time="확인 필요", title="쇼핑 한도 조정을 제안해요",
                       detail=ins.body, cta="한도 올리기", alt="알림만", undo_label=""))
        s.commit()
        publish("approval.enqueued", {"approval_id": aid, "source": "life"})
        return InsightActResponse(approval_id=aid, message="한도 조정을 결재함으로 보냈어요")
    if ins.action == "auto_transfer":
        # 저축 자동 이체 = 작업으로 실체화('함께 설정' 흐름)
        task_id = _create_task(s, "적금 자동 이체 설정", project="me",
                               notes="라이프 금융 인사이트에서 생성 — 매월 저축 자동 분배 룰 세팅")
        publish("task.created", {"task_id": task_id, "source": "life"})
        return InsightActResponse(task_id=task_id, message="저축 자동 이체 설정을 작업으로 만들었어요")
    return InsightActResponse(message="처리할 액션이 없어요")

def act_suggestion(s: Session, item_id: str, target: str) -> KnowledgeSuggestActResponse:
    """지식 아이템 ai 제안 → 작업/캘린더('딥워크 블록 잡아드릴까요')."""
    it = s.get(KnowledgeItem, item_id)
    if target == "calendar":
        ev = _create_event(s, title="딥 워크 블록", note=f"지식 제안에서 생성: {it.title}")
        publish("event.created", {"event_id": ev, "source": "life"})
        return KnowledgeSuggestActResponse(event_id=ev, message="딥 워크 블록을 캘린더에 잡았어요")
    task_id = _create_task(s, f"[지식] {it.title} 후속", project="me", notes=it.ai)
    publish("task.created", {"task_id": task_id, "source": "life"})
    return KnowledgeSuggestActResponse(task_id=task_id, message="제안을 작업으로 보냈어요")

def knowledge_ask(s: Session, q: str) -> KnowledgeAskResponse:
    """phase-10 RAG 재사용. 미가용/오프라인 시 scripted 폴백(데모 결정성)."""
    try:
        from ..rag import query as rag_query     # phase-10
        res = rag_query(q, top_k=3)
        return KnowledgeAskResponse(q=q, body=res.answer_html, sources=[c.title for c in res.cites])
    except Exception:
        # scripted 폴백 — 원본 life.jsx ask() 의 고정 답변 그대로
        return KnowledgeAskResponse(
            q=q,
            body=("저장하신 자료를 종합하면 — <b>검증 가능한 작은 단계로 분해</b>하고, 계획·실행·반성을 "
                  "분리할 때 에이전트 신뢰성이 가장 크게 올라갑니다. ‘분기 회고’ 메모의 리텐션 +6%p 결과와도 "
                  "연결돼요. 온보딩 자동화에 <b>오케스트레이터-워커 패턴</b>을 먼저 적용해보길 추천드려요."),
            sources=["AI 에이전트 설계 패턴", "분기 회고 메모", "재무 자동화 시스템"],
        )
```

> **결재함 모델(phase-7) 필드 가정**: `approval(id, icon, tone, risk(low|high), status(pending|approved|undone|executed), source(automation|agent|mail|calendar|life|...), title, detail, cta, alt, undo_label, time)`. 본 phase 는 `source="life"` 로 항목을 만들고, `autonomy_setting.level` 에 따라 low+mixed 이상은 자동 실행(되돌리기)·high 는 승인 대기로 결재함이 처리한다(라이프는 항목 생성만 책임). 구독 일시정지/예산 한도는 결제·재무 영향이므로 **항상 high-risk**.

---

## 4. 데이터 / 타입 / API 계약

### 4.1 `GET /api/life/overview` 응답 예시

```json
{
  "sources": {
    "health": [
      { "id": "apple", "domain": "health", "name": "Apple Health", "kind": "건강 앱", "tone": "coral", "on": true, "last": "방금 동기화" },
      { "id": "watch", "domain": "health", "name": "Apple Watch", "kind": "웨어러블", "tone": "violet", "on": true, "last": "1분 전" },
      { "id": "fit", "domain": "health", "name": "Google Fit", "kind": "활동 추적", "tone": "green", "on": false, "last": "연결 안 됨" }
    ],
    "finance": [
      { "id": "woori", "domain": "finance", "name": "우리카드", "kind": "신용/체크", "tone": "blue", "on": true, "last": "오늘 09:12" }
    ],
    "knowledge": [
      { "id": "notion", "domain": "knowledge", "name": "Notion", "kind": "메모·문서", "tone": "violet", "on": true, "last": "오늘" }
    ]
  },
  "health": {
    "rings": [ { "label": "걸음", "val": "7,240", "unit": "보", "goal": "10,000보", "pct": 72, "icon": "steps", "tone": "coral" } ],
    "vitals": [ { "label": "안정 심박", "val": "62", "unit": "bpm", "trend": "down", "note": "지난주 –3", "tone": "coral" } ],
    "sleep": {
      "total": "7시간 12분", "score": 86,
      "stages": [ { "label": "깊은 수면", "pct": 22, "tone": "violet" }, { "label": "깸", "pct": 6, "tone": "muted" } ],
      "week": [72, 65, 88, 60, 90, 78, 86],
      "note": "수요일과 어젯밤은 잘 주무셨어요. 하지만 평일 취침 시각이 들쭉날쭉해요 — 자정 전 취침을 목표로 잡아볼까요?"
    },
    "coach": "이번 주 활동량은 <b>지난주보다 12% 늘었어요</b>. ...",
    "habits": [ { "id": "water", "title": "물 마시기", "goal": "하루 8잔", "done": 5, "total": 8, "streak": 4, "icon": "water", "tone": "blue", "at": "오후 3:00 리마인더", "automation_rule_id": null } ]
  },
  "finance": {
    "budget": { "spent": 1284000, "limit": 2000000, "pct": 64, "delta_pct": -8, "days_left": 23 },
    "cats": [ { "name": "식비", "amt": 412000, "pct": 32, "icon": "food", "tone": "coral", "over": false },
              { "name": "쇼핑", "amt": 298000, "pct": 23, "icon": "bag", "tone": "violet", "over": true } ],
    "subs": [ { "id": "sub-netflix", "name": "Netflix", "date": "6월 9일", "in_days": 2, "amt": 17000, "icon": "play", "tone": "coral", "note": "프리미엄", "paused": false } ],
    "coach": "이번 달은 <b>지난달보다 8% 적게</b> 쓰고 계세요. ...",
    "insights": [ { "id": "ins-netflix", "kind": "save", "icon": "repeat", "title": "잠자는 구독 발견", "body": "Netflix를 최근 58일간 시청하지 않았어요. 일시정지 시 월 1.7만 원을 아껴요.", "action": "pause_sub", "action_ref": "sub-netflix" } ]
  },
  "knowledge": {
    "stats": { "items": 1248, "this_week": 23, "collections": 9 },
    "suggested": ["AI 에이전트 설계에서 배운 것 요약해줘", "이번 주 저장한 글 핵심만", "리텐션 관련 메모 모아줘"],
    "collections": [ { "name": "프로덕트 전략", "count": 84, "tone": "blue" } ],
    "items": [ { "id": "k1", "type": "article", "title": "효과적인 AI 에이전트를 설계하는 6가지 패턴", "src": "anthropic.com", "time": "2시간 전", "tone": "blue", "icon": "article", "tags": ["AI·에이전트", "리서치", "설계"], "excerpt": "도구 사용, 계획 수립, 반성(reflection)을 분리하면 ...", "ai": "오케스트레이터-워커 패턴과 ..." } ]
  }
}
```

### 4.2 액션 요청/응답 예시

`POST /api/life/habits/water/tick`
```json
// 응답 — done 5→6
{ "habit": { "id": "water", "title": "물 마시기", "goal": "하루 8잔", "done": 6, "total": 8, "streak": 4, "icon": "water", "tone": "blue", "at": "오후 3:00 리마인더", "automation_rule_id": null } }
```

`POST /api/life/subs/sub-netflix/pause` body `{ "paused": true }`
```json
{
  "sub": { "id": "sub-netflix", "name": "Netflix", "date": "6월 9일", "in_days": 2, "amt": 17000, "icon": "play", "tone": "coral", "note": "프리미엄", "paused": true },
  "approval_id": "ap-life-sub-sub-netflix"
}
```

`POST /api/life/insights/ins-netflix/act`
```json
{ "approval_id": "ap-life-sub-sub-netflix", "task_id": null, "message": "잠자는 구독 일시정지를 결재함으로 보냈어요" }
```

`POST /api/life/knowledge/ask` body `{ "q": "AI 에이전트 설계에서 배운 것 요약해줘" }`
```json
{
  "q": "AI 에이전트 설계에서 배운 것 요약해줘",
  "body": "저장하신 자료를 종합하면 — <b>검증 가능한 작은 단계로 분해</b>하고, ...",
  "sources": ["AI 에이전트 설계 패턴", "분기 회고 메모", "재무 자동화 시스템"]
}
```

`POST /api/life/knowledge/suggest/act` body `{ "item_id": "k4", "target": "calendar" }`
```json
{ "task_id": null, "event_id": "ev-life-deepwork-1", "message": "딥 워크 블록을 캘린더에 잡았어요" }
```

### 4.3 enum / tone 매핑 표

| 필드 | 값 | UI 효과 |
|---|---|---|
| `insight.kind` | `alert` | `.insight.alert` coral tint + 흰 아이콘 박스 |
| | `save` | `.insight.save` green 아이콘 박스 |
| | `info` | `.insight.info` blue 아이콘 박스 |
| `vital.trend` | `up`/`down`/`flat` | 아이콘 `trending`/`heart`/`clock`(원본 155행 매핑) |
| `sleep.stages[].tone` | `violet`/`blue`/`coral` | `var(--tone)` |
| | `muted` | `var(--card-3)`(막대) / `var(--faint)`(범례) — 예외 처리 |
| `knowledge.type` | `article`/`note`/`idea`/`highlight` | 라벨 `아티클/메모/아이디어/하이라이트` + tone 배지 |
| `sub` | `in_days<=3 && !paused` | `.sub-row.soon`(coral 강조) |
| | `in_days>3` | `.sw-in.far`(muted) |

### 4.4 `knowledge_item` ↔ 리서치(phase-10) 공유 명세

| 항목 | 라이프(phase-11) | 리서치(phase-10) |
|---|---|---|
| 테이블 | `knowledge_item`(표시 레이어) | `research_source`(ingest 레이어) + RAG chunk/embed |
| 책임 | 제2의 뇌 스크랩 카드 표시 + ai 제안 | 멀티소스 종합 리포트 + 근거 링크 Q&A |
| Q&A | `POST /life/knowledge/ask` → `rag.query()` 재사용 | `POST /research/qa` → 동일 `rag.query()` |
| 공유 키 | `connector_id`(출처 일치), 동일 자료는 `id` 동기화 | `research_source.knowledge_item_id`(역참조 가능) |

> 한 자료(예 `k1` AI 에이전트 글)는 라이프 제2의 뇌에 카드로, 리서치에 ingest 소스로 동시에 존재할 수 있다. RAG 임베딩/벡터스토어는 `backend/app/rag/` 에 단일 존재하며 두 페이지가 같은 `rag.query()` 를 호출한다(중복 인프라 금지).

---

## 5. 디자인 충실도 노트

REF: `design-reference/assets/life.jsx` · `life-data.js` · `life.css`. 토큰은 `overview.md` §11 = `tokens.css` 재사용(라이프 전용 신규 토큰 없음).

### 5.1 토큰/색 (life.css `:root` = dash.css 와 동일)

| 토큰 | 값 |
|---|---|
| 액센트 | `--blue #4f72e0` · `--coral #df7256` · `--green #4e9b66` · `--violet #8b6fd4` · `--amber #e0a23c` |
| 배경 | `--bg-top #f6efe7` · `--bg-mid #eef0f1` · `--bg-bot #e9ebed` (body `linear-gradient(178deg,...)`, `background-attachment: fixed`) |
| 카드 | `--card #ffffff` · `--card-2 #f4f5f6` · `--card-3 #eef0f1` |
| 라운드 | `--radius 22px` · `--radius-sm 14px` (단 `.card { border-radius: 18px }` — 라이프 카드는 18px) |
| 글래스 | `--glass rgba(255,255,255,.4)` · `--blur blur(26px) saturate(190%)` |
| 다크 | `[data-theme="dark"]` 오버라이드(`--card #2c2925`, `--ink #f3eee6`, `--fill #f4efe6` 반전) |

### 5.2 레이아웃 핵심 px

- `.lwork { grid-template-columns: 256px minmax(0,1fr); gap:16px }` (좌측 레일 256px 고정).
- `.subnav { position: sticky; top: 92px }`.
- `.lgrid { grid-template-columns: repeat(12,1fr); gap:16px }` — col-4/5/7/8/12. `@media(max-width:1180px)` 면 전부 span 12.
- 코칭 히어로 `.coach-hero h1 { font-size: clamp(20px,2.4vw,26px) }`.
- 링 92px · 수면 점수 링 74px · 7일 막대 height 56px.
- 상단바 `.topbar { height:70px; position:sticky; backdrop-filter: blur(14px) saturate(150%) }`(공용 셸).

### 5.3 한국어 UI 문구 (반드시 원본 그대로)

| 위치 | 문구 |
|---|---|
| 모듈 라벨 | `오늘의 라이프`(통합 코칭) · `건강 & 웰니스`(활동·수면·습관) · `금융 & 소비`(예산·구독·자산) · `지식 & 학습`(제2의 뇌) |
| 건강 헤더 | `건강 & 웰니스` / sub `Apple Health · Apple Watch 연동 · 오늘 {N}시 기준` / `주간 리포트` |
| 카드 제목 | `오늘의 활동`(목표까지 조금만 더) · `바이탈`(웨어러블 측정값) · `수면 분석`(어젯밤 · 7일 추이) · `건강한 습관`(리마인더 자동 설정됨) |
| 수면 | `점수` · `권장보다 18분 부족` · 단계 `깊은 수면/렘 수면/얕은 수면/깸` |
| 습관 버튼 | `기록`(plus) / `완료`(tick) · 스트릭 `{N}일` |
| 금융 헤더 | `금융 & 소비` / sub `우리카드 · 토스 · 카카오뱅크 연동 · 6월 예산` / `월간 정산` |
| 예산 | `이달 예산`({N}일 남음) · `지난달 대비 N% 적게 썼어요` · `{N}% 사용` · `남은 예산 {₩}` |
| 지출 | `자동 분류된 지출`(아리가 거래내역을 카테고리로 정리했어요) · `예산 빠름`(over 태그) |
| 구독 | `구독 결제 예정`(결제일 전에 미리 알려드려요) · `알림 켜짐` · `{N}일 뒤` / `정지` / `일시정지됨` |
| 지식 헤더 | `지식 & 학습 · 제2의 뇌` / `{N}개 저장 · 이번 주 +{N} · {N}개 컬렉션` / `스크랩` |
| 검색 | placeholder `저장한 모든 것에 자연어로 물어보세요…` · 칩 3개 / `자동 태깅됨` |
| 빈 상태 | `'{q}'에 해당하는 자료가 없어요. 다르게 물어보거나 새로 스크랩해 보세요.` |
| 컬렉션 | `컬렉션`(주제별 자동 묶음) |
| 스크랩 | `<b>아리 — </b>` ai prefix · `요약` · `열기` |
| 레일 | `연결된 데이터` · `소스 연결하기` · `지우님` / `Pro 플랜 · 9개 소스` · 소스 `연결됨`/`연결` |
| 오늘 히어로 | eyebrow `아리 라이프 코치 · 오늘 아침` · h1 `오늘의 컨디션은 좋아요. 활동만 조금 더 챙기면 완벽해요 ☀️` |
| 오늘 리마인더 | `오늘의 리마인더`(아리가 챙기고 있는 것들) · `구독 결제 임박` |

> **HTML 문구**: `coach`/스크랩 `ai`/Q&A `body` 는 `<b>` 포함 HTML. `overview.md` §15 새니타이즈 원칙(신뢰된 시드/서버 응답만 `dangerouslySetInnerHTML`, 사용자 입력은 escape) 준수. 사용자 검색어(`q`)는 절대 raw 삽입 금지 — 빈 상태 문구의 `'{q}'` 는 텍스트 노드로 렌더.

### 5.4 아이콘 (paths 맵에 추가 — 누락 시 렌더 크래시)

라이프 전용으로 phase-1 `Icon` paths 맵에 추가해야 하는 키(원본 `life.jsx` `P`):
`spark, heart, wallet, brain, steps, flame, water, stretch, pill, feather, food, bag, car, coffee, repeat, ticket, play, music, alert, trending, article, note, idea, highlight, folder, link, layers, moon, sun, bell, plus, x, chev, arrow, more, refresh, tick, clock, send, search, route, cal, check, mail, inbox, grid, zap`. (다수는 phase-1에서 이미 추가됨 — 누락분만 보강하고 단일 출처 가드.)

> **중앙 paths 맵 추가 글리프(신규)**: `alert`(인사이트 `kind=alert`/`icon="alert"` 에서 사용)는 중앙 맵에 없으므로 phase-11 에서 추가한다. path 값:
> `alert: "M12 9v4|M12 17h.01|M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h16a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"`. (다른 라이프 글리프와 마찬가지로 단일 출처(중앙 paths 맵)에만 정의.)

---

## 6. 상태 처리 / 엣지 케이스 (overview §15 상속)

| 상황 | 처리 |
|---|---|
| **로딩** | `useLife()` SWR 로딩 중 카드 골격(skeleton). 모듈 전환은 캐시된 overview 즉시 표시. |
| **빈(empty)** | 지식 검색 무결과: `.kn-empty` 문구. 습관 0개: 카드 헤더만. 인사이트 0건: 카드 숨김(원본은 항상 시드 존재). |
| **에러** | API 실패 시 카드별 인라인 에러 + 재시도. 액션(tick/pause/act) 실패는 토스트(`"기록에 실패했어요"`)로 롤백(낙관적 UI 되돌림). |
| **오프라인/LLM 미가용** | 지식 Q&A 는 `rag.query()` 실패 시 **scripted 폴백**(§3.10) — 데모 결정성 보장. 커넥터 sync 실패는 `last` 를 "동기화 실패"로 표시하되 마지막 캐시 데이터 유지. |
| **커넥터 off** | `fit`/`kb` 처럼 `on:false` 면 점 회색(`.src-dot.off`) + 상태 `연결`. 해당 도메인 데이터는 시드(mock)로 채워짐(연결 안 돼도 데모는 동작). |
| **습관 완료 후** | `done >= total` 이면 버튼 disabled + `.habit.done`(green), 추가 tick 무시(서버도 `done<total` 가드). |
| **구독 이미 정지** | 재호출 시 토글(해제) → `paused:false`, `refresh` 아이콘, soon 재계산. |
| **인사이트 중복 실행** | 같은 인사이트 act 재호출 시 동일 `approval_id` 재사용(idempotent — id 결정적 `ap-life-...`). |
| **금액 오버플로** | `won` 은 `toLocaleString("ko-KR")` 천단위 구분. 음수 delta 는 `Math.abs` 표시 + green(절약). |
| **다크모드** | sleep stage `muted` → `var(--faint)`(다크에서도 대비 유지). 글래스 카드 다크 토큰 자동. |
| **SSR/CSR 시각 불일치** | `오늘 {getHours()}시` 는 hydration mismatch 위험 → 클라이언트 `useEffect` 후 표시 또는 서버 시드 고정값(`"오늘 9시 기준"`) 사용. |
| **reduced-motion** | `@media (prefers-reduced-motion: reduce) { *{animation:none!important;transition:none!important} }`(life.css 585행) 준수. |

---

## 7. 연합 이벤트 (event_bus 발행/구독)

CONTRACT 연합 이벤트 모델 + 횡단 `event_bus`(phase-7). 라이프가 **발행**하는 이벤트와 그 결과:

### 7.1 발행 (publish)

| 이벤트 | 트리거 | 페이로드 | 수신처 |
|---|---|---|---|
| `approval.enqueued` | 구독 일시정지 실행 / 예산 한도 인사이트 act | `{approval_id, source:"life", ref}` | 결재함(phase-7) — high-risk 승인 대기 카드 |
| `task.created` | 저축 자동 이체 인사이트 act / 지식 제안→작업 | `{task_id, source:"life"}` | 작업(phase-3) 트리에 등장 + 리스크 재계산 |
| `event.created` | 지식 제안→캘린더("딥워크 블록") | `{event_id, source:"life"}` | 일정(phase-8) 캘린더 블록 |
| `automation.suggested` | 습관 리마인더 → 규칙 제안 / 위치 기반 아이디어(k3) | `{rule_draft, source:"life"}` | 자동화 `suggester`(phase-7) 제안 목록 |
| `habit.logged` | 습관 tick | `{habit_id, done, total}` | 하루 마감(phase-12) 집계 + 자동화 streak 트리거 |

### 7.2 구독 (subscribe)

| 이벤트 | 발행처 | 라이프 반응 |
|---|---|---|
| `approval.executed` | 결재함(phase-7) | 구독 일시정지 승인 완료 시 `FinanceSub.paused=true` 확정(되돌리기 시 false). |
| `automation.matched` | 자동화 evaluator(phase-7) | 습관 리마인더 규칙 발화 시 습관 카드 `at` 갱신/알림 점. |
| `connector.synced` | 커넥터 sync(phase-13) | 소스 `last` 갱신 + 해당 도메인 데이터 리프레시. |
| `mail.received` | 메일(phase-9) | 영수증/카드명세서 메일 → 금융 카테고리 자동 분류 갱신(교차). |

### 7.3 federation 시나리오 (PROJECT-README §5 반영)

**시나리오 A — 잠자는 구독 → 결재함 → 하루 마감 합산**
```
라이프 금융 인사이트 "잠자는 구독 발견(Netflix)"
  → 사용자 [일시정지] 탭 → POST /life/insights/ins-netflix/act
  → life_insights.act() → FinanceSub.paused=true + Approval(source=life, risk=high) 생성
  → publish("approval.enqueued")
  → 결재함(phase-7): "Netflix 일시정지를 추천해요" 카드 [일시정지][유지]
  → 사용자 승인 → publish("approval.executed") → 라이프 구독 정지 확정
  → 하루 마감(phase-12): "아리가 아낀 돈/시간"에 월 1.7만 원 합산
```

**시나리오 B — 지식 ai 제안 → 캘린더 딥워크 블록**
```
제2의 뇌 'k4 딥 워크 하이라이트' ai 제안: "주 4회 딥 워크 블록을 캘린더에 잡아드릴까요?"
  → 사용자 수락 → POST /life/knowledge/suggest/act {item_id:"k4", target:"calendar"}
  → act_suggestion() → event 생성 + publish("event.created")
  → 일정(phase-8): 오전 빈 블록에 '딥 워크' focus_block 등장
  → 작업 리스크 레이더: 집중 시간 확보 반영
```

**시나리오 C — 습관 → 자동화 규칙 (위치 기반)**
```
제2의 뇌 'k3 위치 기반 리마인더 아이디어' ai: "‘위치 도착 → 리마인더’ 레시피를 제안"
  → publish("automation.suggested", {rule_draft})
  → 자동화(phase-7) 아리 제안 목록 + 레일 알림 점
  → 사용자 채택 → automation_rule 생성 → 습관(stretch/meditate)에 automation_rule_id 연결
```

---

## 8. 테스팅 & 검증

### 8.1 실행 명령

```bash
# 백엔드
cd backend
uv run python -m app.seed                         # 라이프 시드 포함 적재 확인
uv run pytest tests/test_api_life.py -v
uv run pytest tests/test_life_seed.py -v
uv run pytest tests/test_life_federation.py -v
uv run pytest                                       # 전체 (phase-2~11 회귀)

# 프론트
cd frontend
pnpm vitest run components/life                    # 모듈/카드 단위
pnpm playwright test playwright/life.spec.ts       # E2E
pnpm playwright test playwright/life-a11y.spec.ts  # axe
```

### 8.2 백엔드 pytest 케이스

```python
# tests/test_life_seed.py
def test_seed_loads_all_domains(session):
    run_seed(session=session, reset=True)
    assert session.exec(select(ConnectorSource)).all().__len__() == 10   # 3+4+3
    assert {r.id for r in session.exec(select(HealthRing)).all()} == {"ring-steps","ring-exercise","ring-sleep"}
    assert session.get(FinanceBudget, 1).spent == 1284000
    assert session.get(SleepNight, 1).score == 86
    assert len(session.exec(select(FinanceSub)).all()) == 5
    assert {it.id for it in session.exec(select(KnowledgeItem)).all()} == {"k1","k2","k3","k4","k5"}

def test_sources_off_flags(session):
    run_seed(session=session, reset=True)
    fit = session.get(ConnectorSource, "fit"); assert fit.on is False
    kb = session.get(ConnectorSource, "kb"); assert kb.on is False
    apple = session.get(ConnectorSource, "apple"); assert apple.on is True
```

```python
# tests/test_api_life.py
def test_overview_shape(client):           # 모든 영역 키 존재
    r = client.get("/api/life/overview").json()
    assert set(r["sources"]) == {"health","finance","knowledge"}
    assert r["finance"]["budget"]["spent"] == 1284000
    assert r["knowledge"]["stats"]["items"] == 1248
    assert r["health"]["sleep"]["week"] == [72,65,88,60,90,78,86]

def test_habit_tick_increments(client):    # done < total 일 때만 증가
    before = next(h for h in client.get("/api/life/health").json()["habits"] if h["id"]=="water")
    assert before["done"] == 5
    after = client.post("/api/life/habits/water/tick").json()["habit"]
    assert after["done"] == 6

def test_habit_tick_caps_at_total(client): # vitamin done=1 total=1 → 증가 안 함
    r = client.post("/api/life/habits/vitamin/tick").json()["habit"]
    assert r["done"] == 1                   # 1/1 유지

def test_knowledge_search_filter(client):  # 원본 부분일치 필터
    r = client.get("/api/life/knowledge", params={"q":"리텐션"}).json()
    titles = [it["title"] for it in r["items"]]
    assert "분기 회고 — 리텐션 실험 메모" in titles

def test_knowledge_search_empty(client):
    r = client.get("/api/life/knowledge", params={"q":"존재하지않는검색어zzz"}).json()
    assert r["items"] == []

def test_knowledge_ask_fallback(client):   # RAG 미가용 → scripted 폴백
    r = client.post("/api/life/knowledge/ask", json={"q":"AI 에이전트 설계 요약"}).json()
    assert "오케스트레이터-워커" in r["body"]
    assert len(r["sources"]) == 3
```

```python
# tests/test_life_federation.py
def test_sub_pause_creates_high_risk_approval(client):
    r = client.post("/api/life/subs/sub-netflix/pause", json={"paused":True}).json()
    assert r["sub"]["paused"] is True
    assert r["approval_id"] == "ap-life-sub-sub-netflix"
    ap = client.get(f"/api/approvals/{r['approval_id']}").json()   # phase-7
    assert ap["risk"] == "high" and ap["source"] == "life"

def test_insight_act_routes_to_approval(client):
    r = client.post("/api/life/insights/ins-netflix/act").json()
    assert r["approval_id"] is not None and r["task_id"] is None

def test_insight_auto_transfer_creates_task(client):
    r = client.post("/api/life/insights/ins-save/act").json()
    assert r["task_id"] is not None
    t = client.get(f"/api/tasks/{r['task_id']}").json()
    assert "자동 이체" in t["title"]

def test_knowledge_suggest_calendar(client):
    r = client.post("/api/life/knowledge/suggest/act", json={"item_id":"k4","target":"calendar"}).json()
    assert r["event_id"] is not None
```

### 8.3 프론트 Vitest + RTL

```ts
// components/life/HabitCard.test.tsx
it("기록 버튼 클릭 시 onTick 호출", () => {
  const onTick = vi.fn();
  render(<HabitCard hb={waterHabit} onTick={onTick} />);
  fireEvent.click(screen.getByText("기록"));
  expect(onTick).toHaveBeenCalledWith("water");
});
it("done>=total 이면 완료 + disabled", () => {
  render(<HabitCard hb={{...waterHabit, done:8, total:8}} onTick={()=>{}} />);
  expect(screen.getByText("완료")).toBeDisabled();
});

// components/life/SubRow.test.tsx
it("in_days<=3 면 soon 강조", () => {
  const { container } = render(<SubRow s={netflix} onPause={()=>{}} />);
  expect(container.querySelector(".sub-row.soon")).toBeTruthy();
});
it("paused 면 일시정지됨 표기 + opacity", () => {
  const { container } = render(<SubRow s={{...netflix, paused:true}} onPause={()=>{}} />);
  expect(screen.getByText(/일시정지됨/)).toBeTruthy();
  expect(container.querySelector(".sub-row")).toHaveStyle({ opacity: "0.55" });
});

// components/life/KnowledgeModule.test.tsx
it("검색어 입력 후 Enter 시 AI 답변 카드 표시", async () => {
  render(<KnowledgeModule {...mockLife} />);
  fireEvent.change(screen.getByPlaceholderText(/자연어로 물어보세요/), { target:{value:"리텐션"} });
  fireEvent.keyDown(screen.getByPlaceholderText(/자연어로/), { key:"Enter" });
  expect(await screen.findByText(/오케스트레이터-워커/)).toBeTruthy();
});
it("무결과 검색은 빈 상태 문구", () => {
  render(<KnowledgeModule {...mockLife} />);
  fireEvent.change(screen.getByPlaceholderText(/자연어로/), { target:{value:"zzz없음"} });
  expect(screen.getByText(/해당하는 자료가 없어요/)).toBeTruthy();
});
```

### 8.4 Playwright E2E (`playwright/life.spec.ts`)

```ts
test("모듈 레일 전환 + localStorage 유지", async ({ page }) => {
  await page.goto("/life");
  await page.getByRole("button", { name: /금융 & 소비/ }).click();
  await expect(page.getByText("이달 예산")).toBeVisible();
  await page.reload();
  await expect(page.getByText("이달 예산")).toBeVisible();      // ariL.mod 유지
});

test("습관 기록 → done 증가", async ({ page }) => {
  await page.goto("/life");
  await page.getByRole("button", { name: /건강 & 웰니스/ }).click();
  const water = page.locator(".habit", { hasText: "물 마시기" });
  await water.getByText("기록").click();
  await expect(water.getByText("6/8")).toBeVisible();
});

test("잠자는 구독 일시정지 → 결재함 등장", async ({ page }) => {
  await page.goto("/life");
  await page.getByRole("button", { name: /금융 & 소비/ }).click();
  const netflix = page.locator(".sub-row", { hasText: "Netflix" });
  await netflix.getByLabel("일시정지").click();
  await expect(netflix.getByText(/일시정지됨/)).toBeVisible();
  await page.goto("/approvals");                                 // phase-7
  await expect(page.getByText("Netflix 일시정지를 추천해요")).toBeVisible();
});

test("지식 ai 제안 → 캘린더 딥워크 블록", async ({ page }) => {
  await page.goto("/life");
  await page.getByRole("button", { name: /지식 & 학습/ }).click();
  await page.locator(".scrap", { hasText: "딥 워크" }).getByText(/캘린더/).click();
  await page.goto("/calendar");                                  // phase-8
  await expect(page.getByText("딥 워크 블록")).toBeVisible();
});
```

### 8.5 axe 접근성 (`playwright/life-a11y.spec.ts`)

```ts
for (const mod of ["today","health","finance","knowledge"]) {
  test(`a11y: ${mod} 모듈`, async ({ page }) => {
    await page.goto("/life");
    if (mod !== "today") await page.getByRole("button", { name: new RegExp(MOD_LABEL[mod]) }).click();
    const r = await new AxeBuilder({ page }).analyze();
    expect(r.violations).toEqual([]);
  });
}
```

### 8.6 수동 QA 체크리스트

- [ ] `/life` 진입 시 "오늘의 라이프" 코칭 히어로 + 3개 스냅샷 카드(건강/금융/지식) + 오늘의 리마인더.
- [ ] 좌측 레일 4개 모듈 클릭 전환, 활성 모듈 `.mod-row.on` tone별 아이콘 색.
- [ ] 새로고침 후 마지막 모듈 유지(`ariL.mod`).
- [ ] 건강: 링 3개 conic-gradient 채움(걸음 72%/운동 84%/수면 90%), 안쪽 아이콘 tone색.
- [ ] 수면: 점수 86 링, 단계 막대(깊은22/렘24/얕은48/깸6), 7일 막대 peak(수/일) violet, 노트.
- [ ] 습관 4개: 스트릭 flame, 진행 막대, "기록" 클릭 시 done+1 + 토스트 "습관을 기록했어요", 완료 시 green.
- [ ] 금융: 예산 ₩1,284,000 / ₩2,000,000, "지난달 대비 8% 적게", 게이지 64%.
- [ ] 카테고리 6개 막대(쇼핑 over "예산 빠름" coral), 구독 5개(soon=Netflix/Spotify coral 강조).
- [ ] 구독 일시정지 토글 → opacity 0.55 + "일시정지됨" + 결재함 카드 생성.
- [ ] 인사이트 3개(alert/save/info 색 분기), 잠자는 구독 act → 결재함.
- [ ] 지식: stats "1,248개 저장 · 이번 주 +23 · 9개 컬렉션", 검색 → AI 답변 카드 + 근거 칩 3개.
- [ ] 스크랩 5개: 자동 태그(첫 태그 violet), 아리 ai 박스, 요약/열기 토스트.
- [ ] 무결과 검색 → 빈 상태 문구. 컬렉션 4개 클릭 토스트.
- [ ] 연결 소스: 점 색(on=tone, off=회색), fit/kb "연결" 상태. "소스 연결하기".
- [ ] 다크 모드 토글 시 전 영역 토큰 반전, sleep muted 대비 유지.
- [ ] 1180px 이하 반응형 col 전부 span 12, 1080px 이하 레일 정적 배치.

### 8.7 통과 기준

- 백엔드 pytest 전부 green(시드 10소스/5구독/5지식, 습관 cap, 검색 필터, federation 4건).
- Vitest 컴포넌트 테스트 green(습관/구독/검색/빈상태).
- Playwright E2E 4 시나리오 + axe 4 모듈 violations 0.
- 디자인: 5.3 문구 표 전부 원본 일치, 5.1/5.2 토큰·px 일치(시각 diff 검수).

---

## 9. 완료 기준 (Definition of Done)

- [ ] `models.py` 에 11개 신규 테이블 추가, Alembic 마이그레이션 왕복(`downgrade base && upgrade head`) green.
- [ ] `seed_life.py` 가 `life-data.js` 값을 1:1 적재, `run_seed` 통합(`_seed_life` 호출).
- [ ] `routers/life.py` 9개 엔드포인트(`/api` prefix는 main.py에서만), 응답이 §4 계약과 일치.
- [ ] `connectors/`(base/health/finance/knowledge/registry) mock-first 구현, `CONNECTOR_<DOMAIN>=mock|real` 설정, Real 스텁.
- [ ] `life_insights.py` federation(구독→결재함 high-risk, 인사이트→결재/작업, 지식 제안→작업/캘린더, Q&A→RAG+폴백).
- [ ] `knowledge_item` ↔ 리서치(phase-10) 공유 명세(§4.4) 반영, RAG `rag.query()` 단일 재사용.
- [ ] 프론트 `/life` 페이지 + 모듈/카드 컴포넌트가 `life.jsx`/`life.css` 픽셀 충실 재현(토큰/px/문구 인용).
- [ ] event_bus 발행 5종 + 구독 4종 결선(phase-7 모델 가정), 시나리오 A/B/C 동작.
- [ ] 상태/엣지(로딩/빈/에러/오프라인/커넥터off/습관cap/다크/SSR시각) 처리.
- [ ] pytest + Vitest + Playwright + axe 전부 통과(§8.7).
- [ ] 상단 내비 13항목에서 `라이프`(`heart`) active, 다른 페이지와 동일 셸/테마.

---

## 10. 다음 단계

→ **`phase-12-daily-narrative.md`** (여정 + 하루 마감). 라이프의 `habit.logged`·구독 일시정지로 아낀 돈·딥워크 블록은 하루 마감의 집계 narrative("아리가 아낀 시간/돈", 내일 미리보기, 수면 모드)와 여정(4단계 stages 집계 뷰)에 합산된다. 실연동 전환은 `phase-13-integrations.md`(이 phase 의 mock 커넥터를 같은 인터페이스로 real 교체), 능동 알림/멀티모달은 `phase-14-proactive-agent.md`(습관 리마인더 능동 발송·음성 메모 캡처).
