# 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_=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("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 (
{mod === "today" && } {mod === "health" && } {mod === "finance" && } {mod === "knowledge" && }
); } ``` > 원본 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`) 그대로 쓰고, 없으면 `-`(예 `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_=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 (
{r.val}
{r.label}{r.unit && {r.unit}}
목표 {r.goal}
); } ``` ```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 (
{hb.title}{hb.goal}
{hb.streak}일
{hb.done}/{hb.total}
{hb.at}
); } ``` **디자인 충실도 핵심값**(`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` 그대로 시드): - 코칭: `"이번 주 활동량은 지난주보다 12% 늘었어요. 다만 어제 물 섭취가 권장량의 절반에 그쳤고, 오후 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 (
{s.name}{s.note}{s.paused ? " · 일시정지됨" : ""}
{s.date}
3 ? " far" : "")}>{s.paused ? "정지" : s.in_days + "일 뒤"}
{won(s.amt)}
); } ``` ```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 (
{/* kind: alert|save|info */}
{ins.title}

{ins.body}

{ins.action && onAct && ( )}
); } ``` **디자인 충실도**(`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`): - 코칭: `"이번 달은 지난달보다 8% 적게 쓰고 계세요. 다만 쇼핑이 예산의 23%로 평소보다 빠르게 늘고 있어요. 또 이틀 뒤 Netflix·Spotify 결제가 같은 날 빠져나가요 — 최근 두 달간 안 본 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(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개 컬렉션" */}
{ setQ(e.target.value); if (answer) setAnswer(null); }} onKeyDown={(e) => e.key === "Enter" && ask(q)} placeholder="저장한 모든 것에 자연어로 물어보세요…" />
{k.suggested.map((s, i) => ( ))}
{answer && (
"{answer.q}"
{answer.sources.map((s, i) => {s})}
)} {/* 스크랩 + 컬렉션 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 (
{it.title}
{it.src}{it.time}
{TYPE_LABEL[it.type]}

{it.excerpt}

아리 — " + it.ai }} />

{it.tags.map((t, i) => {t})}
); } ``` **디자인 충실도**: - 검색바: `.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)) }`, `아리 — ` 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` 의 `