# backend/app/models.py # 주의: `from __future__ import annotations` 를 쓰지 않는다 — # SQLAlchemy 2.x 가 Relationship 의 list["Project"] 문자열 주석을 해석하지 못해 # 매퍼 초기화에 실패한다(ForwardRef 로 평가되도록 둔다). from datetime import UTC, date, datetime from enum import Enum from typing import Optional from sqlalchemy import JSON, Column, Text from sqlmodel import Field, Relationship, SQLModel def now() -> datetime: return datetime.now(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 email: str = "" # phase-9 메일 발신자 주소 role: str = "" # phase-9 "프로덕트 디자이너" 등 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", ... user_id: str = Field(default="jiwoo", foreign_key="person.id", index=True) # phase-15 소유자 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") # phase-15: user_id 도 person FK 라 assignee 조인을 명시(다중 FK 모호성 해소) assignee: Optional[Person] = Relationship( sa_relationship_kwargs={"foreign_keys": "Task.assignee_id"} ) class TaskComment(SQLModel, table=True): __tablename__ = "task_comment" id: str = Field(primary_key=True) # TEXT PK 예: "c1" 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) edited_at: Optional[datetime] = None # 수정 시각(없으면 미수정) 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", ... user_id: str = Field(default="jiwoo", foreign_key="person.id", index=True) # phase-15 소유자 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" inbox_item_id: str = Field(foreign_key="inbox_item.id") # 1:1 최신 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): """phase-2 읽기전용 → phase-7 '쓰기 가능'으로 확장. 기존 필드는 유지하고 status/source/rule_id/created_at/executed_at/undone_at 추가.""" __tablename__ = "approval" id: str = Field(primary_key=True) # "a1", ..., 신규 "ap-xxxx" user_id: str = Field(default="jiwoo", foreign_key="person.id", index=True) # phase-15 소유자 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 # --- 신규(phase-7) --- status: str = "pending" # pending|approved|executed|undone source: str = "system" # automation|agent|mail|calendar|finance|inbox|life|system rule_id: Optional[str] = Field(default=None, foreign_key="automation_rule.id") created_at: datetime = Field(default_factory=now) executed_at: Optional[datetime] = None undone_at: Optional[datetime] = None 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)' 저장 금지 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일 일요일") 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 # ==================== phase-7: 자율성 코어 (결재함 + 자동화) ==================== # enum 값은 영문(상태 머신/risk), 표시 라벨은 프론트가 매핑. class RiskLevel(str, Enum): low = "low" # 되돌릴 수 있는 일 — mixed 이상 자동 실행 high = "high" # 보내기·결제·타인 전달 — 항상 승인 대기 class ApprovalStatus(str, Enum): pending = "pending" # 승인 대기 approved = "approved" # high 승인 직후(표시용) executed = "executed" # 실행 완료(자동/승인) undone = "undone" # 되돌림 class ApprovalSource(str, Enum): automation = "automation" agent = "agent" mail = "mail" calendar = "calendar" finance = "finance" inbox = "inbox" life = "life" system = "system" class AutonomyLevel(str, Enum): approval_first = "approval_first" # 승인 우선 (원본 id "approve") mixed = "mixed" # 혼합 (추천) full_auto = "full_auto" # 완전 자율 (원본 id "auto") class AutonomySetting(SQLModel, table=True): """단일 row(id="default"). 데모 단일 사용자(지우)의 자율성 레벨.""" __tablename__ = "autonomy_setting" id: str = Field(default="default", primary_key=True) # 단일 row id="default" (TEXT PK) level: str = AutonomyLevel.mixed.value # 기본 '혼합'(원본 추천) updated_at: datetime = Field(default_factory=now) class ApprovalLog(SQLModel, table=True): """결재함 활동 로그 — '밤사이 조용히 한 일'. approve-data.js log 이식.""" __tablename__ = "approval_log" id: str = Field(primary_key=True) # "alog1"... time: str # 표시용("08:55","어제 23:10") text: str approval_id: Optional[str] = Field(default=None, foreign_key="approval.id") sort_order: int = 0 created_at: datetime = Field(default_factory=now) class AutomationRule(SQLModel, table=True): __tablename__ = "automation_rule" id: str = Field(primary_key=True) # "r1"~"r7", 신규 "rule-xxxx" name: str cat: str # mail | cal | focus | life trigger: str cond: Optional[str] = None # None 이면 조건 칩 미표시 action: str on: bool = True # 켜짐/꺼짐 last: str = "" # "오늘 09:12"/"방금 만듦"/"지난주 금" runs: int = 0 # 이번 주 실행 횟수 fresh: bool = False # 막 생성됨 → 프론트 rl-new 애니메이션 source: str = "user" # user | suggestion | example created_at: datetime = Field(default_factory=now) class AutomationSuggestion(SQLModel, table=True): __tablename__ = "automation_suggestion" id: str = Field(primary_key=True) # "asug-s1"... (시드 원본 s1/s2) pattern: str # "월요일 아침마다 ..." offer_name: str offer_cat: str offer_trigger: str offer_cond: Optional[str] = None offer_action: str status: str = "open" # open | accepted | dismissed created_at: datetime = Field(default_factory=now) class AutomationRunLog(SQLModel, table=True): __tablename__ = "automation_run_log" id: str = Field(primary_key=True) # "arun1"... time: str # "12:48"/"어제 18:30" rule_id: Optional[str] = Field(default=None, foreign_key="automation_rule.id") rule: str # 규칙명 스냅샷("영수증 자동 정리") text: str # "점심 결제 13,500원 → 식비로 분류" undone: bool = False sort_order: int = 0 created_at: datetime = Field(default_factory=now) class AutomationStats(SQLModel, table=True): """단일 row(id=1). 페이지 eyebrow 통계. auto-data.js stats 이식.""" __tablename__ = "automation_stats" id: Optional[int] = Field(default=None, primary_key=True) # 단일 row id=1 active: int = 0 # 활성 규칙 수(파생; 시드값 7) runs_week: int = 0 # 이번 주 실행(시드 31) saved: str = "" # "1시간 40분" # ==================== phase-8: 일정 + 회의 도우미 ==================== class Calendar(SQLModel, table=True): """캘린더 카테고리 (cal-data.js: cals).""" __tablename__ = "calendar" id: str = Field(primary_key=True) # work/meeting/personal/team/health name: str # 업무/미팅/개인/팀/건강 tone: str = "ink" # tone 집합 on: bool = True # 기본 표시 (프론트는 localStorage 로 토글 영속) sort_order: int = 0 class CalEvent(SQLModel, table=True): """캘린더 이벤트 (cal-data.js: events) — 시간 그리드용. 대시보드 event 와 별개 테이블.""" __tablename__ = "cal_event" id: str = Field(primary_key=True) # "e1".."e22" day: int # 날짜의 '일'(1..31) — 데모 호환. 실데이터는 date 우선. date: str = "" # 실제 날짜 ISO "YYYY-MM-DD"(외부 동기화). 비면 데모(6월)로 간주. start: str # "HH:MM" end: str # "HH:MM" title: str cal: str = Field(foreign_key="calendar.id") # 카테고리 loc: str = "" note: str = "" soon: bool = False # '곧' 배지 people: str = "" # 콤마구분 문자열(표시용 호환) rrule: str = "" # iCalendar RRULE (반복 일정), 예 "FREQ=WEEKLY;BYDAY=MO,WE" reminders: list = Field(default_factory=list, sa_column=Column(JSON)) # [{minutes:int}] attendees: list = Field(default_factory=list, sa_column=Column(JSON)) # [{email,name,status}] response_status: str = "" # 내 RSVP: accepted|declined|tentative|needsAction sort_order: int = 0 class FocusBlock(SQLModel, table=True): """집중 모드 블록 (cal-data.js: focusBlocks).""" __tablename__ = "focus_block" id: str = Field(primary_key=True) # "f1".."f4" day: int start: str end: str title: str type: str = "light" # "light" | "deep" tag: str = "" task_id: Optional[str] = Field(default=None, foreign_key="task.id") auto: bool = True # 아리 자동 배치 여부 sort_order: int = 0 class MeetPhase(str, Enum): done = "done" live = "live" upcoming = "upcoming" class Meeting(SQLModel, table=True): """회의 (cal-data.js: meets[evId]). 표시 전용 중첩 데이터는 *_json TEXT 로 저장.""" __tablename__ = "meeting" id: str = Field(primary_key=True) # event id 와 동일 키 ("e3","e4","e6","e7") event_id: str = Field(foreign_key="cal_event.id") phase: MeetPhase = MeetPhase.upcoming one_on_one: bool = False meta: str = "" starts_in: str = "" summary_json: str = "[]" decisions_json: str = "[]" agenda_json: str = "[]" last_meeting_json: str = "{}" last_actions_json: str = "[]" insights_json: str = "[]" docs_json: str = "[]" person_json: str = "{}" promises_json: str = "[]" signals_json: str = "[]" talking_points_json: str = "[]" actions: list["MeetingAction"] = Relationship(back_populates="meeting") class MeetingAction(SQLModel, table=True): """회의/이벤트 액션 아이템 (작업으로 실체화 대상).""" __tablename__ = "meeting_action" id: str = Field(primary_key=True) # "ma1"/"ea1"... meeting_id: Optional[str] = Field(default=None, foreign_key="meeting.id") event_id: Optional[str] = Field(default=None, foreign_key="cal_event.id") source: str = "meeting" # "meeting" | "event" idx: int = 0 # 회의 내 순번 text: str who: str = "나" # 담당자 표시명 (나/현우/수아...) when_text: str = "" # 오늘/내일/이번 주 added: bool = False # 작업으로 보냈는가 materialized_task_id: Optional[str] = Field(default=None, foreign_key="task.id") sort_order: int = 0 meeting: Optional[Meeting] = Relationship(back_populates="actions") # ==================== phase-9: 메일 + 알림 ==================== class MailPriority(str, Enum): high = "높음" normal = "보통" low = "낮음" class NotifyBucket(str, Enum): now = "now" # 지금 (직접 봐야 함) later = "later" # 나중에 (묶어서 다이제스트) held = "held" # 아리가 처리 (자동 · 되돌리기 가능) # ---------- 메일 (3계정 통합) ---------- class MailAccount(SQLModel, table=True): __tablename__ = "mail_account" id: str = Field(primary_key=True) # work | personal | side name: str email: str tone: str = "ink" kind: str = "" # "Lumi Workspace" | "Google" | "HEY" unread: int = 0 connector: str = "mock" sort_order: int = 0 class MailFolder(SQLModel, table=True): __tablename__ = "mail_folder" id: str = Field(primary_key=True) # inbox|starred|sent|drafts|archive name: str icon: str = "inbox" sort_order: int = 0 class AccountFolder(SQLModel, table=True): """계정별 실제 폴더/라벨(Gmail 라벨·Outlook 메일폴더)을 sync 때 미러링. 사이드바 트리가 계정마다 진짜 폴더 구조를 보여주는 원천.""" __tablename__ = "account_folder" id: str = Field(primary_key=True) # f"{mail_account}:{slug}" mail_account: str = Field(foreign_key="mail_account.id", index=True) slug: str # 필터 키: inbox/spam/trash/archive/sent | cat:promotions | label: | of: name: str # 표시명(시스템은 한글, 사용자 라벨/커스텀 폴더는 원본) kind: str = "system" # system | category | label | custom icon: str = "folder" provider_id: str = "" # 원격 폴더/라벨 id(Outlook folderId / Gmail labelId) unread: int = 0 # 안읽음 수(Outlook=서버값, Gmail=동기화분 기준) total: int = 0 sort_order: int = 0 class Email(SQLModel, table=True): __tablename__ = "email" id: str = Field(primary_key=True) # "m1".."m8" user_id: str = Field(default="jiwoo", foreign_key="person.id", index=True) # phase-15 소유자 account: str = Field(foreign_key="mail_account.id") from_key: str # people 키 ("hyunwoo" 등). 'from'은 예약어라 from_key to: str = "" cc: str = "" # 받은 메일의 참조(콤마 구분) thread_id: str = "" # Gmail threadId / Outlook conversationId (대화 묶기) subject: str = "" time: str = "" # "8분 전" date: str = "" # "오늘 09:12" read: bool = False starred: bool = False archived: bool = False has_attach: bool = False labels: list = Field(default_factory=list, sa_column=Column(JSON)) preview: str = "" body: list = Field(default_factory=list, sa_column=Column(JSON)) # 원본 HTML 본문(샌드박스 iframe 렌더용; cid: 인라인 이미지는 읽기 시 첨부 URL 로 치환). body_html: str = Field(default="", sa_column=Column(Text)) attachments: list = Field(default_factory=list, sa_column=Column(JSON)) ai_json: dict = Field(default_factory=dict, sa_column=Column(JSON)) sort_order: int = 0 # 실제 수신 시각(Gmail internalDate / Outlook receivedDateTime). 목록 정렬(최신순)·표시 기준. received_at: datetime | None = Field(default=None, index=True) # 메일이 속한 폴더(provider 기준 1개). inbox/spam/trash/archive/sent | of:(Outlook 커스텀). folder: str = Field(default="inbox", index=True) folder_name: str = "" # Outlook 커스텀 폴더 표시명(시스템 폴더는 비움 → 표준 한글명 사용) # Outlook 집중 받은편지함 분류(focused/other). 빈 문자열=미분류(Gmail 등). 받은편지함=focused. inference: str = "" created_at: datetime = Field(default_factory=now) class Sent(SQLModel, table=True): __tablename__ = "sent" id: str = Field(primary_key=True) account: str = Field(foreign_key="mail_account.id") to: str = "" to_key: str = "" subject: str = "" time: str = "" date: str = "" preview: str = "" sort_order: int = 0 created_at: datetime = Field(default_factory=now) class OutboundMail(SQLModel, table=True): """phase-16: 발송 대기 메일. /mail/send 가 결재와 함께 생성, 승인 시 실제 발송. connector_account_id 가 real+connected 면 Gmail/Graph 로 발송, 아니면 mock(Sent 행).""" __tablename__ = "outbound_mail" id: str = Field(primary_key=True) # "ob-xxxx" approval_id: str = Field(foreign_key="approval.id", index=True) connector_account_id: Optional[str] = Field(default=None, foreign_key="connector_account.id") mail_account: str = "" # 발신 MailAccount id(work|personal|ma-xxxx) to: str = "" cc: str = "" # 참조(콤마 구분) bcc: str = "" # 숨은참조(콤마 구분) subject: str = "" body: str = "" # [{name,mime,data_b64}] attachments: list = Field(default_factory=list, sa_column=Column(JSON)) status: str = "pending" # pending | sent | failed | mock error_detail: str = "" created_at: datetime = Field(default_factory=now) sent_at: Optional[datetime] = None class Draft(SQLModel, table=True): __tablename__ = "draft" id: str = Field(primary_key=True) account: str = Field(foreign_key="mail_account.id") to: str = "" subject: str = "" time: str = "" preview: str = "" body: str = "" sort_order: int = 0 created_at: datetime = Field(default_factory=now) # ---------- 알림 트리아지 ---------- class Notification(SQLModel, table=True): __tablename__ = "notification" id: str = Field(primary_key=True) # "n1".., "l1".., "h1".. user_id: str = Field(default="jiwoo", foreign_key="person.id", index=True) # phase-15 소유자 bucket: NotifyBucket = NotifyBucket.now src: str = "app" # mail|cal|chat|fin|app app: str = "" title: str = "" sum: str = "" # 본문 요약 (원본 키 'sum' 보존) time: str = "" why: str = "" # 분류 이유 칩 텍스트 tone: str = "ink" # 이유 칩 색 (muted 포함) act: str = "" # 주요 액션 라벨 (now 전용) held_by_rule: str = "" # held 일 때 적용된 sender_rule id sort_order: int = 0 created_at: datetime = Field(default_factory=now) class NotificationGuard(SQLModel, table=True): """단일 row(id=1). 집중 보호 + 조용한 시간.""" __tablename__ = "notification_guard" id: Optional[int] = Field(default=None, primary_key=True) on: bool = True until: str = "16:00" held: int = 4 quiet: str = "22:30 – 07:00" class Digest(SQLModel, table=True): __tablename__ = "digest" id: str = Field(primary_key=True) # "dg-am" 등 time: str = "" name: str = "" desc: str = "" count: int = 0 done: bool = False sort_order: int = 0 class SenderRule(SQLModel, table=True): __tablename__ = "sender_rule" id: str = Field(primary_key=True) # "sr1".. who: str = "" from_label: str = "" # 원본 'from' rule: str = "" bucket: NotifyBucket = NotifyBucket.now tone: str = "ink" sort_order: int = 0 class NotifyStats(SQLModel, table=True): """단일 row(id=1). 오늘 요약.""" __tablename__ = "notify_stats" id: Optional[int] = Field(default=None, primary_key=True) total: int = 0 seen: int = 0 filtered: str = "" later_more: int = 0 held_more: int = 0 # ==================== phase-10: 리서치 + 여행 ==================== class SourceKind(str, Enum): pdf = "pdf" web = "web" note = "note" class ResearchCollection(SQLModel, table=True): __tablename__ = "research_collection" id: str = Field(primary_key=True) # chip/onb/stroller user_id: str = Field(default="jiwoo", foreign_key="person.id", index=True) # phase-15 소유자 name: str tone: str = "ink" n: int = 0 sort_order: int = 0 class ResearchSource(SQLModel, table=True): __tablename__ = "research_source" id: str = Field(primary_key=True) # s1..s5 kind: SourceKind title: str from_label: str = "" # 원본 from (응답 alias "from") col: Optional[str] = Field(default=None, foreign_key="research_collection.id") learned: bool = False sort_order: int = 0 class ResearchReport(SQLModel, table=True): __tablename__ = "research_report" id: str = Field(primary_key=True) # rp1 title: str asked: str = "" meta: str = "" synthesis: str = "" # HTML note: str = "" counts: list = Field(default_factory=list, sa_column=Column(JSON)) cross: list = Field(default_factory=list, sa_column=Column(JSON)) collection_id: Optional[str] = Field(default=None, foreign_key="research_collection.id") created_at: datetime = Field(default_factory=now) class ResearchQA(SQLModel, table=True): __tablename__ = "knowledge_qa" # CONTRACT 명명 id: str = Field(primary_key=True) # qa1 q: str a: str = "" # HTML refs: list = Field(default_factory=list, sa_column=Column(JSON)) collection_id: Optional[str] = Field(default=None, foreign_key="research_collection.id") created_at: datetime = Field(default_factory=now) class ResearchChart(SQLModel, table=True): __tablename__ = "research_chart" id: str = Field(primary_key=True) # ch1 title: str asked: str = "" unit: str = "" bars: list = Field(default_factory=list, sa_column=Column(JSON)) insight: str = "" # HTML caution: str = "" class RagChunk(SQLModel, table=True): """ingest 된 자료의 청크 + 임베딩(vector store).""" __tablename__ = "rag_chunk" id: str = Field(primary_key=True) # chk_s1_0 source_id: str = Field(foreign_key="research_source.id") seq: int = 0 text: str part_label: str = "" embedding: list = Field(default_factory=list, sa_column=Column(JSON)) dim: int = 0 model: str = "" created_at: datetime = Field(default_factory=now) # ---------- 여행 ---------- class Trip(SQLModel, table=True): __tablename__ = "trip" id: str = Field(primary_key=True) # tp_busan user_id: str = Field(default="jiwoo", foreign_key="person.id", index=True) # phase-15 소유자 dday: str = "" title: str = "" dates: str = "" purpose: str = "" brief: str = "" weather: list = Field(default_factory=list, sa_column=Column(JSON)) expense_budget: int = 0 expense_planned: int = 0 expense_note: str = "" expense_rows: list = Field(default_factory=list, sa_column=Column(JSON)) class TripRoute(SQLModel, table=True): __tablename__ = "trip_route" id: str = Field(primary_key=True) # rt_out/rt_back trip_id: str = Field(foreign_key="trip.id") dir: str # out | back mode: str from_st: str ft: str to_st: str tt: str seat: str = "" note: str = "" class TripStay(SQLModel, table=True): __tablename__ = "trip_stay" id: str = Field(primary_key=True) trip_id: str = Field(foreign_key="trip.id") name: str desc: str = "" conf: str = "" class TripPrep(SQLModel, table=True): __tablename__ = "trip_prep" id: str = Field(primary_key=True) trip_id: str = Field(foreign_key="trip.id") text: str state: str = "done" # done | doing sort_order: int = 0 class TripDay(SQLModel, table=True): __tablename__ = "trip_day" id: str = Field(primary_key=True) # d1/d2 trip_id: str = Field(foreign_key="trip.id") tab: str items: list = Field(default_factory=list, sa_column=Column(JSON)) sort_order: int = 0 class TripChecklist(SQLModel, table=True): __tablename__ = "trip_checklist" id: str = Field(primary_key=True) # c1..c7 trip_id: str = Field(foreign_key="trip.id") group: str text: str auto: str = "" sort_order: int = 0 class SavedTrip(SQLModel, table=True): __tablename__ = "saved_trip" id: str = Field(primary_key=True) # sv1..sv3 title: str tag: str = "" note: str = "" now: str = "" # "89,000원" delta: str = "" down: bool = True watch: bool = True hint: str = "" spark: list = Field(default_factory=list, sa_column=Column(JSON)) sort_order: int = 0 class TripPlan(SQLModel, table=True): __tablename__ = "trip_plan" id: str = Field(primary_key=True) # plan_jeju/plan_tokyo example: str idx: int = 0 title: str meta: str = "" summary: str = "" # HTML weather: str = "" transport: list = Field(default_factory=list, sa_column=Column(JSON)) stay: list = Field(default_factory=list, sa_column=Column(JSON)) days: list = Field(default_factory=list, sa_column=Column(JSON)) budget: dict = Field(default_factory=dict, sa_column=Column(JSON)) checklist: list = Field(default_factory=list, sa_column=Column(JSON)) sources: list = Field(default_factory=list, sa_column=Column(JSON)) # ==================== phase-11: 라이프 케어 (건강·금융·지식) ==================== class ConnectorDomain(str, Enum): health = "health" finance = "finance" knowledge = "knowledge" # phase-13: 외부 연동 도메인 확장(mail/calendar/chat) mail = "mail" calendar = "calendar" chat = "chat" class KnowledgeType(str, Enum): article = "article" note = "note" idea = "idea" highlight = "highlight" class InsightKind(str, Enum): alert = "alert" save = "save" info = "info" class ConnectorSource(SQLModel, table=True): """좌측 레일 '연결된 데이터'. life-data.js sources 그대로.""" __tablename__ = "connector_source" id: str = Field(primary_key=True) # apple/watch/fit/woori/... domain: ConnectorDomain name: str kind: str tone: str = "ink" on: bool = True last: str = "" sort_order: int = 0 class HealthRing(SQLModel, table=True): __tablename__ = "health_ring" id: str = Field(primary_key=True) label: str val: str unit: str = "" goal: str pct: int = 0 icon: str tone: str = "coral" sort_order: int = 0 class HealthVital(SQLModel, table=True): __tablename__ = "health_vital" id: str = Field(primary_key=True) label: str val: str unit: str = "" trend: str = "flat" # up|down|flat note: str = "" tone: str = "coral" sort_order: int = 0 class SleepNight(SQLModel, table=True): """단일 row(id=1). 어젯밤 수면 + 7일 추이.""" __tablename__ = "sleep_night" id: Optional[int] = Field(default=None, primary_key=True) total: str = "" score: int = 0 stages_json: str = "[]" week_json: str = "[]" note: str = "" class HealthHabit(SQLModel, table=True): __tablename__ = "health_habit" id: str = Field(primary_key=True) # water/stretch/vitamin/meditate title: str goal: str done: int = 0 total: int = 1 streak: int = 0 icon: str tone: str = "blue" at: str = "" automation_rule_id: Optional[str] = Field(default=None, foreign_key="automation_rule.id") sort_order: int = 0 class FinanceBudget(SQLModel, table=True): """단일 row(id=1). 이달 예산.""" __tablename__ = "finance_budget" id: Optional[int] = Field(default=None, primary_key=True) spent: int = 0 limit: int = 0 pct: int = 0 delta_pct: int = 0 days_left: int = 0 class FinanceCategory(SQLModel, table=True): __tablename__ = "finance_category" id: str = Field(primary_key=True) name: str amt: int = 0 pct: int = 0 icon: str tone: str = "coral" over: bool = False sort_order: int = 0 class FinanceSub(SQLModel, table=True): __tablename__ = "finance_sub" id: str = Field(primary_key=True) name: str date: str = "" in_days: int = 0 amt: int = 0 icon: str tone: str = "coral" note: str = "" paused: bool = False sort_order: int = 0 class FinanceInsight(SQLModel, table=True): __tablename__ = "finance_insight" id: str = Field(primary_key=True) # ins-shop/ins-netflix/ins-save kind: InsightKind icon: str title: str body: str action: str = "" # pause_sub|raise_budget|auto_transfer|"" action_ref: str = "" sort_order: int = 0 class KnowledgeCollection(SQLModel, table=True): __tablename__ = "knowledge_collection" id: str = Field(primary_key=True) name: str count: int = 0 tone: str = "blue" sort_order: int = 0 class KnowledgeItem(SQLModel, table=True): """제2의 뇌 스크랩. phase-10 research_source 와 공유 가능.""" __tablename__ = "knowledge_item" id: str = Field(primary_key=True) # k1..k5 type: KnowledgeType title: str src: str = "" time: str = "" tone: str = "blue" icon: str tags_json: str = "[]" excerpt: str = "" ai: str = "" # 아리 제안(HTML) collection_id: Optional[str] = Field(default=None, foreign_key="knowledge_collection.id") connector_id: Optional[str] = Field(default=None, foreign_key="connector_source.id") created_at: datetime = Field(default_factory=now) sort_order: int = 0 # ==================== phase-12: 여정 ==================== class JourneyLink(SQLModel, table=True): """여정 보드의 작업↔사람↔미팅 연결선. journey-clean-data.js links 이식.""" __tablename__ = "journey_link" id: str = Field(primary_key=True) # jl1.. from_node: str to_node: str tone: str = "ink" dash: bool = False sort_order: int = 0 # ==================== phase-13: 외부 연동 & 데이터 커넥터 ==================== class ConnectorMode(str, Enum): mock = "mock" csv = "csv" healthkit = "healthkit" ics = "ics" real = "real" class ConnState(str, Enum): connected = "connected" # 정상 연결 (라이프 카드 on:true) disconnected = "disconnected" # 미연결 ("연결 안 됨") syncing = "syncing" # 동기화 중 error = "error" # 마지막 sync 실패 token_expired = "token_expired" # 권한 만료 → 재인증 필요 class ConnectorAccount(SQLModel, table=True): """한 도메인의 한 제공자 계정. 예: mail/work(jiwoo@lumi.co), finance/woori.""" __tablename__ = "connector_account" id: str = Field(primary_key=True) # "ca-mail-work" user_id: str = Field(default="jiwoo", foreign_key="person.id") # 단일 데모(phase-15 멀티유저) domain: ConnectorDomain mode: ConnectorMode = ConnectorMode.mock provider: str = "" # "gmail" | "google_calendar" | "notion" | "woori" ... external_account_id: str = "" # 제공자 계정 식별자 name: str = "" # 표시명 "회사" / "우리카드" kind: str = "" # "Lumi Workspace" / "신용/체크" tone: str = "ink" # blue|violet|coral|green|amber|ink|faint state: ConnState = ConnState.disconnected last_synced_at: Optional[datetime] = None last_label: str = "연결 안 됨" # 라이프 카드 last 문구 token_enc: str = "" # 암호화된 토큰 JSON. 평문 저장 금지. scopes: str = "" # 콤마구분 OAuth 스코프 error_detail: str = "" created_at: datetime = Field(default_factory=now) updated_at: datetime = Field(default_factory=now) class ConnectorSyncState(SQLModel, table=True): """증분 sync 커서. 제공자별 델타 토큰 또는 since 타임스탬프.""" __tablename__ = "connector_sync_state" id: str = Field(primary_key=True) # "cs-" account_id: str = Field(foreign_key="connector_account.id") cursor: str = "" # Gmail historyId / Google syncToken / ISO since page_token: str = "" # 진행 중 페이지네이션 토큰(중단 복구용) last_full_sync_at: Optional[datetime] = None last_delta_sync_at: Optional[datetime] = None items_seen: int = 0 items_upserted: int = 0 class ExternalLink(SQLModel, table=True): """외부 제공자 객체 ↔ 내부 엔티티 매핑(중복/충돌 처리의 핵심). (account_id, external_id) UNIQUE 로 멱등 upsert.""" __tablename__ = "external_link" id: str = Field(primary_key=True) # "xl-" account_id: str = Field(foreign_key="connector_account.id", index=True) external_id: str = Field(index=True) # 제공자측 고유 id entity_type: str # "email" | "event" | "finance_tx" | "health_sample" | ... entity_id: str # 내부 엔티티 PK etag: str = "" # 변경 감지용 external_updated_at: Optional[datetime] = None created_at: datetime = Field(default_factory=now) class OAuthState(SQLModel, table=True): """OAuth Authorization Code 플로우의 CSRF state + PKCE verifier 임시 저장.""" __tablename__ = "oauth_state" id: str = Field(primary_key=True) # state nonce 자체를 PK 로 domain: ConnectorDomain provider: str code_verifier: str = "" # PKCE redirect_after: str = "/life" # 콜백 후 프론트 복귀 경로 created_at: datetime = Field(default_factory=now) class ConnectorAccountLog(SQLModel, table=True): """연결/해제/동기화/오류 활동 로그.""" __tablename__ = "connector_account_log" id: str = Field(primary_key=True) # "cl-xxxx" account_id: str = Field(foreign_key="connector_account.id") action: str # "connected"|"disconnected"|"sync"|"error"|"token_refreshed"|"import" detail: str = "" items: int = 0 created_at: datetime = Field(default_factory=now) class FinanceTx(SQLModel, table=True): """금융 거래(카드 명세/계좌 입출금). CSV 임포트·아그리게이터 sync 의 write 타깃.""" __tablename__ = "finance_tx" id: str = Field(primary_key=True) # "ftx-xxxx" account_id: str = Field(default="", foreign_key="connector_account.id") date: str = "" # "2026-06-08" 또는 "오늘" merchant: str = "" amount: int = 0 # 정수 원화(음수=환불/입금) category: str = "" # 식비/쇼핑/교통/카페·간식/구독/문화·여가 category_id: str = "" # finance_category.id (있으면) account_label: str = "" # "우리카드" 등 created_at: datetime = Field(default_factory=now) class HealthSample(SQLModel, table=True): """건강 샘플(걸음/수면/심박 등). HealthKit export·Google Fit sync 의 write 타깃.""" __tablename__ = "health_sample" id: str = Field(primary_key=True) # "hs-xxxx" account_id: str = Field(default="", foreign_key="connector_account.id") kind: str = "" # "steps" | "sleep" | "heart_rate" | "exercise" value: float = 0.0 unit: str = "" day: str = "" # "2026-06-08" created_at: datetime = Field(default_factory=now) class ChatMessage(SQLModel, table=True): """메신저 인바운드 메시지. chat webhook 의 write 타깃 → notification 트리아지.""" __tablename__ = "chat_message" id: str = Field(primary_key=True) # "cm-xxxx" account_id: str = Field(default="", foreign_key="connector_account.id") sender: str = "" text: str = "" channel: str = "" at: str = "" created_at: datetime = Field(default_factory=now) # ==================== phase-14: 능동 에이전트 + 멀티모달 ==================== class ErrandKind(str, Enum): booking = "booking" # 예약(병원/식당/수리) refund = "refund" # 환불 요청 cancel = "cancel" # 구독 해지/일시정지 (a6 Netflix) support = "support" # 고객센터 문의 class ErrandStatus(str, Enum): planning = "planning" running = "running" awaiting_approval = "awaiting_approval" # external_effect → 결재함 대기 done = "done" failed = "failed" cancelled = "cancelled" class StepState(str, Enum): pending = "pending" running = "running" done = "done" blocked = "blocked" # 승인 대기로 막힘 error = "error" class ProactiveKind(str, Enum): schedule = "schedule" # 미팅 연속 → 점심 보호 finance = "finance" # 청구서 이상 focus = "focus" # 딥워크 보호 health = "health" # 건강 코칭 digest = "digest" # 다이제스트/브리핑 class ErrandTask(SQLModel, table=True): __tablename__ = "errand_task" id: str = Field(primary_key=True) # "er1".."erN" kind: str = ErrandKind.cancel.value title: str goal: str = "" target: Optional[str] = None tone: str = "amber" status: str = ErrandStatus.planning.value autonomy_at_start: str = "mixed" # 시작 시점 자율성 스냅샷 approval_id: Optional[str] = Field(default=None, foreign_key="approval.id") result_summary: Optional[str] = None model: str = "" # "agent:" | "scripted" created_at: datetime = Field(default_factory=now) updated_at: datetime = Field(default_factory=now) class ErrandStep(SQLModel, table=True): """plan→act(tool)→observe→reflect 한 스텝. 타임라인의 1행.""" __tablename__ = "errand_step" id: str = Field(primary_key=True) # "er1-s1" errand_id: str = Field(foreign_key="errand_task.id", index=True) seq: int phase: str # "plan"|"act"|"observe"|"reflect" tool: Optional[str] = None label: str detail: Optional[str] = None external_effect: bool = False # True면 결재함 게이트(발송/결제/해지) state: str = StepState.pending.value created_at: datetime = Field(default_factory=now) class ProactiveCard(SQLModel, table=True): __tablename__ = "proactive_card" id: str = Field(primary_key=True) # "pc1".."pcN" kind: str = ProactiveKind.schedule.value icon: str = "spark" tone: str = "violet" title: str why: str = "" cta: Optional[str] = None action_kind: Optional[str] = None # focus_block|task|automation|approval|none action_payload: Optional[str] = None # JSON(text) detected_at: str = "방금" status: str = "active" # active|accepted|dismissed created_at: datetime = Field(default_factory=now) class WeeklyReview(SQLModel, table=True): __tablename__ = "weekly_review" id: str = Field(primary_key=True) # "wr-2026-w24" week_label: str deep_work_h: float = 0.0 meeting_h: float = 0.0 done_count: int = 0 auto_count: int = 0 saved_minutes: int = 0 patterns_json: str = "[]" # [{title, suggest, action_kind, payload}] note: Optional[str] = None created_at: datetime = Field(default_factory=now) # ==================== phase-15: 인증/멀티유저 + 감사 ==================== # person(=user)을 인증 주체로 쓰고 자격만 분리(시드 person 보존). per-user 스코프는 # 기존 테이블에 user_id 컬럼(기본 "jiwoo")을 더해 일반화한다. class UserCredential(SQLModel, table=True): __tablename__ = "user_credential" id: str = Field(primary_key=True) # "uc-jiwoo" user_id: str = Field(foreign_key="person.id", index=True, unique=True) email: str = Field(index=True, unique=True) # 로그인 식별자 password_hash: str # pbkdf2(salt$hash) — 평문 저장 금지 is_active: bool = True role: str = "member" # member | admin created_at: datetime = Field(default_factory=now) last_login_at: Optional[datetime] = None class AuthSession(SQLModel, table=True): __tablename__ = "auth_session" id: str = Field(primary_key=True) # 세션 id(랜덤 hex) user_id: str = Field(foreign_key="person.id", index=True) created_at: datetime = Field(default_factory=now) expires_at: datetime revoked: bool = False user_agent: Optional[str] = None ip: Optional[str] = None class ApiToken(SQLModel, table=True): __tablename__ = "api_token" id: str = Field(primary_key=True) # "tok-xxxx" user_id: str = Field(foreign_key="person.id", index=True) name: str token_hash: str # sha256(token) — 평문 저장 금지 created_at: datetime = Field(default_factory=now) last_used_at: Optional[datetime] = None revoked: bool = False class AuditLog(SQLModel, table=True): """보안 감사 로그(인증/권한/민감 동작). approval_log(업무 narrative)와 구분.""" __tablename__ = "audit_log" id: str = Field(primary_key=True) # "al-xxxx" user_id: Optional[str] = Field(default=None, foreign_key="person.id", index=True) action: str # login|logout|export|scope.denied|prompt_injection.suspected ... target: Optional[str] = None ip: Optional[str] = None detail: Optional[str] = None created_at: datetime = Field(default_factory=now, index=True) # ---------- 설정(런타임 사용자 환경설정 오버레이) ---------- class AppSetting(SQLModel, table=True): """key/value 사용자 설정. .env(읽기전용 ops 구성)와 별개의 런타임 오버레이. 비어 있으면(기본) 앱 동작은 .env/Settings 와 100% 동일하다. 현재 소비처는 LLM 연결(llm.provider|model|host|timeout) — runtime_config 오버레이가 읽는다. """ __tablename__ = "app_setting" key: str = Field(primary_key=True) # 예: "llm.provider" value: str = "" # 항상 문자열 직렬화(숫자/불리언도 str) updated_at: datetime = Field(default_factory=now)