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.

2634 lines
126 KiB
Markdown

This file contains ambiguous Unicode characters!

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

# Phase 2 — 백엔드 · 데이터 모델 · Ollama 추상화
> 한 줄 요약: SQLite 스키마(SQLModel) · Alembic 마이그레이션 · 시드(지우의 한 주) · 전체 REST API · Ollama 추상화(모델 비종속) · 분류/리스크/스캐폴드 서비스를 구현하여, Phase 3~5의 프론트가 그대로 붙을 수 있는 **확정 계약(contract)** 을 완성한다.
> 이 문서는 `dev/` 문서 세트의 일부입니다 — 먼저 `overview.md`를 읽으세요.
> 선행: `phase-0-foundation.md`(모노레포·개발환경), `phase-1-design-system.md`(토큰·앱 셸). 후속: `phase-3-tasks.md`, `phase-4-inbox.md`, `phase-5-dashboard.md`, `phase-6-integration.md`.
---
## 1. 개요 & 목표
이 phase가 끝나면 **백엔드 단독으로 모든 MVP 데이터가 동작**한다. 구체적으로:
- `backend/` FastAPI 앱이 `uvicorn`으로 기동되고 `GET /api/health``{"status":"ok"}` 응답.
- SQLite DB(`ari.db`)에 CONTRACT의 모든 테이블(person/folder/project/task/task_comment/inbox_item/inbox_classification + 대시보드 읽기전용 event/approval/goal/briefing)이 Alembic 마이그레이션으로 생성.
- `python -m app.seed`**지우(PM)의 6/7~6/12 가상의 한 주** 데이터가 적재 — 원본 `REF/assets/tasks-data.js`, `sinbox-data.js`, `approve-data.js`, `data.js`의 값을 **그대로** 옮긴다(무한 중첩 프로젝트/하위작업/댓글 포함).
- CONTRACT의 REST API 전부 동작: `tree`, `tasks`(중첩 트리 + CRUD + comments + scaffold), `inbox`(capture→classify→confirm/reclassify/dismiss), `dashboard`, `people`, `risks`, `llm/health`.
- LLM 추상화 완성: `LLMProvider` 인터페이스 + `OllamaProvider`(format=json) + `HeuristicProvider`(규칙 폴백). **특정 모델에 종속되지 않음**(모델명은 `OLLAMA_MODEL` 환경변수 주입).
- 서비스 3종: `risk.py`(리스크 레이더, `tasks-risk.jsx` 이식, TODAY=8), `scaffold.py`(`pickScaffold` 정규식 템플릿), `classification.py`(캡처 분류 오케스트레이션 + 폴백).
- 인박스 confirm = **연합(federation)**: 분류 결과를 실제 `task`로 실체화하고 `materialized_task_id` 연결.
- pytest로 단위/통합/골든 분류 4케이스/마이그레이션 왕복이 전부 green.
이 phase는 "프론트가 붙을 계약의 완성"이 목표다. 따라서 **필드명·엔드포인트·동작이 CONTRACT와 1바이트도 다르면 안 된다.**
---
## 2. 선행 조건 / 산출물
### 2.1 선행 조건 (의존 phase)
| 의존 | 내용 |
|---|---|
| `phase-0-foundation.md` | 모노레포 스캐폴딩 완료. `backend/pyproject.toml`, `uv`(또는 venv) 환경, `backend/app/main.py` 골격, `backend/app/db.py` 골격, `frontend/`와의 CORS 설정, `.env` 로딩이 준비되어 있어야 한다. Ollama 데몬이 로컬에서 (있다면) `http://localhost:11434`로 접근 가능. |
| `phase-1-design-system.md` | 백엔드와 직접 의존은 없으나, `frontend/lib/types.ts`가 본 문서 §3의 `schemas.py`**1:1 대응**되어야 하므로 필드명을 공유한다. |
### 2.2 산출물 (Deliverables)
```
backend/
├─ app/
│ ├─ main.py FastAPI 앱 · 라우터 등록 · CORS · lifespan
│ ├─ db.py 엔진/세션 (SQLite) · get_session 의존성
│ ├─ config.py Settings (pydantic-settings, env 주입)
│ ├─ models.py SQLModel 테이블 전부 (관계/FK/enum/무한중첩)
│ ├─ schemas.py Pydantic I/O 스키마 (types.ts 와 1:1)
│ ├─ seed.py 지우의 한 주 시드 (REF 데이터 이식)
│ ├─ routers/
│ │ ├─ __init__.py
│ │ ├─ people.py GET /api/people
│ │ ├─ tree.py GET /api/tree, folders/projects CRUD, pin
│ │ ├─ tasks.py GET/POST/PATCH/DELETE tasks, comments, scaffold, risks
│ │ ├─ inbox.py GET /inbox, capture, reclassify, confirm, dismiss
│ │ ├─ dashboard.py GET /api/dashboard
│ │ └─ llm.py GET /api/llm/health
│ ├─ services/
│ │ ├─ __init__.py
│ │ ├─ risk.py computeRisks 이식 (지연/쏠림/의존)
│ │ ├─ scaffold.py pickScaffold 정규식 템플릿 + LLM 보강
│ │ └─ classification.py 캡처 분류 오케스트레이션
│ └─ llm/
│ ├─ __init__.py
│ ├─ provider.py LLMProvider 추상 인터페이스 + get_provider()
│ ├─ ollama.py OllamaProvider (format=json)
│ ├─ heuristic.py HeuristicProvider (규칙 폴백)
│ └─ prompts.py 분류 프롬프트 (한국어 reason)
├─ migrations/ alembic (env.py, versions/*.py)
├─ alembic.ini
├─ tests/
│ ├─ conftest.py in-memory DB fixture, TestClient, provider override
│ ├─ test_risk.py
│ ├─ test_scaffold.py
│ ├─ test_heuristic.py
│ ├─ test_classification_golden.py
│ ├─ test_api_tree.py
│ ├─ test_api_tasks.py
│ ├─ test_api_inbox.py
│ ├─ test_api_dashboard.py
│ └─ test_seed.py
└─ pyproject.toml
```
---
## 3. 상세 구현
### 3.0 설정 — `config.py`
CONTRACT: "모델 비종속 — 모델명은 환경변수(`OLLAMA_MODEL`)로 주입". `OLLAMA_HOST` 기본 `http://localhost:11434`.
```python
# backend/app/config.py
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
database_url: str = "sqlite:///./ari.db"
# LLM — 절대 특정 모델을 하드코딩하지 않는다. 설치된 모델을 env로 주입.
ollama_host: str = "http://localhost:11434"
ollama_model: str = "llama3.1" # 기본 placeholder. 실제 운영은 .env 로 덮어쓴다.
llm_provider: str = "auto" # auto | ollama | heuristic
llm_timeout: float = 20.0 # 초
# 리스크 계산 기준일 — CONTRACT: TODAY=8 (6/8). 시드가 '6/7~6/12 한 주' 가정.
risk_today: int = 8
# CORS 허용 출처(콤마구분). 단일 환경변수 규약: FRONTEND_ORIGIN.
frontend_origin: str = "http://localhost:3000"
# 테스트 리셋 게이트 (테스트 환경에서만 시드 리셋 허용)
ari_allow_test_reset: bool = False
@lru_cache
def get_settings() -> Settings:
return Settings()
```
> 환경변수 단일 규약: `DATABASE_URL`, `OLLAMA_HOST`, `OLLAMA_MODEL`, `LLM_PROVIDER`(ollama|heuristic|auto), `FRONTEND_ORIGIN`(CORS 허용 출처, 콤마구분), `ARI_ALLOW_TEST_RESET`(테스트 리셋 게이트). pydantic-settings는 필드명을 대문자 env로 자동 매핑한다(`frontend_origin`→`FRONTEND_ORIGIN`). CORS는 `settings.frontend_origin`에서 파생한다.
> `.env` 예시(루트 또는 `backend/.env`):
> ```
> DATABASE_URL=sqlite:///./ari.db
> OLLAMA_HOST=http://localhost:11434
> OLLAMA_MODEL=qwen2.5:7b # ← 사용자가 설치한 모델로 교체. 문서는 특정 모델을 강제하지 않음.
> LLM_PROVIDER=auto
> FRONTEND_ORIGIN=http://localhost:3000
> ```
---
### 3.1 `models.py` — SQLModel 테이블
CONTRACT의 데이터 모델을 **필드명 고정**으로 이식한다. 무한 중첩은 `project.parent_id`(자기참조 FK), `task.parent_id`(자기참조 FK)로 처리한다. enum은 파이썬 `str, Enum` 으로 정의하되 **값(value)은 CONTRACT의 한국어/영문 그대로** 사용한다(예: 우선순위 `높음/보통/낮음`, 칸반 status `todo/doing/waiting/review/done`).
```python
# backend/app/models.py
from __future__ import annotations
from datetime import date, datetime, timezone
from enum import Enum
from typing import Optional
from sqlmodel import SQLModel, Field, Relationship
def now() -> datetime:
return datetime.now(timezone.utc)
# ---------- enums (값은 CONTRACT 고정) ----------
class TaskStatus(str, Enum):
todo = "todo"
doing = "doing"
waiting = "waiting"
review = "review"
done = "done"
class Prio(str, Enum):
high = "높음"
normal = "보통"
low = "낮음"
class InboxKind(str, Enum):
text = "text"
voice = "voice"
image = "image"
class InboxStatus(str, Enum):
new = "new"
classified = "classified"
confirmed = "confirmed"
dismissed = "dismissed"
class ClsType(str, Enum):
task = "task"
event = "event"
idea = "idea"
class Sphere(str, Enum):
work = "work"
life = "life"
# ---------- core ----------
class Person(SQLModel, table=True):
__tablename__ = "person"
id: str = Field(primary_key=True) # 예: "jiwoo"
name: str
initial: str
color: str # 예: "var(--blue)" / "oklch(0.66 0.13 200)"
is_me: bool = False
class Folder(SQLModel, table=True):
__tablename__ = "folder"
id: str = Field(primary_key=True) # "work" / "life"
name: str # "업무" / "개인"
tone: str = "ink" # tone 집합: blue|violet|coral|green|amber|ink|faint
icon: str = "folder" # "folder" / "heart"
sort_order: int = 0
is_system: bool = True
projects: list["Project"] = Relationship(back_populates="folder")
class Project(SQLModel, table=True):
__tablename__ = "project"
id: str = Field(primary_key=True) # "biz", "biz-okr", ...
folder_id: str = Field(foreign_key="folder.id")
parent_id: Optional[str] = Field(default=None, foreign_key="project.id") # 무한 중첩
name: str
tone: str = "ink"
sort_order: int = 0
pinned: bool = False # 즐겨찾기
folder: Optional[Folder] = Relationship(back_populates="projects")
# 자기참조: 부모/자식
parent: Optional["Project"] = Relationship(
back_populates="children",
sa_relationship_kwargs={"remote_side": "Project.id"},
)
children: list["Project"] = Relationship(back_populates="parent")
tasks: list["Task"] = Relationship(back_populates="project")
class Task(SQLModel, table=True):
__tablename__ = "task"
id: str = Field(primary_key=True) # "k1", "kx3", ...
project_id: str = Field(foreign_key="project.id")
parent_id: Optional[str] = Field(default=None, foreign_key="task.id") # 무한 중첩 하위작업
title: str
status: TaskStatus = TaskStatus.todo
assignee_id: Optional[str] = Field(default=None, foreign_key="person.id")
due: Optional[date] = None # 시드는 "06-08" → 2026-06-08 로 적재
prio: Prio = Prio.normal
notes: str = "" # HTML 허용
est: str = ""
delegated: bool = False
sort_order: int = 0
created_at: datetime = Field(default_factory=now)
updated_at: datetime = Field(default_factory=now)
project: Optional[Project] = Relationship(back_populates="tasks")
parent: Optional["Task"] = Relationship(
back_populates="children",
sa_relationship_kwargs={"remote_side": "Task.id"},
)
children: list["Task"] = Relationship(back_populates="parent")
comments: list["TaskComment"] = Relationship(back_populates="task")
assignee: Optional[Person] = Relationship()
class TaskComment(SQLModel, table=True):
__tablename__ = "task_comment"
id: str = Field(primary_key=True) # TEXT PK 예: "c1" (자동증가 int 금지)
task_id: str = Field(foreign_key="task.id")
person_id: str = Field(foreign_key="person.id")
text: str
created_at: datetime = Field(default_factory=now)
task: Optional[Task] = Relationship(back_populates="comments")
person: Optional[Person] = Relationship()
# ---------- inbox (연합의 핵심) ----------
class InboxItem(SQLModel, table=True):
__tablename__ = "inbox_item"
id: str = Field(primary_key=True) # "s1", ...
kind: InboxKind = InboxKind.text
raw: str
status: InboxStatus = InboxStatus.new
created_at: datetime = Field(default_factory=now)
materialized_task_id: Optional[str] = Field(default=None, foreign_key="task.id")
classifications: list["InboxClassification"] = Relationship(back_populates="item")
class InboxClassification(SQLModel, table=True):
__tablename__ = "inbox_classification"
id: str = Field(primary_key=True) # TEXT PK 예: "cls1" (자동증가 int 금지)
inbox_item_id: str = Field(foreign_key="inbox_item.id") # 1:1 최신 (여러 row 중 최신을 사용)
type: ClsType
sphere: Sphere
project_id: Optional[str] = Field(default=None, foreign_key="project.id")
proj_label: str = "" # 예: "개인 여행 — 한국"
tone: str = "ink"
due_text: str = "" # 예: "출발 전 · ~6/14"
when_text: str = "" # 예: "오늘 21:00 빈 시간 추천"
extra: str = "" # 예: "가격 추적 알림 켜둠"
reason: str = "" # 사람이 읽는 한국어 설명
confidence: float = 0.0
model: str = "" # 사용된 provider/model 표기
created_at: datetime = Field(default_factory=now)
item: Optional[InboxItem] = Relationship(back_populates="classifications")
# ---------- 대시보드 읽기전용 시드 ----------
class Event(SQLModel, table=True):
__tablename__ = "event"
id: str = Field(primary_key=True) # TEXT PK 예: "e1"
time: str
title: str
tag: str = ""
dur: str = ""
tone: str = "ink" # tone 키 값('blue' 등). 'var(--blue)' 저장 금지
soon: bool = False
sort_order: int = 0
class Approval(SQLModel, table=True):
__tablename__ = "approval"
id: str = Field(primary_key=True) # "a1", ...
icon: str
tone: str
risk: str # "low" | "high"
time: str
title: str
detail: str = ""
cta: str = ""
alt: str = ""
undo_label: str = ""
sort_order: int = 0
class Goal(SQLModel, table=True):
__tablename__ = "goal"
id: str = Field(primary_key=True) # TEXT PK 예: "g1"
title: str
pct: int
sub: str = ""
tone: str = "blue" # tone 키 값('blue' 등). 'var(--blue)' 저장 금지(프론트가 var(--tone) 변환)
sort_order: int = 0
class Briefing(SQLModel, table=True):
"""단일 row(id=1). 아침 브리핑 + 날씨/출근/수면 + saved_today/today_routed."""
__tablename__ = "briefing"
id: Optional[int] = Field(default=None, primary_key=True) # 단일 row id=1
today: str = "" # 히어로 날짜 라벨 (예: "6월 7일 일요일") — 날짜이지 리스크 TODAY=8(6/8)과 무관
weather_temp: int = 0 # 현재 기온 (예: 24)
weather_cond: str = "" # "맑음 · 한낮 28°" — "한낮 28°"는 일 최고기온(현재기온과 의도적으로 다름)
weather_icon: str = "cloudSun" # 'cloudSun' 저장 → 표시 시 'sun' 으로 매핑
commute: str = ""
sleep: str = ""
note: str = "" # HTML 허용
saved_today: str = "" # "47분"
today_routed: int = 0 # 7
```
#### 무한 중첩 처리 노트
- `Project.parent_id``None` 이면 **폴더 직속 최상위 프로젝트**, 값이 있으면 하위 프로젝트. CONTRACT의 `folder` 는 별도 테이블(`Folder`)이므로 원본 `tasks-data.js``folder:true` 노드(`work`/`life`)는 `Folder`로, 그 children(`biz`/`onb`/`team`/`me`/`life-trip`/`life-fam`)은 `Project(parent_id=None)`로, 손자(`biz-okr` 등)는 `Project(parent_id="biz")`로 적재한다.
- `Task.parent_id``None` 이면 최상위 작업, 값이 있으면 하위작업. 원본의 `children` 배열을 재귀로 펼쳐 `parent_id`로 연결.
- `remote_side` 지정은 SQLAlchemy 자기참조 1:N 관계 필수 설정이다(없으면 매핑 에러).
---
### 3.2 `schemas.py` — Pydantic I/O (frontend/lib/types.ts 와 1:1)
응답 스키마는 프론트가 그대로 쓰는 형태로 **중첩**을 포함한다. enum 값은 모델과 동일.
```python
# backend/app/schemas.py
from __future__ import annotations
from datetime import date, datetime
from typing import Optional
from pydantic import BaseModel
from .models import TaskStatus, Prio, InboxKind, InboxStatus, ClsType, Sphere
# ---------- people ----------
class PersonOut(BaseModel):
id: str
name: str
initial: str
color: str
is_me: bool
# ---------- tree ----------
class ProjectNode(BaseModel):
id: str
folder_id: str
parent_id: Optional[str] = None
name: str
tone: str
sort_order: int
pinned: bool
task_count: int = 0 # 자신+하위 프로젝트의 작업 수 합
children: list["ProjectNode"] = []
class FolderOut(BaseModel):
id: str
name: str
tone: str
icon: str
sort_order: int
is_system: bool
projects: list[ProjectNode] = [] # 최상위 프로젝트(중첩 children 포함)
class FolderCreate(BaseModel):
name: str
tone: Optional[str] = "ink"
icon: Optional[str] = "folder"
class FolderPatch(BaseModel):
name: Optional[str] = None
tone: Optional[str] = None
icon: Optional[str] = None
sort_order: Optional[int] = None
class ProjectCreate(BaseModel):
folder_id: str
parent_id: Optional[str] = None
name: str
tone: Optional[str] = "ink"
class ProjectPatch(BaseModel):
name: Optional[str] = None
tone: Optional[str] = None
parent_id: Optional[str] = None
sort_order: Optional[int] = None
# ---------- tasks ----------
class CommentOut(BaseModel):
id: str
task_id: str
person_id: str
text: str
created_at: datetime
class TaskNode(BaseModel):
id: str
project_id: str
parent_id: Optional[str] = None
title: str
status: TaskStatus
assignee_id: Optional[str] = None
due: Optional[date] = None
prio: Prio
notes: str
est: str
delegated: bool
sort_order: int
created_at: datetime
updated_at: datetime
comments: list[CommentOut] = []
children: list["TaskNode"] = []
class TaskCreate(BaseModel):
title: str
project_id: str
parent_id: Optional[str] = None
status: Optional[TaskStatus] = TaskStatus.todo
assignee_id: Optional[str] = None
due: Optional[date] = None
prio: Optional[Prio] = Prio.normal
notes: Optional[str] = ""
est: Optional[str] = ""
class TaskPatch(BaseModel):
title: Optional[str] = None
project_id: Optional[str] = None
parent_id: Optional[str] = None
status: Optional[TaskStatus] = None
assignee_id: Optional[str] = None
due: Optional[date] = None
prio: Optional[Prio] = None
notes: Optional[str] = None
est: Optional[str] = None
delegated: Optional[bool] = None
sort_order: Optional[int] = None
class CommentCreate(BaseModel):
person_id: str
text: str
# ---------- scaffold ----------
class ScaffoldItem(BaseModel):
title: str
est: str = ""
class ScaffoldOut(BaseModel):
kind: str # "연구 · 논문 작성" 등
icon: str # "brain"|"branch"|"file"|"list"
items: list[ScaffoldItem]
created: bool = False # True면 실제 생성됨, False면 미리보기
created_task_ids: list[str] = []
class ScaffoldRequest(BaseModel):
create: bool = False # True면 하위작업 실제 생성, False면 미리보기
use_llm: bool = False # LLM 보강 사용 여부
# ---------- risks ----------
class RiskOut(BaseModel):
kind: str # "지연 위험" | "업무 쏠림" | "의존성"
icon: str # "clock" | "scale" | "link"
tone: str # "coral" | "amber" | "violet"
task_id: Optional[str] = None # 관련 task id (cta 대상)
text: str # 한국어. 강조어를 ** ** 마크다운으로 감싼다(phase-3 Bolded 파서)
cta: Optional[str] = None # "작업 열기" | "후속 작업 보기"
# ---------- inbox ----------
class ClassificationOut(BaseModel):
id: Optional[str] = None
inbox_item_id: str
type: ClsType
sphere: Sphere
project_id: Optional[str] = None
proj_label: str
tone: str
due_text: str
when_text: str
extra: str
reason: str
confidence: float
model: str
created_at: Optional[datetime] = None
class InboxItemOut(BaseModel):
id: str
kind: InboxKind
raw: str
status: InboxStatus
created_at: datetime
materialized_task_id: Optional[str] = None
classification: Optional[ClassificationOut] = None # 최신 1건
class CaptureRequest(BaseModel):
kind: InboxKind = InboxKind.text
raw: str
class CaptureResponse(BaseModel):
item: InboxItemOut
classification: ClassificationOut
class ReclassifyRequest(BaseModel):
type: Optional[ClsType] = None # 사용자가 타입 강제 가능
class ConfirmResponse(BaseModel):
item: InboxItemOut
task: Optional[TaskNode] = None # type=task 면 생성된 작업
# event/idea 는 MVP에서 task로만 실체화하지 않고 상태만 confirmed 처리(아래 §3.10 참고)
# ---------- llm ----------
class LLMHealth(BaseModel):
reachable: bool
provider: str # "ollama" | "heuristic"
model: str
host: Optional[str] = None
detail: str = ""
# ---------- dashboard ----------
class UserOut(BaseModel):
name: str # 예: "지우"
initial: str # 예: "지"
class WeatherOut(BaseModel):
temp: int # 현재 기온 (예: 24)
cond: str # "맑음 · 한낮 28°" ("한낮 28°"=일 최고기온, 현재기온과 의도적으로 다름)
icon: str # 'cloudSun' 저장 → 표시용 'sun' 으로 매핑해 내려준다
class BriefingOut(BaseModel):
today: str # 히어로 날짜 라벨 (예: "6월 7일 일요일") — weather.temp(현재기온)·리스크 TODAY=8과 별개
weather: WeatherOut
commute: str; sleep: str; note: str # note: HTML 허용
class EventOut(BaseModel):
time: str; title: str; tag: str; dur: str; tone: str; soon: bool
class ApprovalSummaryOut(BaseModel):
id: str; icon: str; tone: str; title: str; time: str
# risk=="high" 만 포함, 최대 3건. detail/cta/alt/undo_label 미포함.
class GoalOut(BaseModel):
id: str; title: str; pct: int; sub: str; tone: str # tone=키('blue' 등)
class InboxRecentOut(BaseModel):
id: str; kind: str; raw: str; type: str; proj_label: str; tone: str
# 평탄화 형태: 최신 classification 에서 type/proj_label/tone 을 끌어와 채운다.
class TaskSummaryItem(BaseModel):
id: str; title: str; prio: str; project: str
class TaskSummaryOut(BaseModel):
open_count: int # 미완료 작업 수
items: list[TaskSummaryItem]
class BadgesOut(BaseModel):
appr: int # 결재함 대기(=high risk 건수)
task: int # 미완료 작업 수(상단 작업 배지)
noti: int # 알림 배지 (시드 상수 6)
class DashboardOut(BaseModel):
user: UserOut
briefing: BriefingOut
saved_today: str # "47분"
today_routed: int # 7
schedule: list[EventOut]
task_summary: TaskSummaryOut
goals: list[GoalOut]
approvals_summary: list[ApprovalSummaryOut] # high-risk 만, 최대 3건
inbox_recent: list[InboxRecentOut] # 최근 3 (평탄화 형태)
badges: BadgesOut
# 재귀 모델 전방참조 해소
ProjectNode.model_rebuild()
TaskNode.model_rebuild()
```
> **types.ts 대응 노트**: `frontend/lib/types.ts`는 위 스키마와 동일한 키/타입을 갖는다. 예: `ProjectNode`, `FolderOut`(→ `Folder`), `TaskNode`(→ `Task`), `ClassificationOut`(→ `Classification`), `DashboardOut`(→ `Dashboard`). `due`는 프론트에서 `string | null`(ISO date), `status`/`prio`는 union literal. 필드명은 snake_case를 유지(FastAPI 기본). 프론트가 camelCase를 원하면 Phase 3에서 `lib/api.ts`가 변환하지 말고 백엔드 키를 그대로 쓴다(CONTRACT 명명 일치 우선).
---
### 3.3 `db.py` + Alembic
```python
# backend/app/db.py
from sqlmodel import SQLModel, Session, create_engine
from .config import get_settings
settings = get_settings()
# SQLite + FastAPI: check_same_thread=False 필요
connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
engine = create_engine(settings.database_url, echo=False, connect_args=connect_args)
def init_db() -> None:
SQLModel.metadata.create_all(engine)
def get_session():
with Session(engine) as session:
yield session
```
#### Alembic 초기화 & 마이그레이션
```bash
# backend/ 에서
uv run alembic init migrations # (phase-0 에서 이미 했다면 생략)
```
`migrations/env.py` 가 SQLModel 메타데이터를 보도록 수정한다:
```python
# migrations/env.py (핵심 부분)
from sqlmodel import SQLModel
from app import models # noqa: F401 ← 모든 테이블이 메타데이터에 등록되도록 import
from app.config import get_settings
target_metadata = SQLModel.metadata
config.set_main_option("sqlalchemy.url", get_settings().database_url)
```
> SQLModel은 컬럼 타입이 `sqlmodel.sql.sqltypes.AutoString` 등으로 잡히므로 마이그레이션 파일 상단에 `import sqlmodel` 이 필요하다. autogenerate가 자동으로 넣지 못하면 수동 추가한다.
마이그레이션 생성/적용:
```bash
uv run alembic revision --autogenerate -m "init schema"
uv run alembic upgrade head
# 롤백 검증(왕복):
uv run alembic downgrade base && uv run alembic upgrade head
```
---
### 3.4 `seed.py` — 지우의 한 주 (REF 데이터 이식)
원본 4개 파일의 값을 **그대로** 적재한다. 핵심 규칙:
- `due``"06-08"` 같은 문자열 → `date(2026, 6, 8)` 로 변환(연도는 데모 기준연도 2026).
- 원본 `people``c`(`"var(--blue)"`, `oklch(...)`) → `Person.color`. `me:true``is_me`.
- 원본 task의 `children` 재귀 → `Task.parent_id` 연결. `assignee`(person key) → `assignee_id`. id 없는 하위작업은 `kx<n>` 규칙으로 새 id 부여(원본 `tuid()`와 동일하게 `kx1, kx2, ...` 순차).
- 하위작업의 `project` 가 비면 부모 작업의 `project_id` 상속(원본 `T()` 기본값 `project:"me"` 이지만, 실제 트리 표시는 부모 프로젝트를 따른다 — 상속이 더 자연스럽다). **상속 규칙**: 하위작업에 명시적 `project`가 있으면 그 값, 없으면 부모의 `project_id`.
- `comments``who`(person key)+`text`+`time` → `TaskComment`. `time`("2시간 전")은 표시용이 아니라 정렬용 `created_at`으로 환산할 수 없으므로, MVP는 `created_at`을 시드 시각으로 두고 원본 표시 문구는 무시(프론트는 상대시간 재계산). **단** 댓글 순서(sort)는 입력 순서 유지.
- 인박스: `sinbox-data.js``items[]``InboxItem` + 최신 `InboxClassification`. 원본 `route``type/sphere/proj/tone/due/when/extra/reason` → classification 필드(`proj`→`proj_label`, `due`→`due_text`, `when`→`when_text`). `status`: 원본 `new`/`done` → 우리 enum은 `new`(미확인)/`confirmed`(처리됨)로 매핑(`done`→`confirmed`). `s1``new`(데모: 사용자가 확인할 카드), 나머지는 `confirmed`.
- 결재함/일정/목표/브리핑: `approve-data.js`, `data.js` 값 그대로. 대시보드 읽기전용 시드는 내부 헬퍼 `_seed_dashboard(session)``run_seed` 안에서 호출한다(공개 진입점 아님).
- **시드 진입 함수 단일 정본**: `run_seed(session: Session | None = None, reset: bool = True)`. CLI(`python -m app.seed`)는 `run_seed()`(자체 세션 생성), 테스트는 `run_seed(session=test_session, reset=True)`(주어진 세션 재사용). `_seed_dashboard(session)``run_seed` 의 하위 호출이다.
- TEXT PK: `TaskComment.id`(`c1`,`c2`,...), `InboxClassification.id`(`cls1`,...), `event.id`(`e1`,...), `goal.id`(`g1`,...)는 모두 문자열 PK(자동증가 int 금지). `briefing`은 단일 row `id=1`.
```python
# backend/app/seed.py
from datetime import date, datetime, timezone
from sqlmodel import Session, select
from .db import engine, init_db
from .models import (
Person, Folder, Project, Task, TaskComment,
InboxItem, InboxClassification, Event, Approval, Goal, Briefing,
TaskStatus, Prio, InboxKind, InboxStatus, ClsType, Sphere,
)
YEAR = 2026
def D(mmdd: str | None):
"""'06-08' -> date(2026,6,8). 빈값/None -> None"""
if not mmdd:
return None
m, d = mmdd.split("-")
return date(YEAR, int(m), int(d))
# ---- people (REF/assets/tasks-data.js people) ----
PEOPLE = [
("jiwoo", "지우", "지", "var(--blue)", True),
("hyunwoo", "현우", "현", "var(--violet)", False),
("minseo", "민서", "민", "var(--green)", False),
("jaeho", "재호", "재", "var(--coral)", False),
("sua", "수아", "수", "oklch(0.66 0.13 200)", False),
]
# ---- folders + projects (REF tree) ----
FOLDERS = [
("work", "업무", "ink", "folder", 0),
("life", "개인", "blue", "heart", 1),
]
# (id, folder_id, parent_id, name, tone, sort)
PROJECTS = [
# 업무
("biz", "work", None, "경영 전략", "coral", 0),
("biz-okr", "work", "biz", "2026 Q2 OKR", "coral", 0),
("biz-report", "work", "biz", "분기 리포트", "coral", 1),
("biz-budget", "work", "biz", "예산 관리", "coral", 2),
("onb", "work", None, "온보딩 리디자인", "violet", 1),
("onb-research","work", "onb", "사용자 리서치", "violet", 0),
("onb-wire", "work", "onb", "와이어프레임", "violet", 1),
("onb-voice", "work", "onb", "음성 인터페이스","violet", 2),
("team", "work", None, "팀 운영", "green", 2),
("team-hr", "work", "team","채용 & 온보딩","green", 0),
("team-retro", "work", "team","회고", "green", 1),
# 개인
("me", "life", None, "일상", "blue", 0),
("life-trip", "life", None, "여행 — 한국", "coral", 1),
("life-fam", "life", None, "가족", "green", 2),
]
# ---- tasks (REF tasks; 중첩) — 원본 구조를 dict 트리로 그대로 옮긴다 ----
# 하위작업 project 미지정 시 부모 project 상속. id 없으면 kx<n> 자동 부여.
TASKS = [
{"id":"k1","title":"분기 리포트 초안 마무리","project":"biz-report","assignee":"jiwoo","due":"06-08","prio":"높음","status":"doing",
"notes":"<h3>목표</h3><p>경영진 미팅용 Q2 성과 리포트. <b>리텐션·매출·예산</b> 세 섹션으로 구성한다.</p><blockquote>핵심 지표는 시각화해서 한눈에 들어오게.</blockquote>",
"comments":[
{"who":"hyunwoo","text":"매출 섹션은 제가 오늘 안으로 넘겨드릴게요."},
{"who":"jiwoo","text":"좋아요, 예산 섹션만 확정되면 취합할게요."}],
"children":[
{"title":"리텐션 데이터 취합","status":"done","assignee":"sua","due":"06-08","project":"biz-report","children":[
{"title":"코호트 정의 확정","status":"done","assignee":"sua","due":"06-07"},
{"title":"주차별 잔존율 추출","status":"done","assignee":"sua","due":"06-08"},
{"title":"이탈 사유 태깅","status":"done","assignee":"jaeho","due":"06-08","children":[
{"title":"인터뷰 발췌 5건","status":"done","assignee":"jaeho"},
{"title":"사유 카테고리 분류","status":"done","assignee":"jaeho"}]}]},
{"title":"매출 섹션 작성","status":"doing","assignee":"jiwoo","due":"06-09","prio":"높음","project":"biz-report",
"notes":"<p>MRR/ARR 표는 완료. 증감 코멘트 마무리 필요.</p>","children":[
{"title":"MRR / ARR 표 정리","status":"done","assignee":"jiwoo"},
{"title":"전분기 대비 증감 코멘트","status":"doing","assignee":"jiwoo"},
{"title":"예측 시나리오 3종","status":"todo","assignee":"jiwoo"}]},
{"title":"예산 섹션 작성","status":"todo","assignee":"jiwoo","due":"06-10","project":"biz-budget"},
{"title":"경영진 검토 요청 메일","status":"todo","assignee":"jiwoo","due":"06-10","project":"biz-report"}]},
{"id":"k2","title":"온보딩 와이어프레임 피드백 정리","project":"onb-wire","assignee":"hyunwoo","due":"06-09","prio":"높음","status":"review",
"notes":"<p>디자인 리뷰에서 나온 3개 화면 수정사항을 취합하고 우선순위를 매긴다.</p>",
"comments":[{"who":"sua","text":"3번 화면 CTA 위치는 아래로 내리는 게 좋겠어요."}],
"children":[
{"title":"환영 화면 수정안","status":"done","assignee":"hyunwoo","project":"onb-wire","children":[
{"title":"카피 톤 조정","status":"done","assignee":"hyunwoo"},
{"title":"일러스트 교체 요청","status":"doing","assignee":"minseo"}]},
{"title":"권한 요청 화면 재배치","status":"doing","assignee":"hyunwoo"},
{"title":"음성 안내 추가 검토","status":"todo","assignee":"sua"}]},
{"id":"k5","title":"사용자 인터뷰 5건 정리","project":"onb-research","assignee":"sua","due":"06-10","prio":"보통","status":"doing",
"children":[
{"title":"녹취 요약 (5건)","status":"doing","assignee":"sua"},
{"title":"인사이트 태깅","status":"todo","assignee":"sua"},
{"title":"리서치 보드 업데이트","status":"todo","assignee":"minseo"}]},
{"id":"k6","title":"OKR 중간 점검 자료 준비","project":"biz-okr","assignee":"jiwoo","due":"06-11","prio":"높음","status":"todo",
"children":[
{"title":"Objective별 진척도 집계","status":"todo","assignee":"jiwoo"},
{"title":"리스크 항목 표시","status":"todo","assignee":"jiwoo"}]},
{"id":"k4","title":"구독 결제 카드 갱신","project":"me","assignee":"jiwoo","due":"06-12","prio":"보통","status":"todo","children":[]},
{"id":"k3","title":"신규 입사자 환영 메일 발송","project":"team-hr","assignee":"minseo","due":"06-07","prio":"보통","status":"done",
"children":[
{"title":"메일 템플릿 작성","status":"done","assignee":"minseo"},
{"title":"수신자 명단 확인","status":"done","assignee":"minseo"}]},
{"id":"k13","title":"스프린트 회고 문서 배포","project":"team-retro","assignee":"minseo","due":"06-08","prio":"낮음","status":"review",
"children":[
{"title":"액션 아이템 담당자 지정","status":"done","assignee":"minseo"},
{"title":"다음 스프린트 반영 확인","status":"doing","assignee":"jaeho"}]},
{"id":"k10","title":"모바일 푸시 알림 QA","project":"onb-wire","assignee":"sua","due":"06-09","prio":"높음","status":"doing",
"children":[
{"title":"iOS 시나리오 3종","status":"done","assignee":"sua"},
{"title":"Android 시나리오 3종","status":"doing","assignee":"sua"},
{"title":"딥링크 라우팅 확인","status":"todo","assignee":"jaeho"}]},
{"id":"k14","title":"데이터 전처리 파이프라인","project":"onb-research","assignee":"minseo","due":"06-10","prio":"보통","status":"waiting","delegated":True,
"notes":"<p>일정이 빠듯해 같은 프로젝트의 <b>민서님</b>께 위임 — 현재 완료를 기다리는 중입니다.</p>",
"children":[
{"title":"원천 데이터 스키마 정리","status":"done","assignee":"minseo"},
{"title":"결측치 처리 규칙 정의","status":"doing","assignee":"minseo"},
{"title":"정제 스크립트 작성","status":"todo","assignee":"minseo"}]},
{"id":"k20","title":"한국행 비행기 티켓 구매","project":"life-trip","assignee":"jiwoo","due":"06-14","prio":"높음","status":"todo",
"notes":"<p>스마트 인박스에서 자동 생성된 작업이에요. 아리가 <b>가격 추적 알림</b>을 켜뒀고, 적정가가 보이면 결재함으로 알려드려요.</p>",
"children":[
{"title":"날짜 후보 확정","status":"doing","assignee":"jiwoo","project":"life-trip"},
{"title":"가격 알림 확인 후 결제","status":"todo","assignee":"jiwoo","project":"life-trip"}]},
{"id":"k21","title":"엄마 생신 선물 알아보기","project":"life-fam","assignee":"jiwoo","due":"06-20","prio":"보통","status":"todo","children":[]},
]
# kx 카운터 (원본 tuid 와 동일하게 kx1,kx2,...)
_kx = 0
def kx() -> str:
global _kx
_kx += 1
return f"kx{_kx}"
# 댓글 TEXT PK 카운터 (c1, c2, ...)
_cidx = 0
def cid() -> str:
global _cidx
_cidx += 1
return f"c{_cidx}"
def insert_task(s: Session, node: dict, parent_id: str | None, project_id: str, order: int):
tid = node.get("id") or kx()
pid = node.get("project") or project_id # 상속 규칙
t = Task(
id=tid, project_id=pid, parent_id=parent_id, title=node["title"],
status=TaskStatus(node.get("status", "todo")),
assignee_id=node.get("assignee", "jiwoo"),
due=D(node.get("due")),
prio=Prio(node.get("prio", "보통")),
notes=node.get("notes", ""), est=node.get("est", ""),
delegated=node.get("delegated", False), sort_order=order,
)
s.add(t)
for i, c in enumerate(node.get("comments", [])):
s.add(TaskComment(id=cid(), task_id=tid, person_id=c["who"], text=c["text"]))
for i, child in enumerate(node.get("children", [])):
insert_task(s, child, tid, pid, i)
# ---- inbox (REF sinbox-data.js) ----
# status: 원본 done -> confirmed, new -> new
INBOX = [
{"id":"s1","kind":"text","status":"new","raw":"다음 주에 한국 놀러가는 비행기 티켓 사기",
"cls":{"type":"task","sphere":"life","proj_label":"개인 여행 — 한국","project_id":"life-trip","tone":"coral",
"due_text":"출발 전 · ~6/14","when_text":"오늘 21:00 빈 시간 추천","extra":"가격 추적 알림 켜둠",
"reason":"구매라는 행동이 있으니 '작업' 맞아요. 작업 트리의 '개인' 아래에 '여행 — 한국' 프로젝트를 만들어 넣었어요 — 따로 섹션이 생기는 게 아니라 다른 작업과 똑같이 보여요. 출발까지 일주일이라 가격 알림도 걸어뒀어요.",
"confidence":0.92}},
{"id":"s2","kind":"text","status":"confirmed","raw":"수요일 11시 자전거 수리 맡기기",
"cls":{"type":"event","sphere":"life","proj_label":"개인 캘린더","project_id":None,"tone":"blue",
"due_text":"수 6/10 11:00","when_text":"캘린더 등록 완료","extra":"",
"reason":"시간이 정해진 일은 작업이 아니라 일정으로 바로 등록해요.","confidence":0.95}},
{"id":"s3","kind":"voice","status":"confirmed","raw":"음성 메모 0:09 — 엄마 생신 선물 미리 알아보기",
"cls":{"type":"task","sphere":"life","proj_label":"가족","project_id":"life-fam","tone":"green",
"due_text":"6/20 전","when_text":"주말 오전 블록","extra":"",
"reason":"기한이 느슨한 개인 작업이라 주말 블록에 배치했어요.","confidence":0.8}},
{"id":"s4","kind":"text","status":"confirmed","raw":"온보딩 환영 화면에 짧은 애니메이션 넣으면 어떨까",
"cls":{"type":"idea","sphere":"work","proj_label":"온보딩 리디자인 · 아이디어 보드","project_id":"onb","tone":"violet",
"due_text":"","when_text":"","extra":"",
"reason":"아직 행동이 정해지지 않아 보드에 보관 — 목요일 디자인 싱크 안건으로도 제안해둘게요.","confidence":0.7}},
]
# ---- dashboard 읽기전용 (REF data.js / approve-data.js) ----
# (id, time, title, tag, dur, tone키, soon)
SCHEDULE = [
("e1","09:30","팀 데일리 스탠드업","프로덕트","15분","blue",False),
("e2","11:00","디자인 리뷰 — 온보딩 플로우","디자인","45분","violet",False),
("e3","14:00","분기 전략 미팅","경영진","60분","coral",True),
("e4","16:30","1:1 — 민서님","팀","30분","green",False),
]
# (id, title, pct, sub, tone키) — tone 은 키('blue' 등), 'var(--blue)' 저장 금지
GOALS = [
("g1","분기 OKR — 사용자 리텐션",68,"12개 중 8개 달성","blue"),
("g2","주 4회 운동",75,"이번 주 3/4회","coral"),
("g3","‘딥 워크’ 책 완독",40,"320쪽 중 128쪽","violet"),
]
APPROVALS = [
("a1","cal","coral","low","07:42","치과 예약을 16:00로 옮겼어요",
"14시 분기 전략 미팅과 겹침 · 병원 예약 시스템에서 빈 슬롯 확인 후 변경","","","원래 시간으로"),
("a2","mail","blue","low","06:10","영수증·뉴스레터 7통을 정리했어요",
"영수증 3통 → 금융 폴더 · 뉴스레터 4통 → 읽을거리, 받은편지함은 중요한 것만 남김","","","되돌리기"),
("a3","cal","violet","low","07:40","내일 오전 딥 워크 2시간을 예약했어요",
"분기 리포트 마감(내일 18시) 대비 · 9:0011:00, 방해 금지로 설정","","","블록 해제"),
("a4","mail","violet","high","보내기 대기","현우님께 회신 초안이 준비됐어요",
"“잘 받았어요! 금요일 오전까지 화면별 코멘트 정리해서 드릴게요.”","보내기","수정",""),
("a5","users","green","high","전달 대기","민서님께 ‘데이터 전처리’ 위임 요청",
"오늘 일정 과부하 감지 · 맥락 요약과 마감(목)을 담은 요청 메시지 작성 완료","전달","내가 할게",""),
("a6","wallet","amber","high","확인 필요","Netflix 일시정지를 추천해요",
"최근 2개월 시청 기록 없음 · 모레 17,000원 결제 예정 — 정지 절차는 준비해뒀어요","일시정지","유지",""),
]
# 날씨 주석: weather_temp=24(현재 기온), weather_cond 의 "한낮 28°"=일 최고기온 — 의도적으로 다른 값(원본 data.js 근거).
# today: 히어로 날짜 라벨(원본 data.js today). 날짜 라벨이지 리스크 TODAY=8(6/8)과 무관.
BRIEFING = dict(
today="6월 7일 일요일",
weather_temp=24, weather_cond="맑음 · 한낮 28°", weather_icon="cloudSun",
commute="출근 23분 · 평소보다 4분 빠름",
sleep="어젯밤 7시간 12분 · 평소만큼 푹 잤어요",
note="오늘은 오후 미팅이 핵심이에요. 오전을 비워 <b>분기 리포트</b>에 집중하시면 좋겠어요. 14시 전엔 비가 그칠 예정이라 우산은 안 챙기셔도 돼요.",
saved_today="47분", today_routed=7,
)
def _seed_dashboard(s: Session) -> None:
"""대시보드 읽기전용 시드 — run_seed 내부에서만 호출하는 헬퍼(공개 진입점 아님)."""
for i, (eid, t, title, tag, dur, tone, soon) in enumerate(SCHEDULE):
s.add(Event(id=eid, time=t, title=title, tag=tag, dur=dur, tone=tone, soon=soon, sort_order=i))
for i, (gid, title, pct, sub, tone) in enumerate(GOALS):
s.add(Goal(id=gid, title=title, pct=pct, sub=sub, tone=tone, sort_order=i))
for i, row in enumerate(APPROVALS):
aid, icon, tone, risk, time, title, detail, cta, alt, undo = row
s.add(Approval(id=aid, icon=icon, tone=tone, risk=risk, time=time, title=title,
detail=detail, cta=cta, alt=alt, undo_label=undo, sort_order=i))
s.add(Briefing(id=1, **BRIEFING)) # 단일 row id=1
def _run(s: Session, reset: bool) -> None:
"""주어진 세션 위에서 시드 적재(트랜잭션 본문)."""
if reset:
for tbl in (TaskComment, Task, InboxClassification, InboxItem,
Project, Folder, Person, Event, Approval, Goal, Briefing):
for row in s.exec(select(tbl)).all():
s.delete(row)
s.commit()
for pid, name, ini, color, me in PEOPLE:
s.add(Person(id=pid, name=name, initial=ini, color=color, is_me=me))
for fid, name, tone, icon, order in FOLDERS:
s.add(Folder(id=fid, name=name, tone=tone, icon=icon, sort_order=order))
for pid, fid, parent, name, tone, order in PROJECTS:
s.add(Project(id=pid, folder_id=fid, parent_id=parent, name=name, tone=tone, sort_order=order))
s.commit()
for i, node in enumerate(TASKS):
insert_task(s, node, None, node["project"], i)
s.commit()
for n, it in enumerate(INBOX, start=1):
s.add(InboxItem(id=it["id"], kind=InboxKind(it["kind"]),
raw=it["raw"], status=InboxStatus(it["status"])))
c = it["cls"]
s.add(InboxClassification(
id=f"cls{n}", # TEXT PK
inbox_item_id=it["id"], type=ClsType(c["type"]), sphere=Sphere(c["sphere"]),
project_id=c["project_id"], proj_label=c["proj_label"], tone=c["tone"],
due_text=c["due_text"], when_text=c["when_text"], extra=c["extra"],
reason=c["reason"], confidence=c["confidence"], model="seed"))
s.commit()
_seed_dashboard(s) # 대시보드 읽기전용 시드 통합
s.commit()
def run_seed(session: Session | None = None, reset: bool = True) -> None:
"""시드 진입 함수 단일 정본.
- CLI: run_seed() → 자체 세션 생성(엔진에서).
- 테스트: run_seed(session=test_session, reset=True) → 주어진 세션 재사용.
"""
init_db()
if session is not None:
_run(session, reset)
else:
with Session(engine) as s:
_run(s, reset)
if __name__ == "__main__":
run_seed()
print("✅ seed 완료")
```
실행:
```bash
uv run python -m app.seed # 또는: uv run alembic upgrade head 후 python -m app.seed
```
---
### 3.5 라우터 — 공통 패턴
라우터 prefix 전략 정본(phase-0 방식): **각 라우터는 내부 prefix 없이** 경로를 정의한다(예: `@router.get("/tasks")`, `@router.get("/health")`). `main.py` 에서 `app.include_router(router, prefix="/api", tags=...)` 로 등록 → 최종 `/api/tasks`, `/api/health`.
```python
# backend/app/main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI, APIRouter
from fastapi.middleware.cors import CORSMiddleware
from .config import get_settings
from .db import init_db
from .routers import people, tree, tasks, inbox, dashboard, llm
settings = get_settings()
health_router = APIRouter() # 내부 prefix 없음. /health → /api/health
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db() # 개발 편의(운영은 alembic). 테이블 보장.
yield
app = FastAPI(title="아리 Ari API", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=[o.strip() for o in settings.frontend_origin.split(",")],
allow_methods=["*"], allow_headers=["*"], allow_credentials=True,
)
# health: 라우터 내부 prefix 없이 "/health" 로 정의 → prefix="/api" 등록 시 /api/health.
@health_router.get("/health")
def health():
return {"status": "ok"}
# 모든 라우터를 prefix="/api" 로 등록(라우터 내부 prefix 없음).
app.include_router(health_router, prefix="/api", tags=["health"])
app.include_router(people.router, prefix="/api", tags=["people"])
app.include_router(tree.router, prefix="/api", tags=["tree"])
app.include_router(tasks.router, prefix="/api", tags=["tasks"])
app.include_router(inbox.router, prefix="/api", tags=["inbox"])
app.include_router(dashboard.router, prefix="/api", tags=["dashboard"])
app.include_router(llm.router, prefix="/api", tags=["llm"])
```
> `GET /api/health` 도 동일 규약을 따른다 — 라우터에 `@router.get("/health")` 로 정의하고 `prefix="/api"` 로 등록한다(위 `health_router`). 모든 라우터가 내부 prefix 를 갖지 않으며 최종 경로는 main.py 의 `prefix="/api"` 에서만 결정된다.
공통 헬퍼(트리 빌드)는 `routers/tree.py` 또는 `services` 안의 작은 util에 둔다. 아래 각 라우터에 인라인으로 보여준다.
---
### 3.6 `routers/people.py`
```python
# backend/app/routers/people.py
from fastapi import APIRouter, Depends
from sqlmodel import Session, select
from ..db import get_session
from ..models import Person
from ..schemas import PersonOut
router = APIRouter() # prefix 없음. main.py 에서 prefix="/api" 등록.
@router.get("/people", response_model=list[PersonOut])
def list_people(s: Session = Depends(get_session)):
return s.exec(select(Person)).all()
```
**응답 예시** `GET /api/people`:
```json
[
{"id":"jiwoo","name":"지우","initial":"지","color":"var(--blue)","is_me":true},
{"id":"hyunwoo","name":"현우","initial":"현","color":"var(--violet)","is_me":false},
{"id":"minseo","name":"민서","initial":"민","color":"var(--green)","is_me":false},
{"id":"jaeho","name":"재호","initial":"재","color":"var(--coral)","is_me":false},
{"id":"sua","name":"수아","initial":"수","color":"oklch(0.66 0.13 200)","is_me":false}
]
```
---
### 3.7 `routers/tree.py` — 트리/폴더/프로젝트
`GET /api/tree` 는 folder[] → 각 folder.projects(최상위만, 내부 children 중첩) → 각 project.task_count(자신+하위 프로젝트의 작업 수 합).
```python
# backend/app/routers/tree.py
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from ..db import get_session
from ..models import Folder, Project, Task
from ..schemas import (FolderOut, ProjectNode, FolderCreate, FolderPatch,
ProjectCreate, ProjectPatch)
import uuid
router = APIRouter() # prefix 없음. main.py 에서 prefix="/api" 등록.
def _slug() -> str:
return "p-" + uuid.uuid4().hex[:8]
def build_tree(s: Session) -> list[FolderOut]:
folders = s.exec(select(Folder).order_by(Folder.sort_order)).all()
projects = s.exec(select(Project).order_by(Project.sort_order)).all()
# 작업 수: project_id 별 직접 작업 수
direct: dict[str, int] = {}
for t in s.exec(select(Task)).all():
direct[t.project_id] = direct.get(t.project_id, 0) + 1
by_parent: dict[str | None, list[Project]] = {}
for p in projects:
by_parent.setdefault(p.parent_id, []).append(p)
def count_recursive(pid: str) -> int:
total = direct.get(pid, 0)
for ch in by_parent.get(pid, []):
total += count_recursive(ch.id)
return total
def node(p: Project) -> ProjectNode:
kids = [node(c) for c in by_parent.get(p.id, [])]
return ProjectNode(
id=p.id, folder_id=p.folder_id, parent_id=p.parent_id, name=p.name,
tone=p.tone, sort_order=p.sort_order, pinned=p.pinned,
task_count=count_recursive(p.id), children=kids)
out = []
for f in folders:
roots = [node(p) for p in by_parent.get(None, []) if p.folder_id == f.id]
out.append(FolderOut(id=f.id, name=f.name, tone=f.tone, icon=f.icon,
sort_order=f.sort_order, is_system=f.is_system, projects=roots))
return out
@router.get("/tree", response_model=list[FolderOut])
def get_tree(s: Session = Depends(get_session)):
return build_tree(s)
@router.post("/folders", response_model=FolderOut)
def create_folder(body: FolderCreate, s: Session = Depends(get_session)):
fid = _slug()
mx = max([f.sort_order for f in s.exec(select(Folder)).all()] + [-1]) + 1
f = Folder(id=fid, name=body.name, tone=body.tone or "ink",
icon=body.icon or "folder", sort_order=mx, is_system=False)
s.add(f); s.commit()
return [x for x in build_tree(s) if x.id == fid][0]
@router.patch("/folders/{fid}", response_model=FolderOut)
def patch_folder(fid: str, body: FolderPatch, s: Session = Depends(get_session)):
f = s.get(Folder, fid)
if not f: raise HTTPException(404, "folder not found")
for k, v in body.model_dump(exclude_none=True).items():
setattr(f, k, v)
s.add(f); s.commit()
return [x for x in build_tree(s) if x.id == fid][0]
@router.delete("/folders/{fid}")
def delete_folder(fid: str, s: Session = Depends(get_session)):
f = s.get(Folder, fid)
if not f: raise HTTPException(404, "folder not found")
if f.is_system: raise HTTPException(400, "system folder cannot be deleted")
if s.exec(select(Project).where(Project.folder_id == fid)).first():
raise HTTPException(400, "folder has projects")
s.delete(f); s.commit()
return {"deleted": fid}
@router.post("/projects", response_model=ProjectNode)
def create_project(body: ProjectCreate, s: Session = Depends(get_session)):
if not s.get(Folder, body.folder_id):
raise HTTPException(404, "folder not found")
if body.parent_id and not s.get(Project, body.parent_id):
raise HTTPException(404, "parent project not found")
pid = _slug()
siblings = s.exec(select(Project).where(Project.parent_id == body.parent_id,
Project.folder_id == body.folder_id)).all()
order = max([p.sort_order for p in siblings] + [-1]) + 1
p = Project(id=pid, folder_id=body.folder_id, parent_id=body.parent_id,
name=body.name, tone=body.tone or "ink", sort_order=order)
s.add(p); s.commit(); s.refresh(p)
return ProjectNode(id=p.id, folder_id=p.folder_id, parent_id=p.parent_id, name=p.name,
tone=p.tone, sort_order=p.sort_order, pinned=p.pinned, task_count=0, children=[])
@router.patch("/projects/{pid}", response_model=ProjectNode)
def patch_project(pid: str, body: ProjectPatch, s: Session = Depends(get_session)):
p = s.get(Project, pid)
if not p: raise HTTPException(404, "project not found")
data = body.model_dump(exclude_none=True)
# parent_id 이동 시 순환(자기 자신/후손으로 이동) 금지
if "parent_id" in data and data["parent_id"]:
cur = s.get(Project, data["parent_id"])
while cur:
if cur.id == pid: raise HTTPException(400, "cannot move into own descendant")
cur = s.get(Project, cur.parent_id) if cur.parent_id else None
for k, v in data.items(): setattr(p, k, v)
s.add(p); s.commit(); s.refresh(p)
def find(nodes):
for n in nodes:
if n.id == pid: return n
r = find(n.children)
if r: return r
for f in build_tree(s):
r = find(f.projects)
if r: return r
raise HTTPException(500, "rebuild failed")
@router.delete("/projects/{pid}")
def delete_project(pid: str, s: Session = Depends(get_session)):
p = s.get(Project, pid)
if not p: raise HTTPException(404, "project not found")
if s.exec(select(Project).where(Project.parent_id == pid)).first():
raise HTTPException(400, "project has sub-projects")
if s.exec(select(Task).where(Task.project_id == pid)).first():
raise HTTPException(400, "project has tasks")
s.delete(p); s.commit()
return {"deleted": pid}
@router.post("/projects/{pid}/pin", response_model=ProjectNode)
def pin_project(pid: str, s: Session = Depends(get_session)):
p = s.get(Project, pid)
if not p: raise HTTPException(404, "project not found")
p.pinned = not p.pinned
s.add(p); s.commit(); s.refresh(p)
return ProjectNode(id=p.id, folder_id=p.folder_id, parent_id=p.parent_id, name=p.name,
tone=p.tone, sort_order=p.sort_order, pinned=p.pinned, task_count=0, children=[])
```
**응답 예시** `GET /api/tree`(축약):
```json
[
{
"id":"work","name":"업무","tone":"ink","icon":"folder","sort_order":0,"is_system":true,
"projects":[
{"id":"biz","folder_id":"work","parent_id":null,"name":"경영 전략","tone":"coral",
"sort_order":0,"pinned":false,"task_count":7,
"children":[
{"id":"biz-okr","folder_id":"work","parent_id":"biz","name":"2026 Q2 OKR","tone":"coral",
"sort_order":0,"pinned":false,"task_count":1,"children":[]},
{"id":"biz-report","folder_id":"work","parent_id":"biz","name":"분기 리포트","tone":"coral",
"sort_order":1,"pinned":false,"task_count":5,"children":[]},
{"id":"biz-budget","folder_id":"work","parent_id":"biz","name":"예산 관리","tone":"coral",
"sort_order":2,"pinned":false,"task_count":1,"children":[]}
]}
]
},
{"id":"life","name":"개인","tone":"blue","icon":"heart", "...":"..."}
]
```
---
### 3.8 `routers/tasks.py` — 작업 CRUD · 댓글 · 스캐폴드 · 리스크
`GET /api/tasks`**중첩 task 트리**를 반환(필터: `area=work|life`, `project_id`, `status`, `assignee`). 필터 우선순위 정본: `project_id` 가 지정되면 → **그 프로젝트(+하위 프로젝트)** 의 작업, 아니고 `area` 가 지정되면 → 그 영역(폴더)의 작업, 둘 다 없으면 → 전체. `status`/`assignee` 는 추가 필터로 함께 적용한다. 필터는 "최상위 작업 기준"으로 적용하고(부모가 통과하면 그 하위작업은 전부 포함), `area`는 작업의 project가 속한 폴더로 판정한다.
```python
# backend/app/routers/tasks.py
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import Session, select
from datetime import datetime, timezone
from ..db import get_session
from ..models import Task, TaskComment, Project, TaskStatus
from ..schemas import (TaskNode, TaskCreate, TaskPatch, CommentCreate, CommentOut,
ScaffoldRequest, ScaffoldOut, RiskOut)
from ..services.risk import compute_risks
from ..services.scaffold import pick_scaffold, scaffold_create
import uuid
router = APIRouter() # prefix 없음. main.py 에서 prefix="/api" 등록.
def _tid() -> str:
return "t-" + uuid.uuid4().hex[:8]
def folder_of_project(s: Session, project_id: str) -> str | None:
p = s.get(Project, project_id)
return p.folder_id if p else None
def project_subtree_ids(s: Session, root_id: str) -> set[str]:
"""root_id 와 그 모든 하위 프로젝트 id 집합."""
all_projects = s.exec(select(Project)).all()
by_parent: dict[str | None, list[Project]] = {}
for p in all_projects:
by_parent.setdefault(p.parent_id, []).append(p)
ids: set[str] = set()
def walk(pid: str):
ids.add(pid)
for ch in by_parent.get(pid, []):
walk(ch.id)
walk(root_id)
return ids
def to_node(s: Session, t: Task, all_tasks: list[Task]) -> TaskNode:
kids = sorted([x for x in all_tasks if x.parent_id == t.id], key=lambda x: x.sort_order)
comments = s.exec(select(TaskComment).where(TaskComment.task_id == t.id)
.order_by(TaskComment.created_at, TaskComment.id)).all()
return TaskNode(
id=t.id, project_id=t.project_id, parent_id=t.parent_id, title=t.title,
status=t.status, assignee_id=t.assignee_id, due=t.due, prio=t.prio,
notes=t.notes, est=t.est, delegated=t.delegated, sort_order=t.sort_order,
created_at=t.created_at, updated_at=t.updated_at,
comments=[CommentOut.model_validate(c, from_attributes=True) for c in comments],
children=[to_node(s, k, all_tasks) for k in kids])
@router.get("/tasks", response_model=list[TaskNode])
def list_tasks(area: str | None = None, project_id: str | None = None,
status: TaskStatus | None = None, assignee: str | None = None,
s: Session = Depends(get_session)):
all_tasks = s.exec(select(Task)).all()
roots = [t for t in all_tasks if t.parent_id is None]
# 필터 우선순위: project_id(+하위 프로젝트) > area > 전체. status/assignee 는 추가 필터.
if project_id:
scope = project_subtree_ids(s, project_id)
roots = [t for t in roots if t.project_id in scope]
elif area:
roots = [t for t in roots if folder_of_project(s, t.project_id) == area]
if status: roots = [t for t in roots if t.status == status]
if assignee: roots = [t for t in roots if t.assignee_id == assignee]
roots.sort(key=lambda t: t.sort_order)
return [to_node(s, t, all_tasks) for t in roots]
@router.get("/tasks/{tid}", response_model=TaskNode)
def get_task(tid: str, s: Session = Depends(get_session)):
t = s.get(Task, tid)
if not t: raise HTTPException(404, "task not found")
return to_node(s, t, s.exec(select(Task)).all())
@router.post("/tasks", response_model=TaskNode)
def create_task(body: TaskCreate, s: Session = Depends(get_session)):
if not s.get(Project, body.project_id):
raise HTTPException(404, "project not found")
if body.parent_id and not s.get(Task, body.parent_id):
raise HTTPException(404, "parent task not found")
siblings = s.exec(select(Task).where(Task.parent_id == body.parent_id,
Task.project_id == body.project_id)).all()
order = max([x.sort_order for x in siblings] + [-1]) + 1
t = Task(id=_tid(), title=body.title, project_id=body.project_id, parent_id=body.parent_id,
status=body.status or TaskStatus.todo, assignee_id=body.assignee_id,
due=body.due, prio=body.prio, notes=body.notes or "", est=body.est or "",
sort_order=order)
s.add(t); s.commit()
return to_node(s, t, s.exec(select(Task)).all())
@router.patch("/tasks/{tid}", response_model=TaskNode)
def patch_task(tid: str, body: TaskPatch, s: Session = Depends(get_session)):
t = s.get(Task, tid)
if not t: raise HTTPException(404, "task not found")
for k, v in body.model_dump(exclude_none=True).items():
setattr(t, k, v)
t.updated_at = datetime.now(timezone.utc)
s.add(t); s.commit()
return to_node(s, t, s.exec(select(Task)).all())
@router.delete("/tasks/{tid}")
def delete_task(tid: str, s: Session = Depends(get_session)):
t = s.get(Task, tid)
if not t: raise HTTPException(404, "task not found")
# 하위작업 재귀 삭제
all_tasks = s.exec(select(Task)).all()
def collect(pid):
ids = [pid]
for c in [x for x in all_tasks if x.parent_id == pid]:
ids += collect(c.id)
return ids
for did in collect(tid):
for c in s.exec(select(TaskComment).where(TaskComment.task_id == did)).all():
s.delete(c)
d = s.get(Task, did); s.delete(d)
s.commit()
return {"deleted": tid}
@router.post("/tasks/{tid}/comments", response_model=CommentOut)
def add_comment(tid: str, body: CommentCreate, s: Session = Depends(get_session)):
if not s.get(Task, tid): raise HTTPException(404, "task not found")
c = TaskComment(id="c-" + uuid.uuid4().hex[:8], task_id=tid,
person_id=body.person_id, text=body.text)
s.add(c); s.commit(); s.refresh(c)
return CommentOut.model_validate(c, from_attributes=True)
@router.post("/tasks/{tid}/scaffold", response_model=ScaffoldOut)
def scaffold(tid: str, body: ScaffoldRequest, s: Session = Depends(get_session)):
t = s.get(Task, tid)
if not t: raise HTTPException(404, "task not found")
tpl = pick_scaffold(t.title, use_llm=body.use_llm)
if body.create:
ids = scaffold_create(s, t, tpl)
return ScaffoldOut(kind=tpl["kind"], icon=tpl["icon"], items=tpl["items"],
created=True, created_task_ids=ids)
return ScaffoldOut(kind=tpl["kind"], icon=tpl["icon"], items=tpl["items"], created=False)
@router.get("/risks", response_model=list[RiskOut])
def risks(area: str = "work", s: Session = Depends(get_session)):
# area 기본값 work. 주어진 area 로 리스크 계산.
return compute_risks(s, area=area)
```
> **RiskRadar 스코프(정본)**: 백엔드 `GET /api/risks?area=work`(기본 work)는 주어진 `area` 로 계산한다. 프론트(phase-3)는 `area==='work'` 일 때만 RiskRadar 를 표시한다(원본: 리스크 레이더는 업무 스코프에서만). 즉 개인(life) 탭에서는 RiskRadar 를 렌더하지 않는다.
**요청/응답 예시**
`GET /api/tasks?area=work&status=doing`(축약):
```json
[
{"id":"k1","project_id":"biz-report","parent_id":null,"title":"분기 리포트 초안 마무리",
"status":"doing","assignee_id":"jiwoo","due":"2026-06-08","prio":"높음",
"notes":"<h3>목표</h3>...","est":"","delegated":false,"sort_order":0,
"comments":[
{"id":"c1","task_id":"k1","person_id":"hyunwoo","text":"매출 섹션은 제가 오늘 안으로 넘겨드릴게요.","created_at":"..."},
{"id":"c2","task_id":"k1","person_id":"jiwoo","text":"좋아요, 예산 섹션만 확정되면 취합할게요.","created_at":"..."}],
"children":[ { "title":"매출 섹션 작성","status":"doing", "...":"..." } ]}
]
```
`PATCH /api/tasks/k1` 본문 `{"status":"review"}` → 갱신된 `TaskNode` 반환(칸반 컬럼 이동).
`POST /api/tasks/k6/scaffold` 본문 `{"create":false,"use_llm":false}` → "OKR 중간 점검 자료 준비"는 `pickScaffold` 정규식 `리포트|문서|보고|자료|작성|초안|회고|정산` 중 "자료/작성"에 매칭되어 **doc 템플릿**(응답 `ScaffoldOut`: `{kind, icon, items, created, created_task_ids}`):
```json
{"kind":"문서 · 리포트","icon":"file",
"items":[
{"title":"목차 & 범위 정의","est":"30분"},
{"title":"핵심 데이터 수집","est":"2시간"},
{"title":"섹션별 초안 작성","est":"3시간"},
{"title":"리뷰 & 수정","est":"1시간"},
{"title":"최종 정리 & 공유","est":"30분"}],
"created":false,"created_task_ids":[]}
```
---
### 3.9 `services/risk.py` — 리스크 레이더 (tasks-risk.jsx 이식)
원본 `computeRisks(tasks)` 로직을 파이썬으로 1:1 이식. 차이점: ① 강조어는 `**굵게**` 마크다운으로 감싼다(phase-3 의 Bolded 파서가 `** **``<b>` 로 렌더). ② `dueD``date.day`로(시드는 6월 한 달 안이므로 `due.day` 사용), ③ TODAY는 `settings.risk_today`(=8). 트리는 **모든 노드(최상위+하위작업)** 를 펼쳐서 계산(원본 `walkAll`). 관련 task 식별 필드는 `task_id`(NOT `id`).
원본 규칙 그대로:
- **① 지연 위험**: `status != done && due && due.day <= TODAY` 인 작업 중 첫 1건. 원본은 최상위 `tasks` 배열만 `.filter`(하위작업 X). → 이식도 **최상위 작업 기준**으로 1건. 텍스트: `**<title>** — 오늘(6/<day>) 마감인데 아직 <상태라벨>이에요`(제목을 `**` 로 강조). `statusLabel = {todo:"시작 전", doing:"진행 중", waiting:"대기 중", review:"검토 중"}`.
- **② 업무 쏠림**: `walkAll`**전체 노드**에서 `status != done && assignee` 카운트. people에 있는 담당자만. entries>1 일 때 정렬 후 top. `topN >= avg*1.5 && topN >= 4` 면 위험. `me`(지우)면 "내게 몰려…" 문구, 아니면 "<이름>님에게 …". 배율 = `round(topN/avg, 1)`. 강조 수치는 `**` 로 감싼다.
- **③ 의존성**: `DEPS` 두 쌍을 제목으로 찾아, 둘 다 `!done` 이면 1건(가장 임박한 1건, break). 텍스트: `**<blocker>**이(가) 늦어지면 **<blocked>**(6/<day>)까지 함께 밀려요`(제목을 `**` 로 강조).
- 최종 `risks[:3]`.
```python
# backend/app/services/risk.py
from sqlmodel import Session, select
from ..config import get_settings
from ..models import Task, Person, Project
from ..schemas import RiskOut
STATUS_LABEL = {"todo": "시작 전", "doing": "진행 중", "waiting": "대기 중", "review": "검토 중"}
DEPS = [
{"blocker": "예산 섹션 작성", "blocked": "경영진 검토 요청 메일"},
{"blocker": "데이터 전처리 파이프라인", "blocked": "사용자 인터뷰 5건 정리"},
]
def _due_day(t: Task) -> int:
return t.due.day if t.due else 99
def _due_txt(t: Task) -> str:
return f"6/{t.due.day}" if t.due else ""
def compute_risks(s: Session, area: str = "work") -> list[RiskOut]:
today = get_settings().risk_today # 8
all_tasks = s.exec(select(Task)).all()
people = {p.id: p for p in s.exec(select(Person)).all()}
# area 필터: 작업이 속한 project 의 folder 로 판정
proj_folder = {p.id: p.folder_id for p in s.exec(select(Project)).all()}
def in_area(t: Task) -> bool:
return proj_folder.get(t.project_id) == area
scoped = [t for t in all_tasks if in_area(t)]
roots = [t for t in scoped if t.parent_id is None]
risks: list[RiskOut] = []
# ① 지연 위험 (최상위 작업, 첫 1건)
late = [t for t in sorted(roots, key=lambda x: x.sort_order)
if t.status != "done" and t.due and _due_day(t) <= today]
if late:
t = late[0]
risks.append(RiskOut(
kind="지연 위험", icon="clock", tone="coral", task_id=t.id, cta="작업 열기",
text=f"**{t.title}** — 오늘({_due_txt(t)}) 마감인데 아직 {STATUS_LABEL.get(t.status,'')}이에요"))
# ② 업무 쏠림 (전체 노드 카운트)
counts: dict[str, int] = {}
for n in scoped:
if n.status != "done" and n.assignee_id:
counts[n.assignee_id] = counts.get(n.assignee_id, 0) + 1
entries = [(w, c) for w, c in counts.items() if w in people]
if len(entries) > 1:
entries.sort(key=lambda x: -x[1])
top_who, top_n = entries[0]
avg = sum(c for _, c in entries) / len(entries)
if top_n >= avg * 1.5 and top_n >= 4:
mult = round(top_n / avg, 1)
p = people[top_who]
if p.is_me:
text = (f"미완료 작업 **{top_n}건**이 내게 몰려 있어요 — 팀 평균의 **{mult}배**. "
f"벅찬 작업은 위임 & 추적으로 넘겨보세요")
else:
text = (f"**{p.name}님**에게 미완료 작업 **{top_n}건**이 몰려 있어요 — "
f"평균의 **{mult}배**, 일부 재배분을 추천드려요")
risks.append(RiskOut(kind="업무 쏠림", icon="scale", tone="amber", text=text))
# ③ 의존성 (가장 임박한 1건)
def find_by_title(title: str) -> Task | None:
for n in scoped:
if n.title == title:
return n
return None
for d in DEPS:
a, b = find_by_title(d["blocker"]), find_by_title(d["blocked"])
if a and b and a.status != "done" and b.status != "done":
suffix = f"({_due_txt(b)})" if b.due else ""
risks.append(RiskOut(
kind="의존성", icon="link", tone="violet", task_id=b.id, cta="후속 작업 보기",
text=f"**{d['blocker']}**이(가) 늦어지면 **{d['blocked']}**{suffix}까지 함께 밀려요"))
break
return risks[:3]
```
**시드 기준 기대값(area=work)**: 자세한 검증은 §7 테스트 참조. 요약:
- ① 지연: 최상위 work 작업 중 `due.day<=8 && !done``k1`(분기 리포트, due 6/8, doing), `k13`(스프린트 회고, due 6/8, review)이 후보. `sort_order` 순서상 첫 통과 작업 1건.
- ② 쏠림: 전체 미완료 work 노드를 담당자별로 카운트하여 `topN>=avg*1.5 && topN>=4` 충족 시 1건.
- ③ 의존성: `예산 섹션 작성`(todo)·`경영진 검토 요청 메일`(todo) 둘 다 미완 → 1건(`k1`의 하위작업이 blocked b의 후보는 `경영진 검토 요청 메일`, blocker는 `예산 섹션 작성`). 두 번째 쌍보다 우선.
---
### 3.10 `services/scaffold.py` — pickScaffold 이식 + LLM 보강
원본 `scaffolds`(research/dev/doc/generic)와 `pickScaffold` 정규식을 그대로 옮긴다.
```python
# backend/app/services/scaffold.py
import re
import uuid
from sqlmodel import Session, select
from ..models import Task, TaskStatus
SCAFFOLDS = {
"research": {"kind": "연구 · 논문 작성", "icon": "brain", "items": [
{"title": "선행 연구 조사", "est": "1일"},
{"title": "실험 데이터 취합", "est": "1일"},
{"title": "아키텍처 다이어그램 작성", "est": "반나절"},
{"title": "서론 초안", "est": "반나절"},
{"title": "결론 작성", "est": "반나절"}]},
"dev": {"kind": "개발 업무", "icon": "branch", "items": [
{"title": "API 엔드포인트 설계", "est": "2시간"},
{"title": "UI 폼 컴포넌트 개발", "est": "3시간"},
{"title": "토큰 저장소 세팅", "est": "1.5시간"},
{"title": "테스트 코드 작성", "est": "2시간"}]},
"doc": {"kind": "문서 · 리포트", "icon": "file", "items": [
{"title": "목차 & 범위 정의", "est": "30분"},
{"title": "핵심 데이터 수집", "est": "2시간"},
{"title": "섹션별 초안 작성", "est": "3시간"},
{"title": "리뷰 & 수정", "est": "1시간"},
{"title": "최종 정리 & 공유", "est": "30분"}]},
"generic": {"kind": "일반 업무", "icon": "list", "items": [
{"title": "범위 정의 & 자료 수집", "est": "1시간"},
{"title": "초안 작성", "est": "2시간"},
{"title": "리뷰 & 피드백 반영", "est": "1시간"},
{"title": "최종 정리 & 공유", "est": "30분"}]},
}
# 원본 pickScaffold 정규식 (순서 중요: research > dev > doc > generic)
RE_RESEARCH = re.compile(r"(논문|연구|리서치|실험|학회|발표|조사|인터뷰)")
RE_DEV = re.compile(r"(구현|개발|API|로직|컴포넌트|버그|배포|인증|앱)")
RE_DOC = re.compile(r"(리포트|문서|보고|자료|작성|초안|회고|정산)")
def pick_scaffold(title: str, use_llm: bool = False) -> dict:
t = title or ""
if RE_RESEARCH.search(t): base = SCAFFOLDS["research"]
elif RE_DEV.search(t): base = SCAFFOLDS["dev"]
elif RE_DOC.search(t): base = SCAFFOLDS["doc"]
else: base = SCAFFOLDS["generic"]
if use_llm:
try:
from ..llm.provider import get_provider
enriched = get_provider().generate_json(
_scaffold_prompt(title, base), schema={"items": "list"})
items = enriched.get("items")
if isinstance(items, list) and items:
norm = [{"title": str(i.get("title", "")).strip(),
"est": str(i.get("est", "")).strip()}
for i in items if i.get("title")]
if norm:
return {"kind": base["kind"], "icon": base["icon"], "items": norm}
except Exception:
pass # 폴백: 정규식 템플릿 그대로
return base
def scaffold_create(s: Session, parent: Task, tpl: dict) -> list[str]:
existing = s.exec(select(Task).where(Task.parent_id == parent.id)).all()
order = max([x.sort_order for x in existing] + [-1]) + 1
ids = []
for i, it in enumerate(tpl["items"]):
tid = "t-" + uuid.uuid4().hex[:8]
s.add(Task(id=tid, title=it["title"], project_id=parent.project_id,
parent_id=parent.id, status=TaskStatus.todo,
assignee_id=parent.assignee_id, prio=parent.prio,
est=it.get("est", ""), sort_order=order + i))
ids.append(tid)
s.commit()
return ids
def _scaffold_prompt(title: str, base: dict) -> str:
return (
"다음 작업을 실행 가능한 하위작업으로 4~6개 쪼개세요. "
"각 항목은 {title, est(예상 소요)} 형태의 JSON. 한국어.\n"
f"작업: {title}\n"
f"참고 템플릿({base['kind']}): {[i['title'] for i in base['items']]}\n"
'반드시 {"items":[{"title":"...","est":"..."}]} 형식 JSON만 출력.'
)
```
---
### 3.11 LLM 추상화 — `llm/provider.py`, `ollama.py`, `heuristic.py`, `prompts.py`
CONTRACT: 인터페이스 `LLMProvider { health(), generate_json(prompt, schema)->dict, classify_capture(raw, context)->Classification }`. **모델 비종속** — `OLLAMA_MODEL` 주입. `LLM_PROVIDER=auto` 면 Ollama가 reachable이면 Ollama, 아니면 Heuristic.
```python
# backend/app/llm/provider.py
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from ..config import get_settings
@dataclass
class Classification:
type: str # task | event | idea
sphere: str # work | life
project_id: str | None = None
proj_label: str = ""
tone: str = "ink"
due_text: str = ""
when_text: str = ""
extra: str = ""
reason: str = "" # 한국어
confidence: float = 0.0
model: str = ""
class LLMProvider(ABC):
name: str = "base"
@abstractmethod
def health(self) -> dict: ...
@abstractmethod
def generate_json(self, prompt: str, schema: dict | None = None) -> dict: ...
@abstractmethod
def classify_capture(self, raw: str, context: dict) -> Classification: ...
_cached: LLMProvider | None = None
def get_provider(force: str | None = None) -> LLMProvider:
"""LLM_PROVIDER=auto|ollama|heuristic. auto면 ollama health 확인 후 폴백."""
global _cached
from .ollama import OllamaProvider
from .heuristic import HeuristicProvider
mode = force or get_settings().llm_provider
if mode == "heuristic":
return HeuristicProvider()
if mode == "ollama":
return OllamaProvider()
# auto
ollama = OllamaProvider()
if ollama.health().get("reachable"):
return ollama
return HeuristicProvider()
def reset_provider_cache():
global _cached
_cached = None
```
```python
# backend/app/llm/ollama.py
import json
import httpx
from ..config import get_settings
from .provider import LLMProvider, Classification
from .prompts import CLASSIFY_SYSTEM, build_classify_prompt
from .heuristic import HeuristicProvider
class OllamaProvider(LLMProvider):
name = "ollama"
def __init__(self):
st = get_settings()
self.host = st.ollama_host
self.model = st.ollama_model # 비종속: env 주입값 그대로 사용
self.timeout = st.llm_timeout
def health(self) -> dict:
try:
r = httpx.get(f"{self.host}/api/tags", timeout=3.0)
ok = r.status_code == 200
tags = [m.get("name") for m in r.json().get("models", [])] if ok else []
return {"reachable": ok, "provider": "ollama", "model": self.model,
"host": self.host, "detail": f"models={tags}"}
except Exception as e:
return {"reachable": False, "provider": "ollama", "model": self.model,
"host": self.host, "detail": str(e)}
def generate_json(self, prompt: str, schema: dict | None = None) -> dict:
# Ollama 구조화 출력: format="json" (모델 비종속). chat API 사용.
payload = {
"model": self.model,
"messages": [{"role": "user", "content": prompt}],
"stream": False,
"format": "json",
"options": {"temperature": 0.2},
}
r = httpx.post(f"{self.host}/api/chat", json=payload, timeout=self.timeout)
r.raise_for_status()
content = r.json()["message"]["content"]
return json.loads(content)
def classify_capture(self, raw: str, context: dict) -> Classification:
prompt = build_classify_prompt(raw, context)
try:
payload = {
"model": self.model,
"messages": [
{"role": "system", "content": CLASSIFY_SYSTEM},
{"role": "user", "content": prompt}],
"stream": False, "format": "json",
"options": {"temperature": 0.2},
}
r = httpx.post(f"{self.host}/api/chat", json=payload, timeout=self.timeout)
r.raise_for_status()
data = json.loads(r.json()["message"]["content"])
return _coerce(data, raw, context, model=f"ollama:{self.model}")
except Exception:
# 모델 미가용/파싱 실패 → 규칙 폴백 (오프라인에서도 동작)
return HeuristicProvider().classify_capture(raw, context)
def _coerce(data: dict, raw: str, context: dict, model: str) -> Classification:
"""LLM JSON을 Classification으로. 누락 필드는 휴리스틱/기본값 보강."""
from .heuristic import HeuristicProvider
h = HeuristicProvider().classify_capture(raw, context) # 폴백 베이스
t = data.get("type") if data.get("type") in ("task", "event", "idea") else h.type
sp = data.get("sphere") if data.get("sphere") in ("work", "life") else h.sphere
return Classification(
type=t, sphere=sp,
project_id=data.get("project_id") or h.project_id,
proj_label=data.get("proj_label") or h.proj_label,
tone=data.get("tone") or h.tone,
due_text=data.get("due_text", h.due_text),
when_text=data.get("when_text", h.when_text),
extra=data.get("extra", h.extra),
reason=data.get("reason") or h.reason,
confidence=float(data.get("confidence", 0.85)),
model=model)
```
```python
# backend/app/llm/heuristic.py
import re
from .provider import LLMProvider, Classification
# 시간 표현 → event (요일/시각/날짜)
RE_TIME = re.compile(
r"(\d{1,2}\s*시|\d{1,2}:\d{2}|오전|오후|"
r"월요일|화요일|수요일|목요일|금요일|토요일|일요일|"
r"월요|화요|수요|목요|금요|토요|일요|내일|모레|오늘)")
# 행동 동사 + 기한 → task
RE_ACTION = re.compile(r"(사기|구매|하기|맡기기|알아보기|보내기|작성|정리|예약|받|비교|확인|회신|준비)")
# work / life 키워드
RE_WORK = re.compile(r"(미팅|회의|리포트|온보딩|OKR|리뷰|배포|스프린트|기획|디자인|개발|보고|발표)")
RE_LIFE = re.compile(r"(가족|여행|선물|구독|병원|생신|자전거|집|수영장|운동|독서|엄마|아빠|티켓)")
WEEKDAY = {"월": "월", "화": "화", "수": "수", "목": "목", "금": "금", "토": "토", "일": "일"}
class HeuristicProvider(LLMProvider):
name = "heuristic"
def health(self) -> dict:
return {"reachable": True, "provider": "heuristic", "model": "rules", "detail": "rule-based fallback"}
def generate_json(self, prompt: str, schema: dict | None = None) -> dict:
return {} # 폴백 시 scaffold 등은 호출측이 템플릿 사용
def classify_capture(self, raw: str, context: dict) -> Classification:
text = raw or ""
# 1) type: 시간명시 → event, 행동+기한 → task, 막연 → idea
has_time = bool(RE_TIME.search(text))
has_clock = bool(re.search(r"(\d{1,2}\s*시|\d{1,2}:\d{2})", text))
has_action = bool(RE_ACTION.search(text))
if has_clock:
ctype = "event"
elif has_action:
ctype = "task"
elif has_time and not has_action:
ctype = "event"
else:
ctype = "idea"
# 2) sphere
if RE_WORK.search(text):
sphere = "work"
elif RE_LIFE.search(text):
sphere = "life"
else:
sphere = "work" # 기본 업무
# 3) project 매칭 (context["projects"]: [{id,name,folder_id}])
project_id, proj_label, tone = self._match_project(text, sphere, context)
# 4) due/when/extra/reason
due_text, when_text = self._time_hints(text, ctype)
extra = "가격 추적 알림 켜둠" if re.search(r"(티켓|구매|가격)", text) else ""
reason = self._reason(ctype, sphere, proj_label)
conf = 0.85 if has_clock or has_action else 0.6
return Classification(type=ctype, sphere=sphere, project_id=project_id,
proj_label=proj_label, tone=tone, due_text=due_text,
when_text=when_text, extra=extra, reason=reason,
confidence=conf, model="heuristic")
def _match_project(self, text, sphere, context):
projects = context.get("projects", [])
# 키워드 → 알려진 프로젝트
rules = [
(r"(여행|티켓|비행기|한국)", "life-trip", "개인 여행 — 한국", "coral"),
(r"(엄마|아빠|가족|생신|부모)", "life-fam", "가족", "green"),
(r"(온보딩)", "onb", "온보딩 리디자인 · 아이디어 보드", "violet"),
(r"(자전거|수리|병원|예약)", None, "개인 캘린더", "blue"),
]
for pat, pid, label, tone in rules:
if re.search(pat, text):
return pid, label, tone
# 없으면 sphere 기본
if sphere == "life":
return "me", "개인 일상", "blue"
return None, "업무 받은 작업", "ink"
def _time_hints(self, text, ctype):
m = re.search(r"(월|화|수|목|금|토|일)요일?\s*(\d{1,2})\s*시", text)
if m:
return "", f"{m.group(1)} {int(m.group(2)):02d}:00"
if "다음 주" in text:
return "출발 전 · ~6/14", "오늘 21:00 빈 시간 추천"
return "", ""
def _reason(self, ctype, sphere, proj_label):
sph = "업무" if sphere == "work" else "개인"
if ctype == "event":
return "시간이 정해진 일은 작업이 아니라 일정으로 바로 등록해요."
if ctype == "task":
return (f"행동이 있으니 '작업' 맞아요. {sph} 트리의 '{proj_label}'에 넣었어요 — "
"따로 섹션이 생기는 게 아니라 다른 작업과 똑같이 보여요.")
return "아직 행동이 정해지지 않아 아이디어 보드에 보관했어요."
```
```python
# backend/app/llm/prompts.py
CLASSIFY_SYSTEM = (
"너는 한국어 개인비서 '아리'의 분류 엔진이다. 사용자가 적은 짧은 메모를 "
"task(작업)/event(일정)/idea(아이디어) 중 하나로 분류하고, work/life sphere를 정한다. "
"원칙: 시간이 명시되면 event, 행동(동사)+기한이면 task, 막연/미정이면 idea. "
"개인 일을 숨기지 않는다 — life도 work와 같은 트리에서 프로젝트로 다룬다. "
"reason은 사람이 읽는 친근한 한국어 1~2문장으로."
)
def build_classify_prompt(raw: str, context: dict) -> str:
projects = context.get("projects", [])
proj_lines = "\n".join(
f"- {p['id']} / {p['name']} ({p.get('folder_id')})" for p in projects)
return (
f"메모: \"{raw}\"\n\n"
f"기존 프로젝트 트리:\n{proj_lines}\n\n"
"위 메모를 분류해 아래 JSON 스키마로만 답하라:\n"
"{\n"
' "type": "task|event|idea",\n'
' "sphere": "work|life",\n'
' "project_id": "기존 프로젝트 id 또는 null",\n'
' "proj_label": "사람이 읽는 경로 (예: 개인 여행 — 한국)",\n'
' "tone": "blue|violet|coral|green|amber|ink|faint",\n'
' "due_text": "기한 표현 또는 빈칸",\n'
' "when_text": "추천 시점 또는 빈칸",\n'
' "extra": "부가 액션 또는 빈칸",\n'
' "reason": "한국어 1~2문장",\n'
' "confidence": 0.0~1.0\n'
"}\n"
"기존 프로젝트와 맞지 않으면 적절한 folder 아래 새 프로젝트를 proj_label로 제안하고 project_id는 null로 둬라."
)
```
```python
# backend/app/llm/__init__.py 는 비어있어도 됨
```
```python
# backend/app/routers/llm.py
from fastapi import APIRouter, Depends
from ..llm.provider import LLMProvider, get_provider
from ..schemas import LLMHealth
router = APIRouter() # prefix 없음. main.py 에서 prefix="/api" 등록.
@router.get("/llm/health", response_model=LLMHealth)
def llm_health(provider: LLMProvider = Depends(get_provider)):
h = provider.health()
return LLMHealth(reachable=h.get("reachable", False), provider=h.get("provider", ""),
model=h.get("model", ""), host=h.get("host"), detail=h.get("detail", ""))
```
> **모델 비종속 강조**: 어디에도 모델명을 하드코딩하지 않는다. `ollama.py`는 `settings.ollama_model`(env `OLLAMA_MODEL`)만 사용한다. 테스트/오프라인에서는 `LLM_PROVIDER=heuristic` 또는 provider override(§7)로 강제한다. 문서에서 모델을 예로 들 때는 "설치된 모델 사용(예: 기본값 `OLLAMA_MODEL`)".
---
### 3.12 `services/classification.py` — 캡처 분류 오케스트레이션
```python
# backend/app/services/classification.py
import uuid
from sqlmodel import Session, select
from ..models import Project, Folder, InboxClassification
from ..llm.provider import LLMProvider, Classification
def _context(s: Session) -> dict:
projects = s.exec(select(Project)).all()
return {"projects": [{"id": p.id, "name": p.name, "folder_id": p.folder_id}
for p in projects]}
def classify(s: Session, raw: str, provider: LLMProvider,
force_type: str | None = None) -> Classification:
# provider 는 라우터에서 Depends(get_provider) 로 주입받아 전달한다(auto: ollama→heuristic).
c = provider.classify_capture(raw, _context(s))
if force_type and force_type in ("task", "event", "idea"):
c.type = force_type # 사용자 강제 타입
c.reason = f"사용자가 '{force_type}'(으)로 지정했어요. " + c.reason
return c
def persist_classification(s: Session, inbox_item_id: str, c: Classification) -> InboxClassification:
row = InboxClassification(
id="cls-" + uuid.uuid4().hex[:8], # TEXT PK
inbox_item_id=inbox_item_id, type=c.type, sphere=c.sphere,
project_id=c.project_id, proj_label=c.proj_label, tone=c.tone,
due_text=c.due_text, when_text=c.when_text, extra=c.extra,
reason=c.reason, confidence=c.confidence, model=c.model)
s.add(row); s.commit(); s.refresh(row)
return row
```
---
### 3.13 `routers/inbox.py` — capture → classify → confirm (federation)
`confirm` 이 핵심: 분류 결과를 **실제 task로 실체화**하고 `materialized_task_id` 연결. MVP에서 `type=event`/`idea`는 task로 만들지 않고 상태만 `confirmed`로 둔다(일정/아이디어 페이지는 MVP 외). 단 `type=task`는 반드시 task 생성.
confirm 으로 생성되는 task 의 필드 정본(R10): `status='todo'`, `prio='보통'`, `due=None`(분류의 `due_text`는 classification 에만 보존, MVP 에선 날짜 파싱 안 함), `assignee_id='jiwoo'`, `project_id = classification.project_id`(예 `'life-trip'`). `notes = "스마트 인박스에서 실체화된 작업이에요."` + (`extra` 가 있으면 `" " + extra`). 비행기 티켓의 경우 `extra="가격 추적 알림 켜둠"` → notes 에 포함된다.
```python
# backend/app/routers/inbox.py
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from datetime import datetime, timezone
from ..db import get_session
from ..models import (InboxItem, InboxClassification, Task, Project,
InboxKind, InboxStatus, TaskStatus, Prio)
from ..schemas import (InboxItemOut, ClassificationOut, CaptureRequest, CaptureResponse,
ReclassifyRequest, ConfirmResponse, TaskNode)
from ..services.classification import classify, persist_classification
from ..llm.provider import LLMProvider, get_provider
from ..routers.tasks import to_node
import uuid
router = APIRouter() # prefix 없음. main.py 에서 prefix="/api" 등록.
def latest_cls(s: Session, item_id: str) -> InboxClassification | None:
# id 가 TEXT PK(UUID)라 정렬 키로 부적합 → created_at 기준 최신.
rows = s.exec(select(InboxClassification)
.where(InboxClassification.inbox_item_id == item_id)
.order_by(InboxClassification.created_at.desc())).all()
return rows[0] if rows else None
def item_out(s: Session, item: InboxItem) -> InboxItemOut:
c = latest_cls(s, item.id)
cls = ClassificationOut.model_validate(c, from_attributes=True) if c else None
return InboxItemOut(id=item.id, kind=item.kind, raw=item.raw, status=item.status,
created_at=item.created_at,
materialized_task_id=item.materialized_task_id, classification=cls)
@router.get("/inbox", response_model=list[InboxItemOut])
def list_inbox(s: Session = Depends(get_session)):
items = s.exec(select(InboxItem).order_by(InboxItem.created_at.desc())).all()
return [item_out(s, it) for it in items]
@router.post("/inbox/capture", response_model=CaptureResponse)
def capture(body: CaptureRequest, s: Session = Depends(get_session),
provider: LLMProvider = Depends(get_provider)):
iid = "s-" + uuid.uuid4().hex[:8]
item = InboxItem(id=iid, kind=body.kind, raw=body.raw, status=InboxStatus.new)
s.add(item); s.commit(); s.refresh(item)
c = classify(s, body.raw, provider) # 동기 분류 (LLM or 폴백)
row = persist_classification(s, iid, c)
item.status = InboxStatus.classified
s.add(item); s.commit()
return CaptureResponse(item=item_out(s, item),
classification=ClassificationOut.model_validate(row, from_attributes=True))
@router.post("/inbox/{iid}/reclassify", response_model=ClassificationOut)
def reclassify(iid: str, body: ReclassifyRequest, s: Session = Depends(get_session),
provider: LLMProvider = Depends(get_provider)):
item = s.get(InboxItem, iid)
if not item: raise HTTPException(404, "inbox item not found")
c = classify(s, item.raw, provider, force_type=body.type.value if body.type else None)
row = persist_classification(s, iid, c) # 새 row → latest 가 최신
item.status = InboxStatus.classified
s.add(item); s.commit()
return ClassificationOut.model_validate(row, from_attributes=True)
@router.post("/inbox/{iid}/confirm", response_model=ConfirmResponse)
def confirm(iid: str, s: Session = Depends(get_session)):
item = s.get(InboxItem, iid)
if not item: raise HTTPException(404, "inbox item not found")
c = latest_cls(s, iid)
if not c: raise HTTPException(400, "no classification to confirm")
created: Task | None = None
if c.type == "task":
# 프로젝트 결정: classification.project_id 있으면 그대로(예: 'life-trip'), 없으면 sphere 기본
pid = c.project_id
if not pid or not s.get(Project, pid):
pid = "me" if c.sphere == "life" else _fallback_work_project(s)
# confirm task 필드 정본(R10): status=todo, prio=보통, due=None(MVP 날짜 파싱 안 함),
# assignee=jiwoo, project_id=classification.project_id.
# notes = "스마트 인박스에서 실체화된 작업이에요." + (extra 있으면 " " + extra)
notes = "스마트 인박스에서 실체화된 작업이에요."
if c.extra:
notes += " " + c.extra # 예: 비행기 티켓 → "가격 추적 알림 켜둠"
tid = "t-" + uuid.uuid4().hex[:8]
created = Task(id=tid, title=item.raw, project_id=pid, status=TaskStatus.todo,
assignee_id="jiwoo", prio=Prio.normal, due=None, notes=notes)
s.add(created); s.commit(); s.refresh(created)
item.materialized_task_id = tid
item.status = InboxStatus.confirmed
s.add(item); s.commit()
node = to_node(s, created, s.exec(select(Task)).all()) if created else None
return ConfirmResponse(item=item_out(s, item), task=node)
@router.post("/inbox/{iid}/dismiss", response_model=InboxItemOut)
def dismiss(iid: str, s: Session = Depends(get_session)):
item = s.get(InboxItem, iid)
if not item: raise HTTPException(404, "inbox item not found")
item.status = InboxStatus.dismissed
s.add(item); s.commit()
return item_out(s, item)
def _fallback_work_project(s: Session) -> str:
p = s.exec(select(Project).where(Project.folder_id == "work")).first()
return p.id if p else "me"
```
**요청/응답 예시**
`POST /api/inbox/capture` 본문:
```json
{"kind":"text","raw":"다음 주에 한국 놀러가는 비행기 티켓 사기"}
```
응답(휴리스틱 폴백 기준):
```json
{
"item":{"id":"s-1a2b3c4d","kind":"text","raw":"다음 주에 한국 놀러가는 비행기 티켓 사기",
"status":"classified","created_at":"...","materialized_task_id":null,
"classification":{ "...": "..." }},
"classification":{
"id":"cls-7a8b9c0d","inbox_item_id":"s-1a2b3c4d","type":"task","sphere":"life",
"project_id":"life-trip","proj_label":"개인 여행 — 한국","tone":"coral",
"due_text":"출발 전 · ~6/14","when_text":"오늘 21:00 빈 시간 추천","extra":"가격 추적 알림 켜둠",
"reason":"행동이 있으니 '작업' 맞아요. 개인 트리의 '개인 여행 — 한국'에 넣었어요 — ...",
"confidence":0.85,"model":"heuristic"}
}
```
`POST /api/inbox/s-1a2b3c4d/confirm``materialized_task_id` 채워지고 새 task 노드 반환:
```json
{"item":{"id":"s-1a2b3c4d","status":"confirmed","materialized_task_id":"t-9f8e7d6c", "...":"..."},
"task":{"id":"t-9f8e7d6c","title":"다음 주에 한국 놀러가는 비행기 티켓 사기",
"project_id":"life-trip","status":"todo","assignee_id":"jiwoo","prio":"보통","...":"..."}}
```
---
### 3.14 `routers/dashboard.py`
`GET /api/dashboard` 는 여러 테이블을 집계하여 **단일 정본 형태**(R1)로 응답한다. 최상위 `user`(`name`/`initial`), `briefing.today`(히어로 날짜 라벨, 예 `"6월 7일 일요일"`), `briefing.weather`(중첩 `{temp,cond,icon}`, `icon``cloudSun` 저장 → 표시용 `sun` 매핑), `saved_today`/`today_routed`(briefing 행에서), `task_summary={open_count, items[]}`, `goals[].tone`(키), `approvals_summary`(**high-risk 만, 최대 3건**, 필드 `{id,icon,tone,title,time}`), `inbox_recent`(최근 3, **평탄화** 형태 `{id,kind,raw,type,proj_label,tone}` — 각 항목 최신 classification 에서 type/proj_label/tone 을 끌어와 채운다). badges: `appr`=high risk approval 수, `task`=미완료 작업 수, `noti`=6(시드 상수, 상단 알림 배지).
```python
# backend/app/routers/dashboard.py
from fastapi import APIRouter, Depends
from sqlmodel import Session, select
from ..db import get_session
from ..models import (Event, Approval, Goal, Briefing, Task, Person, Project,
InboxItem, InboxClassification)
from ..schemas import (DashboardOut, UserOut, WeatherOut, BriefingOut, EventOut,
ApprovalSummaryOut, GoalOut, TaskSummaryOut, TaskSummaryItem,
BadgesOut, InboxRecentOut)
from ..routers.inbox import latest_cls
router = APIRouter() # prefix 없음. main.py 에서 prefix="/api" 등록.
# 날씨 아이콘 저장값 → 표시값 매핑(cloudSun 저장 → sun 표시)
WEATHER_ICON_MAP = {"cloudSun": "sun"}
@router.get("/dashboard", response_model=DashboardOut)
def dashboard(s: Session = Depends(get_session)):
me = s.exec(select(Person).where(Person.is_me == True)).first() # noqa: E712
user = UserOut(name=me.name, initial=me.initial) if me else UserOut(name="", initial="")
br = s.exec(select(Briefing)).first()
if br:
weather = WeatherOut(temp=br.weather_temp, cond=br.weather_cond,
icon=WEATHER_ICON_MAP.get(br.weather_icon, br.weather_icon))
briefing = BriefingOut(today=br.today, weather=weather,
commute=br.commute, sleep=br.sleep, note=br.note)
saved_today, today_routed = br.saved_today, br.today_routed
else:
briefing = BriefingOut(today="", weather=WeatherOut(temp=0, cond="", icon="sun"),
commute="", sleep="", note="")
saved_today, today_routed = "", 0
schedule = [EventOut(time=e.time, title=e.title, tag=e.tag, dur=e.dur, tone=e.tone, soon=e.soon)
for e in s.exec(select(Event).order_by(Event.sort_order)).all()]
tasks = s.exec(select(Task)).all()
proj_name = {p.id: p.name for p in s.exec(select(Project)).all()}
open_tasks = [t for t in tasks if t.status.value != "done"]
items = [TaskSummaryItem(id=t.id, title=t.title, prio=t.prio.value,
project=proj_name.get(t.project_id, ""))
for t in open_tasks]
task_summary = TaskSummaryOut(open_count=len(open_tasks), items=items)
goals = [GoalOut(id=g.id, title=g.title, pct=g.pct, sub=g.sub, tone=g.tone)
for g in s.exec(select(Goal).order_by(Goal.sort_order)).all()]
apprs = s.exec(select(Approval).order_by(Approval.sort_order)).all()
high = [a for a in apprs if a.risk == "high"]
approvals_summary = [ApprovalSummaryOut(
id=a.id, icon=a.icon, tone=a.tone, title=a.title, time=a.time)
for a in high[:3]] # high-risk 만, 최대 3건
recent = s.exec(select(InboxItem).order_by(InboxItem.created_at.desc()).limit(3)).all()
# 평탄화: 각 항목의 최신 classification 에서 type/proj_label/tone 을 끌어와 채운다.
inbox_recent = []
for it in recent:
c = latest_cls(s, it.id)
inbox_recent.append(InboxRecentOut(
id=it.id, kind=it.kind.value if hasattr(it.kind, "value") else it.kind,
raw=it.raw,
type=(c.type.value if c and hasattr(c.type, "value") else (c.type if c else "")),
proj_label=c.proj_label if c else "",
tone=c.tone if c else "ink")) # 최근 3 (평탄화 형태)
badges = BadgesOut(
appr=len(high), # 결재 대기(high risk 건수)
task=len(open_tasks), # 미완료 작업 수
noti=6) # 상단 알림 배지(시드 상수)
return DashboardOut(user=user, briefing=briefing, saved_today=saved_today,
today_routed=today_routed, schedule=schedule,
task_summary=task_summary, goals=goals,
approvals_summary=approvals_summary,
inbox_recent=inbox_recent, badges=badges)
```
**응답 예시**(축약):
```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":"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}
}
```
> 상단 내비 배지(CONTRACT Topbar): 결재함 badge **3**, 작업 badge **4**, 알림 badge **6**. 대시보드 `badges.appr`(=high risk=3)·`badges.noti`(=6)는 이와 일치. 작업 badge 4는 Topbar 고정 표기이므로 프론트에서 상수로 둔다(대시보드 `badges.task`는 실제 미완료 수를 별도 제공).
---
## 4. 데이터/타입/API 계약 요약 (이 phase 관련)
### 4.1 엔드포인트 표
| Method | Path | 본문 | 응답 | 비고 |
|---|---|---|---|---|
| GET | `/api/health` | — | `{status:"ok"}` | |
| GET | `/api/people` | — | `PersonOut[]` | |
| GET | `/api/tree` | — | `FolderOut[]` | project.task_count(재귀) 포함 |
| POST | `/api/folders` | `FolderCreate` | `FolderOut` | |
| PATCH | `/api/folders/{id}` | `FolderPatch` | `FolderOut` | |
| DELETE | `/api/folders/{id}` | — | `{deleted}` | system 폴더 삭제 불가 |
| POST | `/api/projects` | `ProjectCreate` | `ProjectNode` | parent_id로 무한 중첩 |
| PATCH | `/api/projects/{id}` | `ProjectPatch` | `ProjectNode` | 순환 이동 금지 |
| DELETE | `/api/projects/{id}` | — | `{deleted}` | 하위/작업 있으면 거부 |
| POST | `/api/projects/{id}/pin` | — | `ProjectNode` | pinned 토글 |
| GET | `/api/tasks` | `?area&project_id&status&assignee` | `TaskNode[]` | 중첩 트리 |
| GET | `/api/tasks/{id}` | — | `TaskNode` | |
| POST | `/api/tasks` | `TaskCreate` | `TaskNode` | |
| PATCH | `/api/tasks/{id}` | `TaskPatch` | `TaskNode` | 상태 이동·수정 |
| DELETE | `/api/tasks/{id}` | — | `{deleted}` | 하위작업 재귀 삭제 |
| POST | `/api/tasks/{id}/comments` | `CommentCreate` | `CommentOut` | |
| POST | `/api/tasks/{id}/scaffold` | `ScaffoldRequest` | `ScaffoldOut` | 미리보기/생성 |
| GET | `/api/risks` | `?area=work` | `RiskOut[]` | 최대 3건 |
| GET | `/api/inbox` | — | `InboxItemOut[]` | 최신 classification 포함 |
| POST | `/api/inbox/capture` | `CaptureRequest` | `CaptureResponse` | 동기 분류 |
| POST | `/api/inbox/{id}/reclassify` | `ReclassifyRequest` | `ClassificationOut` | 타입 강제 가능 |
| POST | `/api/inbox/{id}/confirm` | — | `ConfirmResponse` | federation(task 실체화) |
| POST | `/api/inbox/{id}/dismiss` | — | `InboxItemOut` | |
| GET | `/api/dashboard` | — | `DashboardOut` | 집계 |
| GET | `/api/llm/health` | — | `LLMHealth` | reachable/provider/model |
### 4.2 Classification 스키마 (CONTRACT)
```
{ type: task|event|idea, sphere: work|life,
project_id?, proj_label, tone, due_text?, when_text?, extra?,
reason(한국어), confidence(0~1), model }
```
### 4.3 enum 값 (고정)
- `TaskStatus`: `todo`/`doing`/`waiting`/`review`/`done` → 라벨 `할 일`/`진행 중`/`대기 중`/`검토`/`완료`(프론트 매핑)
- `Prio`: `높음`/`보통`/`낮음`
- `tone`: `blue|violet|coral|green|amber|ink|faint`
---
## 5. 디자인 충실도 노트
이 phase는 백엔드지만, **표시 문구·색·데이터가 원본과 1바이트도 다르면 프론트 픽셀 충실 재현이 깨진다.** 다음을 보장한다.
- **사람 색**: `REF/assets/tasks-data.js``people`을 그대로. 특히 수아 = `oklch(0.66 0.13 200)`(다른 사람은 `var(--xxx)`). `Person.color` 문자열로 저장 → 프론트가 그대로 CSS에 주입.
- **칸반 컬럼**: `REF``columns`와 동일 — `todo`(할 일, `var(--faint)`)/`doing`(진행 중, `var(--blue)`)/`waiting`(대기 중, `var(--violet)`)/`review`(검토, `var(--coral)`)/`done`(완료, `var(--green)`). 순서·라벨·accent 고정.
- **작업 데이터**: id(`k1`,`k2`,`k5`,`k6`,`k4`,`k3`,`k13`,`k10`,`k14`,`k20`,`k21`), 제목, due(`06-08`→`2026-06-08`), prio(`높음/보통/낮음`), status, notes(HTML: `<h3>`,`<b>`,`<blockquote>`,`<p>`), comments(who/text)를 `REF/assets/tasks-data.js` 그대로.
- **인박스 4건**: `REF/assets/sinbox-data.js``s1~s4` raw/route를 그대로(특히 `s1`의 긴 reason 문구, "따로 섹션이 생기는 게 아니라 다른 작업과 똑같이 보여요"는 제품 철학의 핵심 문장이므로 토씨 유지). `proj`→`proj_label`(`개인 여행 — 한국` 등 `` 구분자 유지).
- **결재함/일정/목표/브리핑**: `REF/assets/approve-data.js`, `data.js`의 한국어 문구·시각·금액(`17,000원`)·이모지 같은 특수문자(``,`“`,`”`,`—`,`·`)를 그대로. tone(coral/blue/violet/green/amber)·icon(cal/mail/users/wallet) 보존. **날짜 라벨**: `briefing.today`(원본 `data.js` `today="6월 7일 일요일"`)는 히어로 날짜 라벨로 그대로 내려준다 — 날짜이지 `weather_temp`(현재기온)나 리스크 `TODAY=8`(6/8)과 별개. **날씨**: `weather_temp=24`(현재기온)와 `weather_cond`의 "한낮 28°"(일 최고기온)는 의도적으로 다른 값(원본 `data.js` 근거). `weather_icon``'cloudSun'`으로 저장하고 `GET /api/dashboard`에서 표시용 `'sun'`으로 매핑해 내려준다. **사용자명**: 대시보드 응답 최상위 `user.{name,initial}`로 내려주며, 프론트 인사("좋은 아침이에요, {name}님")는 이 값을 사용(하드코딩 금지).
- **리스크 텍스트**: `REF/assets/tasks-risk.jsx`의 문구를 그대로(`statusLabel`, "팀 평균의 N배", "위임 & 추적", "함께 밀려요"). 원본의 `<b>` 강조는 백엔드가 `**굵게**` 마크다운으로 감싸 보내고 프론트(phase-3 Bolded 파서)가 `<b>`로 렌더한다. 관련 task 식별 필드는 `task_id`. `TODAY=8` 고정(`risk_today`).
- **스캐폴드 템플릿**: `REF`의 4개 템플릿(연구·논문/개발/문서·리포트/일반)과 정규식을 정확히. icon(`brain/branch/file/list`)·est(`1일`,`반나절`,`2시간` 등) 보존.
> 참조 파일: `REF/assets/data.js`, `REF/assets/tasks-data.js`, `REF/assets/sinbox-data.js`, `REF/assets/approve-data.js`, `REF/assets/tasks-risk.jsx`. 토큰/색 정의는 `phase-1-design-system.md`(`frontend/styles/tokens.css`)에서 다루며, 백엔드는 색 "문자열"만 운반한다.
---
## 6. 상태 처리 & 엣지 케이스
| 상황 | 처리 |
|---|---|
| LLM(Ollama) 미가용/오프라인 | `get_provider()` auto 모드가 health 실패 시 `HeuristicProvider`로 폴백. `classify_capture`는 어떤 경우에도 `Classification` 반환(예외를 위로 던지지 않음). `model` 필드로 어떤 provider가 분류했는지 표기(`ollama:<model>` vs `heuristic`). |
| LLM JSON 파싱 실패 | `OllamaProvider.classify_capture``except`에서 휴리스틱으로 폴백. `generate_json`(scaffold)도 `scaffold.py`에서 `try/except`로 정규식 템플릿 폴백. |
| 빈 트리/작업 없음 | `compute_risks`는 빈 리스트 반환(원본 `if (!risks.length) return null`에 대응 — API는 `[]`). `task_summary``open_count:0, items:[]`. |
| 잘못된 FK(없는 project/parent) | 404. 프로젝트 이동 시 자기 후손으로 이동 400. |
| 시스템 폴더/프로젝트 삭제 | system 폴더 삭제 400, 하위/작업 있는 프로젝트 삭제 400(데이터 보호). |
| 분류 결과의 project_id가 없는 event/idea | confirm 시 task 미생성, 상태만 `confirmed`. `materialized_task_id`는 null 유지. |
| confirm 시 classification 없음 | 400 `no classification to confirm`(capture 없이 confirm 호출). |
| reclassify 반복 | 매번 새 `InboxClassification` row append, `latest_cls`가 가장 최신(created_at desc) 사용 → 1:1 최신 의미 유지. |
| due 파싱(시드) | `06-08``date(2026,6,8)`. due 없으면 None(리스크의 `_due_day`는 99 처리). |
| 동시성(SQLite) | `check_same_thread=False`. MVP 단일 사용자라 락 충돌 거의 없음. 필요 시 `engine` `pool_pre_ping` 고려. |
---
## 7. 테스팅 & 검증 (가장 중요)
### 7.1 실행 명령
```bash
# backend/ 에서
uv run pytest -q # 전체
uv run pytest tests/test_risk.py -q # 단위
uv run pytest -k golden -q # 골든 분류 4케이스
uv run pytest --cov=app # 커버리지(옵션)
# 시드 후 수동 기동
uv run alembic upgrade head
uv run python -m app.seed
uv run uvicorn app.main:app --reload --port 8000
# 헬스/엔드포인트 확인
curl -s localhost:8000/api/health # {"status":"ok"}
curl -s localhost:8000/api/tree | jq '.[].name'
curl -s localhost:8000/api/risks?area=work | jq
curl -s -X POST localhost:8000/api/inbox/capture \
-H 'content-type: application/json' \
-d '{"kind":"text","raw":"수요일 11시 자전거 수리 맡기기"}' | jq
```
### 7.2 conftest — in-memory DB + provider override
```python
# backend/tests/conftest.py
import pytest
from sqlmodel import SQLModel, Session, create_engine
from sqlmodel.pool import StaticPool
from fastapi.testclient import TestClient
from app.db import get_session
from app.main import app
from app import seed as seed_mod
from app.llm.provider import get_provider
from app.llm.heuristic import HeuristicProvider
@pytest.fixture()
def session():
engine = create_engine("sqlite://", connect_args={"check_same_thread": False},
poolclass=StaticPool)
SQLModel.metadata.create_all(engine)
with Session(engine) as s:
yield s, engine
@pytest.fixture()
def client(session):
s, engine = session
# 테스트 세션을 주입해 시드(정본 시그니처: run_seed(session=..., reset=True)).
with Session(engine) as seed_s:
seed_mod.run_seed(session=seed_s, reset=True)
def _get_session():
with Session(engine) as ss:
yield ss
app.dependency_overrides[get_session] = _get_session
# LLM은 기본적으로 heuristic 강제(테스트 결정성). FastAPI 표준 DI 오버라이드.
# phase-6 federation conftest 와 동일 방식(dependency_overrides[get_provider]).
app.dependency_overrides[get_provider] = lambda: HeuristicProvider()
yield TestClient(app)
app.dependency_overrides.clear()
```
> 테스트는 기본적으로 **휴리스틱 강제**로 결정성을 확보한다. Ollama 경로는 별도 테스트(§7.6)에서 httpx를 모킹한다.
### 7.3 단위 — `test_risk.py`
```python
def test_risk_delay_present(client):
r = client.get("/api/risks?area=work").json()
kinds = [x["kind"] for x in r]
assert "지연 위험" in kinds
delay = next(x for x in r if x["kind"] == "지연 위험")
assert "마감인데" in delay["text"] and delay["icon"] == "clock"
def test_risk_dependency(client):
r = client.get("/api/risks?area=work").json()
dep = [x for x in r if x["kind"] == "의존성"]
assert dep, "예산 섹션 작성→경영진 검토 요청 메일 의존성 1건"
assert "함께 밀려요" in dep[0]["text"]
def test_risk_max_three(client):
r = client.get("/api/risks?area=work").json()
assert len(r) <= 3
def test_risk_overload_threshold(client):
# 시드 기준 업무 쏠림이 topN>=avg*1.5 && topN>=4 를 만족하면 포함
r = client.get("/api/risks?area=work").json()
over = [x for x in r if x["kind"] == "업무 쏠림"]
for o in over:
assert o["icon"] == "scale" and o["tone"] == "amber"
```
추가 단위(엔진 직접): `compute_risks` 를 인위적 작업셋으로 호출해 경계조건 검증.
```python
def test_overload_math():
# topN=6, others=[1,1] -> avg=8/3=2.67, 6>=4.0 and 6>=4 -> True, mult=round(6/2.67,1)=2.2
from app.services.risk import compute_risks # 별도 세션 픽스처로 인위 데이터 주입
...
```
### 7.4 단위 — `test_scaffold.py`
```python
import pytest
from app.services.scaffold import pick_scaffold
@pytest.mark.parametrize("title,kind", [
("선행 연구 인터뷰 진행", "연구 · 논문 작성"), # research
("결제 API 개발", "개발 업무"), # dev
("OKR 중간 점검 자료 준비", "문서 · 리포트"), # doc (자료/작성)
("분기 리포트 초안 마무리", "문서 · 리포트"), # doc (리포트/초안)
("화분 물 주기", "일반 업무"), # generic
])
def test_pick_scaffold(title, kind):
assert pick_scaffold(title)["kind"] == kind
def test_scaffold_create_via_api(client):
r = client.post("/api/tasks/k6/scaffold", json={"create": True}).json()
assert r["created"] is True and len(r["created_task_ids"]) == len(r["items"])
# 생성 후 트리에 하위작업 반영
t = client.get("/api/tasks/k6").json()
titles = [c["title"] for c in t["children"]]
assert "목차 & 범위 정의" in titles
```
### 7.5 단위 — `test_heuristic.py`
```python
import pytest
from app.llm.heuristic import HeuristicProvider
CTX = {"projects": [
{"id":"life-trip","name":"여행 — 한국","folder_id":"life"},
{"id":"life-fam","name":"가족","folder_id":"life"},
{"id":"onb","name":"온보딩 리디자인","folder_id":"work"}]}
@pytest.mark.parametrize("raw,ctype", [
("수요일 11시 자전거 수리 맡기기", "event"), # 시각 명시
("다음 주에 비행기 티켓 사기", "task"), # 행동+기한
("온보딩에 애니메이션 넣으면 어떨까", "idea"), # 막연
])
def test_heuristic_type(raw, ctype):
c = HeuristicProvider().classify_capture(raw, CTX)
assert c.type == ctype
```
### 7.6 골든 분류 4케이스 — `test_classification_golden.py` (CONTRACT)
CONTRACT의 4케이스를 **휴리스틱 폴백 기준**으로 검증(LLM 없이도 통과해야 함). LLM이 있으면 더 풍부해지지만, 최소 보장은 폴백.
```python
import pytest
from app.llm.heuristic import HeuristicProvider
CTX = {"projects": [
{"id":"life-trip","name":"여행 — 한국","folder_id":"life"},
{"id":"life-fam","name":"가족","folder_id":"life"},
{"id":"onb","name":"온보딩 리디자인","folder_id":"work"}]}
GOLDEN = [
# (raw, type, sphere, proj_label 포함어, extra 포함어)
("다음 주에 한국 놀러가는 비행기 티켓 사기", "task", "life", "여행", "가격"),
("수요일 11시 자전거 수리 맡기기", "event", "life", "캘린더", ""),
("엄마 생신 선물 미리 알아보기", "task", "life", "가족", ""),
("온보딩 환영 화면에 짧은 애니메이션 넣으면 어떨까", "idea", "work", "온보딩", ""),
]
@pytest.mark.parametrize("raw,typ,sphere,proj_kw,extra_kw", GOLDEN)
def test_golden(raw, typ, sphere, proj_kw, extra_kw):
c = HeuristicProvider().classify_capture(raw, CTX)
assert c.type == typ, f"{raw} → type {c.type}"
assert c.sphere == sphere, f"{raw} → sphere {c.sphere}"
assert proj_kw in c.proj_label, f"{raw} → proj_label {c.proj_label}"
if extra_kw:
assert extra_kw in c.extra
assert c.reason # 한국어 reason 비어있지 않음
```
> 휴리스틱 규칙은 위 4케이스를 통과하도록 §3.11에서 설계됨: "비행기 티켓 사기"=행동(사기)→task, life(티켓/여행), life-trip 매칭, extra=가격. "수요일 11시"=시각→event, life(자전거), 캘린더 라벨. "엄마 생신 선물 알아보기"=행동(알아보기)→task, life(엄마/생신), 가족. "온보딩 애니메이션 넣으면 어떨까"=행동/기한 불명→idea, work(온보딩).
### 7.7 LLM 모킹 — Ollama 경로
```python
# test_ollama_mock.py
import httpx, json
from app.llm.ollama import OllamaProvider
def test_ollama_classify_mock(monkeypatch):
fake = {"message": {"content": json.dumps({
"type":"task","sphere":"life","project_id":"life-trip",
"proj_label":"개인 여행 — 한국","tone":"coral",
"due_text":"~6/14","when_text":"오늘 저녁","extra":"가격 추적",
"reason":"구매 행동이라 작업입니다.","confidence":0.9})}}
class R:
status_code=200
def raise_for_status(self): pass
def json(self): return fake
monkeypatch.setattr(httpx, "post", lambda *a, **k: R())
c = OllamaProvider().classify_capture("비행기 티켓 사기", {"projects":[]})
assert c.type == "task" and c.sphere == "life"
assert c.model.startswith("ollama:")
def test_ollama_fallback_on_error(monkeypatch):
def boom(*a, **k): raise httpx.ConnectError("no ollama")
monkeypatch.setattr(httpx, "post", boom)
c = OllamaProvider().classify_capture("수요일 11시 자전거 수리", {"projects":[]})
assert c.model == "heuristic" # 폴백 동작
```
### 7.8 API 통합
```python
# test_api_tree.py
def test_tree_shape(client):
tree = client.get("/api/tree").json()
assert [f["name"] for f in tree] == ["업무", "개인"]
work = tree[0]
biz = next(p for p in work["projects"] if p["id"] == "biz")
assert {c["id"] for c in biz["children"]} == {"biz-okr","biz-report","biz-budget"}
assert biz["task_count"] >= biz["children"][0]["task_count"] # 재귀 합
def test_project_crud(client):
created = client.post("/api/projects",
json={"folder_id":"work","name":"새 프로젝트"}).json()
pid = created["id"]
client.patch(f"/api/projects/{pid}", json={"name":"이름변경"})
assert client.post(f"/api/projects/{pid}/pin").json()["pinned"] is True
assert client.delete(f"/api/projects/{pid}").json()["deleted"] == pid
# test_api_tasks.py
def test_tasks_nested_and_filter(client):
work = client.get("/api/tasks?area=work").json()
k1 = next(t for t in work if t["id"] == "k1")
assert k1["children"], "k1 has subtasks"
assert len(k1["comments"]) == 2
life = client.get("/api/tasks?area=life").json()
assert {t["id"] for t in life} >= {"k4","k20","k21"}
def test_task_status_move(client):
r = client.patch("/api/tasks/k1", json={"status":"review"}).json()
assert r["status"] == "review"
def test_task_comment(client):
c = client.post("/api/tasks/k1/comments",
json={"person_id":"minseo","text":"확인했습니다"}).json()
assert c["text"] == "확인했습니다"
# test_api_inbox.py
def test_capture_confirm_federation(client):
cap = client.post("/api/inbox/capture",
json={"kind":"text","raw":"다음 주에 한국 놀러가는 비행기 티켓 사기"}).json()
iid = cap["item"]["id"]
assert cap["classification"]["type"] == "task"
conf = client.post(f"/api/inbox/{iid}/confirm").json()
assert conf["item"]["status"] == "confirmed"
assert conf["item"]["materialized_task_id"]
assert conf["task"]["title"].startswith("다음 주에 한국")
def test_reclassify_force(client):
cap = client.post("/api/inbox/capture",
json={"kind":"text","raw":"엄마 생신 선물 알아보기"}).json()
iid = cap["item"]["id"]
re = client.post(f"/api/inbox/{iid}/reclassify", json={"type":"idea"}).json()
assert re["type"] == "idea"
def test_dismiss(client):
cap = client.post("/api/inbox/capture", json={"kind":"text","raw":"잡담"}).json()
iid = cap["item"]["id"]
assert client.post(f"/api/inbox/{iid}/dismiss").json()["status"] == "dismissed"
# test_api_dashboard.py
def test_dashboard(client):
d = client.get("/api/dashboard").json()
assert d["user"]["name"] == "지우" and d["user"]["initial"] == "지"
assert d["briefing"]["today"] == "6월 7일 일요일" # 히어로 날짜 라벨
assert d["briefing"]["weather"]["temp"] == 24
assert d["briefing"]["weather"]["icon"] == "sun" # cloudSun 저장 → sun 표시
assert d["saved_today"] == "47분" and d["today_routed"] == 7
assert any(e["soon"] for e in d["schedule"]) # 14:00 분기 전략 미팅 soon
assert d["badges"]["appr"] == 3 # high risk 3건
assert d["badges"]["noti"] == 6
assert d["task_summary"]["open_count"] > 0
assert len(d["approvals_summary"]) <= 3 # high-risk 만, 최대 3건
assert d["goals"][0]["tone"] == "blue" # tone 키('var(--blue)' 아님)
# inbox_recent 는 평탄화 형태 {id,kind,raw,type,proj_label,tone} (status 등 미포함)
assert set(d["inbox_recent"][0]) == {"id","kind","raw","type","proj_label","tone"}
```
### 7.9 시드 검증 — `test_seed.py`
```python
def test_seed_counts(client):
assert len(client.get("/api/people").json()) == 5
tree = client.get("/api/tree").json()
assert len(tree) == 2
# 최상위 작업 11개 (k1,k2,k5,k6,k4,k3,k13,k10,k14,k20,k21)
work = client.get("/api/tasks?area=work").json()
life = client.get("/api/tasks?area=life").json()
assert len(work) + len(life) == 11
# 인박스 4건, s1 은 new
inbox = {i["id"]: i for i in client.get("/api/inbox").json()}
assert set(inbox) == {"s1","s2","s3","s4"}
assert inbox["s1"]["status"] == "new"
assert inbox["s1"]["classification"]["proj_label"] == "개인 여행 — 한국"
def test_seed_person_color(client):
people = {p["id"]: p for p in client.get("/api/people").json()}
assert people["sua"]["color"] == "oklch(0.66 0.13 200)"
assert people["jiwoo"]["is_me"] is True
```
### 7.10 마이그레이션 왕복
```bash
uv run alembic upgrade head
uv run alembic downgrade base
uv run alembic upgrade head
uv run python -m app.seed && uv run pytest -q # 적재 후 전체 green
```
CI 스크립트 예:
```bash
set -e
uv run alembic downgrade base
uv run alembic upgrade head
uv run pytest -q
```
### 7.11 수동 QA 체크리스트
- [ ] `uvicorn` 기동 후 `/docs`(Swagger)에서 모든 라우트 노출 확인.
- [ ] `GET /api/tree`가 업무/개인 2폴더, 중첩 프로젝트, task_count 표시.
- [ ] `GET /api/tasks?area=work`가 k1 하위작업/댓글 2건 포함.
- [ ] `PATCH /api/tasks/k1 {status:review}` 후 다시 GET 시 상태 유지.
- [ ] `POST /api/inbox/capture` 4 골든 문장 각각 type/sphere/proj_label이 §7.6과 일치.
- [ ] `confirm` 후 해당 inbox item에 `materialized_task_id`가 생기고 `GET /api/tasks`에 새 작업 등장.
- [ ] `GET /api/risks?area=work`가 최대 3건, 지연/의존성 포함, 문구가 `tasks-risk.jsx`와 동일 어휘.
- [ ] `GET /api/dashboard`의 한국어 문구·금액·시각이 `data.js`/`approve-data.js`와 동일.
- [ ] Ollama 끄고(`LLM_PROVIDER=heuristic` 또는 데몬 종료) capture 여전히 동작 → `model:"heuristic"`.
- [ ] Ollama 켜고 `OLLAMA_MODEL`을 설치된 임의 모델로 바꿔도 동작(모델 비종속) → `/api/llm/health` reachable:true.
### 7.12 통과 기준
- `uv run pytest -q` 전부 통과(0 failed). 골든 4케이스 green.
- 마이그레이션 왕복(base↔head) 에러 없음.
- 시드 적재 후 카운트(사람5/폴더2/최상위작업11/인박스4) 일치.
- LLM on/off 양쪽에서 capture가 유효한 Classification 반환(예외 전파 없음).
- 리스크 3종이 시드 데이터에서 규칙대로 계산(지연·쏠림·의존성), 최대 3건.
---
## 8. 완료 기준 (Definition of Done)
- [ ] `models.py` — CONTRACT 전 테이블(person/folder/project/task/task_comment/inbox_item/inbox_classification/event/approval/goal/briefing) SQLModel로, FK·enum·자기참조(무한 중첩) 포함.
- [ ] `schemas.py` — 모든 I/O 스키마. `frontend/lib/types.ts`와 필드명 1:1(snake_case).
- [ ] `db.py` + Alembic — `revision --autogenerate``upgrade head` 동작, 왕복 검증.
- [ ] `seed.py` — REF 4파일 데이터 그대로 적재(중첩 트리/하위작업/댓글/인박스/대시보드). `python -m app.seed` 동작.
- [ ] 라우터 6종 — CONTRACT 엔드포인트·동작·응답형 일치. `/docs` 노출.
- [ ] `services/risk.py``tasks-risk.jsx` 1:1 이식, TODAY=8, 지연/쏠림/의존, 최대 3건.
- [ ] `services/scaffold.py``pickScaffold` 정규식+4템플릿, 미리보기/생성, LLM 보강(폴백 안전).
- [ ] `services/classification.py` + `llm/*` — provider 추상화, Ollama(format=json, 모델 비종속) + Heuristic 폴백, 한국어 reason.
- [ ] inbox confirm = federation(task 실체화 + `materialized_task_id`).
- [ ] pytest 전체 green(단위/통합/골든4/시드/모킹/마이그레이션).
- [ ] LLM on/off 양쪽 동작, `/api/llm/health` 정상.
---
## 9. 다음 단계
백엔드 계약이 확정되었으므로, 프론트 페이지를 작업→인박스→대시보드 순으로 붙인다(대시보드가 다른 데이터를 집계하므로 마지막).
- **다음 문서**: `phase-3-tasks.md` — 작업 페이지(폴더 트리 사이드바, 칸반/리스트(캘린더는 post-MVP placeholder), 하위작업, 상세 드로어, 리스크 레이더). 본 문서의 `GET /api/tree`, `GET /api/tasks`, `PATCH /api/tasks/{id}`, `POST /api/tasks/{id}/scaffold`, `GET /api/risks`를 소비한다.
- 이어서 `phase-4-inbox.md`(capture→classify→confirm 연합 UI), `phase-5-dashboard.md`(`GET /api/dashboard`), 마지막으로 `phase-6-integration.md`(E2E/접근성/성능/수용 기준).