feat: 메일 집중 수정·LLM 프로바이더 확장(Gemini·OpenAI호환)·트리아지 로그

- LLM: gemini/openai_compat 프로바이더 추가 및 provider/ollama 연동
- 메일: 집중/기타·스팸 정리·트리아지 로그 모달 등 수정
- 설정/스키마/모델/워커·암호화·DB 정비

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

@ -12,8 +12,10 @@ class Settings(BaseSettings):
# LLM — 절대 특정 모델을 하드코딩하지 않는다. 설치된 모델을 env로 주입. # LLM — 절대 특정 모델을 하드코딩하지 않는다. 설치된 모델을 env로 주입.
ollama_host: str = "http://localhost:11434" ollama_host: str = "http://localhost:11434"
ollama_model: str = "llama3.1" # 기본 placeholder. 실제 운영은 .env 로 덮어쓴다. ollama_model: str = "llama3.1" # 기본 placeholder. 실제 운영은 .env 로 덮어쓴다.
llm_provider: str = "ollama" # 실 LLM 전용(휴리스틱 폴백 제거, phase-16+) llm_provider: str = "ollama" # ollama | openai(호환) | gemini
llm_timeout: float = 20.0 # 초 llm_timeout: float = 20.0 # 초
# OpenAI 호환/Gemini 용 API 키(.env 기본값; 설정 페이지 오버레이가 우선·암호화 저장).
llm_api_key: str = "" # LLM_API_KEY
# 리스크 계산 기준일 — CONTRACT: TODAY=8 (6/8). 시드가 '6/7~6/12 한 주' 가정. # 리스크 계산 기준일 — CONTRACT: TODAY=8 (6/8). 시드가 '6/7~6/12 한 주' 가정.
risk_today: int = 8 risk_today: int = 8
@ -54,6 +56,10 @@ class Settings(BaseSettings):
# 연결된 real 계정을 증분 sync 한다(별도 워커 프로세스 불필요). # 연결된 real 계정을 증분 sync 한다(별도 워커 프로세스 불필요).
auto_sync_enabled: bool = True # AUTO_SYNC_ENABLED auto_sync_enabled: bool = True # AUTO_SYNC_ENABLED
auto_sync_interval_seconds: int = 10 # AUTO_SYNC_INTERVAL_SECONDS (기본 10초) auto_sync_interval_seconds: int = 10 # AUTO_SYNC_INTERVAL_SECONDS (기본 10초)
# 새 메일 자동 정리: auto-sync 직후 미분석 집중 메일을 백그라운드 LLM 분석 —
# 사용자가 '정리 맡기기'를 누르지 않아도 아리 정리 카드가 미리 채워진다.
auto_triage_enabled: bool = True # AUTO_TRIAGE_ENABLED
auto_triage_batch: int = 3 # 한 자동 사이클당 최대 분석 수(Ollama 부하 제한)
# ---- phase-15: 인증/멀티유저 + 관측 + 운영 ---- # ---- phase-15: 인증/멀티유저 + 관측 + 운영 ----
auth_enabled: bool = False # AUTH_ENABLED 기본 off(데모 무손상) auth_enabled: bool = False # AUTH_ENABLED 기본 off(데모 무손상)

@ -107,6 +107,11 @@ class BaseConnector(ABC):
res.skipped += 1 res.skipped += 1
continue continue
norm = self.normalize(raw) norm = self.normalize(raw)
# provider 가 스팸으로 분류한 메일은 저장하지 않는다(유저: 아예 안 가져옴).
# folder 없는 도메인(캘린더 등)은 .get→None 이라 영향 없음.
if norm.fields.get("folder") == "spam":
res.skipped += 1
continue
entity_id, created = self.write(session, norm) entity_id, created = self.write(session, norm)
self._upsert_link(session, raw, norm, entity_id) self._upsert_link(session, raw, norm, entity_id)
if created: if created:

@ -92,8 +92,9 @@ class GmailConnector(BaseConnector):
session.commit() session.commit()
return return
# new_hist None = historyId 만료 → 아래 윈도우 폴백으로 재동기화. # new_hist None = historyId 만료 → 아래 윈도우 폴백으로 재동기화.
# full / 최초 / 만료: 받은편지함+스팸(+휴지통) 윈도우 수집 후 현재 historyId 캡처. # full / 최초 / 만료: 받은편지함(+휴지통) 윈도우 수집 후 현재 historyId 캡처.
scopes = [("in:inbox", cfg.sync_max_messages), ("in:spam", cfg.sync_page_size)] # 스팸은 가져오지 않는다(유저 요청 — 이미 스팸 분류된 건 수집 제외).
scopes = [("in:inbox", cfg.sync_max_messages)]
if full: if full:
scopes.append(("in:trash", cfg.sync_page_size)) scopes.append(("in:trash", cfg.sync_page_size))
for base_q, cap in scopes: for base_q, cap in scopes:

@ -154,7 +154,7 @@ class OutlookConnector(BaseConnector):
ext_to_link = {lk.external_id: lk for lk in links} ext_to_link = {lk.external_id: lk for lk in links}
eid_to_ext = {lk.entity_id: lk.external_id for lk in links} eid_to_ext = {lk.entity_id: lk.external_id for lk in links}
for fid, slug, _ in fetch_ids: for fid, slug, _ in fetch_ids:
if slug not in ("inbox", "spam"): if slug != "inbox": # 스팸은 동기화 대상에서 제외(유저 요청) — inbox 만 재조회.
continue continue
self._rl.acquire() self._rl.acquire()
r = c.get( r = c.get(
@ -225,8 +225,10 @@ class OutlookConnector(BaseConnector):
# 읽음/별표 상태 동기화(증분 fetch 가 안 건드리는 기존 메일) — 가벼운 재조회. # 읽음/별표 상태 동기화(증분 fetch 가 안 건드리는 기존 메일) — 가벼운 재조회.
self._reconcile_status(session, c, auth, fetch_ids) self._reconcile_status(session, c, auth, fetch_ids)
for fid, slug, fname in fetch_ids: for fid, slug, fname in fetch_ids:
# 받은편지함/스팸만 증분(매 sync) — 나머지(휴지통/보관함/커스텀)는 full 때만. if slug == "spam": # 스팸은 아예 가져오지 않는다(유저 요청).
light = slug in ("inbox", "spam") continue
# 받은편지함만 증분(매 sync) — 나머지(휴지통/보관함/커스텀)는 full 때만.
light = slug == "inbox"
if not light and not full: if not light and not full:
continue continue
params = { params = {

@ -28,3 +28,26 @@ def decrypt_token(enc: str) -> dict:
return json.loads(_fernet().decrypt(enc.encode("utf-8")).decode("utf-8")) return json.loads(_fernet().decrypt(enc.encode("utf-8")).decode("utf-8"))
except (InvalidToken, ValueError): except (InvalidToken, ValueError):
return {} # 키 회전/손상 → 빈 dict (호출측은 token_expired 처리) return {} # 키 회전/손상 → 빈 dict (호출측은 token_expired 처리)
# LLM API 키 등 단일 문자열 비밀의 대칭 암복호화. 평문은 DB 에 저장하지 않는다.
_ENC_PREFIX = "enc:" # 암호문 식별 접두사(평문/암호문 혼재 시 구분)
def encrypt_str(plain: str) -> str:
"""평문 → 'enc:<암호문>'. 빈 값은 그대로 빈 문자열."""
if not plain:
return ""
return _ENC_PREFIX + _fernet().encrypt(plain.encode("utf-8")).decode("utf-8")
def decrypt_str(enc: str) -> str:
"""'enc:<암호문>' → 평문. 접두사 없으면(구 평문값) 그대로 반환(하위호환)."""
if not enc:
return ""
if not enc.startswith(_ENC_PREFIX):
return enc # 접두사 없음 = 암호화 전 평문(하위호환)
try:
return _fernet().decrypt(enc[len(_ENC_PREFIX) :].encode("utf-8")).decode("utf-8")
except (InvalidToken, ValueError):
return "" # 키 회전/손상

@ -31,6 +31,7 @@ _DEV_COLUMNS: list[tuple[str, str, str]] = [
("email", "folder", "TEXT NOT NULL DEFAULT 'inbox'"), # 시스템 폴더/버킷 슬러그 ("email", "folder", "TEXT NOT NULL DEFAULT 'inbox'"), # 시스템 폴더/버킷 슬러그
("email", "folder_name", "TEXT NOT NULL DEFAULT ''"), # Outlook 커스텀 폴더 표시명 ("email", "folder_name", "TEXT NOT NULL DEFAULT ''"), # Outlook 커스텀 폴더 표시명
("email", "inference", "TEXT NOT NULL DEFAULT ''"), # Outlook 집중/기타(focused/other) ("email", "inference", "TEXT NOT NULL DEFAULT ''"), # Outlook 집중/기타(focused/other)
("email", "dismissed", "BOOLEAN NOT NULL DEFAULT 0"), # 아리 정리에서 기각(다이제스트 제외)
("outbound_mail", "cc", "TEXT NOT NULL DEFAULT ''"), ("outbound_mail", "cc", "TEXT NOT NULL DEFAULT ''"),
("outbound_mail", "bcc", "TEXT NOT NULL DEFAULT ''"), ("outbound_mail", "bcc", "TEXT NOT NULL DEFAULT ''"),
("outbound_mail", "attachments", "JSON NOT NULL DEFAULT '[]'"), ("outbound_mail", "attachments", "JSON NOT NULL DEFAULT '[]'"),

@ -0,0 +1,96 @@
# backend/app/llm/gemini.py
# Google Gemini (Generative Language API) 프로바이더.
# 엔드포인트: {base}/models/{model}:generateContent?key=API_KEY (base 기본 v1beta).
# host 오버레이가 ollama 용 값으로 남아 있을 수 있어, gemini 답지 않은 host 는 기본 base 로 무시.
import httpx
from ..runtime_config import effective_llm
from .ollama import _coerce, _lenient_json
from .prompts import CLASSIFY_SYSTEM, build_classify_prompt
from .provider import Classification, LLMProvider
DEFAULT_BASE = "https://generativelanguage.googleapis.com/v1beta"
def _resolve_base(host: str | None) -> str:
h = (host or "").rstrip("/")
# 비어 있거나 ollama 류 host(11434/ollama/localhost)면 gemini 기본 base 사용.
if not h or ":11434" in h or "ollama" in h or "localhost" in h or "127.0.0.1" in h:
return DEFAULT_BASE
return h
class GeminiProvider(LLMProvider):
name = "gemini"
def __init__(
self,
host: str | None = None,
model: str | None = None,
api_key: str | None = None,
timeout: float | None = None,
):
eff = effective_llm()
self.base = _resolve_base(host if host is not None else eff.host)
self.model = model or eff.model
self.api_key = api_key if api_key is not None else eff.api_key
self.timeout = timeout if timeout is not None else eff.timeout
def _generate(self, text: str) -> str:
body = {
"contents": [{"role": "user", "parts": [{"text": text}]}],
"generationConfig": {"temperature": 0.2, "responseMimeType": "application/json"},
}
r = httpx.post(
f"{self.base}/models/{self.model}:generateContent",
params={"key": self.api_key},
json=body,
timeout=self.timeout,
)
r.raise_for_status()
cands = r.json().get("candidates", [])
if not cands:
return "{}"
parts = cands[0].get("content", {}).get("parts", [])
return "".join(p.get("text", "") for p in parts)
def list_models(self) -> list[str]:
try:
r = httpx.get(f"{self.base}/models", params={"key": self.api_key}, timeout=5.0)
if r.status_code != 200:
return []
out = []
for m in r.json().get("models", []):
if "generateContent" in (m.get("supportedGenerationMethods") or []):
out.append((m.get("name") or "").removeprefix("models/"))
return [m for m in out if m]
except Exception:
return []
def health(self) -> dict:
try:
r = httpx.get(f"{self.base}/models", params={"key": self.api_key}, timeout=5.0)
ok = r.status_code == 200
return {
"reachable": ok,
"provider": self.name,
"model": self.model,
"host": self.base,
"detail": "ok" if ok else f"HTTP {r.status_code}",
}
except Exception as e:
return {
"reachable": False,
"provider": self.name,
"model": self.model,
"host": self.base,
"detail": str(e),
}
def generate_json(self, prompt: str, schema: dict | None = None) -> dict:
return _lenient_json(self._generate(prompt))
def classify_capture(self, raw: str, context: dict) -> Classification:
prompt = f"{CLASSIFY_SYSTEM}\n\n{build_classify_prompt(raw, context)}"
data = _lenient_json(self._generate(prompt))
return _coerce(data, raw, context, model=f"gemini:{self.model}")

@ -30,7 +30,9 @@ class OllamaProvider(LLMProvider):
): ):
# 유효 설정(런타임 오버레이 > .env). 인자가 주어지면 그 값으로 덮어쓴다(연결 테스트용). # 유효 설정(런타임 오버레이 > .env). 인자가 주어지면 그 값으로 덮어쓴다(연결 테스트용).
eff = effective_llm() eff = effective_llm()
self.host = host or eff.host # 끝 슬래시 제거 — 원격 엔드포인트(예: https://host/)를 그대로 받아도 `{host}/api/..` 가
# `//api/..` 로 깨지지 않게 정규화.
self.host = (host or eff.host).rstrip("/")
self.model = model or eff.model # 비종속: 주입값 그대로 사용 self.model = model or eff.model # 비종속: 주입값 그대로 사용
self.timeout = timeout if timeout is not None else eff.timeout self.timeout = timeout if timeout is not None else eff.timeout

@ -0,0 +1,108 @@
# backend/app/llm/openai_compat.py
# OpenAI 호환 Chat Completions 엔드포인트용 프로바이더.
# OpenAI 본가뿐 아니라 OpenRouter·Groq·Together·vLLM·LM Studio·Gemini(OpenAI 호환 베이스) 등
# `/v1/chat/completions` + `/v1/models` 규약을 따르는 모든 엔드포인트를 host(base_url)로 받는다.
import httpx
from ..runtime_config import effective_llm
from .ollama import _coerce, _lenient_json
from .prompts import CLASSIFY_SYSTEM, build_classify_prompt
from .provider import Classification, LLMProvider
class OpenAICompatProvider(LLMProvider):
name = "openai"
def __init__(
self,
host: str | None = None,
model: str | None = None,
api_key: str | None = None,
timeout: float | None = None,
):
eff = effective_llm()
# host = base_url(예: https://api.openai.com/v1). 끝 슬래시 정규화.
self.base = (host or eff.host).rstrip("/")
self.model = model or eff.model
self.api_key = api_key if api_key is not None else eff.api_key
self.timeout = timeout if timeout is not None else eff.timeout
def _headers(self) -> dict:
h = {"Content-Type": "application/json"}
if self.api_key:
h["Authorization"] = f"Bearer {self.api_key}"
return h
def _chat(self, messages: list[dict]) -> str:
body = {
"model": self.model,
"messages": messages,
"temperature": 0.2,
"response_format": {"type": "json_object"},
}
try:
r = httpx.post(
f"{self.base}/chat/completions",
json=body,
headers=self._headers(),
timeout=self.timeout,
)
r.raise_for_status()
except httpx.HTTPStatusError as e:
# 일부 호환 서버는 response_format 을 모름(400) → 빼고 1회 재시도.
if e.response is not None and e.response.status_code == 400:
body.pop("response_format", None)
r = httpx.post(
f"{self.base}/chat/completions",
json=body,
headers=self._headers(),
timeout=self.timeout,
)
r.raise_for_status()
else:
raise
return r.json()["choices"][0]["message"]["content"]
def list_models(self) -> list[str]:
try:
r = httpx.get(f"{self.base}/models", headers=self._headers(), timeout=5.0)
if r.status_code != 200:
return []
data = r.json().get("data", [])
return [m.get("id", "") for m in data if m.get("id")]
except Exception:
return []
def health(self) -> dict:
try:
r = httpx.get(f"{self.base}/models", headers=self._headers(), timeout=5.0)
ok = r.status_code == 200
return {
"reachable": ok,
"provider": self.name,
"model": self.model,
"host": self.base,
"detail": "ok" if ok else f"HTTP {r.status_code}",
}
except Exception as e:
return {
"reachable": False,
"provider": self.name,
"model": self.model,
"host": self.base,
"detail": str(e),
}
def generate_json(self, prompt: str, schema: dict | None = None) -> dict:
content = self._chat([{"role": "user", "content": prompt}])
return _lenient_json(content)
def classify_capture(self, raw: str, context: dict) -> Classification:
prompt = build_classify_prompt(raw, context)
content = self._chat(
[
{"role": "system", "content": CLASSIFY_SYSTEM},
{"role": "user", "content": prompt},
]
)
return _coerce(_lenient_json(content), raw, context, model=f"openai:{self.model}")

@ -44,15 +44,39 @@ class LLMProvider(ABC):
_cached: LLMProvider | None = None _cached: LLMProvider | None = None
def build_provider(
provider: str,
*,
host: str | None = None,
model: str | None = None,
api_key: str | None = None,
timeout: float | None = None,
) -> LLMProvider:
"""provider 종류로 구현 인스턴스를 만든다. 인자 None 이면 각 구현이 effective_llm 기본값 사용.
연결 테스트(저장 )에서는 명시 인자(host/model/api_key) 임시 구성한다."""
if provider == "openai":
from .openai_compat import OpenAICompatProvider
return OpenAICompatProvider(host=host, model=model, api_key=api_key, timeout=timeout)
if provider == "gemini":
from .gemini import GeminiProvider
return GeminiProvider(host=host, model=model, api_key=api_key, timeout=timeout)
from .ollama import OllamaProvider
return OllamaProvider(host=host, model=model, timeout=timeout)
def get_provider(force: str | None = None) -> LLMProvider: def get_provider(force: str | None = None) -> LLMProvider:
"""실 LLM(Ollama) 전용. 휴리스틱 폴백 제거(phase-16+): 미가동 시 호출부에서 예외. """유효 설정(오버레이>env)의 provider 로 실 LLM 구현을 만든다(ollama|openai|gemini).
미가동/미설정 호출부에서 예외(휴리스틱 폴백 없음, phase-16+).
테스트는 conftest get_provider 의존성을 결정적 FakeLLM 으로 오버라이드한다. 테스트는 conftest get_provider 의존성을 결정적 FakeLLM 으로 오버라이드한다.
force 인자는 하위호환용(현재 Ollama 단일 무시). force provider 직접 지정할 있다(미지정 effective_llm().provider).
""" """
from .ollama import OllamaProvider from ..runtime_config import effective_llm
return OllamaProvider() return build_provider(force or effective_llm().provider)
def reset_provider_cache(): def reset_provider_cache():

@ -61,7 +61,7 @@ def _register_event_bus() -> None:
async def _auto_sync_loop() -> None: async def _auto_sync_loop() -> None:
"""앱 내장 자동 풀링: 주기적으로 연결된 real 계정(메일·일정)을 증분 sync. """앱 내장 자동 풀링: 주기적으로 연결된 real 계정(메일·일정)을 증분 sync.
blocking httpx/DB 작업이라 to_thread 실행해 이벤트 루프를 막지 않는다.""" blocking httpx/DB 작업이라 to_thread 실행해 이벤트 루프를 막지 않는다."""
from .worker.sync_jobs import run_periodic_sync from .worker.sync_jobs import run_auto_triage, run_periodic_sync
log = logging.getLogger("ari.autosync") log = logging.getLogger("ari.autosync")
interval = max(5, settings.auto_sync_interval_seconds) interval = max(5, settings.auto_sync_interval_seconds)
@ -76,6 +76,16 @@ async def _auto_sync_loop() -> None:
raise raise
except Exception: except Exception:
log.exception("auto-sync failed; retrying next interval") log.exception("auto-sync failed; retrying next interval")
# 새 메일 자동 정리(백그라운드 LLM 분석) — 사용자가 직접 누르지 않아도 카드가 채워진다.
if settings.auto_triage_enabled:
try:
done = await asyncio.to_thread(run_auto_triage, settings.auto_triage_batch)
if done:
log.info("auto-triage: %d mails analyzed", done)
except asyncio.CancelledError:
raise
except Exception:
log.exception("auto-triage failed; retrying next interval")
await asyncio.sleep(interval) await asyncio.sleep(interval)
@ -93,6 +103,16 @@ async def lifespan(app: FastAPI):
with Session(engine) as _s: with Session(engine) as _s:
runtime_config.load_from_db(_s) runtime_config.load_from_db(_s)
# 재시작/리로드로 'syncing' 에 멈춘 계정을 복구(안 하면 주기 sync 가 영원히 건너뜀).
from .worker.sync_jobs import purge_spam, recover_stuck_syncing
if (_recovered := recover_stuck_syncing()):
logging.getLogger("ari.autosync").info(
"recovered %d stuck-syncing account(s) on startup", _recovered
)
# 이미 저장된 스팸 일괄 제거(이후 sync 는 스팸을 아예 저장하지 않음).
if (_purged := purge_spam()):
logging.getLogger("ari.autosync").info("purged %d spam mail(s) on startup", _purged)
sync_task = asyncio.create_task(_auto_sync_loop()) if settings.auto_sync_enabled else None sync_task = asyncio.create_task(_auto_sync_loop()) if settings.auto_sync_enabled else None
try: try:
yield yield

@ -530,6 +530,8 @@ class Email(SQLModel, table=True):
folder_name: str = "" # Outlook 커스텀 폴더 표시명(시스템 폴더는 비움 → 표준 한글명 사용) folder_name: str = "" # Outlook 커스텀 폴더 표시명(시스템 폴더는 비움 → 표준 한글명 사용)
# Outlook 집중 받은편지함 분류(focused/other). 빈 문자열=미분류(Gmail 등). 받은편지함=focused. # Outlook 집중 받은편지함 분류(focused/other). 빈 문자열=미분류(Gmail 등). 받은편지함=focused.
inference: str = "" inference: str = ""
# 아리 정리(비서 다이제스트)에서 사용자가 기각한 메일. 다이제스트에서만 제외(받은편지함엔 잔류).
dismissed: bool = Field(default=False, index=True)
created_at: datetime = Field(default_factory=now) created_at: datetime = Field(default_factory=now)
@ -1304,3 +1306,17 @@ class AppSetting(SQLModel, table=True):
key: str = Field(primary_key=True) # 예: "llm.provider" key: str = Field(primary_key=True) # 예: "llm.provider"
value: str = "" # 항상 문자열 직렬화(숫자/불리언도 str) value: str = "" # 항상 문자열 직렬화(숫자/불리언도 str)
updated_at: datetime = Field(default_factory=now) updated_at: datetime = Field(default_factory=now)
# ---------- 아리 자동 정리 실행 로그(관측성) ----------
class TriageEvent(SQLModel, table=True):
"""아리 자동 정리(auto-triage) 1회 실행 기록 — '오늘 한 일' 로그.
실제로 메일을 읽고 분류했는지/에러가 났는지 사용자가 눈으로 확인할 있게 남긴다.
무활동 사이클(분석 0·에러 없음) 기록하지 않는다(로그가 무의미하게 쌓이지 않게)."""
__tablename__ = "triage_event"
id: Optional[int] = Field(default=None, primary_key=True)
at: datetime = Field(default_factory=now, index=True)
analyzed: int = 0 # 이번 사이클에 LLM 으로 읽은 새 집중 메일 수
cards: int = 0 # 그중 챙길 거리(할 일/일정/답장·중요)로 카드가 된 수
error: Optional[str] = None # 실패 사유(짧게). None=정상

@ -30,6 +30,7 @@ from ..models import (
Project, Project,
Task, Task,
TaskStatus, TaskStatus,
TriageEvent,
now, now,
) )
from ..schemas import ( from ..schemas import (
@ -195,12 +196,17 @@ def _email_in_folder(e: Email, slug: str) -> bool:
folder = e.folder or "inbox" folder = e.folder or "inbox"
if slug in ("inbox", "smart"): if slug in ("inbox", "smart"):
# 집중(Focused): Gmail 기본(primary) 카테고리 + Outlook focused. (프로모션·기타는 제외) # 집중(Focused): Gmail 기본(primary) 카테고리 + Outlook focused. (프로모션·기타는 제외)
return ( focused = (
folder == "inbox" folder == "inbox"
and not e.archived and not e.archived
and _category_for(e.labels) == "primary" and _category_for(e.labels) == "primary"
and (e.inference or "") != "other" and (e.inference or "") != "other"
) )
# 아리 정리(smart)는 사용자가 기각(dismiss)한 메일을 다이제스트에서만 뺀다 —
# 실제 받은편지함(inbox)에는 그대로 남는다(읽음/보관/삭제 아님).
if slug == "smart":
return focused and not e.dismissed
return focused
if slug == "others": if slug == "others":
# 기타(Others): Gmail 비기본(프로모션/소셜/업데이트) 또는 Outlook other. # 기타(Others): Gmail 비기본(프로모션/소셜/업데이트) 또는 Outlook other.
return ( return (
@ -810,62 +816,162 @@ def extract_all(eid: str, s: Session = Depends(get_session)):
) )
@router.post("/mail/triage/analyze") def _ai_actionable(ai: dict | None) -> bool:
def triage_analyze( """분석 결과가 아리 정리 '카드'로 노출되는가 — 할 일/일정/답장이 있거나 중요(높음).
limit: int = Query(4, ge=1, le=12), 프론트 Triage 카드 필터(actionCount>0 || 높음) 동일 기준. 정보성 메일은 카드가 없다."""
if not ai:
return False
reply = 1 if (ai.get("replies") or []) else 0
actions = len(ai.get("tasks") or []) + len(ai.get("events") or []) + reply
return actions > 0 or ai.get("priority") == "높음"
def _is_triage_card(e: Email) -> bool:
"""아리 정리에 실제로 노출될 새 메일인가 — 미분석(곧 정리됨)이거나 분석 결과에 액션이 있음.
분석 끝난 정보성 메일(액션 없음) 카드가 없으니 배지에서도 빼서 패널 숫자와 일치시킨다."""
if not e.ai_json:
return True # 미분석 = 정리 대기(pending) — 곧 자동 분석되어 카드가 되거나 빠진다
return _ai_actionable(e.ai_json)
def _triage_summary(
s: Session, user: Person, since: datetime, day_start: datetime | None = None
) -> dict:
"""아리 정리 요약 — 카드 수(count) + 자동 처리 현황(새 메일 총수·자동 걸러낸 수).
패널이 '아무것도 안 함'처럼 보이지 않게, 아리가 메일을 읽고 무엇을 넘겼는지 보여준다.
- count : 패널에 카드로 노출되는 (미분석 대기 + 액션 있는 집중 메일) = 사이드바 배지.
기준 시각 이후 전체 어제 도착한 챙길 거리도 빠뜨리지 않는다.
- new_total: '오늘'(day_start) 도착한 메일 총수(스팸·홍보 포함). 없으면 기준 이후 전체.
- screened : 그중 집중이 아니라 아리가 자동으로 넘긴 (스팸/홍보/소셜/기타/보관).
오늘로 한정하는 이유: 기준 시각이 며칠 전이면 누적치가 커져('새 메일 60통') 오늘 받은 양과
동떨어진다 표시는 '오늘 온 새 메일' 기준으로 맞춘다(경계=클라 로컬 자정)."""
rows = [e for e in s.exec(select(Email)).all() if e.user_id == user.id]
new = [e for e in rows if _is_new_mail(e, since)]
count = sum(1 for e in new if _email_in_folder(e, "smart") and _is_triage_card(e))
# 표시용 총수/걸러낸 수는 오늘 도착분만(누적 방지). 집중 후보(기각 포함)=inbox 판정과 동일.
floor = max(since, _as_utc(day_start)) if day_start else since
today = [e for e in new if _is_new_mail(e, floor)]
focused = sum(1 for e in today if _email_in_folder(e, "inbox"))
return {"count": count, "new_total": len(today), "screened": len(today) - focused}
def _triage_new_count(s: Session, user: Person, since: datetime) -> int:
"""기준 시각 이후 도착한 집중(focused) 새 메일 중 '아리 정리에 노출되는' 수 — 배지=패널 일치.
미분석(대기) 포함하되, 분석 끝난 정보성 메일(액션 없음) 카드가 없으므로 제외(읽음 무관)."""
return _triage_summary(s, user, since)["count"]
def _parse_day_start(day_start: str | None) -> datetime | None:
"""클라가 보낸 로컬 자정(ISO)을 UTC-aware datetime 으로 — 파싱 실패 시 None."""
if not day_start:
return None
try:
return _as_utc(datetime.fromisoformat(day_start))
except ValueError:
return None
@router.get("/mail/triage/baseline")
def triage_baseline_get(
day_start: str | None = Query(None), # 클라 로컬 자정(ISO) — '오늘' 경계(서버 UTC TZ 보정)
s: Session = Depends(get_session), s: Session = Depends(get_session),
user: Person = Depends(current_user), user: Person = Depends(current_user),
provider: LLMProvider = Depends(get_provider),
): ):
"""아리 비서: 안읽음 집중 받은편지함 중 미분석 메일을 LLM 으로 배치 분석(best-effort). """아리 정리 기준 시각(ISO) + '오늘'(day_start) 도착한 새 메일 요약 — 없으면 지금으로 초기화."""
번에 limit 통만 처리하고 remaining 으로 남은 수를 알려 점진 분석을 가능케 한다."""
from ..services.mail_ai import analyze_email
since = _ensure_triage_since(s) since = _ensure_triage_since(s)
rows = [e for e in s.exec(select(Email)).all() if e.user_id == user.id] summary = _triage_summary(s, user, since, _parse_day_start(day_start))
todo = [ return {"since": since.isoformat(), **summary}
e
for e in rows
if not e.ai_json @router.get("/mail/triage/log")
and _email_in_folder(e, "smart") def triage_log(
and _is_new_mail(e, since) # 기준 시각 이후 새 메일(읽음 무관 — 폰서 먼저 읽어도 정리) day_start: str | None = Query(None), # 클라 로컬 자정(ISO) — '오늘' 경계
s: Session = Depends(get_session),
user: Person = Depends(current_user),
):
"""오늘(day_start 이후) 아리 자동 정리 실행 로그 — 최근 것부터 최대 20개.
실제로 메일을 읽고 분류했는지/에러가 났는지 보여주는 관측성 피드(무활동 사이클은 기록 )."""
floor = _parse_day_start(day_start)
rows = s.exec(select(TriageEvent).order_by(TriageEvent.at.desc())).all()
if floor is not None:
rows = [r for r in rows if _as_utc(r.at) >= floor]
return [
{"at": r.at.isoformat(), "analyzed": r.analyzed, "cards": r.cards, "error": r.error}
for r in rows[:20]
] ]
todo.sort(key=_email_sort_key, reverse=True)
analyzed, errors = 0, 0
for e in todo[:limit]:
try:
analyze_email(s, e, provider)
analyzed += 1
except Exception: # LLM 일시 오류·파싱 실패는 건너뛴다(다음 호출서 재시도).
errors += 1
return {"analyzed": analyzed, "remaining": max(0, len(todo) - analyzed), "errors": errors}
def _triage_new_count(s: Session, user: Person, since: datetime) -> int: def _triage_bucket(e: Email) -> str:
"""기준 시각 이후 도착한 집중(focused) 새 메일 수 — 아리 정리 배지(읽음 여부 무관). """이 메일을 아리가 어디로 분류했는지 — 상세 로그용 한 단어 라벨(집중/홍보/스팸 등)."""
사용자가 /다른 앱에서 먼저 읽어도 '정리할 새 메일' 센다(서버 read True 되므로).""" folder = e.folder or "inbox"
rows = [e for e in s.exec(select(Email)).all() if e.user_id == user.id] if folder == "spam":
return sum(1 for e in rows if _email_in_folder(e, "smart") and _is_new_mail(e, since)) return "스팸"
if folder == "trash":
return "휴지통"
if e.archived:
return "보관"
if _email_in_folder(e, "inbox"):
return "집중"
if (e.inference or "") == "other":
return "기타"
return {"promotions": "홍보", "social": "소셜", "updates": "업데이트"}.get(
_category_for(e.labels), "기타"
)
@router.get("/mail/triage/baseline") @router.get("/mail/triage/classified")
def triage_baseline_get( def triage_classified(
day_start: str | None = Query(None), # 클라 로컬 자정(ISO) — '오늘' 경계
s: Session = Depends(get_session), s: Session = Depends(get_session),
user: Person = Depends(current_user), user: Person = Depends(current_user),
): ):
"""아리 정리 기준 시각(ISO) + 그 이후 도착한 새 메일 수 — 없으면 지금으로 초기화.""" """오늘 도착한 새 메일을 '아리가 어떻게 분류·분석했는지' 하나씩 — 상세 로그 뷰용.
집중/홍보/스팸 버킷 + (집중이면) 분석 결과(우선순위· /일정/답장)까지. 최신순 최대 200."""
since = _ensure_triage_since(s) since = _ensure_triage_since(s)
return {"since": since.isoformat(), "count": _triage_new_count(s, user, since)} floor = max(since, _parse_day_start(day_start) or since)
mine = [e for e in s.exec(select(Email)).all() if e.user_id == user.id]
rows = [e for e in mine if _is_new_mail(e, floor)]
rows.sort(key=_email_sort_key, reverse=True)
out = []
for e in rows[:200]:
ai = e.ai_json or None
from_name, _ = sender_parts(e.from_key)
out.append(
{
"id": e.id,
"account": e.account,
"from_name": from_name,
"subject": e.subject,
"time": _list_time(e.received_at, e.time),
"bucket": _triage_bucket(e),
"focused": _email_in_folder(e, "inbox"),
"analyzed": ai is not None,
"priority": ai.get("priority") if ai else None,
"tasks": len(ai.get("tasks") or []) if ai else 0,
"events": len(ai.get("events") or []) if ai else 0,
"replies": 1 if (ai and (ai.get("replies") or [])) else 0,
"is_card": _email_in_folder(e, "smart") and _is_triage_card(e),
}
)
return out
@router.post("/mail/triage/baseline") @router.post("/mail/{mail_id}/triage/dismiss")
def triage_baseline_reset( def triage_dismiss(
mail_id: str,
s: Session = Depends(get_session), s: Session = Depends(get_session),
user: Person = Depends(current_user), user: Person = Depends(current_user),
): ):
"""기준 시각을 지금으로 재설정 — 이후 도착하는 새 메일부터 다시 정리.""" """아리 정리에서 이 카드를 기각 — 다이제스트에서 빼고 다시 분석/표시하지 않는다.
since = _set_triage_since(s, now()) 실제 받은편지함에는 그대로 남는다(읽음/보관/삭제와 무관)."""
return {"since": since.isoformat(), "count": _triage_new_count(s, user, since)} e = s.get(Email, mail_id)
if not e or e.user_id != user.id:
raise HTTPException(404, "메일을 찾을 수 없어요")
e.dismissed = True
s.add(e)
s.commit()
since = _ensure_triage_since(s)
return {"ok": True, **_triage_summary(s, user, since)}
@router.post("/mail/{eid}/reply-draft", response_model=ReplyDraftOut) @router.post("/mail/{eid}/reply-draft", response_model=ReplyDraftOut)

@ -5,8 +5,7 @@ from sqlmodel import Session
from .. import runtime_config from .. import runtime_config
from ..config import get_settings from ..config import get_settings
from ..db import get_session from ..db import get_session
from ..llm.ollama import OllamaProvider from ..llm.provider import LLMProvider, build_provider
from ..llm.provider import LLMProvider
from ..schemas import ( from ..schemas import (
LLMConfigOut, LLMConfigOut,
LLMTestIn, LLMTestIn,
@ -49,7 +48,9 @@ def _llm_config(s: Session) -> LLMConfigOut:
model=eff.model, model=eff.model,
host=eff.host, host=eff.host,
timeout=eff.timeout, timeout=eff.timeout,
provider_options=list(runtime_config._PROVIDERS),
overridden=settings_service.overlay_active(s), overridden=settings_service.overlay_active(s),
api_key_set=bool(eff.api_key), # 값은 노출 금지 — 설정 여부만.
stt_provider=st.stt_provider, stt_provider=st.stt_provider,
vision_provider=st.vision_provider, vision_provider=st.vision_provider,
) )
@ -69,6 +70,7 @@ def update_llm(body: LLMUpdateIn, s: Session = Depends(get_session)):
model=body.model, model=body.model,
host=body.host, host=body.host,
timeout=body.timeout, timeout=body.timeout,
api_key=body.api_key,
) )
except ValueError as e: except ValueError as e:
raise HTTPException(400, str(e)) from e raise HTTPException(400, str(e)) from e
@ -77,12 +79,16 @@ def update_llm(body: LLMUpdateIn, s: Session = Depends(get_session)):
@router.post("/settings/llm/test", response_model=LLMTestOut) @router.post("/settings/llm/test", response_model=LLMTestOut)
def test_llm(body: LLMTestIn): def test_llm(body: LLMTestIn):
"""저장 없이 연결만 테스트. provider/host/model 미지정 시 유효 설정값 사용.""" """저장 없이 연결만 테스트. provider/host/model/api_key 미지정 시 유효 설정값 사용.
api_key 미지정이면 저장된 (eff.api_key) 테스트( 재입력 없이 모델 목록 조회 가능)."""
eff = runtime_config.effective_llm() eff = runtime_config.effective_llm()
provider = body.provider or eff.provider provider = body.provider or eff.provider
impl: LLMProvider = build_provider(
# 실 LLM 전용(휴리스틱 제거): 항상 Ollama 도달성으로 테스트. provider,
impl: LLMProvider = OllamaProvider(host=body.host or eff.host, model=body.model or eff.model) host=body.host or eff.host,
model=body.model or eff.model,
api_key=body.api_key if body.api_key is not None else eff.api_key,
)
h = impl.health() h = impl.health()
return LLMTestOut( return LLMTestOut(
reachable=bool(h.get("reachable")), reachable=bool(h.get("reachable")),

@ -12,19 +12,22 @@ from dataclasses import dataclass
from .config import get_settings from .config import get_settings
# 허용된 LLM 오버레이 키(이 외의 키는 무시). # 허용된 LLM 오버레이 키(이 외의 키는 무시).
LLM_KEYS = ("llm.provider", "llm.model", "llm.host", "llm.timeout") LLM_KEYS = ("llm.provider", "llm.model", "llm.host", "llm.timeout", "llm.api_key")
_PROVIDERS = ("ollama",) # 실 LLM 전용(auto/heuristic 제거, phase-16+) # api_key 는 암호화 저장(crypto.encrypt_str) — DB 평문 금지.
_SECRET_KEYS = ("llm.api_key",)
_PROVIDERS = ("ollama", "openai", "gemini") # ollama | openai(호환) | gemini
# 인메모리 오버레이. key → str value. # 인메모리 오버레이. key → str value(api_key 는 복호화된 평문으로 보관).
_overrides: dict[str, str] = {} _overrides: dict[str, str] = {}
@dataclass(frozen=True) @dataclass(frozen=True)
class EffectiveLLM: class EffectiveLLM:
provider: str # auto | ollama | heuristic provider: str # ollama | openai | gemini
model: str model: str
host: str host: str
timeout: float timeout: float
api_key: str = ""
def clear() -> None: def clear() -> None:
@ -49,15 +52,16 @@ def set_llm(values: dict[str, object]) -> None:
def load_from_db(session) -> None: def load_from_db(session) -> None:
"""앱 시작 시(lifespan) app_setting → 오버레이 적재.""" """앱 시작 시(lifespan) app_setting → 오버레이 적재. 비밀키(api_key)는 복호화."""
from sqlmodel import select from sqlmodel import select
from .crypto import decrypt_str
from .models import AppSetting from .models import AppSetting
_overrides.clear() _overrides.clear()
for row in session.exec(select(AppSetting)).all(): for row in session.exec(select(AppSetting)).all():
if row.key in LLM_KEYS: if row.key in LLM_KEYS:
_overrides[row.key] = row.value _overrides[row.key] = decrypt_str(row.value) if row.key in _SECRET_KEYS else row.value
def effective_llm() -> EffectiveLLM: def effective_llm() -> EffectiveLLM:
@ -68,9 +72,10 @@ def effective_llm() -> EffectiveLLM:
provider = "ollama" # 미지원/구 값(auto·heuristic) → 실 LLM 으로 강제 provider = "ollama" # 미지원/구 값(auto·heuristic) → 실 LLM 으로 강제
model = _overrides.get("llm.model") or st.ollama_model model = _overrides.get("llm.model") or st.ollama_model
host = _overrides.get("llm.host") or st.ollama_host host = _overrides.get("llm.host") or st.ollama_host
api_key = _overrides.get("llm.api_key") or st.llm_api_key
raw_timeout = _overrides.get("llm.timeout") raw_timeout = _overrides.get("llm.timeout")
try: try:
timeout = float(raw_timeout) if raw_timeout else st.llm_timeout timeout = float(raw_timeout) if raw_timeout else st.llm_timeout
except ValueError: except ValueError:
timeout = st.llm_timeout timeout = st.llm_timeout
return EffectiveLLM(provider=provider, model=model, host=host, timeout=timeout) return EffectiveLLM(provider=provider, model=model, host=host, timeout=timeout, api_key=api_key)

@ -1138,24 +1138,26 @@ class TokenInfoOut(BaseModel):
class LLMConfigOut(BaseModel): class LLMConfigOut(BaseModel):
"""유효 LLM 연결 설정 + 선택지 + 보조 프로바이더(읽기전용).""" """유효 LLM 연결 설정 + 선택지 + 보조 프로바이더(읽기전용)."""
provider: str # 유효(오버레이>env): auto | ollama | heuristic provider: str # 유효(오버레이>env): ollama | openai | gemini
model: str model: str
host: str host: str
timeout: float timeout: float
provider_options: list[str] = ["ollama"] provider_options: list[str] = ["ollama"]
overridden: bool = False # 런타임 오버레이가 활성인가(env 기본과 다른가) overridden: bool = False # 런타임 오버레이가 활성인가(env 기본과 다른가)
api_key_set: bool = False # API 키가 설정돼 있는가(값은 절대 노출하지 않음)
# 보조 프로바이더(읽기전용, env 관리) # 보조 프로바이더(읽기전용, env 관리)
stt_provider: str = "" stt_provider: str = ""
vision_provider: str = "" vision_provider: str = ""
class LLMUpdateIn(BaseModel): class LLMUpdateIn(BaseModel):
"""LLM 연결 저장. None/빈 문자열은 '기본으로 되돌리기'.""" """LLM 연결 저장. None=미변경, 빈 문자열은 '기본으로 되돌리기'."""
provider: Optional[str] = None provider: Optional[str] = None
model: Optional[str] = None model: Optional[str] = None
host: Optional[str] = None host: Optional[str] = None
timeout: Optional[float] = None timeout: Optional[float] = None
api_key: Optional[str] = None # 암호화 저장. 빈 문자열=키 삭제, None=미변경
class LLMTestIn(BaseModel): class LLMTestIn(BaseModel):
@ -1164,6 +1166,7 @@ class LLMTestIn(BaseModel):
provider: Optional[str] = None provider: Optional[str] = None
host: Optional[str] = None host: Optional[str] = None
model: Optional[str] = None model: Optional[str] = None
api_key: Optional[str] = None # 미지정 시 저장된 키 사용
class LLMTestOut(BaseModel): class LLMTestOut(BaseModel):

@ -28,11 +28,14 @@ def update_llm(
model: str | None = None, model: str | None = None,
host: str | None = None, host: str | None = None,
timeout: float | None = None, timeout: float | None = None,
api_key: str | None = None,
) -> None: ) -> None:
"""LLM 설정을 영속(app_setting)하고 런타임 오버레이에 즉시 반영. """LLM 설정을 영속(app_setting)하고 런타임 오버레이에 즉시 반영.
provider 'auto'(=env 기본과 동등하게 수도 있으나) 라도 명시 저장은 허용한다. provider ollama|openai|gemini. None 필드는 건드리지 않는다(부분 갱신).
None 필드는 건드리지 않는다(부분 갱신).""" api_key 암호화 저장(평문 DB 금지) 문자열이면 삭제( 제거)."""
from ..crypto import encrypt_str
fields: dict[str, object | None] = {} fields: dict[str, object | None] = {}
if provider is not None: if provider is not None:
if provider.strip() == "": if provider.strip() == "":
@ -48,6 +51,9 @@ def update_llm(
if timeout is not None: if timeout is not None:
# timeout<=0 은 '기본으로 되돌리기' 신호(프론트 리셋용). # timeout<=0 은 '기본으로 되돌리기' 신호(프론트 리셋용).
fields["llm.timeout"] = None if timeout <= 0 else timeout fields["llm.timeout"] = None if timeout <= 0 else timeout
if api_key is not None:
# 빈 문자열 → 삭제(None), 그 외 → 암호화 후 저장.
fields["llm.api_key"] = None if api_key.strip() == "" else encrypt_str(api_key)
for key, val in fields.items(): for key, val in fields.items():
_upsert(session, key, None if val is None else str(val)) _upsert(session, key, None if val is None else str(val))

@ -1,11 +1,61 @@
# backend/app/worker/sync_jobs.py — phase-13 주기 sync 잡 + 수동 트리거 진입점 # backend/app/worker/sync_jobs.py — phase-13 주기 sync 잡 + 수동 트리거 진입점
# 프로토타입은 수동 트리거(/api/connectors/sync-all)도 제공(데모 결정성). # 프로토타입은 수동 트리거(/api/connectors/sync-all)도 제공(데모 결정성).
from sqlmodel import Session from sqlmodel import Session, select
from ..config import get_settings from ..config import get_settings
from ..connectors.registry import ConnectorRegistry from ..connectors.registry import ConnectorRegistry
from ..db import engine from ..db import engine
from ..models import ConnState from ..models import ConnState, Email, ExternalLink
def recover_stuck_syncing(session: Session | None = None) -> int:
"""기동 시 'syncing' 으로 멈춰 있는 계정을 'connected' 로 되돌린다.
sync() 시작할 state=syncing 즉시 커밋하고 끝에서 connected/error 바꾸는데,
사이에 프로세스가 죽거나(uvicorn --reload, 크래시) 재시작되면 syncing DB 영구히 남는다.
run_periodic_sync connected/error sync 하므로 멈춘 syncing 계정은 영영 동기화에서 빠진다.
기동 직후엔 실제 진행 중인 sync 없으므로 syncing 모두 '깨진 상태' connected 복구."""
def _do(s: Session) -> int:
stuck = [a for a in ConnectorRegistry.accounts(s) if a.state == ConnState.syncing]
for a in stuck:
a.state = ConnState.connected
s.add(a)
if stuck:
s.commit()
return len(stuck)
if session is not None:
return _do(session)
with Session(engine) as s:
return _do(s)
def purge_spam(session: Session | None = None) -> int:
"""이미 저장된 스팸(provider 가 스팸 분류)을 일괄 삭제 — 더는 스팸을 들고 있지 않는다.
이후 sync 스팸을 저장하지 않으므로(fetch 제외 + base.sync 안전망) 재유입 없음."""
def _do(s: Session) -> int:
spam = s.exec(select(Email).where(Email.folder == "spam")).all()
ids = [e.id for e in spam]
if not ids:
return 0
links = s.exec(
select(ExternalLink).where(
ExternalLink.entity_type == "email",
ExternalLink.entity_id.in_(ids),
)
).all()
for lk in links:
s.delete(lk)
for e in spam:
s.delete(e)
s.commit()
return len(ids)
if session is not None:
return _do(session)
with Session(engine) as s:
return _do(s)
def run_periodic_sync(session: Session | None = None) -> list[dict]: def run_periodic_sync(session: Session | None = None) -> list[dict]:
@ -27,6 +77,50 @@ def run_periodic_sync(session: Session | None = None) -> list[dict]:
return _do(s) return _do(s)
def run_auto_triage(limit: int = 3) -> int:
"""새 메일 자동 정리: 기준 시각 이후 도착한 미분석 '집중' 메일을 LLM 으로 백그라운드 분석.
auto-sync 루프가 sync 직후 호출 사용자가 '정리 맡기기' 누르지 않아도 카드가 미리 채워진다.
사이클에 limit 통만 처리(부하 제한). LLM 미가용/오류 즉시 중단(다음 주기 재시도)."""
# 라우터 헬퍼(폴더 판정·기준 시각·정렬)를 재사용. 순환 임포트 방지로 함수 안에서 지연 임포트.
from ..llm.provider import get_provider
from ..models import TriageEvent
from ..routers.mail import (
_ai_actionable,
_email_in_folder,
_email_sort_key,
_ensure_triage_since,
_is_new_mail,
)
from ..services.mail_ai import analyze_email
with Session(engine) as s:
since = _ensure_triage_since(s)
todo = [
e
for e in s.exec(select(Email)).all()
if not e.ai_json and _email_in_folder(e, "smart") and _is_new_mail(e, since)
]
if not todo:
return 0
todo.sort(key=_email_sort_key, reverse=True)
provider = get_provider()
analyzed, cards, err = 0, 0, None
for e in todo[:limit]:
try:
ai = analyze_email(s, e, provider)
analyzed += 1
if _ai_actionable(ai):
cards += 1
except Exception as ex: # LLM 미가용/파싱 실패 — 이번 사이클 중단, 다음 주기에 재시도
err = (str(ex) or ex.__class__.__name__)[:200]
break
# '오늘 한 일' 로그: 실제로 읽었거나 에러가 났을 때만 기록(무활동은 남기지 않음).
if analyzed or err:
s.add(TriageEvent(analyzed=analyzed, cards=cards, error=err))
s.commit()
return analyzed
def register(scheduler) -> None: def register(scheduler) -> None:
"""phase-7 worker.main(APScheduler)에서 호출(있을 때). 없으면 수동 트리거로 동작.""" """phase-7 worker.main(APScheduler)에서 호출(있을 때). 없으면 수동 트리거로 동작."""
mins = get_settings().sync_interval_minutes mins = get_settings().sync_interval_minutes

@ -64,6 +64,61 @@ def test_llm_test_uses_ollama(client):
assert isinstance(d["reachable"], bool) # 실 Ollama 가동 여부에 따라 달라짐(환경 의존) assert isinstance(d["reachable"], bool) # 실 Ollama 가동 여부에 따라 달라짐(환경 의존)
def test_llm_provider_options_include_openai_gemini(client):
d = client.get("/api/settings/llm").json()
assert "openai" in d["provider_options"]
assert "gemini" in d["provider_options"]
assert d["api_key_set"] is False # 기본은 키 없음
def test_llm_openai_with_api_key_encrypted(client, session):
s, _ = session
r = client.put(
"/api/settings/llm",
json={
"provider": "openai",
"host": "https://api.openai.com/v1",
"model": "gpt-4o-mini",
"api_key": "sk-secret-xyz",
},
)
assert r.status_code == 200
body = r.json()
assert body["provider"] == "openai" and body["model"] == "gpt-4o-mini"
assert body["api_key_set"] is True
assert "api_key" not in body # 키 값은 절대 응답에 없음
# 유효 설정/팩토리에 반영(복호화 라운드트립)
eff = runtime_config.effective_llm()
assert eff.provider == "openai" and eff.api_key == "sk-secret-xyz"
assert get_provider().name == "openai"
# 저장은 암호화(평문 금지)
from app.models import AppSetting
row = s.get(AppSetting, "llm.api_key")
assert row is not None and row.value.startswith("enc:") and "sk-secret-xyz" not in row.value
def test_llm_gemini_provider_select(client):
r = client.put("/api/settings/llm", json={"provider": "gemini", "model": "gemini-2.5-flash"})
assert r.status_code == 200 and r.json()["provider"] == "gemini"
assert runtime_config.effective_llm().provider == "gemini"
assert get_provider().name == "gemini"
def test_llm_api_key_cleared_on_reset(client):
client.put("/api/settings/llm", json={"provider": "openai", "api_key": "sk-zzz"})
assert client.get("/api/settings/llm").json()["api_key_set"] is True
# 빈 문자열들 → 전부 기본으로 되돌리기(키 삭제 포함)
client.put(
"/api/settings/llm",
json={"provider": "", "model": "", "host": "", "timeout": 0, "api_key": ""},
)
d = client.get("/api/settings/llm").json()
assert d["api_key_set"] is False and d["provider"] == "ollama"
def test_profile_update(client): def test_profile_update(client):
r = client.patch("/api/me", json={"name": "지우님", "role": "프로덕트 리드"}) r = client.patch("/api/me", json={"name": "지우님", "role": "프로덕트 리드"})
assert r.status_code == 200 assert r.status_code == 200

@ -0,0 +1,28 @@
# backend/tests/test_spam_purge.py
# 스팸은 저장하지 않는다(유저 요청): 이미 들어온 스팸은 일괄 삭제, 일반 메일은 그대로.
from sqlmodel import select
from app.models import Email, ExternalLink
from app.worker.sync_jobs import purge_spam
from tests._factories import make_email, make_mail_account
def test_purge_spam_removes_spam_and_links_keeps_normal(session):
s, _ = session
make_mail_account(s, id="work", name="회사", tone="coral", sort_order=0)
make_email(s, id="keep", account="work", subject="보통 메일")
junk = make_email(s, id="junk", account="work", subject="스팸 메일")
junk.folder = "spam"
s.add(junk)
s.add(ExternalLink(id="xl-junk", account_id="work", external_id="x-junk",
entity_type="email", entity_id="junk"))
s.add(ExternalLink(id="xl-keep", account_id="work", external_id="x-keep",
entity_type="email", entity_id="keep"))
s.commit()
assert purge_spam(s) == 1 # 스팸 1통만 삭제
assert s.get(Email, "junk") is None # 스팸 메일 제거
assert s.get(Email, "keep") is not None # 일반 메일 유지
# 스팸의 ExternalLink 도 제거(일반 것은 유지) — 재유입/유령 링크 방지.
remaining = sorted(lk.external_id for lk in s.exec(select(ExternalLink)).all())
assert remaining == ["x-keep"]

@ -1,10 +1,22 @@
# backend/tests/test_triage_baseline.py # backend/tests/test_triage_baseline.py
# 아리 정리(비서) 기준 시각: smart 보기/분석은 '기준 이후 도착한 새 메일'만 다룬다. # 아리 정리(비서) 기준 시각: smart 보기/분석은 '기준 이후 도착한 새 메일'만 다룬다.
from datetime import datetime, timedelta from datetime import UTC, datetime, timedelta
from app.models import AppSetting, TriageEvent
from tests._factories import make_email, make_mail_account from tests._factories import make_email, make_mail_account
def _set_baseline(s, dt: datetime) -> None:
"""기준 시각(mail.triage_since)을 직접 설정 — 수동 리셋 엔드포인트가 없어졌으므로 테스트에서 주입."""
row = s.get(AppSetting, "mail.triage_since")
if row:
row.value = dt.isoformat()
s.add(row)
else:
s.add(AppSetting(key="mail.triage_since", value=dt.isoformat()))
s.commit()
def _seed_old_new(s, since: datetime): def _seed_old_new(s, since: datetime):
"""기준 시각 전/후로 도착한 집중(focused) 안읽음 메일을 하나씩 생성.""" """기준 시각 전/후로 도착한 집중(focused) 안읽음 메일을 하나씩 생성."""
make_mail_account(s, id="work", name="회사", tone="coral", sort_order=0) make_mail_account(s, id="work", name="회사", tone="coral", sort_order=0)
@ -16,7 +28,7 @@ def _seed_old_new(s, since: datetime):
def test_smart_folder_shows_only_new_mail(client, session): def test_smart_folder_shows_only_new_mail(client, session):
s, _ = session s, _ = session
since = datetime.fromisoformat(client.post("/api/mail/triage/baseline").json()["since"]) since = datetime.fromisoformat(client.get("/api/mail/triage/baseline").json()["since"])
_seed_old_new(s, since) _seed_old_new(s, since)
# 아리 정리(smart): 기준 이후 도착한 'new'만. # 아리 정리(smart): 기준 이후 도착한 'new'만.
smart = [r["id"] for r in client.get("/api/mail?folder=smart").json()] smart = [r["id"] for r in client.get("/api/mail?folder=smart").json()]
@ -28,21 +40,115 @@ def test_smart_folder_shows_only_new_mail(client, session):
assert inbox == ["new", "old"] assert inbox == ["new", "old"]
def test_baseline_get_initializes_and_post_advances(client, session): def test_baseline_get_initializes(client, session):
"""기준 시각은 첫 GET 조회 때 now 로 lazy-init 되고 이후 안정적으로 유지된다.
(수동 재설정 POST 제거 메일 정리는 전부 자동이라 사용자가 기준을 만질 일이 없다.)"""
s, _ = session s, _ = session
first = client.get("/api/mail/triage/baseline").json()["since"] first = client.get("/api/mail/triage/baseline").json()["since"]
assert first # 첫 조회 시 now 로 lazy-init assert first # 첫 조회 시 now 로 lazy-init
advanced = client.post("/api/mail/triage/baseline").json()["since"] assert client.get("/api/mail/triage/baseline").json()["since"] == first # 재조회 시 유지
assert datetime.fromisoformat(advanced) >= datetime.fromisoformat(first)
def test_triage_analyze_skips_old_mail(client, session): def test_smart_count_excludes_analyzed_informational(client, session):
"""분석이 끝난 '정보성'(액션 없음·낮음) 새 메일은 아리 정리 카드가 안 생기므로 배지에서 제외 —
배지(count) 아리 정리 패널(카드) 숫자를 일치시킨다(배지 1인데 패널은 비어 보이는 혼선 방지)."""
s, _ = session s, _ = session
since = datetime.fromisoformat(client.post("/api/mail/triage/baseline").json()["since"]) since = datetime.fromisoformat(client.get("/api/mail/triage/baseline").json()["since"])
_seed_old_new(s, since) make_mail_account(s, id="work", name="회사", tone="coral", sort_order=0)
res = client.post("/api/mail/triage/analyze").json() make_email(s, id="info", account="work", subject="운임 변경 알림", read=True,
assert res["analyzed"] == 1 # 새 메일 1통만 분석 received_at=since + timedelta(hours=1),
assert res["remaining"] == 0 ai_json={"summary": "안내", "priority": "낮음",
# 기준 이후 새 메일만 분석되고, 이전 메일의 ai 는 비어 있다. "tasks": [], "events": [], "replies": []})
assert client.get("/api/mail/new").json()["ai"] is not None # 카드가 없으므로 배지 0 — 단, 요약은 '아리가 1통을 읽었다'를 보고한다(빈 화면≠고장).
assert client.get("/api/mail/old").json()["ai"] is None b = client.get("/api/mail/triage/baseline").json()
assert b["count"] == 0 and b["new_total"] == 1 and b["screened"] == 0
# 단, 받은편지함/smart 목록에는 그대로 남는다(분석만 끝났을 뿐 — 패널이 카드로 필터).
assert [r["id"] for r in client.get("/api/mail?folder=smart").json()] == ["info"]
def test_triage_summary_reports_screened(client, session):
"""홍보·스팸 등 집중이 아닌 새 메일은 screened 로 집계 — 패널이 '아리가 N통 걸렀어요'를 보여준다."""
s, _ = session
since = datetime.fromisoformat(client.get("/api/mail/triage/baseline").json()["since"])
make_mail_account(s, id="work", name="회사", tone="coral", sort_order=0)
# 집중(primary) 새 메일 1통 + 홍보 새 메일 1통
make_email(s, id="focus", account="work", subject="검토 요청",
received_at=since + timedelta(hours=1),
ai_json={"summary": "검토", "priority": "보통",
"tasks": [{"text": "검토"}], "events": [], "replies": []})
make_email(s, id="promo", account="work", subject="여름 세일", labels=["프로모션"],
received_at=since + timedelta(hours=1))
b = client.get("/api/mail/triage/baseline").json()
assert b["new_total"] == 2 # 새 메일 총 2통
assert b["screened"] == 1 # 그중 홍보 1통은 집중이 아니라 자동으로 걸러짐
assert b["count"] == 1 # 카드는 집중 액션 메일 1통만
def test_smart_count_includes_actionable(client, session):
"""할 일/일정/답장이 있거나 중요(높음)인 분석 메일은 카드가 생기므로 배지에 포함(읽음 무관)."""
s, _ = session
since = datetime.fromisoformat(client.get("/api/mail/triage/baseline").json()["since"])
make_mail_account(s, id="work", name="회사", tone="coral", sort_order=0)
make_email(s, id="todo", account="work", subject="검토 요청", read=True,
received_at=since + timedelta(hours=1),
ai_json={"summary": "검토", "priority": "보통",
"tasks": [{"text": "문서 검토"}], "events": [], "replies": []})
assert client.get("/api/mail/triage/baseline").json()["count"] == 1
def test_summary_new_total_counts_today_only(client, session):
"""new_total/screened 는 day_start(클라 로컬 자정) 이후 도착분만 — 기준 시각이 며칠 전이어도
누적('새 메일 60통')되지 않고 '오늘 온 새 메일' 표시. , 배지(count) 기준 이후 전체라
어제 도착한 챙길 거리(카드) 유지한다."""
s, _ = session
day_start = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0) # 오늘 자정
_set_baseline(s, day_start - timedelta(days=3)) # 기준 시각은 3일 전
make_mail_account(s, id="work", name="회사", tone="coral", sort_order=0)
actionable = {"summary": "검토", "priority": "보통", "tasks": [{"text": "검토"}],
"events": [], "replies": []}
# 어제 도착(집중·액션) + 오늘 도착(집중·액션) + 오늘 도착(홍보)
make_email(s, id="yday", account="work", subject="어제 검토",
received_at=day_start - timedelta(hours=5), ai_json=actionable)
make_email(s, id="tfocus", account="work", subject="오늘 검토",
received_at=day_start + timedelta(hours=2), ai_json=actionable)
make_email(s, id="tpromo", account="work", subject="오늘 세일", labels=["프로모션"],
received_at=day_start + timedelta(hours=3))
b = client.get("/api/mail/triage/baseline", params={"day_start": day_start.isoformat()}).json()
assert b["new_total"] == 2 # 오늘 도착분(집중+홍보)만 — 어제(yday)는 제외
assert b["screened"] == 1 # 오늘 홍보 1통
assert b["count"] == 2 # 배지=기준 이후 전체 카드(어제+오늘 집중) — 어제 챙길 거리도 유지
def test_triage_log_returns_today_events_newest_first(client, session):
"""자동 정리 실행 로그: 오늘(day_start 이후) 이벤트만 최신순 — 분석 통수·에러를 그대로 노출."""
s, _ = session
day_start = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
s.add(TriageEvent(at=day_start - timedelta(hours=2), analyzed=2, cards=1)) # 어제(범위 밖)
s.add(TriageEvent(at=day_start + timedelta(hours=1), analyzed=3, cards=2)) # 오늘
s.add(TriageEvent(at=day_start + timedelta(hours=3), analyzed=0, cards=0, error="모델 응답 없음"))
s.commit()
rows = client.get("/api/mail/triage/log", params={"day_start": day_start.isoformat()}).json()
assert len(rows) == 2 # 오늘 것만(어제 제외)
assert rows[0]["error"] == "모델 응답 없음" # 최신순 — 에러가 먼저
assert rows[1]["analyzed"] == 3 and rows[1]["cards"] == 2
def test_triage_classified_lists_today_mail_with_bucket(client, session):
"""상세 로그: 오늘 새 메일을 버킷(집중/홍보)·분석 결과와 함께 하나씩 — 어제 것은 제외."""
s, _ = session
day_start = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
_set_baseline(s, day_start - timedelta(days=3))
make_mail_account(s, id="work", name="회사", tone="coral", sort_order=0)
make_email(s, id="yday", account="work", subject="어제", received_at=day_start - timedelta(hours=5))
make_email(s, id="focus", account="work", subject="검토 요청",
received_at=day_start + timedelta(hours=2),
ai_json={"summary": "검토", "priority": "보통",
"tasks": [{"text": "검토"}], "events": [], "replies": []})
make_email(s, id="promo", account="work", subject="세일", labels=["프로모션"],
received_at=day_start + timedelta(hours=1))
rows = client.get("/api/mail/triage/classified", params={"day_start": day_start.isoformat()}).json()
byid = {r["id"]: r for r in rows}
assert set(byid) == {"focus", "promo"} # 오늘 둘만(어제 yday 제외)
assert byid["focus"]["bucket"] == "집중" and byid["focus"]["focused"] is True
assert byid["focus"]["analyzed"] is True and byid["focus"]["tasks"] == 1
assert byid["promo"]["bucket"] == "홍보" and byid["promo"]["focused"] is False

@ -12,6 +12,7 @@ import { MailSidebar } from "./MailSidebar";
import { MailList } from "./MailList"; import { MailList } from "./MailList";
import { Reader } from "./Reader"; import { Reader } from "./Reader";
import { Triage } from "./Triage"; import { Triage } from "./Triage";
import { TriageLogModal } from "./TriageLogModal";
import { Compose, type ComposeState } from "./Compose"; import { Compose, type ComposeState } from "./Compose";
// 계정 구분 색 — 계정 tone 이 서로 겹쳐(둘 다 blue) dot 으로 구분이 안 되므로 팔레트에서 배정. // 계정 구분 색 — 계정 tone 이 서로 겹쳐(둘 다 blue) dot 으로 구분이 안 되므로 팔레트에서 배정.
@ -53,7 +54,7 @@ export function MailClient() {
const [compose, setCompose] = useState<ComposeState | null>(null); const [compose, setCompose] = useState<ComposeState | null>(null);
const [toasts, setToasts] = useState<Toast[]>([]); const [toasts, setToasts] = useState<Toast[]>([]);
const [syncing, setSyncing] = useState(false); const [syncing, setSyncing] = useState(false);
const [analyzing, setAnalyzing] = useState(false); const [logOpen, setLogOpen] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set()); const [selected, setSelected] = useState<Set<string>>(new Set());
const shellRef = useRef<HTMLDivElement>(null); const shellRef = useRef<HTMLDivElement>(null);
const tid = useRef(0); const tid = useRef(0);
@ -78,10 +79,25 @@ export function MailClient() {
revalidateOnFocus: false, revalidateOnFocus: false,
}); });
// 아리 정리 기준 시각(이 시각 이후 새 메일만 정리) + 새 안읽음 수(사이드바 배지). // 아리 정리 기준 시각(이 시각 이후 새 메일만 정리) + 새 안읽음 수(사이드바 배지).
// 배지는 어느 폴더 보기에서도 필요하므로 항상 조회한다. // 배지는 어느 폴더 보기에서도 필요하므로 항상 조회한다. day_start=로컬 자정을 넘겨
// '오늘 온 새 메일' 수(new_total/screened)를 클라 TZ 기준으로 산출(서버는 UTC).
const { data: baseline, mutate: mutateBaseline } = useSWR( const { data: baseline, mutate: mutateBaseline } = useSWR(
"mail/triage/baseline", "mail/triage/baseline",
() => mailApi.triageBaseline(), () => {
const d = new Date();
d.setHours(0, 0, 0, 0);
return mailApi.triageBaseline(d.toISOString());
},
{ revalidateOnFocus: false, refreshInterval: 30000 },
);
// 오늘 아리 자동 정리 실행 로그('한 일' 피드) — 실제로 돌았는지/에러났는지 눈으로 확인용.
const { data: triageLog } = useSWR(
"mail/triage/log",
() => {
const d = new Date();
d.setHours(0, 0, 0, 0);
return mailApi.triageLog(d.toISOString());
},
{ revalidateOnFocus: false, refreshInterval: 30000 }, { revalidateOnFocus: false, refreshInterval: 30000 },
); );
@ -319,39 +335,16 @@ export function MailClient() {
const doAdd = (key: string, kind: ExtractKind, index: number) => const doAdd = (key: string, kind: ExtractKind, index: number) =>
doAddFor(selId!, key, kind, index); doAddFor(selId!, key, kind, index);
// 아리 비서: 새 메일을 가져와(sync) 미분석분을 LLM 으로 정리. 먼저 동기화해 // 아리 정리 카드 기각 — 다이제스트에서만 빼고(받은편지함엔 잔류) 배지/목록 갱신.
// 기준 시각 이후 새로 도착한 메일을 받고, 작은 배치(2통)로 나눠 반복 분석 — const onDismissCard = async (id: string) => {
// 요청을 짧게 유지하고 라운드마다 목록을 갱신해 카드를 점진적으로 노출한다. mutate((list) => (list || []).filter((m) => m.id !== id), false); // 낙관적 제거
const runAnalyze = async () => {
if (analyzing) return;
setAnalyzing(true);
let total = 0;
try {
await mailApi.sync().catch(() => {}); // 새 메일 먼저 가져오기(실패해도 분석 진행)
await revalidateAll();
for (let round = 0; round < 8; round++) {
const res = await mailApi.triageAnalyze(2);
total += res.analyzed;
await mutate(); // 라운드마다 카드 갱신
if (res.remaining === 0 || res.analyzed === 0) break;
}
toast(total > 0 ? `새 메일 ${total}통을 정리했어요` : "정리할 새 메일이 없어요", "blue");
} catch {
if (total > 0) await mutate();
toast("정리하지 못했어요 (아리 모델 확인)", "coral");
} finally {
setAnalyzing(false);
}
};
// 기준 시각 재설정 — 지금 이후 도착하는 새 메일부터 다시 정리.
const onResetBaseline = async () => {
try { try {
await mailApi.triageBaselineReset(); await mailApi.triageDismiss(id);
await Promise.all([mutateBaseline(), mutate()]); await Promise.all([mutateBaseline(), globalMutate("mail/triage/baseline")]);
toast("지금부터 도착하는 새 메일만 정리할게요", "blue"); toast("이 메일은 정리에서 뺐어요", "blue");
} catch { } catch {
toast("기준 시각을 바꾸지 못했어요", "coral"); await mutate(); // 롤백(서버 상태로 복원)
toast("기각하지 못했어요", "coral");
} }
}; };
@ -532,16 +525,24 @@ export function MailClient() {
<Triage <Triage
emails={emails} emails={emails}
people={peopleMap} people={peopleMap}
accounts={accMap}
accColor={accColor} accColor={accColor}
added={added} added={added}
analyzing={analyzing} newTotal={baseline?.new_total ?? 0}
since={baseline?.since} onOpenLog={() => setLogOpen(true)}
onAnalyze={runAnalyze}
onResetBaseline={onResetBaseline}
onOpen={openMail} onOpen={openMail}
onAdd={doAddFor} onAdd={doAddFor}
onAddAll={addAll} onAddAll={addAll}
onReply={onReply} onReply={onReply}
onDismiss={onDismissCard}
/>
<TriageLogModal
open={logOpen}
onClose={() => setLogOpen(false)}
log={triageLog ?? []}
accounts={accMap}
accColor={accColor}
onOpenMail={openMail}
/> />
{selId && readerEl} {selId && readerEl}
</> </>

@ -2,31 +2,17 @@
// 새 메일 중 '챙길 거리'를 발신자 카드로 정리: 요약 + 할 일/일정/답장 액션을 바로 적용. // 새 메일 중 '챙길 거리'를 발신자 카드로 정리: 요약 + 할 일/일정/답장 액션을 바로 적용.
"use client"; "use client";
import { Icon } from "@/components/Icon"; import { Icon } from "@/components/Icon";
import type { AiAnalysis, AiReply, EmailRow, ExtractKind, Person } from "@/lib/types"; import type {
AiAnalysis,
AiReply,
EmailRow,
ExtractKind,
MailAccount,
Person,
} from "@/lib/types";
const PALETTE = ["var(--blue)", "var(--violet)", "var(--coral)", "var(--green)", "var(--amber)"];
const PRIO_RANK: Record<string, number> = { : 0, : 1, : 2 }; const PRIO_RANK: Record<string, number> = { : 0, : 1, : 2 };
// 기준 시각(ISO) → 사용자 로컬 표기('6월 18일 오후 2:30').
function fmtSince(iso?: string): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleString("ko-KR", {
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
// 발신자 이름 → 안정적 색(메일마다 같은 발신자는 같은 색).
function senderColor(name: string): string {
let h = 0;
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
return PALETTE[h % PALETTE.length];
}
// 메일 한 통에 달린 액션 수(할 일 + 일정 + 답장 1). // 메일 한 통에 달린 액션 수(할 일 + 일정 + 답장 1).
function actionCount(ai: AiAnalysis): number { function actionCount(ai: AiAnalysis): number {
return ai.tasks.length + ai.events.length + (ai.replies.length ? 1 : 0); return ai.tasks.length + ai.events.length + (ai.replies.length ? 1 : 0);
@ -80,33 +66,44 @@ function ActionRow({
function SecretaryCard({ function SecretaryCard({
m, m,
name, name,
color, account,
accColor,
added, added,
onOpen, onOpen,
onAdd, onAdd,
onAddAll, onAddAll,
onReply, onReply,
onDismiss,
}: { }: {
m: EmailRow; m: EmailRow;
name: string; name: string;
color: string; account?: MailAccount;
accColor: string;
added: Record<string, boolean>; added: Record<string, boolean>;
onOpen: (id: string) => void; onOpen: (id: string) => void;
onAdd: (mailId: string, key: string, kind: ExtractKind, index: number) => void; onAdd: (mailId: string, key: string, kind: ExtractKind, index: number) => void;
onAddAll: (m: EmailRow) => void; onAddAll: (m: EmailRow) => void;
onReply: (m: EmailRow, idx: number, r: AiReply) => void; onReply: (m: EmailRow, idx: number, r: AiReply) => void;
onDismiss: (id: string) => void;
}) { }) {
const ai = m.ai; const ai = m.ai;
if (!ai) return null; if (!ai) return null;
const status = statusOf(ai); const status = statusOf(ai);
const n = actionCount(ai); const n = actionCount(ai);
const reply = ai.replies[0]; const reply = ai.replies[0];
// 아바타는 받은편지함 행과 동일하게 '어느 계정'(발신자 아님) — 계정 색 + 계정 이니셜.
const accName = account?.name ?? m.account;
const avaColor = accColor ?? (account?.tone ? `var(--${account.tone})` : "var(--muted)");
return ( return (
<article className="sc-card"> <article className="sc-card">
<header className="sc-head"> <header className="sc-head">
<div className="sc-ava" style={{ background: color }}> <div
{name.slice(0, 1).toUpperCase()} className="sc-ava"
style={{ background: avaColor }}
title={`${accName} · ${account?.email ?? ""}`}
>
{accName.slice(0, 1).toUpperCase()}
</div> </div>
<div className="sc-who"> <div className="sc-who">
<b>{name}</b> <b>{name}</b>
@ -115,6 +112,14 @@ function SecretaryCard({
<button className="sc-open" onClick={() => onOpen(m.id)} aria-label="메일 열기"> <button className="sc-open" onClick={() => onOpen(m.id)} aria-label="메일 열기">
<Icon name="arrow" /> <Icon name="arrow" />
</button> </button>
<button
className="sc-dismiss"
onClick={() => onDismiss(m.id)}
aria-label="정리에서 빼기"
title="이 메일은 정리에서 빼기(받은편지함엔 남아요)"
>
<Icon name="x" />
</button>
</header> </header>
<h3 className="sc-title" onClick={() => onOpen(m.id)}> <h3 className="sc-title" onClick={() => onOpen(m.id)}>
@ -176,33 +181,31 @@ function SecretaryCard({
export function Triage({ export function Triage({
emails, emails,
people, people,
accounts,
accColor, accColor,
added, added,
analyzing, newTotal,
since, onOpenLog,
onAnalyze,
onResetBaseline,
onOpen, onOpen,
onAdd, onAdd,
onAddAll, onAddAll,
onReply, onReply,
onDismiss,
}: { }: {
emails: EmailRow[]; emails: EmailRow[];
people: Record<string, Person>; people: Record<string, Person>;
accounts: Record<string, MailAccount>;
accColor: (id: string) => string; accColor: (id: string) => string;
added: Record<string, boolean>; added: Record<string, boolean>;
analyzing: boolean; newTotal: number;
since?: string; onOpenLog: () => void;
onAnalyze: () => void;
onResetBaseline: () => void;
onOpen: (id: string) => void; onOpen: (id: string) => void;
onAdd: (mailId: string, key: string, kind: ExtractKind, index: number) => void; onAdd: (mailId: string, key: string, kind: ExtractKind, index: number) => void;
onAddAll: (m: EmailRow) => void; onAddAll: (m: EmailRow) => void;
onReply: (m: EmailRow, idx: number, r: AiReply) => void; onReply: (m: EmailRow, idx: number, r: AiReply) => void;
onDismiss: (id: string) => void;
}) { }) {
const nameOf = (m: EmailRow) => people[m.from]?.name ?? m.from_name ?? m.from; const nameOf = (m: EmailRow) => people[m.from]?.name ?? m.from_name ?? m.from;
const colorOf = (m: EmailRow) =>
people[m.from]?.color ?? senderColor(nameOf(m)) ?? accColor(m.account);
// 새 메일 = 기준 시각 이후 도착(서버가 smart 보기로 이미 필터). 읽음 여부와 무관 — // 새 메일 = 기준 시각 이후 도착(서버가 smart 보기로 이미 필터). 읽음 여부와 무관 —
// 사용자가 폰/다른 앱에서 먼저 읽어도 아리가 챙길 거리를 정리한다. // 사용자가 폰/다른 앱에서 먼저 읽어도 아리가 챙길 거리를 정리한다.
@ -228,96 +231,83 @@ export function Triage({
); );
// 아직 아리가 정리하지 않은(분석 전) 새 메일 수. // 아직 아리가 정리하지 않은(분석 전) 새 메일 수.
const pending = emails.filter((m) => !m.ai).length; const pending = emails.filter((m) => !m.ai).length;
const hasStats = tot.tasks + tot.events + tot.replies > 0;
// 아리가 백그라운드로 자동 정리 중인 상태(수동 버튼 없음 — auto-sync 루프가 알아서 분석).
const working = pending > 0;
// 화면 상태 → 제목 한 줄 + 설명 한 줄. 같은 말을 두 번 하지 않도록 히어로 한 곳에서만 보여준다.
let title: string;
let sub: string;
if (cards.length > 0) {
title = `챙길 메일 ${cards.length}건을 추렸어요`;
sub = `새 메일 ${fresh.length}통에서 챙길 거리만 골랐어요.`;
} else if (working) {
title = `새 메일 ${pending}통, 아리가 정리하는 중…`;
sub = "잠시 후 챙길 거리만 카드로 정리해 둘게요.";
} else if (newTotal > 0) {
title = `오늘 온 새 메일 ${newTotal}통, 챙길 건 없었어요`;
sub = "챙길 게 생기면 할 일·일정·답장 카드로 여기 정리해 둘게요.";
} else {
title = "지금은 챙길 새 메일이 없어요";
sub = "새 메일이 오면 아리가 할 일·일정·답장으로 정리해 둘게요.";
}
return ( return (
<div className="secretary"> <div className="secretary">
<div className="sec-scroll" role="region" aria-label="아리 비서 정리" tabIndex={0}> <div className="sec-scroll" role="region" aria-label="아리 비서 정리" tabIndex={0}>
<header className="sec-hero"> <header className="sec-hero">
<div className="sec-spark"> <div className={"sec-spark" + (working ? " working" : "")}>
<Icon name="spark" /> <Icon name={working ? "refresh" : "spark"} />
</div> </div>
<div className="sec-hero-main"> <div className="sec-hero-main">
<div className="sec-eyebrow"> · </div> <h2>{title}</h2>
<h2> <p>{sub}</p>
{cards.length > 0 {hasStats && (
? `새 메일 ${fresh.length}통 중 ${cards.length}건을 챙겨뒀어요`
: pending > 0
? `새 메일 ${pending}통, 정리해 드릴까요?`
: "지금은 챙길 새 메일이 없어요"}
</h2>
<p>
<b> </b> ·· .
, .
</p>
<div className="sec-hero-row">
{cards.length > 0 && (
<div className="sec-stats"> <div className="sec-stats">
{tot.tasks > 0 && (
<span className="sec-stat task"> <span className="sec-stat task">
<Icon name="check" /> <Icon name="check" />
<b>{tot.tasks}</b> <b>{tot.tasks}</b>
</span> </span>
)}
{tot.events > 0 && (
<span className="sec-stat event"> <span className="sec-stat event">
<Icon name="cal" /> <Icon name="cal" />
<b>{tot.events}</b> <b>{tot.events}</b>
</span> </span>
)}
{tot.replies > 0 && (
<span className="sec-stat reply"> <span className="sec-stat reply">
<Icon name="reply" /> <Icon name="reply" />
<b>{tot.replies}</b> <b>{tot.replies}</b>
</span> </span>
</div>
)} )}
<button className="sec-analyze" onClick={onAnalyze} disabled={analyzing}>
<Icon name={analyzing ? "refresh" : "spark"} />
{analyzing
? "아리가 읽는 중…"
: pending > 0
? `새 메일 ${pending}통 정리 맡기기`
: "새 메일 확인하기"}
</button>
</div>
{since && (
<div className="sec-baseline">
<span>
<b>{fmtSince(since)}</b>
</span>
<button onClick={onResetBaseline} disabled={analyzing}>
</button>
</div> </div>
)} )}
{/* 오늘 새 메일이 어떻게 분류/분석됐는지 상세는 다이얼로그로. */}
<button className="sec-loglink" onClick={onOpenLog}>
<Icon name="list" />
</button>
</div> </div>
</header> </header>
{cards.length === 0 ? ( {cards.length > 0 && (
<div className="sec-empty">
<div className="se-ico">
<Icon name={pending > 0 ? "spark" : "check"} />
</div>
{pending > 0 ? (
<>
<h3> {pending} </h3>
<p> ·· .</p>
</>
) : (
<>
<h3> </h3>
<p> ·· .</p>
</>
)}
</div>
) : (
<div className="sec-cards"> <div className="sec-cards">
{cards.map((m) => ( {cards.map((m) => (
<SecretaryCard <SecretaryCard
key={m.id} key={m.id}
m={m} m={m}
name={nameOf(m)} name={nameOf(m)}
color={colorOf(m)} account={accounts[m.account]}
accColor={accColor(m.account)}
added={added} added={added}
onOpen={onOpen} onOpen={onOpen}
onAdd={onAdd} onAdd={onAdd}
onAddAll={onAddAll} onAddAll={onAddAll}
onReply={onReply} onReply={onReply}
onDismiss={onDismiss}
/> />
))} ))}
</div> </div>

@ -0,0 +1,188 @@
// frontend/components/mail/TriageLogModal.tsx — '오늘 메일 상세 로그' 다이얼로그(히어로 버튼으로 열림)
// 정직성: 집중/홍보/스팸 '분류'는 Gmail·Outlook 이 한 것. 아리가 한 일은 '집중 메일 분석'뿐.
"use client";
import { useEffect } from "react";
import useSWR from "swr";
import { Icon } from "@/components/Icon";
import { mailApi, type TriageLogEntry } from "@/lib/mail/api";
import type { MailAccount } from "@/lib/types";
// ISO → 'HH:MM'(로컬).
function fmtTime(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleTimeString("ko-KR", { hour: "numeric", minute: "2-digit" });
}
// 분류 버킷 → 칩 색 클래스 / 표시 순서.
const BUCKET_TONE: Record<string, string> = {
: "focus",
: "promo",
: "social",
: "update",
: "spam",
: "muted",
: "muted",
: "muted",
};
const BUCKET_ORDER = ["집중", "홍보", "소셜", "업데이트", "스팸", "휴지통", "보관", "기타"];
export function TriageLogModal({
open,
onClose,
log,
accounts,
accColor,
onOpenMail,
}: {
open: boolean;
onClose: () => void;
log: TriageLogEntry[];
accounts: Record<string, MailAccount>;
accColor: (id: string) => string;
onOpenMail: (id: string) => void;
}) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
// 열렸을 때만 상세 분류를 가져온다.
const { data: rows } = useSWR(
open ? "mail/triage/classified" : null,
() => {
const d = new Date();
d.setHours(0, 0, 0, 0);
return mailApi.triageClassified(d.toISOString());
},
{ revalidateOnFocus: false },
);
if (!open) return null;
const list = rows ?? [];
const counts: Record<string, number> = {};
list.forEach((r) => (counts[r.bucket] = (counts[r.bucket] ?? 0) + 1));
const buckets = BUCKET_ORDER.filter((b) => counts[b]);
return (
<div
className="tlog-overlay"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label="오늘 메일 상세 로그"
>
<div className="tlog-panel" onClick={(e) => e.stopPropagation()}>
<header className="tlog-head">
<div className="tlog-title">
<Icon name="list" />
</div>
<button className="tlog-close" onClick={onClose} aria-label="닫기">
<Icon name="x" />
</button>
</header>
<div className="tlog-body">
{/* 아리가 실제로 한 일 = 집중 메일 LLM 분석(분류 아님). */}
<section className="tlog-sec">
<h4> </h4>
{log.length === 0 ? (
<p className="tlog-muted"> .</p>
) : (
<ul className="tlog-events">
{log.map((e, i) => (
<li key={i} className={e.error ? "err" : ""}>
<time>{fmtTime(e.at)}</time>
<span>
{e.error
? `분석 실패 · ${e.error}`
: `집중 메일 ${e.analyzed}통 분석${e.cards ? ` · ${e.cards}건 챙길 거리` : ""}`}
</span>
</li>
))}
</ul>
)}
</section>
{/* 버킷 분류는 Gmail·Outlook 이 한 것 — 정직하게 출처 표기. */}
<section className="tlog-sec">
<h4> {list.length}</h4>
<p className="tlog-note">
·· <b>Gmail·Outlook</b> .
·· .
</p>
{buckets.length > 0 && (
<div className="tlog-chips">
{buckets.map((b) => (
<span key={b} className={"tlog-chip " + (BUCKET_TONE[b] ?? "muted")}>
{b} {counts[b]}
</span>
))}
</div>
)}
{!rows ? (
<p className="tlog-muted"> </p>
) : list.length === 0 ? (
<p className="tlog-muted"> .</p>
) : (
<ul className="tlog-mails">
{list.map((r) => {
const accName = accounts[r.account]?.name ?? r.account;
const noAction = r.tasks === 0 && r.events === 0 && r.replies === 0;
return (
<li
key={r.id}
className="tlog-mail"
onClick={() => {
onOpenMail(r.id);
onClose();
}}
>
<div
className="tm-ava"
style={{ background: accColor(r.account) }}
title={accName}
>
{accName.slice(0, 1).toUpperCase()}
</div>
<div className="tm-main">
<div className="tm-top">
<span className="tm-from">{r.from_name}</span>
<span className="tm-time">{r.time}</span>
</div>
<div className="tm-subj">{r.subject}</div>
</div>
<div className="tm-tags">
<span className={"tlog-chip " + (BUCKET_TONE[r.bucket] ?? "muted")}>
{r.bucket}
</span>
{r.focused &&
(r.analyzed ? (
<span className="tm-ana">
{[
r.priority,
r.tasks > 0 && `할 일 ${r.tasks}`,
r.events > 0 && `일정 ${r.events}`,
r.replies > 0 && "답장",
noAction && "정보성",
]
.filter(Boolean)
.join(" · ")}
</span>
) : (
<span className="tm-ana wait"> </span>
))}
</div>
</li>
);
})}
</ul>
)}
</section>
</div>
</div>
</div>
);
}

@ -1,4 +1,5 @@
// frontend/components/settings/LlmTab.tsx — LLM 연결(provider/model/host/timeout) 편집·테스트·적용 // frontend/components/settings/LlmTab.tsx — LLM 연결(provider/model/host/api키/timeout) 편집·테스트·적용
// 지원 provider: ollama | openai(호환 엔드포인트) | gemini. provider 별로 입력 필드가 달라진다.
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
@ -9,21 +10,84 @@ import { useLlmConfig } from "@/lib/hooks/useSettings";
import type { LlmProvider, LlmTestResult } from "@/lib/types"; import type { LlmProvider, LlmTestResult } from "@/lib/types";
import type { ToastFn } from "./SettingsClient"; import type { ToastFn } from "./SettingsClient";
const PROVIDER_LABEL: Record<LlmProvider, string> = { const PROVIDER_LABEL: Record<string, string> = {
auto: "자동", auto: "자동",
ollama: "Ollama", ollama: "Ollama",
heuristic: "규칙(오프라인)", heuristic: "규칙(오프라인)",
openai: "OpenAI 호환",
gemini: "Gemini",
}; };
type ProviderMeta = {
hint: string;
hostLabel: string;
hostPlaceholder: string;
modelPlaceholder: string;
needsHost: boolean;
needsKey: boolean;
keyPlaceholder: string;
presets: { label: string; url: string }[];
};
// provider 별 입력 메타. needsHost=호스트/베이스URL 필드 표시, needsKey=API 키 필드 표시.
const PROVIDER_META: Record<string, ProviderMeta> = {
ollama: {
hint: "Ollama 서버(로컬/원격)를 사용해요.",
hostLabel: "Ollama 호스트 (API 주소)",
hostPlaceholder: "https://ollama.yirugi.synology.me",
modelPlaceholder: "예: gemma4:26b-a4b-it-q8_0",
needsHost: true,
needsKey: false,
keyPlaceholder: "",
presets: [
{ label: "Synology 원격", url: "https://ollama.yirugi.synology.me" },
{ label: "로컬", url: "http://localhost:11434" },
],
},
openai: {
hint: "OpenAI 호환 엔드포인트(OpenAI·OpenRouter·Groq·로컬 vLLM/LM Studio 등).",
hostLabel: "API 베이스 URL",
hostPlaceholder: "https://api.openai.com/v1",
modelPlaceholder: "예: gpt-4o-mini",
needsHost: true,
needsKey: true,
keyPlaceholder: "sk-…",
presets: [
{ label: "OpenAI", url: "https://api.openai.com/v1" },
{ label: "OpenRouter", url: "https://openrouter.ai/api/v1" },
{ label: "Gemini(호환)", url: "https://generativelanguage.googleapis.com/v1beta/openai" },
],
},
gemini: {
hint: "Google Gemini API. 키만 입력하면 기본 엔드포인트를 사용해요.",
hostLabel: "API 베이스 URL (선택)",
hostPlaceholder: "https://generativelanguage.googleapis.com/v1beta",
modelPlaceholder: "예: gemini-2.5-flash",
needsHost: false,
needsKey: true,
keyPlaceholder: "AIza…",
presets: [],
},
};
function metaFor(p: string): ProviderMeta {
return PROVIDER_META[p] ?? PROVIDER_META.ollama;
}
export function LlmTab({ toast }: { toast: ToastFn }) { export function LlmTab({ toast }: { toast: ToastFn }) {
const { data: cfg, isLoading, refresh } = useLlmConfig(); const { data: cfg, isLoading, refresh } = useLlmConfig();
const [provider, setProvider] = useState<LlmProvider>("ollama"); const [provider, setProvider] = useState<LlmProvider>("ollama");
const [model, setModel] = useState(""); const [model, setModel] = useState("");
const [host, setHost] = useState(""); const [host, setHost] = useState("");
const [apiKey, setApiKey] = useState("");
const [timeout, setTimeoutVal] = useState(""); const [timeout, setTimeoutVal] = useState("");
const [test, setTest] = useState<LlmTestResult | null>(null); const [test, setTest] = useState<LlmTestResult | null>(null);
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
// 호스트/키에서 불러온 사용 가능한 모델 목록(드롭다운 선택). reloadKey 증가 시 강제 재조회.
const [models, setModels] = useState<string[]>([]);
const [loadingModels, setLoadingModels] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => { useEffect(() => {
if (cfg) { if (cfg) {
@ -31,17 +95,66 @@ export function LlmTab({ toast }: { toast: ToastFn }) {
setModel(cfg.model); setModel(cfg.model);
setHost(cfg.host); setHost(cfg.host);
setTimeoutVal(String(cfg.timeout)); setTimeoutVal(String(cfg.timeout));
setApiKey(""); // 키는 절대 내려받지 않음 — 비워두고 'set' 여부만 표시.
} }
}, [cfg]); }, [cfg]);
const isHeuristic = provider === "heuristic"; const meta = metaFor(provider);
const keySet = !!cfg?.api_key_set;
// provider 전환: 잔여 host/key 혼입을 막고 새 provider 기본 호스트로 초기화.
const changeProvider = (p: LlmProvider) => {
setProvider(p);
setTest(null);
setModels([]);
setModel(""); // provider 마다 모델명 체계가 달라 초기화(목록서 다시 선택)
setApiKey("");
const presets = metaFor(p).presets;
setHost(presets[0]?.url ?? "");
};
// host/key 가 준비되면 모델 목록을 자동 조회 → 드롭다운. 디바운스 450ms. 새로고침=reloadKey.
useEffect(() => {
if (provider === "heuristic") {
setModels([]);
return;
}
const needsKey = provider === "openai" || provider === "gemini";
const needsHost = provider === "ollama" || provider === "openai";
if (needsHost && !host.trim()) {
setModels([]);
return;
}
if (needsKey && !apiKey.trim() && !cfg?.api_key_set) {
setModels([]);
return;
}
let cancelled = false;
const run = async () => {
setLoadingModels(true);
try {
const r = await settingsApi.testLlm({ provider, host, api_key: apiKey || undefined });
if (!cancelled) setModels(r.reachable ? r.models : []);
} catch {
if (!cancelled) setModels([]);
} finally {
if (!cancelled) setLoadingModels(false);
}
};
const t = setTimeout(run, 450);
return () => {
cancelled = true;
clearTimeout(t);
};
}, [host, provider, apiKey, reloadKey, cfg?.api_key_set]);
const onTest = async () => { const onTest = async () => {
setTesting(true); setTesting(true);
setTest(null); setTest(null);
try { try {
const r = await settingsApi.testLlm({ provider, host, model }); const r = await settingsApi.testLlm({ provider, host, model, api_key: apiKey || undefined });
setTest(r); setTest(r);
if (r.reachable) setModels(r.models);
toast( toast(
r.reachable ? "연결에 성공했어요" : "연결할 수 없어요", r.reachable ? "연결에 성공했어요" : "연결할 수 없어요",
r.reachable ? "green" : "coral", r.reachable ? "green" : "coral",
@ -62,8 +175,11 @@ export function LlmTab({ toast }: { toast: ToastFn }) {
model, model,
host, host,
timeout: Number.isFinite(t) && t > 0 ? t : undefined, timeout: Number.isFinite(t) && t > 0 ? t : undefined,
// 입력했을 때만 키 전송(빈칸=기존 키 유지). 지우려면 '기본값으로'.
api_key: apiKey.trim() ? apiKey : undefined,
}); });
await refresh(); await refresh();
setApiKey("");
toast("LLM 설정을 저장했어요", "green"); toast("LLM 설정을 저장했어요", "green");
} catch { } catch {
toast("저장에 실패했어요", "coral"); toast("저장에 실패했어요", "coral");
@ -75,7 +191,7 @@ export function LlmTab({ toast }: { toast: ToastFn }) {
const onReset = async () => { const onReset = async () => {
setSaving(true); setSaving(true);
try { try {
await settingsApi.updateLlm({ provider: "", model: "", host: "", timeout: 0 }); await settingsApi.updateLlm({ provider: "", model: "", host: "", timeout: 0, api_key: "" });
const next = await refresh(); const next = await refresh();
if (next) { if (next) {
setProvider(next.provider); setProvider(next.provider);
@ -83,6 +199,7 @@ export function LlmTab({ toast }: { toast: ToastFn }) {
setHost(next.host); setHost(next.host);
setTimeoutVal(String(next.timeout)); setTimeoutVal(String(next.timeout));
} }
setApiKey("");
setTest(null); setTest(null);
toast("기본값(.env)으로 되돌렸어요", "blue"); toast("기본값(.env)으로 되돌렸어요", "blue");
} catch { } catch {
@ -104,53 +221,112 @@ export function LlmTab({ toast }: { toast: ToastFn }) {
<div className="set-field"> <div className="set-field">
<label></label> <label></label>
<SegmentToggle <SegmentToggle
options={(cfg?.provider_options ?? ["ollama"]).map((p) => ({ options={(cfg?.provider_options ?? ["ollama", "openai", "gemini"]).map((p) => ({
id: p, id: p,
label: PROVIDER_LABEL[p as LlmProvider] ?? p, label: PROVIDER_LABEL[p] ?? p,
}))} }))}
value={provider} value={provider}
onChange={(id) => setProvider(id as LlmProvider)} onChange={(id) => changeProvider(id as LlmProvider)}
/> />
<small className="set-hint"> Ollama .</small> <small className="set-hint">{meta.hint}</small>
</div> </div>
{meta.needsHost && (
<div className="set-field"> <div className="set-field">
<label htmlFor="llm-host">Ollama </label> <label htmlFor="llm-host">{meta.hostLabel}</label>
<input <input
id="llm-host" id="llm-host"
className="set-input set-mono" className="set-input set-mono"
value={host} value={host}
disabled={isHeuristic}
onChange={(e) => setHost(e.target.value)} onChange={(e) => setHost(e.target.value)}
placeholder="http://localhost:11434" placeholder={meta.hostPlaceholder}
/> />
{meta.presets.length > 0 && (
<div className="set-chips" role="list" aria-label="자주 쓰는 호스트">
{meta.presets.map((h) => (
<button
key={h.url}
type="button"
role="listitem"
className={"set-chip" + (h.url === host ? " on" : "")}
onClick={() => setHost(h.url)}
title={h.url}
>
{h.label}
</button>
))}
</div>
)}
</div>
)}
{meta.needsKey && (
<div className="set-field">
<label htmlFor="llm-key">API </label>
<input
id="llm-key"
type="password"
autoComplete="off"
className="set-input set-mono"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={keySet ? "설정됨 · 새 키를 입력하면 교체돼요" : meta.keyPlaceholder}
/>
<small className="set-hint">
{keySet
? "키가 저장돼 있어요(암호화). 비워 두면 그대로 유지, 입력하면 교체돼요."
: "키는 암호화해서 저장하고, 화면에는 다시 표시하지 않아요."}
</small>
</div> </div>
)}
<div className="set-field"> <div className="set-field">
<label htmlFor="llm-model"></label> <label htmlFor="llm-model"></label>
{models.length > 0 ? (
<select
id="llm-model"
className="set-input set-mono"
value={model}
onChange={(e) => setModel(e.target.value)}
>
{model && !models.includes(model) && (
<option value={model}>{model} ( · )</option>
)}
{models.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
) : (
<input <input
id="llm-model" id="llm-model"
className="set-input set-mono" className="set-input set-mono"
value={model} value={model}
disabled={isHeuristic}
onChange={(e) => setModel(e.target.value)} onChange={(e) => setModel(e.target.value)}
placeholder="예: llama3.1" placeholder={meta.modelPlaceholder}
/> />
{test && test.models.length > 0 && ( )}
<div className="set-chips" role="list" aria-label="사용 가능한 모델"> <div className="set-modelmeta">
{test.models.map((m) => ( <small className="set-hint">
{loadingModels
? "모델 목록 불러오는 중…"
: models.length > 0
? `${models.length}개 모델 사용 가능 · 드롭다운에서 선택`
: meta.needsKey
? "키를 입력하면 모델 목록을 자동으로 불러와요"
: "호스트에 연결되면 모델 목록을 자동으로 불러와요"}
</small>
<button <button
key={m}
type="button" type="button"
role="listitem" className="set-chip"
className={"set-chip" + (m === model ? " on" : "")} onClick={() => setReloadKey((k) => k + 1)}
onClick={() => setModel(m)} disabled={loadingModels}
> >
{m} <Icon name="refresh" />
</button> </button>
))}
</div> </div>
)}
</div> </div>
<div className="set-field set-field-narrow"> <div className="set-field set-field-narrow">
@ -161,7 +337,6 @@ export function LlmTab({ toast }: { toast: ToastFn }) {
min={1} min={1}
className="set-input set-mono" className="set-input set-mono"
value={timeout} value={timeout}
disabled={isHeuristic}
onChange={(e) => setTimeoutVal(e.target.value)} onChange={(e) => setTimeoutVal(e.target.value)}
/> />
</div> </div>

@ -25,8 +25,11 @@ export function useMail(account: string | null, folder: string, query = "") {
// 않게 해 전체 화면 스켈레톤(사이드바 언마운트=트리 닫힘)을 막는다. // 않게 해 전체 화면 스켈레톤(사이드바 언마운트=트리 닫힘)을 막는다.
keepPreviousData: true, keepPreviousData: true,
}); });
// 계정별 안읽음 배지(집중/기타)의 소스. 폰 등 외부에서 읽으면 서버 카운트가 줄지만,
// 자동 재검증이 없으면 배지가 예전 값(예: 1)에 멈춘다 → 포커스·주기(30초) 갱신을 켠다.
const { data: accounts } = useSWR<MailAccount[]>("mail/accounts", () => mailApi.accounts(), { const { data: accounts } = useSWR<MailAccount[]>("mail/accounts", () => mailApi.accounts(), {
revalidateOnFocus: false, revalidateOnFocus: true,
refreshInterval: 30_000,
}); });
const { data: folders } = useSWR<AccountFolder[]>("mail/folders", () => mailApi.folders(), { const { data: folders } = useSWR<AccountFolder[]>("mail/folders", () => mailApi.folders(), {
revalidateOnFocus: false, revalidateOnFocus: false,

@ -14,6 +14,26 @@ import type {
} from "@/lib/types"; } from "@/lib/types";
const J = { "Content-Type": "application/json" }; const J = { "Content-Type": "application/json" };
// 아리 자동 정리 실행 로그 1줄(사이클 단위).
export type TriageLogEntry = { at: string; analyzed: number; cards: number; error: string | null };
// 오늘 새 메일 1통의 '아리 분류·분석' 상세 — 상세 로그 뷰용.
export type TriageClassifiedRow = {
id: string;
account: string;
from_name: string;
subject: string;
time: string;
bucket: string; // 집중/홍보/소셜/업데이트/스팸/휴지통/보관/기타
focused: boolean;
analyzed: boolean;
priority: string | null;
tasks: number;
events: number;
replies: number;
is_card: boolean;
};
async function ok<T>(r: Response): Promise<T> { async function ok<T>(r: Response): Promise<T> {
if (!r.ok) { if (!r.ok) {
let msg = `${r.status} ${r.statusText}`; let msg = `${r.status} ${r.statusText}`;
@ -72,22 +92,31 @@ export const mailApi = {
ok<ExtractAllResponse>(r), ok<ExtractAllResponse>(r),
), ),
// 아리 비서: 안읽음 집중 메일 중 미분석분을 LLM 으로 배치 분석(점진). // 아리 정리 기준 시각 + 요약: count(카드/배지) · new_total(오늘 온 새 메일) · screened(자동 걸러낸 수).
triageAnalyze: (limit = 6) => // 새 메일 분석은 백그라운드 auto-triage 가 전담 — 수동 분석/기준 재설정 엔드포인트는 없앴다.
fetch(`/api/mail/triage/analyze?limit=${limit}`, { method: "POST", headers: J, body: "{}" }).then( // dayStart=클라 로컬 자정(ISO) → new_total/screened 를 '오늘 도착분'으로 한정(서버 UTC TZ 보정).
(r) => ok<{ analyzed: number; remaining: number; errors: number }>(r), triageBaseline: (dayStart?: string) =>
), fetch(`/api/mail/triage/baseline${dayStart ? `?day_start=${encodeURIComponent(dayStart)}` : ""}`, {
cache: "no-store",
// 아리 정리 기준 시각(이 시각 이후 도착한 새 메일만 정리) + 새 메일 수(읽음 무관). }).then((r) => ok<{ since: string; count: number; new_total: number; screened: number }>(r)),
triageBaseline: () =>
fetch("/api/mail/triage/baseline", { cache: "no-store" }).then((r) => // 오늘 아리 자동 정리 실행 로그(관측성) — 실제 분석/에러를 시각과 함께. dayStart=클라 로컬 자정.
ok<{ since: string; count: number }>(r), triageLog: (dayStart?: string) =>
), fetch(`/api/mail/triage/log${dayStart ? `?day_start=${encodeURIComponent(dayStart)}` : ""}`, {
cache: "no-store",
// 기준 시각을 지금으로 재설정 — 이후 새 메일부터 다시. }).then((r) => ok<TriageLogEntry[]>(r)),
triageBaselineReset: () =>
fetch("/api/mail/triage/baseline", { method: "POST", headers: J, body: "{}" }).then((r) => // 오늘 새 메일별 분류·분석 상세(상세 로그 뷰) — 어떤 메일을 어디로 분류했는지 하나씩.
ok<{ since: string; count: number }>(r), triageClassified: (dayStart?: string) =>
fetch(
`/api/mail/triage/classified${dayStart ? `?day_start=${encodeURIComponent(dayStart)}` : ""}`,
{ cache: "no-store" },
).then((r) => ok<TriageClassifiedRow[]>(r)),
// 아리 정리에서 이 메일 카드를 기각 — 다이제스트에서 빼고 다시 표시하지 않음(받은편지함엔 잔류).
triageDismiss: (id: string) =>
fetch(`/api/mail/${id}/triage/dismiss`, { method: "POST", headers: J, body: "{}" }).then((r) =>
ok<{ ok: boolean; count: number }>(r),
), ),
replyDraft: (id: string, reply_index: number | null) => replyDraft: (id: string, reply_index: number | null) =>

@ -29,7 +29,7 @@ export const settingsApi = {
body: JSON.stringify(body), body: JSON.stringify(body),
}).then((r) => ok<LlmConfig>(r)), }).then((r) => ok<LlmConfig>(r)),
testLlm: (body: { provider?: string; host?: string; model?: string }) => testLlm: (body: { provider?: string; host?: string; model?: string; api_key?: string }) =>
fetch("/api/settings/llm/test", { fetch("/api/settings/llm/test", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },

@ -640,7 +640,7 @@ export type Me = {
}; };
// ==================== 설정(settings) ==================== // ==================== 설정(settings) ====================
export type LlmProvider = "auto" | "ollama" | "heuristic"; export type LlmProvider = "auto" | "ollama" | "heuristic" | "openai" | "gemini";
export interface LlmConfig { export interface LlmConfig {
provider: LlmProvider; provider: LlmProvider;
@ -649,9 +649,10 @@ export interface LlmConfig {
timeout: number; timeout: number;
provider_options: LlmProvider[]; provider_options: LlmProvider[];
overridden: boolean; overridden: boolean;
embed_provider: string; api_key_set?: boolean;
embed_model: string; embed_provider?: string;
agent_provider: string; embed_model?: string;
agent_provider?: string;
stt_provider: string; stt_provider: string;
vision_provider: string; vision_provider: string;
} }
@ -661,6 +662,7 @@ export interface LlmUpdate {
model?: string; model?: string;
host?: string; host?: string;
timeout?: number; timeout?: number;
api_key?: string;
} }
export interface LlmTestResult { export interface LlmTestResult {

@ -2226,13 +2226,6 @@
.mailpage .sec-hero-main { .mailpage .sec-hero-main {
min-width: 0; min-width: 0;
} }
.mailpage .sec-eyebrow {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
color: var(--coral);
margin-bottom: 4px;
}
.mailpage .sec-hero-main h2 { .mailpage .sec-hero-main h2 {
font-size: 18px; font-size: 18px;
font-weight: 800; font-weight: 800;
@ -2242,7 +2235,7 @@
} }
.mailpage .sec-hero-main p { .mailpage .sec-hero-main p {
font-size: 13px; font-size: 13px;
line-height: 1.6; line-height: 1.55;
color: var(--ink-2); color: var(--ink-2);
margin: 0; margin: 0;
} }
@ -2368,6 +2361,26 @@
width: 17px; width: 17px;
height: 17px; height: 17px;
} }
.mailpage .sc-dismiss {
width: 30px;
height: 30px;
flex-shrink: 0;
border-radius: 9px;
display: grid;
place-items: center;
color: var(--muted);
transition:
background 0.13s,
color 0.13s;
}
.mailpage .sc-dismiss:hover {
background: var(--coral-tint, rgba(244, 63, 94, 0.12));
color: var(--coral);
}
.mailpage .sc-dismiss .ic {
width: 16px;
height: 16px;
}
.mailpage .sc-title { .mailpage .sc-title {
font-size: 14.5px; font-size: 14.5px;
font-weight: 700; font-weight: 700;
@ -2517,117 +2530,277 @@
height: 15px; height: 15px;
} }
/* ---- 비어있음 ---- */ /* 자동 정리 중 — 스파크 아이콘이 돌며 '아리가 일하는 중'을 알린다(수동 버튼 없음). */
.mailpage .sec-empty { .mailpage .sec-spark.working .ic {
text-align: center; animation: sec-spin 0.9s linear infinite;
padding: 50px 30px; }
@keyframes sec-spin {
to {
transform: rotate(360deg);
}
}
/* 히어로 '오늘 메일 상세 로그' 버튼 — 다이얼로그를 연다. */
.mailpage .sec-loglink {
display: inline-flex;
align-items: center;
gap: 5px;
margin-top: 12px;
padding: 6px 12px;
border: 1px solid var(--line);
border-radius: 999px;
font-size: 12px;
font-weight: 600;
color: var(--ink-2); color: var(--ink-2);
background: var(--card);
cursor: pointer;
transition:
color 0.12s,
border-color 0.12s;
}
.mailpage .sec-loglink:hover {
color: var(--ink);
border-color: var(--line-2);
}
.mailpage .sec-loglink .ic {
width: 14px;
height: 14px;
} }
.mailpage .sec-empty .se-ico {
width: 52px; /* ---- 상세 로그 다이얼로그 ---- */
height: 52px; .tlog-overlay {
margin: 0 auto 14px; position: fixed;
border-radius: 15px; inset: 0;
z-index: 80;
display: grid; display: grid;
place-items: center; place-items: center;
color: var(--green); padding: 24px;
background: color-mix(in oklab, var(--green) 14%, transparent); background: rgba(15, 18, 28, 0.46);
backdrop-filter: blur(2px);
} }
.mailpage .sec-empty .se-ico .ic { .tlog-panel {
width: 24px; display: flex;
height: 24px; flex-direction: column;
width: min(640px, 100%);
max-height: min(80vh, 760px);
background: var(--card);
border: 1px solid var(--line);
border-radius: 18px;
box-shadow: 0 24px 60px -20px rgba(0, 0, 0, 0.45);
overflow: hidden;
} }
.mailpage .sec-empty h3 { .tlog-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 18px;
border-bottom: 1px solid var(--line);
}
.tlog-title {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 15px; font-size: 15px;
font-weight: 700; font-weight: 800;
color: var(--ink); color: var(--ink);
margin: 0 0 6px;
} }
.mailpage .sec-empty p { .tlog-title .ic {
font-size: 13px; width: 17px;
height: 17px;
color: var(--coral);
}
.tlog-close {
width: 30px;
height: 30px;
display: grid;
place-items: center;
border-radius: 9px;
color: var(--muted);
cursor: pointer;
transition:
background 0.13s,
color 0.13s;
}
.tlog-close:hover {
background: var(--card-2);
color: var(--ink);
}
.tlog-close .ic {
width: 17px;
height: 17px;
}
.tlog-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 16px 18px 18px;
scrollbar-width: thin;
}
.tlog-sec {
margin-bottom: 18px;
}
.tlog-sec:last-child {
margin-bottom: 0;
}
.tlog-sec h4 {
font-size: 12px;
font-weight: 700;
color: var(--ink-2);
margin: 0 0 8px;
}
.tlog-note {
font-size: 11.5px;
line-height: 1.5;
color: var(--muted);
margin: 0 0 10px;
}
.tlog-note b {
color: var(--ink-2);
}
.tlog-events {
display: flex;
flex-direction: column;
gap: 6px;
margin: 0; margin: 0;
padding: 0;
list-style: none;
font-size: 12.5px;
color: var(--ink-2);
} }
.tlog-events li {
/* 비서 히어로: 통계 + 정리 맡기기 버튼 한 줄. */
.mailpage .sec-hero-row {
display: flex; display: flex;
align-items: center;
gap: 10px; gap: 10px;
flex-wrap: wrap;
margin-top: 12px;
} }
.mailpage .sec-hero-row .sec-stats { .tlog-events time {
margin-top: 0; flex-shrink: 0;
width: 58px;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.tlog-events li.err span {
color: var(--coral);
font-weight: 600;
}
.tlog-muted {
font-size: 12.5px;
color: var(--muted);
margin: 0;
} }
.mailpage .sec-analyze { .tlog-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 10px;
}
.tlog-chip {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; padding: 3px 9px;
margin-left: auto; border-radius: 999px;
padding: 8px 15px; font-size: 11.5px;
border-radius: 11px; font-weight: 600;
font-size: 12.5px; color: var(--ink-2);
font-weight: 700; background: var(--card-2);
color: #fff; border: 1px solid var(--line);
background: linear-gradient(135deg, var(--coral), var(--violet)); white-space: nowrap;
box-shadow: 0 8px 18px -9px var(--coral);
cursor: pointer;
transition:
transform 0.12s,
filter 0.14s;
} }
.mailpage .sec-analyze:hover { .tlog-chip.focus {
transform: translateY(-1px); color: var(--violet);
filter: brightness(1.05); border-color: color-mix(in oklab, var(--violet) 34%, transparent);
background: color-mix(in oklab, var(--violet) 12%, transparent);
} }
.mailpage .sec-analyze:disabled { .tlog-chip.promo {
cursor: default; color: #b45309;
opacity: 0.85; border-color: color-mix(in oklab, #f59e0b 36%, transparent);
background: color-mix(in oklab, #f59e0b 13%, transparent);
} }
.mailpage .sec-analyze .ic { .tlog-chip.social {
width: 15px; color: var(--green);
height: 15px; border-color: color-mix(in oklab, var(--green) 34%, transparent);
background: color-mix(in oklab, var(--green) 12%, transparent);
} }
.mailpage .sec-analyze:disabled .ic { .tlog-chip.update {
animation: sec-spin 0.9s linear infinite; color: var(--blue);
border-color: color-mix(in oklab, var(--blue) 34%, transparent);
background: color-mix(in oklab, var(--blue) 12%, transparent);
} }
@keyframes sec-spin { .tlog-chip.spam {
to { color: var(--coral);
transform: rotate(360deg); border-color: color-mix(in oklab, var(--coral) 34%, transparent);
background: color-mix(in oklab, var(--coral) 12%, transparent);
} }
.tlog-mails {
display: flex;
flex-direction: column;
gap: 2px;
margin: 0;
padding: 0;
list-style: none;
} }
/* 기준 시각 안내 + '지금부터 다시' 재설정 */ .tlog-mail {
.mailpage .sec-baseline {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px;
padding: 8px 8px;
border-radius: 10px;
cursor: pointer;
transition: background 0.12s;
}
.tlog-mail:hover {
background: var(--card-2);
}
.tlog-mail .tm-ava {
width: 28px;
height: 28px;
flex-shrink: 0;
border-radius: 8px;
display: grid;
place-items: center;
color: #fff;
font-weight: 800;
font-size: 12px;
}
.tlog-mail .tm-main {
min-width: 0;
flex: 1;
}
.tlog-mail .tm-top {
display: flex;
align-items: baseline;
gap: 8px; gap: 8px;
flex-wrap: wrap;
margin-top: 12px;
font-size: 11.5px;
color: var(--muted);
} }
.mailpage .sec-baseline b { .tlog-mail .tm-from {
color: var(--text); font-size: 12.5px;
font-weight: 650; font-weight: 600;
color: var(--ink);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.mailpage .sec-baseline button { .tlog-mail .tm-time {
padding: 3px 9px; flex-shrink: 0;
border: 1px solid var(--line);
border-radius: 999px;
font-size: 11px; font-size: 11px;
font-weight: 600;
color: var(--muted); color: var(--muted);
background: var(--surface);
cursor: pointer;
transition:
color 0.12s,
border-color 0.12s;
} }
.mailpage .sec-baseline button:hover { .tlog-mail .tm-subj {
color: var(--text); font-size: 12px;
border-color: var(--muted); color: var(--ink-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.mailpage .sec-baseline button:disabled { .tlog-mail .tm-tags {
cursor: default; display: flex;
opacity: 0.6; flex-direction: column;
align-items: flex-end;
gap: 3px;
flex-shrink: 0;
}
.tlog-mail .tm-ana {
font-size: 10.5px;
color: var(--muted);
white-space: nowrap;
}
.tlog-mail .tm-ana.wait {
color: var(--blue);
} }

@ -264,6 +264,24 @@
font-size: 12px; font-size: 12px;
color: var(--muted); color: var(--muted);
} }
/* 모델 드롭다운 아래 메타 줄(안내 문구 + 새로고침) */
.settingspage .set-modelmeta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
}
/* 네이티브 select 를 set-input 룩에 맞춤(화살표 여백 확보) */
.settingspage select.set-input {
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%2399a' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 34px;
cursor: pointer;
}
/* ===== 태그/배지 ===== */ /* ===== 태그/배지 ===== */
.settingspage .set-tag { .settingspage .set-tag {

Loading…
Cancel
Save