# backend/app/schemas.py from __future__ import annotations from datetime import date, datetime from typing import Any, Optional from pydantic import BaseModel, ConfigDict, Field from .models import ClsType, InboxKind, InboxStatus, Prio, Sphere, TaskStatus # ---------- 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 edited_at: Optional[datetime] = None class CommentPatch(BaseModel): text: str 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 class TaskReorderRequest(BaseModel): ids: list[str] # 같은 컬럼 내 새 순서대로 정렬된 task id 목록 # ---------- 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 # 한국어. 강조어를 ** ** 마크다운으로 감싼다 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 면 생성된 작업 # ---------- 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°" icon: str # 'cloudSun' 저장 → 표시용 'sun' 으로 매핑 class BriefingOut(BaseModel): today: str # 히어로 날짜 라벨 (예: "6월 7일 일요일") weather: WeatherOut commute: str sleep: str note: str # HTML 허용 class EventOut(BaseModel): id: str 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() # ==================== phase-7: 결재함 + 자동화 ==================== # ---------- 결재함 ---------- class ApprovalOut(BaseModel): id: str icon: str tone: str risk: str # "low" | "high" time: str title: str detail: str cta: str alt: str undo_label: str status: str # pending|approved|executed|undone source: str rule_id: Optional[str] = None sort_order: int class ApprovalLogOut(BaseModel): id: str time: str text: str approval_id: Optional[str] = None class AutonomyOut(BaseModel): level: str # approval_first|mixed|full_auto class AutonomyPatch(BaseModel): level: str # 셋 중 하나만 허용(검증) class ApprovalQueueOut(BaseModel): """결재함 페이지 응답 정본. 자율성 레벨로 큐를 분기한 결과.""" autonomy: str saved_today: str # "47분" auto_count_night: int # 7 pending: list[ApprovalOut] done: list[ApprovalOut] log: list[ApprovalLogOut] badges_appr: int # 상단 결재함 배지 = pending 수 # ---------- 자동화 ---------- class FlowOut(BaseModel): trigger: str cond: Optional[str] = None action: str class RuleOut(BaseModel): id: str name: str cat: str # mail|cal|focus|life trigger: str cond: Optional[str] = None action: str on: bool last: str runs: int fresh: bool source: str class RuleCreate(BaseModel): name: str cat: str trigger: str cond: Optional[str] = None action: str source: Optional[str] = "user" class RulePatch(BaseModel): name: Optional[str] = None cat: Optional[str] = None trigger: Optional[str] = None cond: Optional[str] = None action: Optional[str] = None on: Optional[bool] = None class ParseRequest(BaseModel): text: str class ParsePreviewOut(BaseModel): matched: bool name: str cat: str # mail|cal|focus|life parse: FlowOut model: str # "llm:" | "heuristic" | "example" confidence: float class SuggestionOut(BaseModel): id: str pattern: str offer: FlowOut offer_name: str offer_cat: str status: str class RunLogOut(BaseModel): id: str time: str rule: str text: str undone: bool class AutomationStatsOut(BaseModel): active: int runs_week: int saved: str class AutomationPageOut(BaseModel): """자동화 페이지 응답 정본(한 번에 모든 뷰 데이터).""" stats: AutomationStatsOut rules: list[RuleOut] suggests: list[SuggestionOut] log: list[RunLogOut] examples: list[ParsePreviewOut] # ==================== phase-8: 일정 + 회의 도우미 ==================== class CalendarOut(BaseModel): id: str name: str tone: str on: bool count: int = 0 class EventActionOut(BaseModel): text: str when: str class AttendeeOut(BaseModel): email: str name: str = "" status: str = "" # accepted|declined|tentative|needsAction class CalEventOut(BaseModel): id: str day: int date: str = "" # 실제 날짜 ISO "YYYY-MM-DD" start: str end: str title: str cal: str loc: str = "" note: str = "" soon: bool = False people: list[str] = [] rrule: str = "" # 반복 규칙(iCal RRULE) reminders: list[int] = [] # 시작 N분 전 알림 attendees: list[AttendeeOut] = [] response_status: str = "" # 내 RSVP actions: list[EventActionOut] = [] has_meeting: bool = False meet_label: str = "" class EventWriteRequest(BaseModel): """새 일정 생성/수정 요청. start 빈값 = 종일.""" title: str date: str # ISO "YYYY-MM-DD" start: str = "" # "HH:MM" end: str = "" # "HH:MM" loc: str = "" note: str = "" people: list[str] = [] # 참석자 이메일 cal: str = "" # 대상 캘린더 id(빈값=기본 연결 캘린더 또는 로컬) rrule: str = "" # 반복 규칙(빈값=단일). 예 "FREQ=WEEKLY;BYDAY=MO" reminders: list[int] = [] # 시작 N분 전 알림(분 단위) class EventRsvpRequest(BaseModel): status: str # accepted | declined | tentative class FocusBlockOut(BaseModel): id: str day: int date: str = "" # 실제 날짜 ISO(데모는 6월로 매핑) start: str end: str title: str type: str tag: str task_id: Optional[str] = None auto: bool = True sort_order: int = 0 class MeetingActionOut(BaseModel): id: str idx: int text: str who: str when: str = "" added: bool materialized_task_id: Optional[str] = None class MeetingOut(BaseModel): id: str event_id: str phase: str one_on_one: bool = False meta: str = "" starts_in: str = "" summary: list[str] = [] decisions: list[str] = [] agenda: list[str] = [] last_meeting: dict[str, Any] = {} last_actions: list[dict[str, Any]] = [] insights: list[str] = [] docs: list[str] = [] person: dict[str, Any] = {} promises: list[dict[str, Any]] = [] signals: list[dict[str, Any]] = [] talking_points: list[str] = [] actions: list[MeetingActionOut] = [] class DayBundleOut(BaseModel): day: int date: str = "" events: list[CalEventOut] focus_blocks: list[FocusBlockOut] class WeekBundleOut(BaseModel): today: int today_date: str = "" # 실제 오늘 ISO(real 연결 시 실날짜, 아니면 데모 2026-06-08) mode: str = "demo" # "real" | "demo" week: list[int] weekdays: list[str] calendars: list[CalendarOut] events: list[CalEventOut] focus_blocks: list[FocusBlockOut] class MaterializeResponse(BaseModel): action: MeetingActionOut task: Optional[TaskNode] = None all_added: bool = False class FocusSuggestRequest(BaseModel): day: int min_minutes: int = 20 create: bool = False class FocusSuggestResponse(BaseModel): day: int suggestions: list[FocusBlockOut] created: bool = False MaterializeResponse.model_rebuild() # ==================== phase-9: 메일 + 알림 ==================== class MailAccountOut(BaseModel): id: str name: str email: str tone: str kind: str unread: int # 집중(focused) 받은편지함 안읽음 others: int = 0 # 기타(Outlook other) 받은편지함 안읽음 class MailFolderOut(BaseModel): id: str name: str icon: str class AccountFolderOut(BaseModel): """계정별 실제 폴더/라벨(사이드바 트리). slug 가 list_mail folder 필터 키.""" account: str slug: str name: str kind: str # system | category | label | custom icon: str unread: int = 0 total: int = 0 sort_order: int = 0 class AiTaskOut(BaseModel): text: str project: str due: str = "" prio: str = "" class AiEventOut(BaseModel): title: str date: str = "" day: str = "" time: str = "" dur: str = "" place: str = "" class AiReplyOut(BaseModel): tone: str preview: str body: str class AiFileOut(BaseModel): project: str reason: str class AiAnalysisOut(BaseModel): summary: str = "" priority: str = "보통" category: str = "" tasks: list[AiTaskOut] = [] events: list[AiEventOut] = [] replies: list[AiReplyOut] = [] file: Optional[AiFileOut] = None class MailAttachmentOut(BaseModel): id: str = "" # 프로바이더 attachment id(다운로드용) name: str mime: str = "" size: int = 0 class EmailRowOut(BaseModel): model_config = ConfigDict(populate_by_name=True) id: str account: str from_: str = Field(alias="from") from_name: str = "" # 표시 이름(이름 없으면 도메인 추정) — 목록/리더에서 노출 from_email: str = "" # 실제 메일 주소(리더 보조 표시) to: str cc: str = "" thread_id: str = "" subject: str time: str read: bool starred: bool has_attach: bool labels: list[str] = [] preview: str category: str = "primary" # 카테고리 탭(primary/promotions/social/updates) — 라벨에서 산출 ai: Optional[AiAnalysisOut] = None class EmailDetailOut(EmailRowOut): date: str body: list[str] = [] body_html: str = "" # 원본 HTML 본문(샌드박스 iframe 렌더용; cid 는 첨부 URL 로 치환됨) attachments: list[MailAttachmentOut] = [] class ConversationOut(BaseModel): """대화(스레드) 묶음 — 같은 thread_id 메일들.""" thread_id: str subject: str count: int messages: list[EmailRowOut] = [] class ExtractRequest(BaseModel): kind: str # task | event | file index: int = 0 class ExtractResponse(BaseModel): kind: str task: Optional[TaskNode] = None event_id: Optional[str] = None project: Optional[str] = None message: str class ExtractAllResponse(BaseModel): created_task_ids: list[str] = [] created_event_ids: list[str] = [] filed_project: Optional[str] = None message: str class ReplyDraftRequest(BaseModel): reply_index: Optional[int] = None tone: Optional[str] = None class ReplyDraftOut(BaseModel): to: str from_account: str subject: str body: str class ComposeAttachment(BaseModel): name: str mime: str = "application/octet-stream" data_b64: str # base64 인코딩 본문 class SendRequest(BaseModel): from_account: str to: str cc: str = "" bcc: str = "" subject: str body: str in_reply_to: Optional[str] = None attachments: list[ComposeAttachment] = [] class SendResponse(BaseModel): approval_id: str status: str message: str class MailStarRequest(BaseModel): starred: bool class MailReadRequest(BaseModel): read: bool class MailLabelRequest(BaseModel): """라벨/카테고리 추가·제거(폴더 이동 포함).""" add: list[str] = [] remove: list[str] = [] class BulkMailRequest(BaseModel): ids: list[str] action: str # read | unread | archive | unarchive | trash | star | unstar # ---------- 알림 ---------- class NotificationOut(BaseModel): id: str bucket: str src: str app: str title: str sum: str time: str why: str tone: str act: str = "" class TriageOut(BaseModel): now: list[NotificationOut] later: list[NotificationOut] held: list[NotificationOut] counts: dict stats: dict class ReclassifyNotifRequest(BaseModel): to_bucket: str class GuardOut(BaseModel): on: bool until: str held: int quiet: str class GuardPatch(BaseModel): on: bool class DigestOut(BaseModel): id: str time: str name: str desc: str count: int done: bool class SenderRuleOut(BaseModel): model_config = ConfigDict(populate_by_name=True) id: str who: str from_label: str = Field(serialization_alias="from") rule: str tone: str bucket: str class SenderRuleCreate(BaseModel): who: str from_label: str rule: str bucket: str = "now" tone: str = "ink" ExtractResponse.model_rebuild() # ==================== phase-12: 여정 + 하루 마감 ==================== class JourneyPersonOut(BaseModel): id: str name: str initial: str color: str me: bool = False tasks: int = 0 role: str = "" class JourneyStageOut(BaseModel): id: str title: str time: str tone: str n: str class RoutineCardOut(BaseModel): node: str icon: str title: str meta: str tag: str tool: str toolTone: str = "" class TaskCardOut(BaseModel): node: str title: str meta: str who: str done: bool = False class MeetCardOut(BaseModel): node: str time: str title: str meta: str who: str soon: bool = False class TileCardOut(BaseModel): node: str hi: bool = False icon: str tt: str ts: str class JourneyCardsOut(BaseModel): s1: list[RoutineCardOut] s2: list[TaskCardOut] s3: list[MeetCardOut] s4: list[TileCardOut] class JourneyLinkOut(BaseModel): from_node: str to_node: str tone: str dash: bool = False class JourneyRowOut(BaseModel): node: str title: str star: bool = False statusKey: str statusLabel: str startTime: str who: str class GaugeOut(BaseModel): pct: int tone: str val: str unit: str label: str sub: str class JourneyMetaOut(BaseModel): stageCount: int itemCount: int collabCount: int peopleMore: str = "+3" class JourneyHeaderOut(BaseModel): dateLabel: str weather: str class JourneyTableOut(BaseModel): subtitle: str class JourneyOut(BaseModel): header: JourneyHeaderOut meta: JourneyMetaOut people: list[JourneyPersonOut] stages: list[JourneyStageOut] cards: JourneyCardsOut links: list[JourneyLinkOut] rows: list[JourneyRowOut] table: JourneyTableOut gauges: list[GaugeOut] gaugeFoot: str # ==================== phase-13: 외부 연동 & 데이터 커넥터 ==================== class ConnectorStatusOut(BaseModel): id: str domain: str provider: str name: str # "회사" / "우리카드" / "Apple Health" kind: str # "Lumi Workspace" / "신용/체크" / "건강 앱" tone: str # blue|violet|coral|green|amber|ink|faint mode: str # mock | csv | healthkit | real env_mode: str # CONNECTOR_ 값(real 허용 여부) state: str # connected|disconnected|syncing|error|token_expired on: bool # state==connected (라이프 카드 on 호환) last: str # last_label last_synced_at: Optional[datetime] = None error_detail: str = "" email: str = "" # phase-16 연결된 실계정 주소(external_account_id) @classmethod def from_account(cls, a, env_mode: str) -> ConnectorStatusOut: dom = a.domain.value if hasattr(a.domain, "value") else a.domain mode = a.mode.value if hasattr(a.mode, "value") else a.mode state = a.state.value if hasattr(a.state, "value") else a.state return cls( id=a.id, domain=dom, provider=a.provider, name=a.name, kind=a.kind, tone=a.tone, mode=mode, env_mode=env_mode, state=state, on=(state == "connected"), last=a.last_label, last_synced_at=a.last_synced_at, error_detail=a.error_detail, email=getattr(a, "external_account_id", "") or "", ) class OAuthStartOut(BaseModel): authorize_url: str class ConnectorProviderOut(BaseModel): """phase-16 '계정 추가' 버튼이 보여줄 OAuth provider 목록 + 구성 여부.""" domain: str # mail | calendar | knowledge ... provider: str # gmail | outlook | google_calendar | notion label: str # "Gmail" / "Outlook" configured: bool # client_id 설정되어 real 연동 가능 class SyncResultOut(BaseModel): domain: str account_id: str seen: int = 0 upserted: int = 0 skipped: int = 0 errors: int = 0 events_published: list[str] = Field(default_factory=list) detail: str = "" class ImportResultOut(BaseModel): entity_type: str imported: int = 0 skipped: int = 0 errors: int = 0 detail: str = "" # ==================== phase-14: 능동 워커 + 멀티모달 ==================== class ProactiveCardOut(BaseModel): id: str kind: str icon: str tone: str title: str why: str cta: Optional[str] = None action_kind: Optional[str] = None detected_at: str status: str class ProactiveAcceptOut(BaseModel): id: str action_kind: str created_id: Optional[str] = None message: str class WeeklyReviewOut(BaseModel): id: str week_label: str deep_work_h: float meeting_h: float done_count: int auto_count: int saved_minutes: int patterns: list[dict] = Field(default_factory=list) note: Optional[str] = None class WorkerRunOut(BaseModel): job: str ok: bool detail: str class TranscribeOut(BaseModel): text: str seconds: float model: str confidence: float class CaptionOut(BaseModel): text: str ocr: str model: str confidence: float # ==================== phase-15: 인증/멀티유저 + 운영 ==================== class LoginIn(BaseModel): email: str password: str class MeOut(BaseModel): id: str name: str initial: str email: str role: str # member | admin class TokenOut(BaseModel): id: str name: str token: Optional[str] = None # 발급 시 1회만 평문 created_at: str class ReadyOut(BaseModel): ready: bool checks: dict[str, str] # ==================== 설정(settings) ==================== class ProfileUpdateIn(BaseModel): """프로필 편집. 모두 선택 — 주어진 필드만 갱신.""" name: Optional[str] = None role: Optional[str] = None class TokenInfoOut(BaseModel): """토큰 목록 항목(평문 미포함).""" id: str name: str created_at: str last_used_at: Optional[str] = None revoked: bool = False class LLMConfigOut(BaseModel): """유효 LLM 연결 설정 + 선택지 + 보조 프로바이더(읽기전용).""" provider: str # 유효(오버레이>env): ollama | openai | gemini model: str host: str timeout: float provider_options: list[str] = ["ollama"] overridden: bool = False # 런타임 오버레이가 활성인가(env 기본과 다른가) api_key_set: bool = False # API 키가 설정돼 있는가(값은 절대 노출하지 않음) # 보조 프로바이더(읽기전용, env 관리) stt_provider: str = "" vision_provider: str = "" class LLMUpdateIn(BaseModel): """LLM 연결 저장. None=미변경, 빈 문자열은 '기본으로 되돌리기'.""" provider: Optional[str] = None model: Optional[str] = None host: Optional[str] = None timeout: Optional[float] = None api_key: Optional[str] = None # 암호화 저장. 빈 문자열=키 삭제, None=미변경 class LLMTestIn(BaseModel): """연결 테스트(저장 없이). 미지정 필드는 유효 설정값 사용.""" provider: Optional[str] = None host: Optional[str] = None model: Optional[str] = None api_key: Optional[str] = None # 미지정 시 저장된 키 사용 class LLMTestOut(BaseModel): reachable: bool provider: str model: str host: Optional[str] = None models: list[str] = [] # ollama /api/tags 결과 detail: str = "" class SystemConfigOut(BaseModel): """런타임/환경 상태 스냅샷(읽기전용). env(.env)로 관리됨을 명시.""" env: str # dev | prod auth_enabled: bool worker_enabled: bool database: str # sqlite | postgres | ... sync_interval_minutes: int sync_page_size: int connector_modes: dict[str, str] # {calendar: mock, mail: mock}