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

1071 lines
55 KiB
Markdown

This file contains ambiguous Unicode characters!

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

# Phase 6 — 통합 · 연합 흐름 · QA & MVP 수용 기준
> 3개 페이지(작업·인박스·대시보드)를 하나의 연합(federation) 흐름으로 묶고, E2E·접근성·성능·실행법·수용 기준을 확정하는 마지막 단계.
>
> 이 문서는 `dev/` 문서 세트의 일부입니다 — 먼저 **overview.md**를 읽으세요. 선행 단계는 `phase-0-foundation.md` → `phase-1-design-system.md` → `phase-2-backend.md` → `phase-3-tasks.md` → `phase-4-inbox.md` → `phase-5-dashboard.md` 순서입니다. 이 문서(phase-6)는 그 결과물을 통합·검증합니다.
---
## 1. 개요 & 목표
지금까지 각 phase는 한 조각씩 만들어 왔습니다.
- `phase-2-backend.md`: 데이터 모델 · 시드 · REST API · Ollama 추상화 · 분류/리스크 서비스
- `phase-3-tasks.md`: 작업 페이지(트리 사이드바, 칸반/리스트 — 캘린더는 post-MVP placeholder, 하위작업, 상세 드로어, 리스크 레이더)
- `phase-4-inbox.md`: 인박스 페이지(캡처 컴포저, 실시간 분류, 결과 칩/이유, 확인·재분류, 행선지 카드, 실체화)
- `phase-5-dashboard.md`: 대시보드(아침 브리핑, 결재함/인박스 요약, 일정/작업/목표, 자연어 명령)
**이 phase가 끝나면 동작하는 것:**
1. **연합 시나리오 3종**이 한 흐름으로 검증된다.
- ① 인박스 캡처 → 분류 → 확인(실체화) → **작업 트리에 새 task 등장**
- ② 작업 데이터 변화 → **리스크 레이더가 자동 재계산**(`GET /api/risks`)
- ③ 대시보드가 작업/인박스/결재함을 **집계해 요약**(`GET /api/dashboard`)
2. **Playwright E2E 사용자 여정**(대시보드 → 인박스 캡처 → 분류 좋아요 → 작업 페이지 확인 → 리스크 → 대시보드 갱신)이 그린(green)으로 통과한다.
3. **접근성 감사**(axe 0 violations, 키보드 내비, 포커스, 대비, 한국어 스크린리더)와 **반응형**이 통과한다.
4. **성능 예산**(초기 로드, 집계 응답, 분류 지연 처리, N+1 제거)이 충족된다.
5. **실행 문서** 한 장으로 누구나 frontend + backend + Ollama를 띄우고 시드를 초기화해 데모를 재현한다.
6. **MVP 수용 기준 체크리스트**가 전부 체크되고, **데모 스크립트**대로 시연이 끊김 없이 흐른다.
핵심 철학은 변하지 않습니다 — **"적을 때는 분류하지 않는다 — 분류·배치·자동화는 아리가."** Phase 6의 통합 검증도 결국 "사용자는 읽고 탭 한 번, 나머지는 아리가" 한다는 것을 E2E로 증명하는 작업입니다. 그리고 개인 일도 숨기지 않습니다 — 비행기 티켓(개인 여행 — 한국)이 업무 task와 **같은 트리에서 필터로만** 구분되어 나타나는 것을 끝까지 확인합니다.
---
## 2. 선행 조건(의존 phase) / 산출물(Deliverables)
### 2.1 선행 조건
| 의존 phase | 이 phase가 의존하는 산출물 |
|---|---|
| `phase-0-foundation.md` | 모노레포 구조(`frontend/`, `backend/`), pnpm/uv 스크립트, `.env` 규약, Ollama 연결 확인 |
| `phase-1-design-system.md` | `tokens.css`, `Topbar`(MAIN 13항목), `Icon`, 테마 토글, placeholder 라우팅 |
| `phase-2-backend.md` | `/api/health`, `/api/people`, `/api/tree`, `/api/tasks`, `/api/risks`, `/api/inbox*`, `/api/dashboard`, `/api/llm/health`, `seed.py`, `classification.py`, `risk.py`, `scaffold.py`, `llm/{provider,ollama,heuristic}.py` |
| `phase-3-tasks.md` | `/tasks` 페이지: 트리 사이드바·칸반/리스트(캘린더는 placeholder)·하위작업·상세 드로어·`RiskRadar` |
| `phase-4-inbox.md` | `/inbox` 페이지: 캡처 컴포저·분류 칩·이유·확인/재분류/무시·행선지 카드 |
| `phase-5-dashboard.md` | `/dashboard` 페이지: 브리핑·요약 카드·자연어 명령 |
### 2.2 산출물
```
frontend/
├─ playwright/
│ ├─ federation.spec.ts ← 연합 E2E 사용자 여정(이 phase 핵심)
│ ├─ a11y.spec.ts ← axe 접근성 감사 3페이지
│ ├─ responsive.spec.ts ← 뷰포트별 반응형 스냅샷
│ └─ fixtures/seed-reset.ts ← E2E 전 백엔드 시드 초기화 헬퍼
├─ playwright.config.ts ← webServer로 frontend+backend 자동 기동
└─ tests/ ← (phase 3~5에서 만든 Vitest 컴포넌트 테스트 유지)
backend/
└─ tests/
├─ test_federation.py ← 캡처→확인→tasks 등장→risks→dashboard 집계 통합 테스트
├─ test_nplus1.py ← 트리/대시보드 쿼리 수 회귀(N+1 방지)
└─ conftest.py ← 인메모리 SQLite + 시드 fixture (phase-2에서 시작, 여기서 확장)
dev/
└─ phase-6-integration.md ← 이 문서
README.md ← 루트 실행 가이드(이 phase에서 "한 장 실행법" 확정)
.github/workflows/ci.yml ← (선택) CI 매트릭스
scripts/dev.sh ← (선택) backend+frontend 동시 기동
```
---
## 3. 상세 구현 (파일별, 단계별)
### 3.1 연합 백엔드 통합 테스트 — `backend/tests/test_federation.py`
연합의 진실은 **백엔드 한 곳**에 있습니다. 프론트 E2E를 돌리기 전에, 캡처→확인→tasks 등장→risks→dashboard 집계가 API 레벨에서 정합한지 먼저 못 박습니다. (golden case: "다음 주에 한국 놀러가는 비행기 티켓 사기" → task / life / 개인 여행 — 한국)
```python
# backend/tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from sqlmodel import SQLModel, create_engine, Session
from sqlmodel.pool import StaticPool
from app.main import app
from app.db import get_session
from app.seed import run_seed
from app.llm.heuristic import HeuristicProvider
from app.llm.provider import get_provider # DI 훅
@pytest.fixture(name="session")
def session_fixture():
# 인메모리 SQLite — 테스트마다 깨끗한 DB
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
SQLModel.metadata.create_all(engine)
with Session(engine) as session:
run_seed(session=session, reset=True) # REF/assets 시드를 그대로 적재
session.commit()
yield session
@pytest.fixture(name="client")
def client_fixture(session):
# LLM은 테스트에서 항상 HeuristicProvider로 고정(오프라인·결정론적)
app.dependency_overrides[get_session] = lambda: session
app.dependency_overrides[get_provider] = lambda: HeuristicProvider()
client = TestClient(app)
yield client
app.dependency_overrides.clear()
```
```python
# backend/tests/test_federation.py
"""
연합 시나리오 통합 테스트 — 캡처→분류→확인→tasks 등장→risks→dashboard 집계.
LLM은 HeuristicProvider로 고정(결정론적). golden case 기준.
"""
GOLDEN_RAW = "다음 주에 한국 놀러가는 비행기 티켓 사기"
def test_health(client):
assert client.get("/api/health").json() == {"status": "ok"}
def test_capture_classifies_to_task_life_travel(client):
"""① 캡처 → 분류: 비행기 티켓 = 작업 / life / 개인 여행 — 한국"""
r = client.post("/api/inbox/capture", json={"kind": "text", "raw": GOLDEN_RAW})
assert r.status_code == 200
body = r.json()
c = body["classification"]
assert c["type"] == "task"
assert c["sphere"] == "life"
assert "여행" in c["proj_label"] and "한국" in c["proj_label"]
# reason은 사람이 읽는 한국어 문장 — '작업'이라는 단어 포함
assert "작업" in c["reason"]
assert 0.0 <= c["confidence"] <= 1.0
assert body["item"]["status"] == "classified"
def test_confirm_materializes_into_task_tree(client):
"""① 확인(실체화): confirm 시 task 생성 + tasks 트리·life 필터에 등장"""
cap = client.post("/api/inbox/capture",
json={"kind": "text", "raw": GOLDEN_RAW}).json()
item_id = cap["item"]["id"]
# 확인 전: life task 트리에 '비행기 티켓' 신규 항목이 없음(시드 k20과 구분 위해 제목으로 식별)
confirmed = client.post(f"/api/inbox/{item_id}/confirm").json()
new_task_id = confirmed["id"]
assert new_task_id
# 인박스 아이템이 confirmed 상태 + materialized_task_id 연결
inbox = client.get("/api/inbox").json()
target = next(i for i in inbox if i["id"] == item_id)
assert target["status"] == "confirmed"
assert target["materialized_task_id"] == new_task_id
# life 필터 작업 트리에서 새 task가 보인다(개인은 숨기지 않는다)
life = client.get("/api/tasks", params={"area": "life"}).json()
flat = _flatten(life)
assert any(t["id"] == new_task_id for t in flat)
created = next(t for t in flat if t["id"] == new_task_id)
# 연합 단언은 project_id · title · status 만 검증한다(due/prio 세부값은 단언하지 않음)
# 개인 여행 — 한국 프로젝트(life-trip) 아래로 배치
assert created["project_id"] == "life-trip"
assert "비행기 티켓" in created["title"]
assert created["status"] == "todo"
def test_risk_recompute_after_task_change(client):
"""② 작업 데이터 → 리스크 레이더 자동 계산(최대 3건, TODAY=8 기준)"""
risks = client.get("/api/risks", params={"area": "work"}).json()
assert len(risks) <= 3
kinds = [r["kind"] for r in risks]
# 시드 기준: k1(분기 리포트, due 06-08, doing) → 지연 위험
assert "지연 위험" in kinds
delay = next(r for r in risks if r["kind"] == "지연 위험")
assert delay["tone"] == "coral" and delay["icon"] == "clock"
# 의존성: '예산 섹션 작성' → '경영진 검토 요청 메일'
if "의존성" in kinds:
dep = next(r for r in risks if r["kind"] == "의존성")
assert dep["tone"] == "violet" and dep["icon"] == "link"
def test_dashboard_aggregates_tasks_inbox_approvals(client):
"""③ 대시보드 집계 — 작업/인박스/결재함 요약 + 배지"""
cap = client.post("/api/inbox/capture",
json={"kind": "text", "raw": GOLDEN_RAW}).json()
client.post(f"/api/inbox/{cap['item']['id']}/confirm")
d = client.get("/api/dashboard").json()
assert "user" in d and d["user"]["name"] # 인사말 출처(하드코딩 금지)
assert "briefing" in d and "schedule" in d
assert "task_summary" in d and "goals" in d
assert "approvals_summary" in d and "inbox_recent" in d
# 작업 요약은 {open_count, items} 형태
assert "open_count" in d["task_summary"] and "items" in d["task_summary"]
# 결재함 요약은 high-risk만(최대 3건)
assert len(d["approvals_summary"]) <= 3
# 배지: 결재/작업/알림 (원본 Topbar 배지 3/4/6 기준 시드)
assert set(d["badges"]) == {"appr", "task", "noti"}
# 인박스 최근 항목에 방금 확인한 캡처 흔적이 반영(혹은 confirmed 제외 규칙 명시대로)
assert isinstance(d["inbox_recent"], list)
def _flatten(nodes):
out = []
for n in nodes:
out.append(n)
out.extend(_flatten(n.get("children", []) or []))
return out
```
> **DI 훅 주의**: `get_provider`(LLM)와 `get_session`(DB)을 FastAPI `Depends`로 주입해야 위 `dependency_overrides`가 동작합니다. phase-2에서 라우터가 `provider: LLMProvider = Depends(get_provider)` 형태로 LLM을 받도록 설계되어 있어야 합니다(전역 싱글턴이면 테스트가 Ollama에 붙어 비결정적이 됨).
### 3.2 N+1 쿼리 회귀 테스트 — `backend/tests/test_nplus1.py`
트리(`/api/tree`)와 대시보드(`/api/dashboard`)는 중첩·집계가 많아 N+1의 단골입니다. SQLAlchemy 이벤트로 실제 발행 쿼리 수를 센다.
```python
# backend/tests/test_nplus1.py
from contextlib import contextmanager
from sqlalchemy import event
@contextmanager
def count_queries(session):
counter = {"n": 0}
engine = session.get_bind()
def _before(conn, cursor, statement, *a, **k):
counter["n"] += 1
event.listen(engine, "before_cursor_execute", _before)
try:
yield counter
finally:
event.remove(engine, "before_cursor_execute", _before)
def test_tree_no_nplus1(client, session):
with count_queries(session) as c:
r = client.get("/api/tree")
assert r.status_code == 200
# folder 2개 + 다수 project + task_count 집계를 1~6 쿼리 이내로 (selectinload/group-by 집계)
assert c["n"] <= 6, f"트리 쿼리 {c['n']}개 — N+1 의심"
def test_dashboard_query_budget(client, session):
with count_queries(session) as c:
client.get("/api/dashboard")
# 집계 6개 섹션 → 섹션당 1쿼리 수준, 12 이내
assert c["n"] <= 12, f"대시보드 쿼리 {c['n']}개 — 집계 합치기 필요"
```
> 예산을 넘으면 phase-2의 라우터에서 `selectinload(Project.children)` / `func.count()` group-by 집계로 묶거나, 트리 1회 로드 후 메모리에서 `task_count`를 계산하도록 리팩터합니다.
### 3.3 E2E 시드 초기화 헬퍼 — `frontend/playwright/fixtures/seed-reset.ts`
E2E는 **실제 백엔드 + 실제 시드**에 붙되, 매 실행이 동일 상태에서 시작해야 합니다. 백엔드에 테스트 전용 리셋 엔드포인트를 두거나(권장: 환경변수 가드), 시드 재적재 CLI를 호출합니다.
```ts
// frontend/playwright/fixtures/seed-reset.ts
import { request } from "@playwright/test";
const API = process.env.API_BASE ?? "http://localhost:8000";
/** 백엔드 시드를 초기 상태로 되돌린다. ARI_ALLOW_TEST_RESET=1 일 때만 동작. */
export async function resetSeed(): Promise<void> {
const ctx = await request.newContext();
const res = await ctx.post(`${API}/api/_test/reset`);
if (!res.ok()) {
throw new Error(
`시드 리셋 실패(${res.status()}). 백엔드를 ARI_ALLOW_TEST_RESET=1 로 띄웠는지 확인하세요.`
);
}
await ctx.dispose();
}
```
```python
# backend/app/routers/_test.py (테스트 전용 — 운영 빌드에서 가드)
import os
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, SQLModel
from app.db import get_session, engine
from app.seed import run_seed
router = APIRouter(prefix="/api/_test", tags=["test"])
@router.post("/reset")
def reset_seed(session: Session = Depends(get_session)):
if os.getenv("ARI_ALLOW_TEST_RESET") != "1":
raise HTTPException(403, "test reset disabled")
SQLModel.metadata.drop_all(engine)
SQLModel.metadata.create_all(engine)
run_seed(session=session, reset=True)
session.commit()
return {"status": "reset"}
```
> 라우터는 `os.getenv("ARI_ALLOW_TEST_RESET")` 가드 없이는 403을 반환하므로 운영에서 안전합니다. CI/로컬 E2E에서만 이 변수를 켭니다.
### 3.4 Playwright 설정 — `frontend/playwright.config.ts`
`webServer`로 백엔드와 프론트를 같이 띄워, E2E 한 명령(`pnpm playwright test`)으로 전 스택을 검증합니다.
```ts
// frontend/playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./playwright",
timeout: 30_000,
expect: { timeout: 8_000 }, // 분류/집계 비동기 여유
fullyParallel: false, // 단일 백엔드 시드 공유 → 직렬
retries: process.env.CI ? 1 : 0,
reporter: [["list"], ["html", { open: "never" }]],
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
screenshot: "only-on-failure",
locale: "ko-KR", // 한국어 UI/스크린리더 검증
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "mobile", use: { ...devices["iPhone 13"] } }, // 반응형
],
webServer: [
{
// 백엔드 — 테스트 리셋 허용 + Heuristic 강제(결정론적 E2E)
command:
"ARI_ALLOW_TEST_RESET=1 LLM_PROVIDER=heuristic uv run uvicorn app.main:app --port 8000",
cwd: "../backend",
url: "http://localhost:8000/api/health",
reuseExistingServer: !process.env.CI,
timeout: 60_000,
},
{
command: "pnpm dev --port 3000",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
timeout: 60_000,
},
],
});
```
> **E2E LLM 정책**: E2E는 `LLM_PROVIDER=heuristic`로 고정합니다. Ollama 실연동은 `phase-4-inbox.md`의 수동 QA와 `llm/health` 스모크에서 별도로 다룹니다(모델 비종속·외부 의존이라 CI 결정성을 깨므로). golden case 4건은 Heuristic 폴백이 동일 결과를 내도록 phase-2에서 정규식 규칙으로 보장되어 있어야 합니다.
### 3.5 연합 E2E 사용자 여정 — `frontend/playwright/federation.spec.ts`
CONTRACT가 요구한 **전체 여정**: 대시보드 진입 → 인박스에서 "비행기 티켓..." 캡처 → 분류 좋아요(확인) → 작업 페이지 `개인 여행 — 한국`에서 확인 → 리스크 레이더 확인 → 대시보드 요약 갱신.
```ts
// frontend/playwright/federation.spec.ts
import { test, expect } from "@playwright/test";
import { resetSeed } from "./fixtures/seed-reset";
const RAW = "다음 주에 한국 놀러가는 비행기 티켓 사기";
test.beforeEach(async () => {
await resetSeed(); // 매 케이스 동일 시드 상태
});
test("연합: 캡처 → 분류 → 확인 → 작업 트리 등장 → 리스크 → 대시보드 집계", async ({
page,
}) => {
// 0) 대시보드 진입 — / 는 /dashboard 로 리다이렉트
await page.goto("/");
await expect(page).toHaveURL(/\/dashboard$/);
// 아침 브리핑(아리)과 상단 내비 13항목 노출
await expect(page.getByRole("navigation")).toContainText("작업");
await expect(page.getByRole("navigation")).toContainText("인박스");
// 1) 인박스로 이동 후 캡처
await page.getByRole("link", { name: "인박스" }).click();
await expect(page).toHaveURL(/\/inbox$/);
const composer = page.getByPlaceholder(/적으세요|메모|생각/); // 캡처 컴포저
await composer.fill(RAW);
await page.getByRole("button", { name: /보내기|캡처|추가/ }).click();
// 2) 분류 결과 칩/이유 확인 — 작업 / 개인 여행 — 한국
const card = page.locator('[data-inbox-item]').filter({ hasText: "비행기 티켓" });
await expect(card).toContainText("작업"); // typeLabel
await expect(card).toContainText("개인 여행 — 한국"); // proj_label
await expect(card).toContainText("구매"); // reason 일부
// 3) "좋아요"(확인=실체화) 탭 — 읽고 탭 한 번
await card.getByRole("button", { name: /확인|좋아요|이대로/ }).click();
await expect(card).toContainText(/확인됨|작업으로 보냈|완료/);
// 4) 작업 페이지로 이동 → 개인(life) 필터 → 여행 — 한국 프로젝트에서 새 task 확인
await page.getByRole("link", { name: "작업" }).click();
await expect(page).toHaveURL(/\/tasks$/);
await page.getByRole("button", { name: /개인/ }).click(); // 업무/개인 필터
await page.getByRole("treeitem", { name: /여행 — 한국/ }).click(); // 트리 프로젝트
// 개인 일도 숨기지 않는다 — 다른 작업과 똑같이 보인다
await expect(page.getByText(/비행기 티켓/)).toBeVisible();
// 5) 리스크 레이더 — 작업 데이터로 자동 계산(업무 필터 기준 지연 위험)
await page.getByRole("button", { name: /업무/ }).click();
const radar = page.locator(".rradar");
await expect(radar).toContainText("리스크 레이더");
await expect(radar).toContainText("지연 위험"); // k1 분기 리포트 06-08
// 6) 대시보드 복귀 → 요약 갱신(인박스 처리 반영 / 작업 요약)
await page.getByRole("link", { name: "대시보드" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await expect(page.getByText(/할 일|작업/)).toBeVisible();
});
```
> **셀렉터 정책**: 클래스명(`.rradar`, `.mainnav`)은 원본 CSS와 1:1이라 안정적입니다. 동적 카드는 `data-inbox-item` / `data-task-id` 같은 **테스트 훅 속성**을 phase-4/3 컴포넌트에 부여하는 것을 권장합니다(텍스트 셀렉터는 한국어 문구 변경에 취약). 문구 셀렉터는 원본 데이터(`sinbox-data.js`)의 실제 한국어 — "작업", "개인 여행 — 한국", reason의 "구매" — 를 사용합니다.
### 3.6 접근성 E2E — `frontend/playwright/a11y.spec.ts`
```ts
// frontend/playwright/a11y.spec.ts
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
const pages = ["/dashboard", "/inbox", "/tasks"];
for (const path of pages) {
test(`a11y(axe): ${path} — 위반 0건`, async ({ page }) => {
await page.goto(path);
const results = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21aa"])
.analyze();
expect(results.violations, JSON.stringify(results.violations, null, 2)).toEqual([]);
});
}
test("키보드 내비: Tab 순서로 상단 내비 → 본문 도달, Enter로 이동", async ({ page }) => {
await page.goto("/dashboard");
await page.keyboard.press("Tab"); // 첫 포커스(브랜드 또는 skip-link)
// 내비 링크까지 Tab 이동 가능 + 포커스 링 가시
const focused = await page.evaluate(() => document.activeElement?.tagName);
expect(focused).toBeTruthy();
});
test("다크 테마에서도 대비 위반 0건", async ({ page }) => {
await page.goto("/dashboard");
await page.getByRole("button", { name: "테마" }).click(); // 라이트→다크
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
const results = await new AxeBuilder({ page }).withTags(["wcag2aa"]).analyze();
expect(results.violations).toEqual([]);
});
```
### 3.7 반응형 E2E — `frontend/playwright/responsive.spec.ts`
```ts
// frontend/playwright/responsive.spec.ts
import { test, expect } from "@playwright/test";
const viewports = [
{ name: "mobile", w: 390, h: 844 },
{ name: "tablet", w: 834, h: 1112 },
{ name: "desktop", w: 1440, h: 900 },
];
for (const v of viewports) {
test(`반응형(${v.name}): 가로 스크롤 없음 + 내비 접근 가능`, async ({ page }) => {
await page.setViewportSize({ width: v.w, height: v.h });
await page.goto("/tasks");
const scrollW = await page.evaluate(() => document.documentElement.scrollWidth);
const clientW = await page.evaluate(() => document.documentElement.clientWidth);
expect(scrollW).toBeLessThanOrEqual(clientW + 1); // 의도치 않은 가로 스크롤 금지
// 모바일에서도 13항목 내비에 도달 가능(햄버거/스크롤 내비 허용)
await expect(page.getByRole("link", { name: "작업" })).toBeVisible();
});
}
```
---
## 4. 연합 시나리오 (단계별 기대 상태)
### 4.1 시나리오 ① 캡처 → 분류 → 확인 → 작업 트리 등장 (인박스 → 작업)
| 단계 | 트리거 | API | 기대 상태 |
|---|---|---|---|
| 1. 캡처 | 인박스 컴포저에 `다음 주에 한국 놀러가는 비행기 티켓 사기` 입력 후 전송 | `POST /api/inbox/capture {kind:"text", raw}` | `inbox_item.status = classified`, `inbox_classification` 1건 생성(동기 분류) |
| 2. 분류 표시 | (자동) | 응답의 `classification` | `type=task`, `sphere=life`, `proj_label="개인 여행 — 한국"`, `tone=coral`, reason="구매라는 행동이 있으니 '작업' 맞아요 …", confidence ∈ [0,1] |
| 3. 행선지 카드 | (자동) | — | 칩: `작업` · `개인 여행 — 한국` · 마감 `출발 전 · ~6/14` · 추천 `오늘 21:00 빈 시간 추천` · extra `가격 추적 알림 켜둠` |
| 4. 확인(실체화) | "확인/좋아요" 탭 | `POST /api/inbox/{id}/confirm` | `task` 신규 생성(`project_id="life-trip"`, `assignee_id="jiwoo"`, `due=None`, `prio="보통"`, `status="todo"`, `notes`에 "가격 추적 알림 켜둠" 포함); `inbox_item.status=confirmed`, `materialized_task_id` 연결 |
| 5. 작업 트리 등장 | 작업 페이지 → 개인 필터 → 여행 — 한국 | `GET /api/tasks?area=life&project_id=life-trip` | 새 task가 트리에 나타남. **개인은 별도 섹션이 아님** — 업무 task와 동일 컴포넌트로 렌더(필터로만 구분) |
> 원본 근거: `sinbox-data.js`의 `s1` 항목(route.type=task, sphere=life, proj="개인 여행 — 한국", tone="coral", due="출발 전 · ~6/14", when="오늘 21:00 빈 시간 추천", extra="가격 추적 알림 켜둠", reason 전문)과 `tasks-data.js`의 `k20`("한국행 비행기 티켓 구매", project="life-trip", due="06-14", prio="높음", "스마트 인박스에서 자동 생성된 작업").
### 4.2 시나리오 ② 작업 데이터 → 리스크 레이더 자동 계산
리스크 계산 규칙은 `REF/assets/tasks-risk.jsx`를 백엔드 `services/risk.py`로 그대로 이식합니다. **TODAY = 8(6월 8일)** 고정.
| 리스크 종류 | 규칙(원본) | 시드 적중 | 표시(tone/icon/cta) |
|---|---|---|---|
| 지연 위험 | `status !== "done" && due 일(日) <= 8` 중 첫 1건 | `k1` 분기 리포트 초안 마무리(due 06-08, doing) | coral / clock / "작업 열기" — "**분기 리포트 초안 마무리** — 오늘(6/8) 마감인데 아직 진행 중이에요" |
| 업무 쏠림 | 담당자별 미완료 수, 최다자 ≥ 평균×1.5 **그리고** ≥ 4 | 지우(me)에게 미완료 다수면 발동 | amber / scale, me면 "미완료 작업 **N건**이 내게 몰려 있어요 — 팀 평균의 X배. … **위임 & 추적**" |
| 의존성 | `DEPS` 제목쌍 둘 다 미완료면 첫 1건 | `예산 섹션 작성``경영진 검토 요청 메일` (둘 다 todo) | violet / link / "후속 작업 보기" — "**예산 섹션 작성**이(가) 늦어지면 **경영진 검토 요청 메일**(6/10)까지 함께 밀려요" |
- 결과는 **최대 3건**(`risks.slice(0, 3)`), `area=work` 필터 기준.
- `risks`가 비면 `RiskRadar``null`(렌더 안 함) — 빈 상태 처리.
- **연합 포인트**: 작업 상태를 칸반에서 `done`으로 옮기면(`PATCH /api/tasks/{id}`) 다음 `GET /api/risks` 호출에서 해당 리스크가 사라져야 함(자동 재계산). E2E에서는 시드 기준 "지연 위험" 존재만 확인하지만, 회귀 테스트(`test_federation.py`)에서 상태 변경 후 재호출로 검증 가능.
### 4.3 시나리오 ③ 대시보드 집계 (작업/인박스/결재함)
`GET /api/dashboard` 응답이 세 페이지의 데이터를 한 화면으로 모읍니다.
```json
{
"user": { "name": "지우", "initial": "지" },
"briefing": {
"today": "6월 7일 일요일",
"weather": { "temp": 24, "cond": "맑음 · 한낮 28°", "icon": "sun" },
"commute": "출근 23분 · 평소보다 4분 빠름",
"sleep": "어젯밤 7시간 12분 · 평소만큼 푹 잤어요",
"note": "오늘은 오후 미팅이 핵심이에요. 오전을 비워 <b>분기 리포트</b>에 집중하시면 좋겠어요. …"
},
"saved_today": "47분",
"today_routed": 7,
"schedule": [
{ "time": "09:30", "title": "팀 데일리 스탠드업", "tag": "프로덕트", "dur": "15분", "tone": "blue", "soon": false },
{ "time": "14:00", "title": "분기 전략 미팅", "tag": "경영진", "dur": "60분", "tone": "coral", "soon": true }
],
"task_summary": {
"open_count": 4,
"items": [
{ "id": "k1", "title": "분기 리포트 초안 마무리", "prio": "높음", "project": "분기 리포트" }
]
},
"goals": [
{ "id": "g1", "title": "분기 OKR — 사용자 리텐션", "pct": 68, "sub": "12개 중 8개 달성", "tone": "blue" }
],
"approvals_summary": [
{ "id": "a4", "icon": "mail", "tone": "violet", "title": "현우님께 회신 초안이 준비됐어요", "time": "보내기 대기" }
],
"inbox_recent": [
{ "id": "s1", "kind": "text", "raw": "다음 주에 한국 놀러가는 비행기 티켓 사기", "type": "task", "proj_label": "개인 여행 — 한국", "tone": "coral" }
],
"badges": { "appr": 3, "task": 4, "noti": 6 }
}
```
| 집계 카드 | 출처 데이터 | 갱신 트리거 |
|---|---|---|
| 결재함 요약(`approvals_summary`) | `approve-data.js`(items 중 **risk=="high"만, 최대 3건**; 필드 `{id,icon,tone,title,time}`) — **읽기 전용** | 시드 고정 |
| 인박스 최근(`inbox_recent`) | `inbox_item` + 최신 classification (최근 3) | 캡처/확인 시 변동 |
| 작업 요약(`task_summary`) | `task` 집계 — `{open_count, items[]}`(미완료 수 + 항목 리스트) | task 생성/이동 시 변동 |
| 일정 요약(`schedule`) | `event`(schedule 시드) — 읽기 전용 | 시드 고정 |
| 목표(`goals`) | `goal`(goals 시드, `tone`=키) — 읽기 전용 | 시드 고정 |
| 절약/라우팅 | `saved_today` "47분" / `today_routed` 7 (원본 `approve-data.js`) — 읽기 전용 | 시드 고정 |
| 배지(`badges`) | appr 3 / task 4 / noti 6 (원본 Topbar 배지) | 시드 고정(MVP) |
---
## 5. 데이터/타입/API 계약 (이 phase 관련)
이 phase는 새 엔드포인트를 거의 추가하지 않습니다(테스트용 `/api/_test/reset`만 가드 하에 추가). 이미 확정된 계약을 **통합 관점**에서 재확인합니다.
| 흐름 | 호출 순서 | 핵심 필드 일관성 |
|---|---|---|
| 캡처→확인→작업 | `POST /inbox/capture``POST /inbox/{id}/confirm``GET /tasks?area=life` | `materialized_task_id``task.id` 동일; `classification.project_id="life-trip"` ↔ 생성 task `project_id="life-trip"` |
| 작업→리스크 | `PATCH /tasks/{id}``GET /risks?area=work` | `task.status`/`due`/`assignee_id` 변경이 리스크 재계산에 반영 |
| 집계 | `GET /dashboard` | `badges.appr/task/noti`, `task_summary``/tasks` 카운트와 모순 없음 |
프론트 타입(`frontend/lib/types.ts`)과 백엔드 스키마(`backend/app/schemas.py`)는 **필드명 1:1**이어야 합니다. 통합 시 자주 어긋나는 지점:
- `due`는 ISO 날짜(`"2026-06-14"`)로 통일. UI 표기 "6/14"는 프론트에서 포맷.
- `tone` 값 집합 고정: `blue|violet|coral|green|amber|ink|faint`.
- `status` enum: `todo|doing|waiting|review|done`(라벨: 할 일/진행 중/대기 중/검토/완료) — 절대 변경 금지.
- 분류 `confidence`는 0~1 float. UI는 퍼센트(%)로 표기 가능.
타입 드리프트 방지(권장): 백엔드 `GET /api/openapi.json``openapi-typescript``frontend/lib/api-types.gen.ts` 생성하고 `types.ts`와 대조하는 lint를 CI에 추가.
```bash
# (선택) 스키마-타입 드리프트 검출
pnpm dlx openapi-typescript http://localhost:8000/api/openapi.json -o lib/api-types.gen.ts
```
---
## 6. 디자인 충실도 노트 (원본 재현)
통합 단계에서도 픽셀/토큰 충실도를 회귀로 지킵니다. 기준은 `REF/assets/dash.css :root`.
- **토큰 일치 검증**: `frontend/styles/tokens.css`의 값이 원본과 동일한지 단위 테스트로 못 박습니다.
```ts
// frontend/tests/tokens.test.ts (Vitest)
import { test, expect } from "vitest";
import fs from "node:fs";
const css = fs.readFileSync("styles/tokens.css", "utf8");
const cases: [string, string][] = [
["--bg-top", "#f6efe7"],
["--bg-mid", "#eef0f1"],
["--bg-bot", "#e9ebed"],
["--ink", "#211f1c"],
["--blue", "#4f72e0"],
["--coral", "#df7256"],
["--green", "#4e9b66"],
["--violet", "#8b6fd4"],
["--amber", "#e0a23c"],
["--lime", "#c2f24a"],
["--radius", "22px"],
["--radius-sm", "14px"],
];
for (const [name, val] of cases) {
test(`token ${name} = ${val}`, () => {
expect(css).toMatch(new RegExp(`${name.replace("--", "\\-\\-")}\\s*:\\s*${val.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "i"));
});
}
```
- **배경 그라데이션**: `body``178deg` 3-스톱 그라데이션 + `background-attachment: fixed`(원본 dash.css `body`). 통합 후 어떤 페이지에서도 동일해야 함.
- **한국어 타이포**: `body``font-family: var(--font-ui)`(Pretendard), `word-break: keep-all`, `letter-spacing: -0.011em`. 줄바꿈이 단어 중간에서 깨지지 않는지 한국어 긴 문구(브리핑 note)로 시각 확인.
- **글래스 효과**: `--blur: blur(26px) saturate(190%)`, `--glass-hi: inset 0 1px 0 rgba(255,255,255,.75)`. 상단 내비 active pill이 글래스로 보이는지.
- **다크 테마**: `[data-theme="dark"]` 오버라이드(`--card #2c2925`, `--ink #f3eee6` 등)와 `localStorage` 영속(next-themes). a11y.spec의 다크 대비 테스트가 이를 보증.
- **리스크 레이더 톤**: `.rr-item.t-coral/.t-amber/.t-violet`(원본 `tasks.css`)이 각각 지연/쏠림/의존성과 매칭. 아이콘 path는 `tasks-risk.jsx``RP`(radar/clock/scale/link/chev)를 중앙 `Icon` paths 맵으로 이식 — **누락 시 렌더 깨짐**(원본 개발 메모 경고).
- **아이콘 맵 완전성**: `shell.jsx``P` 맵 전체(spark/grid/route/cal/check/mail/inbox/bell/…)와 `tasks-risk.jsx``RP`를 합쳐 중앙 paths 맵에 넣었는지 — 통합 스모크에서 콘솔 에러 0건으로 확인(아래 §7.4).
---
## 7. 상태 처리(로딩/빈/에러/오프라인) & 엣지 케이스 — 전수 점검표
| # | 페이지/흐름 | 상태 | 기대 UI/동작 | 검증 위치 |
|---|---|---|---|---|
| E1 | 인박스 캡처 | 로딩(분류 대기) | 컴포저 비활성 + "아리가 분류 중…" 스피너, 입력 보존 | a11y/E2E |
| E2 | 인박스 캡처 | 빈 입력 | 전송 버튼 비활성, 422 미발생 | Vitest |
| E3 | 인박스 | 빈 인박스 | "지금은 비어 있어요 — 떠오르면 적으세요" 빈 상태 | Vitest/E2E |
| E4 | 인박스 | 분류 실패(LLM 5xx/타임아웃) | Heuristic 폴백으로 분류, 배지 "규칙 기반"; 폴백도 실패 시 "다시 시도" | §8 폴백 |
| E5 | 인박스 | 재분류 | `POST /reclassify {type}` 후 칩 즉시 갱신, 낙관적 업데이트 + 실패 롤백 | E2E |
| E6 | 인박스 | 확인 중복 클릭 | 두 번째 클릭은 멱등(이미 confirmed면 동일 task 반환, 중복 생성 금지) | test_federation 보강 |
| E7 | 작업 | 트리 로딩 | 스켈레톤(트리/칸반 컬럼) | Vitest |
| E8 | 작업 | 빈 프로젝트 | "이 프로젝트엔 아직 작업이 없어요" | Vitest |
| E9 | 작업 | 리스크 0건 | `RiskRadar` 미표시(null) — 빈 영역 없이 자연 축소 | risk.py + E2E |
| E10 | 작업 | 칸반 드롭 실패 | 낙관적 이동 후 PATCH 실패 시 원위치 복귀 + 토스트 | Vitest |
| E11 | 작업 | 캘린더 뷰(post-MVP) | MVP는 칸반/리스트 2개 뷰만 — 캘린더는 "준비 중" placeholder로 표시 | Vitest |
| E12 | 대시보드 | 일부 섹션 빈 | 카드별 개별 빈 상태(전체 깨지지 않음) | Vitest |
| E13 | 대시보드 | 집계 지연 | 카드별 스켈레톤, 다른 카드는 먼저 표시 | E2E |
| E14 | 전역 | 백엔드 다운(네트워크 오류) | 페이지 상단 비차단 배너 "아리 서버에 연결할 수 없어요 — 재시도", 캐시된 데이터 유지 | api.ts |
| E15 | 전역 | 오프라인 | 캡처 입력은 로컬 보관(낙관적), 복구 시 재전송 큐(MVP: 토스트 안내 + 재시도 버튼 수준) | api.ts |
| E16 | 전역 | 404 placeholder 페이지 | MVP 외 10개 내비 항목은 "준비 중" 플레이스홀더로 라우팅(내비 일관성) | phase-1 |
| E17 | 전역 | 테마 토글 | `data-theme` 즉시 반영 + localStorage 영속, 새로고침 유지 | a11y.spec |
오프라인/에러 표준 처리는 `frontend/lib/api.ts`의 fetch 래퍼에 집중:
```ts
// frontend/lib/api.ts (발췌 — 통합 에러/오프라인 처리)
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
if (typeof navigator !== "undefined" && !navigator.onLine) {
throw new ApiError("offline", "오프라인 상태예요 — 연결되면 다시 시도할게요");
}
let res: Response;
try {
res = await fetch(`${API_BASE}${path}`, {
...init,
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
signal: AbortSignal.timeout(15_000), // 분류 등 LLM 경유 여유
});
} catch (e) {
throw new ApiError("network", "아리 서버에 연결할 수 없어요 — 잠시 후 다시 시도해 주세요");
}
if (!res.ok) {
throw new ApiError(String(res.status), await safeMessage(res));
}
return res.json() as Promise<T>;
}
```
---
## 8. 성능 (초기 로드/집계/Ollama 지연/N+1)
### 8.1 성능 예산
| 지표 | 목표(로컬, 시드 규모) | 측정 |
|---|---|---|
| 초기 로드 TTI(/dashboard) | < 1.5s (dev), < 1.0s (build+start) | Playwright `page.metrics`/수동 |
| `GET /api/dashboard` 응답 | < 150ms (Heuristic, 캐시 없음) | `test_dashboard_query_budget` + 수동 curl |
| `GET /api/tree` 응답 | < 100ms | 동일 |
| `POST /api/inbox/capture` (Heuristic) | < 50ms | test |
| `POST /api/inbox/capture` (Ollama) | 모델 의존(보통 0.5~5s) UI 즉시 로딩 상태 | 수동 |
| 트리 쿼리 | 6 | `test_nplus1.py` |
| 대시보드 쿼리 | 12 | `test_nplus1.py` |
### 8.2 Ollama 분류 지연 처리 (로딩/타임아웃/폴백)
CONTRACT: LLM **모델 비종속**, `OLLAMA_MODEL` 주입, 미가용 `HeuristicProvider` 폴백.
```python
# backend/app/services/classification.py (발췌 — 지연/폴백 정책)
import asyncio, logging
from app.llm.provider import LLMProvider
from app.llm.heuristic import HeuristicProvider
log = logging.getLogger("ari.classify")
OLLAMA_TIMEOUT_S = 12 # 분류 1건 상한
async def classify_capture(raw: str, ctx, provider: LLMProvider):
try:
return await asyncio.wait_for(
provider.classify_capture(raw, ctx), timeout=OLLAMA_TIMEOUT_S
)
except (asyncio.TimeoutError, Exception) as e:
log.warning("LLM 분류 실패/지연 → Heuristic 폴백: %s", e)
result = HeuristicProvider().classify_capture(raw, ctx)
result.model = "heuristic-fallback" # 응답에 폴백 출처 명시
return result
```
- **UI 로딩**: 캡처 즉시 `inbox_item` `new` 낙관적 추가하고 "아리가 분류 중…" 표시. 응답(또는 폴백) 도착 칩으로 치환.
- **타임아웃**: 12 초과 Heuristic 폴백 사용자는 멈추지 않음. `classification.model` `heuristic-fallback` 남겨 투명성 확보(원본 철학: 신뢰+투명성).
- **`/api/llm/health`**: 대시보드/설정에서 `{reachable, model}` 표시. unreachable이면 인박스에 "지금은 규칙 기반으로 분류해요" 안내.
### 8.3 N+1 / 집계 최적화
- `/api/tree`: `selectinload(Folder.projects)` + `task_count` `func.count` group-by 1쿼리 또는 트리 메모리 집계. `test_nplus1.py` 회귀 방지.
- `/api/dashboard`: 섹션별 단건 쿼리로 묶고 N+1 금지. 읽기 전용 시드(일정/목표/결재함)는 단순 select.
- `/api/risks`: 작업 트리를 1 로드 메모리에서 계산(`tasks-risk.jsx`와 동일 알고리즘) 추가 쿼리 0.
---
## 9. 실행법 (frontend + backend + Ollama 함께)
### 9.1 환경변수 표
| 변수 | 위치 | 기본값 | 설명 |
|---|---|---|---|
| `OLLAMA_HOST` | backend `.env` | `http://localhost:11434` | Ollama HTTP 엔드포인트 |
| `OLLAMA_MODEL` | backend `.env` | (설치된 모델 주입; : 기본값) | 분류용 모델명(**비종속** 환경변수로만 지정) |
| `LLM_PROVIDER` | backend env | `auto` | `auto` \| `ollama` \| `heuristic` (테스트/E2E `heuristic`) |
| `DATABASE_URL` | backend `.env` | `sqlite:///./ari.db` | DB 연결 URL(SQLite 경로) |
| `FRONTEND_ORIGIN` | backend `.env` | `http://localhost:3000` | CORS 허용 출처(콤마 구분). `settings.frontend_origin` CORS |
| `ARI_ALLOW_TEST_RESET` | backend env | (미설정) | `1`이면 `/api/_test/reset` 허용(E2E 전용) |
| `NEXT_PUBLIC_API_BASE` | frontend `.env.local` | `http://localhost:8000` | 프론트가 호출할 백엔드 베이스 |
`backend/.env.example`, `frontend/.env.local.example` 리포에 두고 복사해서 사용합니다.
### 9.2 최초 1회 셋업
```bash
# 0) 사전: Node ≥ 20, Python ≥ 3.11, pnpm, uv, Ollama 설치
# 1) 백엔드 의존성
cd /Users/kim1634/mycloud/personal/workspace/ari_assistant/workspace/backend
uv sync # (또는: python -m venv .venv && source .venv/bin/activate && pip install -e .)
cp .env.example .env
# 2) DB 마이그레이션 + 시드
uv run alembic upgrade head
uv run python -m app.seed # REF/assets 시드 적재(또는 main.py 기동 시 자동 시드 옵션)
# 3) 프론트 의존성
cd ../frontend
pnpm install
cp .env.local.example .env.local
# 4) Ollama 모델 준비(예시 — 모델 강제 아님)
ollama pull "$OLLAMA_MODEL" # 설치된 모델 사용; .env의 OLLAMA_MODEL과 일치시킬 것
ollama serve # 11434 리스닝(별도 터미널)
```
### 9.3 개발 실행 (3개 프로세스)
터미널 A Ollama:
```bash
ollama serve
```
터미널 B 백엔드:
```bash
cd /Users/kim1634/mycloud/personal/workspace/ari_assistant/workspace/backend
uv run uvicorn app.main:app --reload --port 8000
# 확인: curl -s http://localhost:8000/api/health → {"status":"ok"}
# curl -s http://localhost:8000/api/llm/health → {"reachable":true,"model":"..."}
```
터미널 C 프론트:
```bash
cd /Users/kim1634/mycloud/personal/workspace/ari_assistant/workspace/frontend
pnpm dev # http://localhost:3000 → / 는 /dashboard 로 리다이렉트
```
(선택) 기동 스크립트 `scripts/dev.sh`:
```bash
#!/usr/bin/env bash
set -euo pipefail
ROOT="/Users/kim1634/mycloud/personal/workspace/ari_assistant/workspace"
( cd "$ROOT/backend" && uv run uvicorn app.main:app --reload --port 8000 ) &
( cd "$ROOT/frontend" && pnpm dev ) &
wait
```
### 9.4 시드 초기화(데모 리셋)
```bash
cd /Users/kim1634/mycloud/personal/workspace/ari_assistant/workspace/backend
rm -f ari.db
uv run alembic upgrade head
uv run python -m app.seed
# 또는 E2E용: ARI_ALLOW_TEST_RESET=1 로 띄운 뒤
curl -X POST http://localhost:8000/api/_test/reset
```
---
## 10. MVP 수용 기준(Acceptance Criteria) & 데모 스크립트
### 10.1 페이지별 수용 기준
**작업(/tasks)**
- [ ] 폴더 트리(업무/개인) 프로젝트 작업 무한 중첩 렌더. 원본 트리(`work`: 경영 전략/온보딩 리디자인/팀 운영, `life`: 일상/여행 한국/가족) 일치.
- [ ] 칸반 5컬럼: 일/진행 중/대기 중/검토/완료(라벨·순서 고정). 칸반/리스트 2 토글(SegmentToggle) 캘린더는 post-MVP placeholder.
- [ ] 작업 상태 드래그 이동 `PATCH /api/tasks/{id}` 반영, 낙관적 업데이트.
- [ ] 상세 드로어: notes(HTML 허용), 담당자(지우/현우/민서/재호/수아), 댓글(`task_comment`), 하위작업.
- [ ] 리스크 레이더: 지연 위험(k1, 6/8) · 의존성(예산 섹션→경영진 메일) 표시, 최대 3건, 매칭.
- [ ] 업무/개인 필터로만 구분 개인 task 숨기지 않음.
**인박스(/inbox)**
- [ ] 멀티모달 캡처(text/voice/image) 컴포저. 입력 가드.
- [ ] 캡처 즉시 분류 실행, 로딩 상태("아리가 분류 중…").
- [ ] golden 4 정확 분류:
- [ ] "다음 주에 한국 놀러가는 비행기 티켓 사기" task / life / 개인 여행 한국 / 가격추적 extra
- [ ] "수요일 11 자전거 수리 맡기기" event / life / 개인 캘린더 / 11:00
- [ ] "엄마 생신 선물 미리 알아보기" task / life / 가족
- [ ] "온보딩 환영 화면에 짧은 애니메이션 넣으면 어떨까" idea / work / 온보딩 리디자인 · 아이디어 보드
- [ ] 결과 칩(타입·프로젝트·마감·추천·extra) + reason(한국어 문장) 표시.
- [ ] 확인(실체화) task 생성 + `materialized_task_id` 연결. 재분류/무시 동작.
**대시보드(/dashboard)**
- [ ] `/` `/dashboard` 리다이렉트.
- [ ] 아침 브리핑(날씨 24°/맑음, 출근 23분, 수면 7시간 12분, note HTML).
- [ ] 일정/할 일/목표 요약 + 결재함·인박스 요약 카드(읽기 전용 시드).
- [ ] 배지 appr3/task4/noti6, 자연어 명령 입력창.
**내비/셸(전 페이지)**
- [ ] 상단 내비 13항목 모두 노출(대시보드·인박스·결재함[3]·자동화·여정·일정·작업[4]·메일·알림[6]·리서치·여행·라이프·하루 마감).
- [ ] MVP 10항목은 "준비 중" 플레이스홀더 라우팅.
- [ ] 테마 토글(라이트/다크) + localStorage 영속.
### 10.2 연합 수용 기준
- [ ] 시나리오 : 캡처→분류→확인→작업 트리(개인 여행 한국) 등장 (E2E green).
- [ ] 시나리오 : 작업 데이터로 리스크 자동 계산, 상태 변경 재계산.
- [ ] 시나리오 : 대시보드가 작업/인박스/결재함 집계, 모순 없음.
### 10.3 비기능 수용 기준
- [ ] axe 위반 0건(3페이지, 라이트+다크).
- [ ] 키보드만으로 주요 흐름 수행 가능, 포커스 가시.
- [ ] 모바일/태블릿/데스크톱에서 가로 스크롤 없음, 내비 접근 가능.
- [ ] 성능 예산 충족(§8.1), N+1 회귀 통과.
- [ ] Ollama 미가용 Heuristic 폴백으로 분류 계속, 출처 투명 표시.
### 10.4 데모 스크립트(시연 순서, 약 3분)
1. **대시보드 진입** "아리의 아침 브리핑입니다. 오늘은 6 8일, 오후 미팅이 핵심이라고 알려주네요." (브리핑·일정·할 일·목표·결재함 요약)
2. **인박스로 이동, 캡처** "비행기 티켓을 사야겠다는 생각이 들면, 분류하지 않고 그냥 적습니다." `다음 주에 한국 놀러가는 비행기 티켓 사기` 입력 전송.
3. **자동 분류 관찰** "아리가 즉시 **작업 / 개인 여행 한국**으로 분류하고, '구매라는 행동이 있으니 작업'이라고 이유까지 설명합니다. 가격 추적 알림도 걸어뒀네요."
4. **확인(탭 번)** "읽고 번. '이대로 좋아요'를 누르면 끝."
5. **작업 페이지 개인 필터 여행 한국** "개인 일이라고 숨기지 않습니다. 업무 작업과 **같은 트리에서 필터로만** 구분돼 똑같이 보입니다."
6. **리스크 레이더** 업무 필터로 전환. "아리가 마감·업무량·의존성을 훑어서, '분기 리포트가 오늘 마감인데 아직 진행 중'이라고 경고합니다."
7. **대시보드 복귀** "다시 관제탑으로. 방금 처리한 것이 요약에 반영됩니다. 적을 때는 분류하지 않는다 분류·배치·자동화는 아리가."
---
## 11. 회귀 테스트 매트릭스 & CI 제안
### 11.1 회귀 매트릭스
| 영역 | 도구 | 파일 | 게이트 |
|---|---|---|---|
| 백엔드 단위/통합 | pytest + httpx TestClient | `backend/tests/*` (test_federation, test_nplus1, 분류/리스크 단위) | 전부 green |
| 분류 골든 | pytest | `test_classification_golden.py`(phase-2/4) 4 케이스 | 4/4 통과 |
| 프론트 컴포넌트 | Vitest + RTL | `frontend/tests/*`, `tokens.test.ts` | 전부 green |
| E2E 연합 | Playwright | `federation.spec.ts` | green |
| 접근성 | Playwright + axe | `a11y.spec.ts` | 위반 0 |
| 반응형 | Playwright | `responsive.spec.ts` | 가로 스크롤 0 |
| 스키마 드리프트 | openapi-typescript | (선택) | diff 없음 |
### 11.2 CI 워크플로 — `.github/workflows/ci.yml`
```yaml
name: ci
on: [push, pull_request]
jobs:
backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
- run: uv sync
working-directory: backend
- run: uv run pytest -q # test_federation/test_nplus1/golden 포함
working-directory: backend
frontend-unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: pnpm, cache-dependency-path: frontend/pnpm-lock.yaml }
- run: pnpm install --frozen-lockfile
working-directory: frontend
- run: pnpm test -- --run # Vitest + tokens
working-directory: frontend
e2e:
runs-on: ubuntu-latest
needs: [backend, frontend-unit]
env:
LLM_PROVIDER: heuristic # CI는 Ollama 없이 결정론적
ARI_ALLOW_TEST_RESET: "1"
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
- run: uv sync && uv run alembic upgrade head && uv run python -m app.seed
working-directory: backend
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: pnpm, cache-dependency-path: frontend/pnpm-lock.yaml }
- run: pnpm install --frozen-lockfile
working-directory: frontend
- run: pnpm exec playwright install --with-deps chromium
working-directory: frontend
- run: pnpm playwright test # webServer가 backend+frontend 기동
working-directory: frontend
- uses: actions/upload-artifact@v4
if: failure()
with: { name: playwright-report, path: frontend/playwright-report }
```
> CI에서 Ollama를 띄우지 않습니다(모델 비종속·외부 의존·비결정성). Ollama 실연동 검증은 로컬 수동 QA로 분리합니다.
---
## 12. 테스팅 & 검증 (가장 중요)
### 12.1 실행 명령 (전체)
```bash
# 백엔드 (통합·N+1·골든·단위)
cd /Users/kim1634/mycloud/personal/workspace/ari_assistant/workspace/backend
uv run pytest -q
uv run pytest tests/test_federation.py -v # 연합 통합만
uv run pytest tests/test_nplus1.py -v # N+1 회귀만
# 프론트 컴포넌트 + 토큰
cd ../frontend
pnpm test -- --run
pnpm test -- --run tokens.test.ts
# E2E (연합 / 접근성 / 반응형) — webServer 자동 기동
pnpm playwright test
pnpm playwright test federation.spec.ts # 연합 여정만
pnpm playwright test a11y.spec.ts # 접근성만
pnpm playwright test responsive.spec.ts # 반응형만
pnpm playwright show-report # 실패 시 트레이스/스크린샷
# LLM/Ollama 스모크 (수동)
curl -s http://localhost:8000/api/llm/health | jq
curl -s -X POST http://localhost:8000/api/inbox/capture \
-H 'content-type: application/json' \
-d '{"kind":"text","raw":"다음 주에 한국 놀러가는 비행기 티켓 사기"}' | jq '.classification'
```
### 12.2 구체 테스트 케이스 목록
**백엔드(pytest)**
1. `test_health` `/api/health == {status: ok}`.
2. `test_capture_classifies_to_task_life_travel` golden #1 분류 정합.
3. `test_confirm_materializes_into_task_tree` confirm `life-trip` task 등장 + `materialized_task_id`.
4. `test_confirm_idempotent`(보강) confirm 호출해도 task 1개(멱등).
5. `test_risk_recompute_after_task_change` 지연/의존성 리스크, 최대 3건, TODAY=8.
6. `test_risk_disappears_when_done`(보강) k1 done으로 PATCH `/risks`에서 지연 위험 사라짐.
7. `test_dashboard_aggregates_tasks_inbox_approvals` 6섹션 + 배지 키.
8. `test_tree_no_nplus1` / `test_dashboard_query_budget` 쿼리 예산.
9. 분류 골든 4건(phase-4) task/event/idea, work/life 분기.
10. 리스크 단위(phase-2) 업무 쏠림 임계(≥평균×1.5 && 4) 경계값.
**프론트(Vitest + RTL)**
- 토큰 일치(`tokens.test.ts`).
- 인박스 입력 가드/빈 상태/로딩 칩.
- 칸반 드롭 실패 롤백, 칸반/리스트 토글(캘린더는 placeholder).
- RiskRadar: risks=[] null 렌더.
**E2E(Playwright)**
- `federation.spec.ts` §3.5 7단계 여정.
- `a11y.spec.ts` axe 0(라이트/다크) + 키보드.
- `responsive.spec.ts` 3 뷰포트 가로 스크롤 0.
### 12.3 수동 QA 체크리스트
- [ ] `ollama serve` + 백엔드 + 프론트 3프로세스 기동, `/api/health`·`/api/llm/health` 200.
- [ ] 대시보드 화면에 브리핑/일정/요약 정상, 콘솔 에러 0.
- [ ] 인박스에 golden 4문장 순차 입력 분류·이유 육안 확인(아래 대로).
- [ ] "비행기 티켓" 확인 작업 페이지 개인 필터 여행 한국에 등장.
- [ ] 작업 업무 필터에서 리스크 레이더 "지연 위험"(분기 리포트, 6/8) 표시.
- [ ] 작업 하나 done 이동 리스크 갱신 확인.
- [ ] 테마 다크 전환 새로고침 다크 유지(localStorage).
- [ ] MVP 내비(예: 메일) 클릭 "준비 중" 플레이스홀더.
- [ ] Ollama 끄고 캡처 Heuristic 폴백으로 분류 계속, "규칙 기반/heuristic-fallback" 표시.
- [ ] 모바일(390px)에서 가로 스크롤 없음, 내비 도달 가능.
골든 분류 육안 기준표:
| 입력 | type | sphere | 프로젝트 | 비고 |
|---|---|---|---|---|
| 다음 주에 한국 놀러가는 비행기 티켓 사기 | 작업 | life | 개인 여행 한국 | 가격 추적 extra, 6/14 |
| 수요일 11 자전거 수리 맡기기 | 일정 | life | 개인 캘린더 | 11:00 |
| 엄마 생신 선물 미리 알아보기 | 작업 | life | 가족 | 주말 블록 |
| 온보딩 환영 화면에 짧은 애니메이션 넣으면 어떨까 | 아이디어 | work | 온보딩 리디자인 · 아이디어 보드 | 보드 보관 |
### 12.4 통과 기준
- `uv run pytest` : **0 failed**.
- `pnpm test --run` : **0 failed**.
- `pnpm playwright test` : federation/a11y/responsive **전부 passed**, axe **violations = 0**.
- 수동 QA 체크리스트 ** 항목 체크**, 콘솔 **에러 0**(아이콘 path 누락 없음).
- Ollama 미가용에서도 분류 흐름 **중단 없음**.
### 12.5 출시 전 최종 체크리스트
- [ ] `README.md` 장으로 누구나 §9 절차대로 기동·시드·데모 재현 가능.
- [ ] `.env.example`/`.env.local.example` 최신, 비밀값 없음.
- [ ] `/api/_test/reset` `ARI_ALLOW_TEST_RESET` 가드 하에서만 동작(운영 403).
- [ ] CI 3잡(backend/frontend-unit/e2e) green.
- [ ] 데모 스크립트(§10.4) 리허설 완료, 3 끊김 없음.
- [ ] 모든 phase 문서 상호 링크 정확(overview/phase-0~6).
---
## 13. 완료 기준(Definition of Done)
- [ ] 연합 시나리오 ①②③ 전부 통과(백엔드 통합 테스트 + E2E).
- [ ] `federation.spec.ts` 7단계 여정 green.
- [ ] axe 위반 0(3페이지 × 라이트/다크), 키보드/포커스/반응형 통과.
- [ ] 성능 예산 충족, N+1 회귀 통과, Ollama 폴백 동작.
- [ ] 페이지별·연합·비기능 수용 기준(§10) 항목 충족.
- [ ] 실행 문서(§9)로 3자가 재현 가능, CI green.
- [ ] 디자인 충실도(토큰/그라데이션/한국어 타이포/리스크 톤/아이콘 맵) 회귀 통과.
---
## 14. 다음 단계
Phase 6 MVP **마지막** 단계입니다. 이후 확장은 MVP 범위 밖(`overview.md`의 로드맵 참고):
- MVP 10 페이지(결재함·자동화·여정·일정·메일·알림·리서치·여행·라이프·하루 마감)를 "준비 중" 플레이스홀더에서 실제 구현으로 승격.
- LLM 분류 품질 고도화(프롬프트 튜닝, few-shot, 평가셋 확대) `phase-2-backend.md` `llm/prompts.py` 기반.
- 실데이터 연동(캘린더/메일) 인증.
연합 흐름·실행법·수용 기준이 확정되었으므로, 확장은 문서의 회귀 매트릭스(§11)와 수용 기준(§10)을 게이트로 삼아 안전하게 진행할 있습니다.
---
### 부록 A — 참조 문서 맵
`overview.md` · `phase-0-foundation.md` · `phase-1-design-system.md` · `phase-2-backend.md` · `phase-3-tasks.md` · `phase-4-inbox.md` · `phase-5-dashboard.md` · **phase-6-integration.md**(이 문서)
### 부록 B — 원본 디자인 근거 파일
`REF/PROJECT-README.md`(제품/연합 가치 §5) · `REF/assets/dash.css`(:root 토큰) · `REF/assets/shell.jsx`(MAIN 13항목, Icon P 맵, Topbar/SubRail) · `REF/assets/tasks-data.js`(트리·people·columns·scaffold·tasks 시드) · `REF/assets/tasks-risk.jsx`(리스크 계산 TODAY=8) · `REF/assets/sinbox-data.js`(인박스 golden 시드) · `REF/assets/data.js`(대시보드 브리핑/일정/목표) · `REF/assets/approve-data.js`(결재함 요약).