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