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.

1705 lines
94 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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