55 KiB
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가 끝나면 동작하는 것:
- 연합 시나리오 3종이 한 흐름으로 검증된다.
- ① 인박스 캡처 → 분류 → 확인(실체화) → 작업 트리에 새 task 등장
- ② 작업 데이터 변화 → 리스크 레이더가 자동 재계산(
GET /api/risks) - ③ 대시보드가 작업/인박스/결재함을 집계해 요약(
GET /api/dashboard)
- Playwright E2E 사용자 여정(대시보드 → 인박스 캡처 → 분류 좋아요 → 작업 페이지 확인 → 리스크 → 대시보드 갱신)이 그린(green)으로 통과한다.
- 접근성 감사(axe 0 violations, 키보드 내비, 포커스, 대비, 한국어 스크린리더)와 반응형이 통과한다.
- 성능 예산(초기 로드, 집계 응답, 분류 지연 처리, N+1 제거)이 충족된다.
- 실행 문서 한 장으로 누구나 frontend + backend + Ollama를 띄우고 시드를 초기화해 데모를 재현한다.
- 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)을 FastAPIDepends로 주입해야 위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.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 응답이 세 페이지의 데이터를 한 화면으로 모읍니다.
{
"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.statusenum: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에 추가.
# (선택) 스키마-타입 드리프트 검출
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"));
});
}
- 배경 그라데이션:
body는178deg3-스톱 그라데이션 +background-attachment: fixed(원본 dash.cssbody). 통합 후 어떤 페이지에서도 동일해야 함. - 한국어 타이포:
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)를 중앙Iconpaths 맵으로 이식 — 누락 시 렌더 깨짐(원본 개발 메모 경고). - 아이콘 맵 완전성:
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 래퍼에 집중:
// 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_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.countgroup-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분)
- 대시보드 진입 — "아리의 아침 브리핑입니다. 오늘은 6월 8일, 오후 미팅이 핵심이라고 알려주네요." (브리핑·일정·할 일·목표·결재함 요약)
- 인박스로 이동, 캡처 — "비행기 티켓을 사야겠다는 생각이 들면, 분류하지 않고 그냥 적습니다."
다음 주에 한국 놀러가는 비행기 티켓 사기입력 → 전송. - 자동 분류 관찰 — "아리가 즉시 작업 / 개인 › 여행 — 한국으로 분류하고, '구매라는 행동이 있으니 작업'이라고 이유까지 설명합니다. 가격 추적 알림도 걸어뒀네요."
- 확인(탭 한 번) — "읽고 탭 한 번. '이대로 좋아요'를 누르면 끝."
- 작업 페이지 → 개인 필터 → 여행 — 한국 — "개인 일이라고 숨기지 않습니다. 업무 작업과 같은 트리에서 필터로만 구분돼 똑같이 보입니다."
- 리스크 레이더 — 업무 필터로 전환. "아리가 마감·업무량·의존성을 훑어서, '분기 리포트가 오늘 마감인데 아직 진행 중'이라고 경고합니다."
- 대시보드 복귀 — "다시 관제탑으로. 방금 처리한 것이 요약에 반영됩니다. 적을 때는 분류하지 않는다 — 분류·배치·자동화는 아리가."
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)
test_health—/api/health == {status: ok}.test_capture_classifies_to_task_life_travel— golden #1 분류 정합.test_confirm_materializes_into_task_tree— confirm 후life-triptask 등장 +materialized_task_id.test_confirm_idempotent(보강) — confirm 두 번 호출해도 task 1개(멱등).test_risk_recompute_after_task_change— 지연/의존성 리스크, 최대 3건, TODAY=8.test_risk_disappears_when_done(보강) — k1을 done으로 PATCH 후/risks에서 지연 위험 사라짐.test_dashboard_aggregates_tasks_inbox_approvals— 6섹션 + 배지 키.test_tree_no_nplus1/test_dashboard_query_budget— 쿼리 예산.- 분류 골든 4건(phase-4) — task/event/idea, work/life 분기.
- 리스크 단위(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/health200.- 대시보드 첫 화면에 브리핑/일정/요약 정상, 콘솔 에러 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.ts7단계 여정 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(결재함 요약).