feat: 메일 집중 수정·LLM 프로바이더 확장(Gemini·OpenAI호환)·트리아지 로그
- LLM: gemini/openai_compat 프로바이더 추가 및 provider/ollama 연동 - 메일: 집중/기타·스팸 정리·트리아지 로그 모달 등 수정 - 설정/스키마/모델/워커·암호화·DB 정비 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>main
parent
6fe8a74e59
commit
67d6bd85b9
@ -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}")
|
||||
@ -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}")
|
||||
@ -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"]
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue