# 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` 규칙으로 새 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 자동 부여. TASKS = [ {"id":"k1","title":"분기 리포트 초안 마무리","project":"biz-report","assignee":"jiwoo","due":"06-08","prio":"높음","status":"doing", "notes":"

목표

경영진 미팅용 Q2 성과 리포트. 리텐션·매출·예산 세 섹션으로 구성한다.

핵심 지표는 시각화해서 한눈에 들어오게.
", "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":"

MRR/ARR 표는 완료. 증감 코멘트 마무리 필요.

","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":"

디자인 리뷰에서 나온 3개 화면 수정사항을 취합하고 우선순위를 매긴다.

", "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":"

일정이 빠듯해 같은 프로젝트의 민서님께 위임 — 현재 완료를 기다리는 중입니다.

", "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":"

스마트 인박스에서 자동 생성된 작업이에요. 아리가 가격 추적 알림을 켜뒀고, 적정가가 보이면 결재함으로 알려드려요.

", "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:00–11: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="오늘은 오후 미팅이 핵심이에요. 오전을 비워 분기 리포트에 집중하시면 좋겠어요. 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":"

목표

...","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 파서가 `** **` 를 `` 로 렌더). ② `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건. 텍스트: `**** — 오늘(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/접근성/성능/수용 기준).