From c5cf6816f81dce4e913bf5ea9380a8aae736cb35 Mon Sep 17 00:00:00 2001 From: I Luk Kim Date: Fri, 19 Jun 2026 15:52:14 +0900 Subject: [PATCH] =?UTF-8?q?feat(core):=20=EB=AA=A8=EB=8D=B8=C2=B7=EC=84=A4?= =?UTF-8?q?=EC=A0=95=C2=B7DB=C2=B7=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98=C2=B7=EC=9B=8C=EC=BB=A4=C2=B7LLM=20=EC=8B=9C?= =?UTF-8?q?=EB=93=9C=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- backend/app/config.py | 23 +-- backend/app/db.py | 52 ++++- backend/app/llm/ollama.py | 74 ++++---- backend/app/llm/provider.py | 20 +- backend/app/main.py | 43 +++-- backend/app/models.py | 62 +++++- backend/app/runtime_config.py | 4 +- backend/app/seed.py | 178 +----------------- backend/app/seed_mail.py | 72 +------ backend/app/worker/main.py | 26 ++- backend/app/worker/triggers.py | 2 - .../versions/p17e5f6a7b8c9_outbound_mail.py | 46 +++++ .../p18f6a7b8c9d0_advanced_features.py | 58 ++++++ .../versions/p19a7b8c9d0e1_email_body_html.py | 25 +++ .../p20b8c9d0e1f2_email_received_at.py | 38 ++++ .../versions/p21c9d0e1f2a3_account_folders.py | 49 +++++ .../versions/p22d0e1f2a3b4_email_inference.py | 28 +++ backend/tests/test_demo_unchanged.py | 11 +- backend/tests/test_heuristic.py | 4 +- backend/tests/test_ollama_mock.py | 8 +- backend/tests/test_scaffold.py | 5 +- backend/tests/test_seed.py | 8 +- backend/tests/test_seed_calendar.py | 31 --- 23 files changed, 490 insertions(+), 377 deletions(-) create mode 100644 backend/migrations/versions/p17e5f6a7b8c9_outbound_mail.py create mode 100644 backend/migrations/versions/p18f6a7b8c9d0_advanced_features.py create mode 100644 backend/migrations/versions/p19a7b8c9d0e1_email_body_html.py create mode 100644 backend/migrations/versions/p20b8c9d0e1f2_email_received_at.py create mode 100644 backend/migrations/versions/p21c9d0e1f2a3_account_folders.py create mode 100644 backend/migrations/versions/p22d0e1f2a3b4_email_inference.py delete mode 100644 backend/tests/test_seed_calendar.py diff --git a/backend/app/config.py b/backend/app/config.py index 5517b3d..d65d968 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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_=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(데모 무손상) diff --git a/backend/app/db.py b/backend/app/db.py index b044751..80bf72c 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -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(): diff --git a/backend/app/llm/ollama.py b/backend/app/llm/ollama.py index 31bc666..60482d6 100644 --- a/backend/app/llm/ollama.py +++ b/backend/app/llm/ollama.py @@ -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 펜스· 추론블록·잡설 제거 후 파싱.""" + s = content or "" + s = re.sub(r".*?", "", 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, ) diff --git a/backend/app/llm/provider.py b/backend/app/llm/provider.py index 719edc3..7d1eb6b 100644 --- a/backend/app/llm/provider.py +++ b/backend/app/llm/provider.py @@ -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(): diff --git a/backend/app/main.py b/backend/app/main.py index 11267d3..a7119fb 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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"]) diff --git a/backend/app/models.py b/backend/app/models.py index 8c0e8fa..e2aaa95 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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: | 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" @@ -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:(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) diff --git a/backend/app/runtime_config.py b/backend/app/runtime_config.py index 751ff24..9b8a506 100644 --- a/backend/app/runtime_config.py +++ b/backend/app/runtime_config.py @@ -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") diff --git a/backend/app/seed.py b/backend/app/seed.py index 4ec82da..9286272 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -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 일 때만) diff --git a/backend/app/seed_mail.py b/backend/app/seed_mail.py index da23922..969927e 100644 --- a/backend/app/seed_mail.py +++ b/backend/app/seed_mail.py @@ -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() diff --git a/backend/app/worker/main.py b/backend/app/worker/main.py index e36a0f2..6ae64e5 100644 --- a/backend/app/worker/main.py +++ b/backend/app/worker/main.py @@ -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) diff --git a/backend/app/worker/triggers.py b/backend/app/worker/triggers.py index 9c2ceb5..0239beb 100644 --- a/backend/app/worker/triggers.py +++ b/backend/app/worker/triggers.py @@ -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)}, } diff --git a/backend/migrations/versions/p17e5f6a7b8c9_outbound_mail.py b/backend/migrations/versions/p17e5f6a7b8c9_outbound_mail.py new file mode 100644 index 0000000..3d880e9 --- /dev/null +++ b/backend/migrations/versions/p17e5f6a7b8c9_outbound_mail.py @@ -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") diff --git a/backend/migrations/versions/p18f6a7b8c9d0_advanced_features.py b/backend/migrations/versions/p18f6a7b8c9d0_advanced_features.py new file mode 100644 index 0000000..3bb0e4c --- /dev/null +++ b/backend/migrations/versions/p18f6a7b8c9d0_advanced_features.py @@ -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") diff --git a/backend/migrations/versions/p19a7b8c9d0e1_email_body_html.py b/backend/migrations/versions/p19a7b8c9d0e1_email_body_html.py new file mode 100644 index 0000000..5279752 --- /dev/null +++ b/backend/migrations/versions/p19a7b8c9d0e1_email_body_html.py @@ -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") diff --git a/backend/migrations/versions/p20b8c9d0e1f2_email_received_at.py b/backend/migrations/versions/p20b8c9d0e1f2_email_received_at.py new file mode 100644 index 0000000..9df4d30 --- /dev/null +++ b/backend/migrations/versions/p20b8c9d0e1f2_email_received_at.py @@ -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") diff --git a/backend/migrations/versions/p21c9d0e1f2a3_account_folders.py b/backend/migrations/versions/p21c9d0e1f2a3_account_folders.py new file mode 100644 index 0000000..f2f794b --- /dev/null +++ b/backend/migrations/versions/p21c9d0e1f2a3_account_folders.py @@ -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") diff --git a/backend/migrations/versions/p22d0e1f2a3b4_email_inference.py b/backend/migrations/versions/p22d0e1f2a3b4_email_inference.py new file mode 100644 index 0000000..e4f8b97 --- /dev/null +++ b/backend/migrations/versions/p22d0e1f2a3b4_email_inference.py @@ -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") diff --git a/backend/tests/test_demo_unchanged.py b/backend/tests/test_demo_unchanged.py index 107836c..5d0ed7a 100644 --- a/backend/tests/test_demo_unchanged.py +++ b/backend/tests/test_demo_unchanged.py @@ -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건 diff --git a/backend/tests/test_heuristic.py b/backend/tests/test_heuristic.py index e9a33df..09ce126 100644 --- a/backend/tests/test_heuristic.py +++ b/backend/tests/test_heuristic.py @@ -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 diff --git a/backend/tests/test_ollama_mock.py b/backend/tests/test_ollama_mock.py index a714e62..6f4f828 100644 --- a/backend/tests/test_ollama_mock.py +++ b/backend/tests/test_ollama_mock.py @@ -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": []}) diff --git a/backend/tests/test_scaffold.py b/backend/tests/test_scaffold.py index f0c9fe2..222b5d3 100644 --- a/backend/tests/test_scaffold.py +++ b/backend/tests/test_scaffold.py @@ -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"]) # 생성 후 트리에 하위작업 반영 diff --git a/backend/tests/test_seed.py b/backend/tests/test_seed.py index 79ecbdb..056bc2b 100644 --- a/backend/tests/test_seed.py +++ b/backend/tests/test_seed.py @@ -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" diff --git a/backend/tests/test_seed_calendar.py b/backend/tests/test_seed_calendar.py deleted file mode 100644 index 44ead66..0000000 --- a/backend/tests/test_seed_calendar.py +++ /dev/null @@ -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