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.

55 KiB

Phase 6 — 통합 · 연합 흐름 · QA & MVP 수용 기준

3개 페이지(작업·인박스·대시보드)를 하나의 연합(federation) 흐름으로 묶고, E2E·접근성·성능·실행법·수용 기준을 확정하는 마지막 단계.

이 문서는 dev/ 문서 세트의 일부입니다 — 먼저 overview.md를 읽으세요. 선행 단계는 phase-0-foundation.mdphase-1-design-system.mdphase-2-backend.mdphase-3-tasks.mdphase-4-inbox.mdphase-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 / 개인 여행 — 한국)

# 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()
# 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 이벤트로 실제 발행 쿼리 수를 센다.

# 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를 호출합니다.

// 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();
}
# 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)으로 전 스택을 검증합니다.

// 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가 요구한 전체 여정: 대시보드 진입 → 인박스에서 "비행기 티켓..." 캡처 → 분류 좋아요(확인) → 작업 페이지 개인 여행 — 한국에서 확인 → 리스크 레이더 확인 → 대시보드 요약 갱신.

// 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

// 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

// 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.jss1 항목(route.type=task, sphere=life, proj="개인 여행 — 한국", tone="coral", due="출발 전 · ~6/14", when="오늘 21:00 빈 시간 추천", extra="가격 추적 알림 켜둠", reason 전문)과 tasks-data.jsk20("한국행 비행기 티켓 구매", 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가 비면 RiskRadarnull(렌더 안 함) — 빈 상태 처리.
  • 연합 포인트: 작업 상태를 칸반에서 done으로 옮기면(PATCH /api/tasks/{id}) 다음 GET /api/risks 호출에서 해당 리스크가 사라져야 함(자동 재계산). E2E에서는 시드 기준 "지연 위험" 존재만 확인하지만, 회귀 테스트(test_federation.py)에서 상태 변경 후 재호출로 검증 가능.

4.3 시나리오 ③ 대시보드 집계 (작업/인박스/결재함)

GET /api/dashboard 응답이 세 페이지의 데이터를 한 화면으로 모읍니다.

{
  "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/capturePOST /inbox/{id}/confirmGET /tasks?area=life materialized_task_idtask.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.jsonopenapi-typescriptfrontend/lib/api-types.gen.ts 생성하고 types.ts와 대조하는 lint를 CI에 추가.

# (선택) 스키마-타입 드리프트 검출
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의 값이 원본과 동일한지 단위 테스트로 못 박습니다.
// 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"));
  });
}
  • 배경 그라데이션: body178deg 3-스톱 그라데이션 + background-attachment: fixed(원본 dash.css body). 통합 후 어떤 페이지에서도 동일해야 함.
  • 한국어 타이포: bodyfont-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.jsxRP(radar/clock/scale/link/chev)를 중앙 Icon paths 맵으로 이식 — 누락 시 렌더 깨짐(원본 개발 메모 경고).
  • 아이콘 맵 완전성: shell.jsxP 맵 전체(spark/grid/route/cal/check/mail/inbox/bell/…)와 tasks-risk.jsxRP를 합쳐 중앙 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 래퍼에 집중:

// 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 폴백.

# 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_itemnew로 낙관적 추가하고 "아리가 분류 중…" 표시. 응답(또는 폴백) 도착 시 칩으로 치환.
  • 타임아웃: 12초 초과 시 Heuristic 폴백 — 사용자는 멈추지 않음. classification.modelheuristic-fallback을 남겨 투명성 확보(원본 철학: 신뢰+투명성).
  • /api/llm/health: 대시보드/설정에서 {reachable, model} 표시. unreachable이면 인박스에 "지금은 규칙 기반으로 분류해요" 안내.

8.3 N+1 / 집계 최적화

  • /api/tree: selectinload(Folder.projects) + task_countfunc.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회 셋업

# 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:

ollama serve

터미널 B — 백엔드:

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 — 프론트:

cd /Users/kim1634/mycloud/personal/workspace/ari_assistant/workspace/frontend
pnpm dev      # http://localhost:3000 → / 는 /dashboard 로 리다이렉트

(선택) 한 줄 기동 스크립트 scripts/dev.sh:

#!/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 시드 초기화(데모 리셋)

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

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 실행 명령 (전체)

# 백엔드 (통합·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/resetARI_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.mdllm/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(결재함 요약).