# 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`(결재함 요약).
