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

125 lines
4.8 KiB
Python

# backend/app/services/mail_ai.py
# 아리 메일 분석. 기존 ai_json 재사용 + 신규 메일은 실 LLM(휴리스틱 폴백 없음, phase-16+).
from sqlmodel import Session
from ..llm.provider import LLMProvider
from ..models import Email
ANALYZE_SCHEMA = {
"type": "object",
"properties": {
"summary": {"type": "string"},
"priority": {"type": "string", "enum": ["높음", "보통", "낮음"]},
"category": {"type": "string"},
"tasks": {"type": "array", "items": {"type": "object"}},
"events": {"type": "array", "items": {"type": "object"}},
"replies": {"type": "array", "items": {"type": "object"}},
"file": {"type": ["object", "null"]},
},
"required": ["summary", "priority", "category"],
}
ANALYZE_PROMPT = """당신은 한국어 비서 '아리'입니다. 아래 이메일을 읽고 JSON 으로만 답하세요.
실제로 사용자가 해야 할 행동이 있을 때만 tasks/events/replies 를 채우고, 없으면 빈 배열로 두세요.
광고·뉴스레터·자동알림은 보통 priority=낮음 이고 tasks/events 가 비어 있을 수 있어요.
반드시 아래 필드명을 그대로 사용하세요(다른 이름 금지):
- summary: 한 줄 요약(한국어)
- priority: "높음" | "보통" | "낮음"
- category: 짧은 분류 라벨(예: 검토 요청, 마감 임박, 영수증, 결제/금융, 뉴스레터)
- tasks: [{{"text": 할 일 문장, "project": 분류, "due": "MM/DD" 또는 "", "prio": "높음|보통|낮음"}}]
- events: [{{"title": 제목, "date": "MM/DD", "day": 요일, "time": "HH:MM", "place": 장소}}]
- replies: [{{"tone": 한 줄 의도, "preview": 미리보기, "body": 보낼 본문 전체}}]
- file: {{"project": 분류, "reason": 사유}} 또는 null
제목: {subject}
보낸 사람: {sender}
본문:
{body}
"""
def _s(v) -> str:
return v if isinstance(v, str) else ("" if v is None else str(v))
def _coerce_analysis(d: dict) -> dict:
"""모델 출력의 필드명 흔들림을 우리 스키마로 정규화(text/title 등 별칭 흡수)."""
prio = _s(d.get("priority")).strip()
if prio not in ("높음", "보통", "낮음"):
prio = "보통"
tasks = []
for t in d.get("tasks") or []:
if not isinstance(t, dict):
continue
text = _s(t.get("text") or t.get("title") or t.get("task") or t.get("name")).strip()
if not text:
continue
tasks.append(
{
"text": text,
"project": _s(t.get("project") or t.get("category") or "메일"),
"due": _s(t.get("due") or t.get("due_date") or t.get("deadline")),
"prio": _s(t.get("prio") or t.get("priority") or "보통") or "보통",
}
)
events = []
for e in d.get("events") or []:
if not isinstance(e, dict):
continue
title = _s(e.get("title") or e.get("summary") or e.get("name")).strip()
if not title:
continue
events.append(
{
"title": title,
"date": _s(e.get("date") or e.get("start_date")),
"day": _s(e.get("day") or e.get("weekday")),
"time": _s(e.get("time") or e.get("start") or e.get("start_time")),
"dur": _s(e.get("dur") or e.get("duration")),
"place": _s(e.get("place") or e.get("location")),
}
)
replies = []
for r in d.get("replies") or []:
if not isinstance(r, dict):
continue
body = _s(r.get("body") or r.get("content") or r.get("text")).strip()
if not body:
continue
replies.append(
{
"tone": _s(r.get("tone") or r.get("intent") or "정중한 답장"),
"preview": _s(r.get("preview") or body[:60]),
"body": body,
}
)
file = None
f = d.get("file")
if isinstance(f, dict) and (f.get("project") or f.get("reason")):
file = {"project": _s(f.get("project") or "메일"), "reason": _s(f.get("reason"))}
return {
"summary": _s(d.get("summary")).strip(),
"priority": prio,
"category": _s(d.get("category")).strip() or "메일",
"tasks": tasks,
"events": events,
"replies": replies,
"file": file,
}
def analyze_email(s: Session, email: Email, provider: LLMProvider) -> dict:
"""신규/재분석용. 이미 분석된 메일은 ai_json 재사용, 신규는 실 LLM(폴백 없음)."""
if email.ai_json:
return email.ai_json
prompt = ANALYZE_PROMPT.format(
subject=email.subject, sender=email.from_key, body="\n".join(email.body or [])[:4000]
)
raw = provider.generate_json(prompt, ANALYZE_SCHEMA) # LLM 미가용 시 예외 전파
result = _coerce_analysis(raw)
email.ai_json = result
s.add(email)
s.commit()
return result