You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1307 lines
44 KiB
Python

This file contains ambiguous Unicode characters!

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

# 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:<name> | of:<id>
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:<id>(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>"
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-<hash>"
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:<model>" | "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)