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