feat(core): 모델·설정·DB·마이그레이션·워커·LLM 시드 갱신

- models/schemas 확장(메일 본문 HTML·received_at·폴더·inference, 발송 메일 등)
  마이그레이션 p17~p22 추가
- db.py WAL+busy_timeout(동시 sync 잠금 방지), config/runtime_config 정리
- llm(ollama/provider) 실모델 경로 정비, 워커 트리거·시드(seed/seed_mail) 갱신

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
main
I Luk Kim 2 months ago
parent e0937a7064
commit c5cf6816f8

@ -12,7 +12,7 @@ class Settings(BaseSettings):
# LLM — 절대 특정 모델을 하드코딩하지 않는다. 설치된 모델을 env로 주입.
ollama_host: str = "http://localhost:11434"
ollama_model: str = "llama3.1" # 기본 placeholder. 실제 운영은 .env 로 덮어쓴다.
llm_provider: str = "auto" # auto | ollama | heuristic
llm_provider: str = "ollama" # 실 LLM 전용(휴리스틱 폴백 제거, phase-16+)
llm_timeout: float = 20.0 # 초
# 리스크 계산 기준일 — CONTRACT: TODAY=8 (6/8). 시드가 '6/7~6/12 한 주' 가정.
@ -28,16 +28,6 @@ class Settings(BaseSettings):
# ---- 포스트-MVP 커넥터 (CONNECTOR_<DOMAIN>=mock|real, 기본 mock) ----
connector_calendar: str = "mock" # phase-8/13
connector_mail: str = "mock" # phase-9/13
connector_chat: str = "mock" # phase-9/13
connector_finance: str = "mock" # phase-11/13
connector_health: str = "mock" # phase-11/13
connector_knowledge: str = "mock" # phase-11/13
# ---- 포스트-MVP 에이전트 / RAG (phase-10) ----
embed_provider: str = "ollama" # EMBED_PROVIDER=ollama|heuristic
embed_model: str = "nomic-embed-text" # EMBED_MODEL (모델 비종속, env 교체)
agent_provider: str = "auto" # AGENT_PROVIDER=auto|tool|scripted
web_search_provider: str = "mock" # WEB_SEARCH_PROVIDER=mock|real
# ---- phase-13: 외부 연동 토큰 암호화 + OAuth 자격증명 + 동기화 정책 ----
# 토큰 암호화 키(real 켤 때 필수). Fernet 키 파생용.
@ -46,13 +36,24 @@ class Settings(BaseSettings):
google_client_id: str = "" # Gmail + Google Calendar 공용
google_client_secret: str = ""
google_redirect_uri: str = "http://localhost:31800/api/connectors/oauth/callback"
# Microsoft(Outlook/Graph) — 개인+조직 공용 tenant 기본 "common".
microsoft_client_id: str = ""
microsoft_client_secret: str = ""
microsoft_tenant: str = "common"
notion_client_id: str = ""
notion_client_secret: str = ""
readwise_token: str = "" # Readwise 는 단순 토큰(OAuth 아님)
# google/microsoft 공용 단일 콜백(state 로 provider 식별). 양쪽 콘솔에 동일 URI 등록.
oauth_redirect_uri: str = "http://localhost:31800/api/connectors/oauth/callback"
# 동기화 정책
sync_interval_minutes: int = 15 # worker 주기 sync
sync_page_size: int = 50
sync_max_messages: int = 500 # 초기/전체 sync 시 1계정 최대 수집 건수(페이지네이션 상한)
connector_http_timeout: float = 20.0
# 앱 내장 자동 풀링(메일·일정): uvicorn lifespan 의 백그라운드 루프가 주기적으로
# 연결된 real 계정을 증분 sync 한다(별도 워커 프로세스 불필요).
auto_sync_enabled: bool = True # AUTO_SYNC_ENABLED
auto_sync_interval_seconds: int = 10 # AUTO_SYNC_INTERVAL_SECONDS (기본 10초)
# ---- phase-15: 인증/멀티유저 + 관측 + 운영 ----
auth_enabled: bool = False # AUTH_ENABLED 기본 off(데모 무손상)

@ -1,16 +1,64 @@
# backend/app/db.py
from sqlalchemy import event, text
from sqlmodel import Session, SQLModel, create_engine
from .config import get_settings
settings = get_settings()
# SQLite + FastAPI: check_same_thread=False 필요
connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
_is_sqlite = settings.database_url.startswith("sqlite")
# SQLite + FastAPI: check_same_thread=False 필요. timeout = busy_timeout(초) — 쓰기 잠금 대기.
connect_args = {"check_same_thread": False, "timeout": 30} if _is_sqlite else {}
engine = create_engine(settings.database_url, echo=False, connect_args=connect_args)
if _is_sqlite:
# WAL: 동기화(쓰기) 중에도 API(읽기)가 막히지 않게 — 'database is locked' 500 방지.
@event.listens_for(engine, "connect")
def _sqlite_pragmas(dbapi_conn, _rec): # noqa: ANN001
cur = dbapi_conn.cursor()
cur.execute("PRAGMA journal_mode=WAL")
cur.execute("PRAGMA busy_timeout=30000")
cur.execute("PRAGMA synchronous=NORMAL")
cur.close()
# 개발용 추가 컬럼(create_all 은 기존 테이블에 컬럼을 추가하지 못함).
# 운영은 alembic 마이그레이션을 쓰고, 여기서는 기존 SQLite DB 데이터를 보존한 채
# 누락 컬럼만 멱등하게 ALTER ADD 한다. 전부 additive + 기본값 보유라 안전.
_DEV_COLUMNS: list[tuple[str, str, str]] = [
("email", "cc", "TEXT NOT NULL DEFAULT ''"),
("email", "thread_id", "TEXT NOT NULL DEFAULT ''"),
("email", "body_html", "TEXT NOT NULL DEFAULT ''"),
("email", "received_at", "TIMESTAMP"), # 수신 시각(최신순 정렬·표시). NULL 허용.
("email", "folder", "TEXT NOT NULL DEFAULT 'inbox'"), # 시스템 폴더/버킷 슬러그
("email", "folder_name", "TEXT NOT NULL DEFAULT ''"), # Outlook 커스텀 폴더 표시명
("email", "inference", "TEXT NOT NULL DEFAULT ''"), # Outlook 집중/기타(focused/other)
("outbound_mail", "cc", "TEXT NOT NULL DEFAULT ''"),
("outbound_mail", "bcc", "TEXT NOT NULL DEFAULT ''"),
("outbound_mail", "attachments", "JSON NOT NULL DEFAULT '[]'"),
("cal_event", "rrule", "TEXT NOT NULL DEFAULT ''"),
("cal_event", "reminders", "JSON NOT NULL DEFAULT '[]'"),
("cal_event", "attendees", "JSON NOT NULL DEFAULT '[]'"),
("cal_event", "response_status", "TEXT NOT NULL DEFAULT ''"),
("task_comment", "edited_at", "DATETIME"),
]
def _ensure_dev_columns() -> None:
if not settings.database_url.startswith("sqlite"):
return
with engine.begin() as conn:
for table, column, ddl in _DEV_COLUMNS:
rows = conn.exec_driver_sql(f"PRAGMA table_info({table})").fetchall()
if not rows: # 테이블 자체가 없으면 create_all 이 만들어 준다
continue
existing = {r[1] for r in rows}
if column in existing:
continue
conn.execute(text(f'ALTER TABLE {table} ADD COLUMN {column} {ddl}'))
def init_db() -> None:
SQLModel.metadata.create_all(engine)
_ensure_dev_columns()
def get_session():

@ -1,14 +1,24 @@
# backend/app/llm/ollama.py
import json
import re
import httpx
from ..runtime_config import effective_llm
from .heuristic import HeuristicProvider
from .prompts import CLASSIFY_SYSTEM, build_classify_prompt
from .provider import Classification, LLMProvider
def _lenient_json(content: str) -> dict:
"""모델 출력에서 JSON 객체 추출 — ```json 펜스·<think> 추론블록·잡설 제거 후 파싱."""
s = content or ""
s = re.sub(r"<think>.*?</think>", "", s, flags=re.DOTALL) # 추론형 모델 사고블록
i, j = s.find("{"), s.rfind("}")
if i != -1 and j != -1 and j > i:
s = s[i : j + 1] # 첫 { ~ 마지막 } (펜스·머리말 제거)
return json.loads(s)
class OllamaProvider(LLMProvider):
name = "ollama"
@ -57,58 +67,54 @@ class OllamaProvider(LLMProvider):
def generate_json(self, prompt: str, schema: dict | None = None) -> dict:
# Ollama 구조화 출력: format="json" (모델 비종속). chat API 사용.
# think=False: 추론형 모델의 사고 단계를 끈다(분류는 즉답이 더 적합·빠름).
# 미지원 모델은 무시되거나, 거부 시 except 에서 폴백 처리.
# num_predict: 충분한 토큰(미지정 시 잘려 빈 응답 → 파싱 실패).
payload = {
"model": self.model,
"messages": [{"role": "user", "content": prompt}],
"stream": False,
"format": "json",
"think": False,
"options": {"temperature": 0.2},
"options": {"temperature": 0.2, "num_predict": 2048},
}
r = httpx.post(f"{self.host}/api/chat", json=payload, timeout=self.timeout)
r.raise_for_status()
content = r.json()["message"]["content"]
return json.loads(content)
return _lenient_json(content)
def classify_capture(self, raw: str, context: dict) -> Classification:
# 실 LLM 전용: 미가용/파싱 실패는 예외로 전파(휴리스틱 폴백 없음, phase-16+).
prompt = build_classify_prompt(raw, context)
try:
payload = {
"model": self.model,
"messages": [
{"role": "system", "content": CLASSIFY_SYSTEM},
{"role": "user", "content": prompt},
],
"stream": False,
"format": "json",
"think": False, # 추론 단계 off → 분류 즉답(수초). 미지원 시 except 폴백.
"options": {"temperature": 0.2},
}
r = httpx.post(f"{self.host}/api/chat", json=payload, timeout=self.timeout)
r.raise_for_status()
data = json.loads(r.json()["message"]["content"])
return _coerce(data, raw, context, model=f"ollama:{self.model}")
except Exception:
# 모델 미가용/파싱 실패 → 규칙 폴백 (오프라인에서도 동작)
return HeuristicProvider().classify_capture(raw, context)
payload = {
"model": self.model,
"messages": [
{"role": "system", "content": CLASSIFY_SYSTEM},
{"role": "user", "content": prompt},
],
"stream": False,
"format": "json",
"think": False, # 추론 단계 off → 분류 즉답(수초).
"options": {"temperature": 0.2, "num_predict": 2048},
}
r = httpx.post(f"{self.host}/api/chat", json=payload, timeout=self.timeout)
r.raise_for_status()
data = _lenient_json(r.json()["message"]["content"])
return _coerce(data, raw, context, model=f"ollama:{self.model}")
def _coerce(data: dict, raw: str, context: dict, model: str) -> Classification:
"""LLM JSON을 Classification으로. 누락 필드는 휴리스틱/기본값 보강."""
h = HeuristicProvider().classify_capture(raw, context) # 폴백 베이스
t = data.get("type") if data.get("type") in ("task", "event", "idea") else h.type
sp = data.get("sphere") if data.get("sphere") in ("work", "life") else h.sphere
"""LLM JSON을 Classification으로. 누락 필드는 명시적 기본값으로 보강(휴리스틱 의존 없음)."""
t = data.get("type") if data.get("type") in ("task", "event", "idea") else "idea"
sp = data.get("sphere") if data.get("sphere") in ("work", "life") else "work"
return Classification(
type=t,
sphere=sp,
project_id=data.get("project_id") or h.project_id,
proj_label=data.get("proj_label") or h.proj_label,
tone=data.get("tone") or h.tone,
due_text=data.get("due_text", h.due_text),
when_text=data.get("when_text", h.when_text),
extra=data.get("extra", h.extra),
reason=data.get("reason") or h.reason,
project_id=data.get("project_id"),
proj_label=data.get("proj_label", ""),
tone=data.get("tone") or "ink",
due_text=data.get("due_text", ""),
when_text=data.get("when_text", ""),
extra=data.get("extra", ""),
reason=data.get("reason", ""),
confidence=float(data.get("confidence", 0.85)),
model=model,
)

@ -45,26 +45,14 @@ _cached: LLMProvider | None = None
def get_provider(force: str | None = None) -> LLMProvider:
"""LLM_PROVIDER=auto|ollama|heuristic. auto면 ollama health 확인 후 폴백.
"""실 LLM(Ollama) 전용. 휴리스틱 폴백 제거(phase-16+): 미가동 시 호출부에서 예외.
force 없으면 런타임 오버레이(설정 페이지 저장값) .env 순으로 유효 provider 한다.
오버레이가 비어 있으면 .env 동일(데모/테스트 무손상). force 항상 최우선(테스트 결정성).
테스트는 conftest get_provider 의존성을 결정적 FakeLLM 으로 오버라이드한다.
force 인자는 하위호환용(현재 Ollama 단일 무시).
"""
global _cached
from ..runtime_config import effective_llm
from .heuristic import HeuristicProvider
from .ollama import OllamaProvider
mode = force or effective_llm().provider
if mode == "heuristic":
return HeuristicProvider()
if mode == "ollama":
return OllamaProvider()
# auto
ollama = OllamaProvider()
if ollama.health().get("reachable"):
return ollama
return HeuristicProvider()
return OllamaProvider()
def reset_provider_cache():

@ -1,4 +1,7 @@
# backend/app/main.py
import asyncio
import contextlib
import logging
from contextlib import asynccontextmanager
from fastapi import APIRouter, FastAPI
@ -8,7 +11,6 @@ from .config import get_settings
from .db import engine, init_db
from .routers import (
_test,
agents,
approvals,
automation,
calendar,
@ -16,16 +18,13 @@ from .routers import (
dashboard,
inbox,
journey,
life,
llm,
mail,
notify,
ops,
people,
research,
tasks,
tree,
trip,
worker,
)
from .routers import (
@ -59,6 +58,27 @@ def _register_event_bus() -> None:
register_event_subscribers(bus, session_factory)
async def _auto_sync_loop() -> None:
"""앱 내장 자동 풀링: 주기적으로 연결된 real 계정(메일·일정)을 증분 sync.
blocking httpx/DB 작업이라 to_thread 실행해 이벤트 루프를 막지 않는다."""
from .worker.sync_jobs import run_periodic_sync
log = logging.getLogger("ari.autosync")
interval = max(5, settings.auto_sync_interval_seconds)
await asyncio.sleep(15) # 기동 직후 한 번(초기 연결 sync 와 겹치지 않게 약간 지연)
while True:
try:
results = await asyncio.to_thread(run_periodic_sync)
new = sum(r.get("upserted", 0) for r in results)
if new:
log.info("auto-sync: %d new records across %d accounts", new, len(results))
except asyncio.CancelledError:
raise
except Exception:
log.exception("auto-sync failed; retrying next interval")
await asyncio.sleep(interval)
@asynccontextmanager
async def lifespan(app: FastAPI):
from .observability.logging import configure_logging
@ -73,7 +93,14 @@ async def lifespan(app: FastAPI):
with Session(engine) as _s:
runtime_config.load_from_db(_s)
yield
sync_task = asyncio.create_task(_auto_sync_loop()) if settings.auto_sync_enabled else None
try:
yield
finally:
if sync_task:
sync_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await sync_task
app = FastAPI(title="아리 Ari API", lifespan=lifespan)
@ -112,17 +139,11 @@ app.include_router(calendar.router, prefix="/api", tags=["calendar"])
# phase-9: 메일 + 알림
app.include_router(mail.router, prefix="/api", tags=["mail"])
app.include_router(notify.router, prefix="/api", tags=["notify"])
# phase-10: 리서치 + 여행
app.include_router(research.router, prefix="/api", tags=["research"])
app.include_router(trip.router, prefix="/api", tags=["trip"])
# phase-11: 라이프 케어
app.include_router(life.router, prefix="/api", tags=["life"])
# phase-12: 여정
app.include_router(journey.router, prefix="/api", tags=["journey"])
app.include_router(connectors.router, prefix="/api", tags=["connectors"])
app.include_router(agents.router, prefix="/api", tags=["agents"])
app.include_router(worker.router, prefix="/api", tags=["worker"])
app.include_router(auth_router.router, prefix="/api", tags=["auth"])

@ -6,7 +6,7 @@ from datetime import UTC, date, datetime
from enum import Enum
from typing import Optional
from sqlalchemy import JSON, Column
from sqlalchemy import JSON, Column, Text
from sqlmodel import Field, Relationship, SQLModel
@ -134,6 +134,7 @@ class TaskComment(SQLModel, table=True):
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()
@ -362,7 +363,8 @@ class CalEvent(SQLModel, table=True):
__tablename__ = "cal_event"
id: str = Field(primary_key=True) # "e1".."e22"
day: int # 6월 날짜 (7..13)
day: int # 날짜의 '일'(1..31) — 데모 호환. 실데이터는 date 우선.
date: str = "" # 실제 날짜 ISO "YYYY-MM-DD"(외부 동기화). 비면 데모(6월)로 간주.
start: str # "HH:MM"
end: str # "HH:MM"
title: str
@ -370,7 +372,11 @@ class CalEvent(SQLModel, table=True):
loc: str = ""
note: str = ""
soon: bool = False # '곧' 배지
people: str = "" # 콤마구분 문자열
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
@ -476,6 +482,23 @@ class MailFolder(SQLModel, table=True):
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"
@ -483,6 +506,8 @@ class Email(SQLModel, table=True):
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"
@ -493,9 +518,18 @@ class Email(SQLModel, table=True):
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)
@ -513,6 +547,28 @@ class Sent(SQLModel, table=True):
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)

@ -13,7 +13,7 @@ from .config import get_settings
# 허용된 LLM 오버레이 키(이 외의 키는 무시).
LLM_KEYS = ("llm.provider", "llm.model", "llm.host", "llm.timeout")
_PROVIDERS = ("auto", "ollama", "heuristic")
_PROVIDERS = ("ollama",) # 실 LLM 전용(auto/heuristic 제거, phase-16+)
# 인메모리 오버레이. key → str value.
_overrides: dict[str, str] = {}
@ -65,7 +65,7 @@ def effective_llm() -> EffectiveLLM:
st = get_settings()
provider = _overrides.get("llm.provider") or st.llm_provider
if provider not in _PROVIDERS:
provider = st.llm_provider
provider = "ollama" # 미지원/구 값(auto·heuristic) → 실 LLM 으로 강제
model = _overrides.get("llm.model") or st.ollama_model
host = _overrides.get("llm.host") or st.ollama_host
raw_timeout = _overrides.get("llm.timeout")

@ -26,10 +26,8 @@ from .models import (
ClsType,
ConnectorAccount,
ConnectorAccountLog,
ConnectorDomain,
ConnectorSource,
ConnectorSyncState,
ConnState,
Digest,
Draft,
Email,
@ -92,10 +90,7 @@ from .models import (
UserCredential,
WeeklyReview,
)
from .seed_calendar import _seed_calendar
from .seed_life import _seed_life
from .seed_mail import _seed_mail, _seed_notify
from .seed_research_trip import _seed_research, _seed_trip
YEAR = 2026
@ -826,165 +821,6 @@ def _seed_automation(s: Session) -> None:
s.add(AutomationStats(id=1, active=7, runs_week=31, saved="1시간 40분"))
# phase-13: 외부 연동 mock 계정 — mail-data.js / life-data.js / cal-data.js 원본 그대로.
# (domain, id, provider, name, kind, tone, on, last)
_CONNECTORS = [
# mail (mail-data.js accounts) — 회사/개인/사이드 3계정
(
"mail",
"ca-mail-work",
"google_workspace",
"회사",
"Lumi Workspace",
"coral",
True,
"방금 동기화",
),
("mail", "ca-mail-personal", "gmail", "개인", "Google", "blue", True, "방금 동기화"),
("mail", "ca-mail-side", "hey", "사이드", "HEY", "violet", True, "방금 동기화"),
# calendar (연동 단위는 google_calendar 1계정)
(
"calendar",
"ca-cal-google",
"google_calendar",
"Google 캘린더",
"캘린더",
"blue",
True,
"방금 동기화",
),
# health (life-data.js sources.health)
(
"health",
"ca-health-apple",
"apple_health",
"Apple Health",
"건강 앱",
"coral",
True,
"방금 동기화",
),
(
"health",
"ca-health-watch",
"apple_watch",
"Apple Watch",
"웨어러블",
"violet",
True,
"1분 전",
),
(
"health",
"ca-health-fit",
"google_fit",
"Google Fit",
"활동 추적",
"green",
False,
"연결 안 됨",
),
# finance (life-data.js sources.finance)
("finance", "ca-fin-woori", "woori", "우리카드", "신용/체크", "blue", True, "오늘 09:12"),
("finance", "ca-fin-toss", "toss", "토스", "계좌 통합", "coral", True, "오늘 08:40"),
("finance", "ca-fin-kakao", "kakaobank", "카카오뱅크", "입출금", "amber", True, "어제"),
("finance", "ca-fin-kb", "kb_sec", "KB증권", "투자", "green", False, "연결 안 됨"),
# knowledge (life-data.js sources.knowledge)
("knowledge", "ca-kn-notion", "notion", "Notion", "메모·문서", "violet", True, "오늘"),
("knowledge", "ca-kn-web", "clipper", "웹 클리퍼", "아티클 스크랩", "blue", True, "2시간 전"),
("knowledge", "ca-kn-readwise", "readwise", "Readwise", "하이라이트", "coral", True, "어제"),
]
def _seed_connectors(s: Session) -> None:
for domain, cid, provider, name, kind, tone, on, last in _CONNECTORS:
s.add(
ConnectorAccount(
id=cid,
user_id="jiwoo",
domain=ConnectorDomain(domain),
provider=provider,
name=name,
kind=kind,
tone=tone,
external_account_id=name,
last_label=last,
state=ConnState.connected if on else ConnState.disconnected,
)
)
# phase-14: 심부름(a6 Netflix 일시정지) — 게이트 직전(awaiting_approval) 상태로 시드.
# (phase, tool, label, detail, external) — agents/errand.py SCRIPTED 와 동일
_ERRAND_STEPS = [
("plan", None, "해지 절차 계획 수립", "결제일·해지 경로·되돌리기 옵션 확인", False),
(
"act",
"web_search",
"Netflix 해지/일시정지 방법 확인",
"계정 설정 > 멤버십 일시정지 경로 확인",
False,
),
("observe", None, "최근 시청 기록 확인", "최근 2개월 시청 0건 — 일시정지 적합", False),
(
"act",
"http_fetch",
"해지 페이지 접속·세션 준비",
"로그인 세션 확보, 일시정지 폼 로드",
False,
),
("reflect", None, "결제 임박 확인", "모레 17,000원 결제 예정 — 그 전에 처리 필요", False),
("act", "form_fill", "일시정지 신청 제출", "‘일시정지’ 버튼 제출 — 외부 영향(결제 변경)", True),
]
def _seed_errands(s: Session) -> None:
s.add(
ErrandTask(
id="er1",
kind="cancel",
title="Netflix 일시정지",
goal="모레 17,000원 결제 전 일시정지, 되돌리기 가능하게",
target="Netflix",
tone="amber",
status="awaiting_approval",
autonomy_at_start="mixed",
approval_id="ap-er1",
model="scripted",
)
)
for seq, (phase, tool, label, detail, ext) in enumerate(_ERRAND_STEPS):
s.add(
ErrandStep(
id=f"er1-s{seq}",
errand_id="er1",
seq=seq,
phase=phase,
tool=tool,
label=label,
detail=detail,
external_effect=ext,
state="blocked" if ext else "done",
)
)
s.add(
Approval(
id="ap-er1",
icon="wallet",
tone="amber",
risk="high",
status="pending",
source="agent",
time="확인 필요",
title="Netflix 일시정지을(를) 추천해요",
detail="‘일시정지’ 버튼 제출 — 외부 영향(결제 변경)",
cta="일시정지",
alt="유지",
sort_order=99, # 대시보드 high-risk 요약(top 3) 뒤에 배치
)
)
def _seed_proactive(s: Session) -> None:
s.add(
ProactiveCard(
@ -1196,10 +1032,7 @@ def _run(s: Session, reset: bool) -> None:
)
s.commit()
for i, node in enumerate(TASKS):
insert_task(s, node, None, node["project"], i)
s.commit()
# phase-16+: 작업(Task) mock 시드 제거 — 빈 상태로 시작(앱에서 직접 생성).
for n, it in enumerate(INBOX, start=1):
s.add(
InboxItem(
@ -1232,15 +1065,10 @@ def _run(s: Session, reset: bool) -> None:
_seed_dashboard(s) # 대시보드 읽기전용 시드 통합
_seed_approvals(s) # phase-7 결재함
_seed_automation(s) # phase-7 자동화
_seed_calendar(s) # phase-8 일정/회의
_seed_mail(s) # phase-9 메일
# phase-16+: 일정(_seed_calendar) mock 시드 제거 — Google 캘린더 연결로 유입.
_seed_mail(s) # phase-9 메일 — 연락처(Person)만 유지(메일/계정 mock 제거)
_seed_notify(s) # phase-9 알림
_seed_research(s) # phase-10 리서치 (RAG ingest 포함)
_seed_trip(s) # phase-10 여행
_seed_life(s) # phase-11 라이프 케어
_seed_journey(s) # phase-12 여정/하루마감
_seed_connectors(s) # phase-13 외부 연동 mock 계정
_seed_errands(s) # phase-14 심부름(a6 Netflix 일시정지, 게이트 직전)
_seed_proactive(s) # phase-14 능동 카드 2장
_seed_weekly_review(s) # phase-14 주간 리뷰
_seed_users(s) # phase-15 로그인 자격(AUTH_ENABLED 일 때만)

@ -3,17 +3,12 @@ from sqlmodel import Session
from .models import (
Digest,
Draft,
Email,
MailAccount,
MailFolder,
Notification,
NotificationGuard,
NotifyBucket,
NotifyStats,
Person,
SenderRule,
Sent,
)
# ---- 메일 전용 추가 발신자 (mail-data.js people) ----
@ -491,6 +486,9 @@ DRAFTS = [
def _seed_mail(s: Session) -> None:
# phase-16+: 메일 mock 데이터(계정/폴더/메일/보낸함/임시) 제거.
# 연락처(Person)만 유지 — 알림·결재 등 타 기능이 발신자를 참조한다.
# 실제 메일/계정은 Gmail·Outlook 을 '계정 추가'로 연결하면 유입된다.
for pid, name, ini, color, email, role in MAIL_PEOPLE:
if not s.get(Person, pid):
s.add(
@ -498,76 +496,12 @@ def _seed_mail(s: Session) -> None:
id=pid, name=name, initial=ini, color=color, is_me=False, email=email, role=role
)
)
# 기존 person 메일 정보 보강
for pid, (email, role) in PEOPLE_MAIL_INFO.items():
p = s.get(Person, pid)
if p:
p.email = email
p.role = role
s.add(p)
for i, (aid, name, email, tone, kind, unread) in enumerate(MAIL_ACCOUNTS):
s.add(
MailAccount(
id=aid,
name=name,
email=email,
tone=tone,
kind=kind,
unread=unread,
connector="mock",
sort_order=i,
)
)
for fid, name, icon, order in MAIL_FOLDERS:
s.add(MailFolder(id=fid, name=name, icon=icon, sort_order=order))
for i, m in enumerate(EMAILS):
s.add(
Email(
id=m["id"],
account=m["account"],
from_key=m["from_key"],
to=m.get("to", ""),
subject=m["subject"],
time=m["time"],
date=m["date"],
read=m.get("read", False),
starred=m.get("starred", False),
has_attach=m.get("has_attach", False),
labels=m.get("labels", []),
preview=m.get("preview", ""),
body=m.get("body", []),
attachments=m.get("attachments", []),
ai_json=m.get("ai", {}),
sort_order=i,
)
)
for i, (sid, acc, to, tokey, subj, time, date, prev) in enumerate(SENT):
s.add(
Sent(
id=sid,
account=acc,
to=to,
to_key=tokey,
subject=subj,
time=time,
date=date,
preview=prev,
sort_order=i,
)
)
for i, (did, acc, to, subj, time, prev, body) in enumerate(DRAFTS):
s.add(
Draft(
id=did,
account=acc,
to=to,
subject=subj,
time=time,
preview=prev,
body=body,
sort_order=i,
)
)
s.commit()

@ -50,12 +50,28 @@ def register(scheduler) -> None:
def register_event_subscribers(bus, session_factory) -> None:
"""런타임 event_bus 구독: approval.executed → 승인된 심부름 이어 실행."""
from .jobs.errand_runner import run_errand_queue
"""런타임 event_bus 구독: approval.executed → 승인된 메일 결재 발송(phase-16)."""
from sqlmodel import select
from ..connectors.mail.outbound import send_outbound
from ..models import Approval, OutboundMail
def _on_approval_executed(ev):
if (ev.payload or {}).get("source") == "agent" or True:
with session_factory() as s:
run_errand_queue(s)
# phase-16: 승인된 메일 결재 → 실제 발송(real) 또는 mock(Sent)
approval_id = (ev.payload or {}).get("approval_id")
if not approval_id:
return
with session_factory() as s:
appr = s.get(Approval, approval_id)
if not appr or appr.source != "mail":
return
ob = s.exec(
select(OutboundMail).where(
OutboundMail.approval_id == approval_id,
OutboundMail.status == "pending",
)
).first()
if ob:
send_outbound(s, ob)
bus.subscribe("approval.executed", _on_approval_executed)

@ -1,6 +1,5 @@
# backend/app/worker/triggers.py — 수동 트리거 매핑 (WORKER_ENABLED=false 프로토타입)
from .jobs.digest import run_digest
from .jobs.errand_runner import run_errand_queue
from .jobs.proactive import run_proactive
from .jobs.weekly_review import run_weekly_review
@ -10,5 +9,4 @@ JOBS = {
"weekly_review": lambda s, p: {
"id": run_weekly_review(s, week_label=p.get("week", "6/7~6/13")).id
},
"errand_runner": lambda s, p: {"resumed": run_errand_queue(s)},
}

@ -0,0 +1,46 @@
"""phase-16: outbound_mail(발송 대기 메일) 테이블
Revision ID: p17e5f6a7b8c9
Revises: p16d4e5f6a7b8
Create Date: 2026-06-15 14:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
import sqlmodel
from alembic import op
revision: str = "p17e5f6a7b8c9"
down_revision: Union[str, Sequence[str], None] = "p16d4e5f6a7b8"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
S = sqlmodel.sql.sqltypes.AutoString
def upgrade() -> None:
op.create_table(
"outbound_mail",
sa.Column("id", S(), nullable=False),
sa.Column("approval_id", S(), nullable=False),
sa.Column("connector_account_id", S(), nullable=True),
sa.Column("mail_account", S(), nullable=False),
sa.Column("to", S(), nullable=False),
sa.Column("subject", S(), nullable=False),
sa.Column("body", S(), nullable=False),
sa.Column("status", S(), nullable=False),
sa.Column("error_detail", S(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("sent_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["approval_id"], ["approval.id"]),
sa.ForeignKeyConstraint(["connector_account_id"], ["connector_account.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_outbound_mail_approval_id", "outbound_mail", ["approval_id"])
def downgrade() -> None:
op.drop_index("ix_outbound_mail_approval_id", table_name="outbound_mail")
op.drop_table("outbound_mail")

@ -0,0 +1,58 @@
"""phase-18: 고급 기능용 컬럼(메일 cc/thread, 발송 cc/bcc/첨부, 일정 반복/알림/참석자, 댓글 수정)
Revision ID: p18f6a7b8c9d0
Revises: p17e5f6a7b8c9
Create Date: 2026-06-16 10:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
import sqlmodel
from alembic import op
revision: str = "p18f6a7b8c9d0"
down_revision: Union[str, Sequence[str], None] = "p17e5f6a7b8c9"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
S = sqlmodel.sql.sqltypes.AutoString
def upgrade() -> None:
# 메일: 받은 메일 참조 + 대화(스레드) 묶기
op.add_column("email", sa.Column("cc", S(), nullable=False, server_default=""))
op.add_column("email", sa.Column("thread_id", S(), nullable=False, server_default=""))
# 발송 메일: cc/bcc/첨부
op.add_column("outbound_mail", sa.Column("cc", S(), nullable=False, server_default=""))
op.add_column("outbound_mail", sa.Column("bcc", S(), nullable=False, server_default=""))
op.add_column(
"outbound_mail", sa.Column("attachments", sa.JSON(), nullable=False, server_default="[]")
)
# 일정: 반복/알림/참석자/내 RSVP
op.add_column("cal_event", sa.Column("rrule", S(), nullable=False, server_default=""))
op.add_column(
"cal_event", sa.Column("reminders", sa.JSON(), nullable=False, server_default="[]")
)
op.add_column(
"cal_event", sa.Column("attendees", sa.JSON(), nullable=False, server_default="[]")
)
op.add_column(
"cal_event", sa.Column("response_status", S(), nullable=False, server_default="")
)
# 작업 댓글: 수정 시각
op.add_column("task_comment", sa.Column("edited_at", sa.DateTime(), nullable=True))
def downgrade() -> None:
op.drop_column("task_comment", "edited_at")
op.drop_column("cal_event", "response_status")
op.drop_column("cal_event", "attendees")
op.drop_column("cal_event", "reminders")
op.drop_column("cal_event", "rrule")
op.drop_column("outbound_mail", "attachments")
op.drop_column("outbound_mail", "bcc")
op.drop_column("outbound_mail", "cc")
op.drop_column("email", "thread_id")
op.drop_column("email", "cc")

@ -0,0 +1,25 @@
"""phase-19: 원본 HTML 본문(email.body_html) — 샌드박스 iframe 렌더용
Revision ID: p19a7b8c9d0e1
Revises: p18f6a7b8c9d0
Create Date: 2026-06-17 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "p19a7b8c9d0e1"
down_revision: Union[str, Sequence[str], None] = "p18f6a7b8c9d0"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("email", sa.Column("body_html", sa.Text(), nullable=False, server_default=""))
def downgrade() -> None:
op.drop_column("email", "body_html")

@ -0,0 +1,38 @@
"""phase-20: 수신 시각(email.received_at) — 목록 최신순 정렬·표시 기준
Revision ID: p20b8c9d0e1f2
Revises: p19a7b8c9d0e1
Create Date: 2026-06-18 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "p20b8c9d0e1f2"
down_revision: Union[str, Sequence[str], None] = "p19a7b8c9d0e1"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("email", sa.Column("received_at", sa.DateTime(), nullable=True))
op.create_index("ix_email_received_at", "email", ["received_at"])
# 기존 행 백필: ExternalLink.external_updated_at(실제 수신 시각)에서 채운다.
op.execute(
"""
UPDATE email SET received_at = (
SELECT el.external_updated_at FROM external_link el
WHERE el.entity_id = email.id AND el.entity_type = 'email'
LIMIT 1
)
WHERE received_at IS NULL
"""
)
def downgrade() -> None:
op.drop_index("ix_email_received_at", table_name="email")
op.drop_column("email", "received_at")

@ -0,0 +1,49 @@
"""phase-21: 계정별 실제 폴더 미러(account_folder) + email.folder 라우팅
Revision ID: p21c9d0e1f2a3
Revises: p20b8c9d0e1f2
Create Date: 2026-06-19 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "p21c9d0e1f2a3"
down_revision: Union[str, Sequence[str], None] = "p20b8c9d0e1f2"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"email",
sa.Column("folder", sa.String(), nullable=False, server_default="inbox"),
)
op.add_column(
"email",
sa.Column("folder_name", sa.String(), nullable=False, server_default=""),
)
op.create_index("ix_email_folder", "email", ["folder"])
op.create_table(
"account_folder",
sa.Column("id", sa.String(), primary_key=True),
sa.Column("mail_account", sa.String(), sa.ForeignKey("mail_account.id"), index=True),
sa.Column("slug", sa.String(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("kind", sa.String(), nullable=False, server_default="system"),
sa.Column("icon", sa.String(), nullable=False, server_default="folder"),
sa.Column("provider_id", sa.String(), nullable=False, server_default=""),
sa.Column("unread", sa.Integer(), nullable=False, server_default="0"),
sa.Column("total", sa.Integer(), nullable=False, server_default="0"),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
)
def downgrade() -> None:
op.drop_table("account_folder")
op.drop_index("ix_email_folder", table_name="email")
op.drop_column("email", "folder_name")
op.drop_column("email", "folder")

@ -0,0 +1,28 @@
"""phase-22: email.inference(Outlook 집중/기타 focused/other 분류)
Revision ID: p22d0e1f2a3b4
Revises: p21c9d0e1f2a3
Create Date: 2026-06-19 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "p22d0e1f2a3b4"
down_revision: Union[str, Sequence[str], None] = "p21c9d0e1f2a3"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"email",
sa.Column("inference", sa.String(), nullable=False, server_default=""),
)
def downgrade() -> None:
op.drop_column("email", "inference")

@ -13,9 +13,8 @@ def test_capture_and_list_unscoped_demo(client):
assert cap["item"]["id"] in ids
def test_seeded_pages_full_for_demo_user(client):
# 시드 데이터(전부 jiwoo)가 데모에서 그대로 보임 — 스코프가 깎아내지 않음
assert len(client.get("/api/tasks").json()) > 0
assert len(client.get("/api/mail").json()) > 0
assert len(client.get("/api/approvals").json()["pending"]) == 4 # phase-14 포함
assert client.get("/api/trip/upcoming").status_code == 200
def test_demo_three_domains_empty_others_intact(client):
# phase-16+: 메일/일정/작업은 빈 상태로 시작, 그 외 데모 기능(결재)은 그대로
assert client.get("/api/tasks").json() == []
assert client.get("/api/mail").json() == []
assert len(client.get("/api/approvals").json()["pending"]) == 3 # high 3건

@ -1,6 +1,6 @@
import pytest
from app.llm.heuristic import HeuristicProvider
from tests._fake_llm import FakeLLMProvider
CTX = {
"projects": [
@ -20,5 +20,5 @@ CTX = {
],
)
def test_heuristic_type(raw, ctype):
c = HeuristicProvider().classify_capture(raw, CTX)
c = FakeLLMProvider().classify_capture(raw, CTX)
assert c.type == ctype

@ -1,6 +1,7 @@
import json
import httpx
import pytest
from app.llm.ollama import OllamaProvider
@ -40,10 +41,11 @@ def test_ollama_classify_mock(monkeypatch):
assert c.model.startswith("ollama:")
def test_ollama_fallback_on_error(monkeypatch):
def test_ollama_raises_on_error(monkeypatch):
# phase-16+: 휴리스틱 폴백 제거 → 미가용 시 예외 전파(실 LLM 전용).
def boom(*a, **k):
raise httpx.ConnectError("no ollama")
monkeypatch.setattr(httpx, "post", boom)
c = OllamaProvider().classify_capture("수요일 11시 자전거 수리", {"projects": []})
assert c.model == "heuristic" # 폴백 동작
with pytest.raises(httpx.ConnectError):
OllamaProvider().classify_capture("수요일 11시 자전거 수리", {"projects": []})

@ -1,6 +1,7 @@
import pytest
from app.services.scaffold import pick_scaffold
from tests._factories import make_task
@pytest.mark.parametrize(
@ -17,7 +18,9 @@ def test_pick_scaffold(title, kind):
assert pick_scaffold(title)["kind"] == kind
def test_scaffold_create_via_api(client):
def test_scaffold_create_via_api(client, session):
s, _ = session
make_task(s, id="k6", title="분기 리포트 초안 마무리", project_id="biz-report")
r = client.post("/api/tasks/k6/scaffold", json={"create": True}).json()
assert r["created"] is True and len(r["created_task_ids"]) == len(r["items"])
# 생성 후 트리에 하위작업 반영

@ -1,16 +1,16 @@
def test_seed_counts(client):
# phase-9 에서 메일 전용 발신자 5명 추가 → 핵심 5 + 메일 5 = 10
# 연락처(Person)는 유지: 핵심 5 + 메일 발신자 5 = 10
people = client.get("/api/people").json()
assert len(people) == 10
core = {p["id"] for p in people} & {"jiwoo", "hyunwoo", "minseo", "jaeho", "sua"}
assert len(core) == 5
tree = client.get("/api/tree").json()
assert len(tree) == 2
# 최상위 작업 11개 (k1,k2,k5,k6,k4,k3,k13,k10,k14,k20,k21)
# phase-16+: 작업 mock 시드 제거 → 빈 상태로 시작
work = client.get("/api/tasks?area=work").json()
life = client.get("/api/tasks?area=life").json()
assert len(work) + len(life) == 11
# 인박스 4건, s1 은 new
assert len(work) + len(life) == 0
# 인박스 4건, s1 은 new (인박스는 범위 밖 — 유지)
inbox = {i["id"]: i for i in client.get("/api/inbox").json()}
assert set(inbox) == {"s1", "s2", "s3", "s4"}
assert inbox["s1"]["status"] == "new"

@ -1,31 +0,0 @@
# backend/tests/test_seed_calendar.py
# 주의: 이 프로젝트 conftest 의 `session` fixture 는 시드를 적재하지 않는다.
# `client` 가 같은 엔진에 시드를 적재하므로, DB 직접 접근 테스트는 (client, session) 둘 다 요청한다.
from sqlmodel import select
from app.models import Calendar, CalEvent, FocusBlock, Meeting, MeetingAction
def test_calendar_seed_counts(client, session):
s, _ = session
assert len(s.exec(select(Calendar)).all()) == 5
assert len(s.exec(select(CalEvent)).all()) == 22
assert len(s.exec(select(FocusBlock)).all()) == 4
assert {m.id for m in s.exec(select(Meeting)).all()} == {"e3", "e4", "e6", "e7"}
e3 = [a for a in s.exec(select(MeetingAction)).all() if a.meeting_id == "e3"]
assert len(e3) == 3
assert sum(1 for a in e3 if not a.added) == 1
def test_focus_seed_deep(client, session):
s, _ = session
deep = [f for f in s.exec(select(FocusBlock)).all() if f.type == "deep"]
assert len(deep) == 1 and deep[0].id == "f3"
assert deep[0].start == "15:10" and deep[0].end == "16:25"
def test_event_actions_seeded(client, session):
s, _ = session
eacts = [a for a in s.exec(select(MeetingAction)).all() if a.source == "event"]
assert {a.event_id for a in eacts} == {"e3", "e4"}
assert len(eacts) == 4
Loading…
Cancel
Save