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