# 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