You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

94 KiB

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.mddev/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 에 클라이언트 모듈 상태로 구현한다(검색엔진 인덱싱 불필요한 개인 데이터이므로 쿼리 동기화는 선택).

// 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 그대로:

/* 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).

# 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)

# 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.pyinclude_router(prefix="/api", tags=["life"]) 에서만 /api 부착.

# 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 패턴):

# 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행 이식:

# 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).

# 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
# 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}
# 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"}
# 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-13MockConnector 를 떼고 같은 메서드로 갈아끼운다. 페이지·API·시드는 변경 없음(mock-first 원칙).


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

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

// 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>
  );
}
// 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--cvar(--${r.tone}) 로 주입되어야 한다(누락 시 검정으로 보임).


3.6 금융 모듈 (FinanceModule.tsx)

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

// 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>
  );
}
// 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), overbackground: 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 제안).

// 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 매핑 */}
    </>
  );
}
// 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개 소스).

// 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 와 동일 구조).

# 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줄 추가):

# 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.idk1..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.

# 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 응답 예시

{
  "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

// 응답 — 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 }

{
  "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

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

POST /api/life/knowledge/ask body { "q": "AI 에이전트 설계에서 배운 것 요약해줘" }

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

POST /api/life/knowledge/suggest/act body { "item_id": "k4", "target": "calendar" }

{ "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/askrag.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-...).
금액 오버플로 wontoLocaleString("ko-KR") 천단위 구분. 음수 delta 는 Math.abs 표시 + green(절약).
다크모드 sleep stage mutedvar(--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 실행 명령

# 백엔드
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 케이스

# 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
# 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
# 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

// 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)

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)

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.pylife-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(습관 리마인더 능동 발송·음성 메모 캡처).