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
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
|