"""모닝 브리핑 — 일정·메일·보고·뉴스 요약 → Synology Chat (LaunchAgent, 매일 07:30)""" import json import logging import os from datetime import datetime, timedelta, timezone import httpx from dotenv import load_dotenv load_dotenv() logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") logger = logging.getLogger("morning_briefing") CALDAV_BRIDGE_URL = os.getenv("CALDAV_BRIDGE_URL", "http://127.0.0.1:8092").replace("host.docker.internal", "127.0.0.1") KARAKEEP_URL = os.getenv("KARAKEEP_API_URL", os.getenv("KARAKEEP_URL", "http://localhost:3000")) KARAKEEP_API_KEY = os.getenv("KARAKEEP_API_KEY", "") GPU_OLLAMA_URL = os.getenv("GPU_OLLAMA_URL", "http://192.168.1.186:11434") SYNOLOGY_CHAT_WEBHOOK_URL = os.getenv("SYNOLOGY_CHAT_WEBHOOK_URL", "") PG_HOST = os.getenv("PG_HOST", "127.0.0.1") PG_PORT = int(os.getenv("PG_PORT", "15478")) PG_USER = os.getenv("POSTGRES_USER", "bot") PG_PASS = os.getenv("POSTGRES_PASSWORD", "") PG_DB = os.getenv("POSTGRES_DB", "chatbot") KST = timezone(timedelta(hours=9)) WEEKDAYS = ["월", "화", "수", "목", "금", "토", "일"] def get_db_connection(): import psycopg2 return psycopg2.connect( host=PG_HOST, port=PG_PORT, user=PG_USER, password=PG_PASS, dbname=PG_DB, ) def fetch_today_events() -> list[dict]: """CalDAV 브릿지에서 오늘 일정 조회.""" now = datetime.now(KST) start = now.replace(hour=0, minute=0, second=0, microsecond=0).isoformat() end = now.replace(hour=23, minute=59, second=59, microsecond=0).isoformat() try: resp = httpx.post( f"{CALDAV_BRIDGE_URL}/calendar/query", json={"start": start, "end": end}, timeout=10, ) data = resp.json() if not data.get("success"): return [] events = data.get("events", []) events.sort(key=lambda e: e.get("start", "")) return events except Exception as e: logger.error(f"CalDAV fetch failed: {e}") return [] def fetch_important_mails() -> list[dict]: """DB에서 최근 24시간 업무 메일 조회.""" try: conn = get_db_connection() with conn.cursor() as cur: cur.execute( "SELECT from_address, subject, summary FROM mail_logs " "WHERE label = '업무' AND mail_date > NOW() - INTERVAL '24 hours' " "ORDER BY mail_date DESC LIMIT 10" ) rows = cur.fetchall() conn.close() return [{"from": r[0], "subject": r[1], "summary": r[2]} for r in rows] except Exception as e: logger.error(f"Mail fetch failed: {e}") return [] def fetch_open_reports() -> list[dict]: """DB에서 미해결 현장보고 조회.""" try: conn = get_db_connection() with conn.cursor() as cur: cur.execute( "SELECT category, summary, created_at FROM field_reports " "WHERE status = 'open' " "ORDER BY created_at DESC LIMIT 10" ) rows = cur.fetchall() conn.close() return [{"category": r[0], "summary": r[1], "created_at": r[2]} for r in rows] except Exception as e: logger.error(f"Reports fetch failed: {e}") return [] def fetch_news(since: datetime) -> list[dict]: """Karakeep에서 최근 24시간 뉴스 조회.""" headers = {"Authorization": f"Bearer {KARAKEEP_API_KEY}"} if KARAKEEP_API_KEY else {} try: resp = httpx.get( f"{KARAKEEP_URL}/api/v1/bookmarks", params={"limit": 50}, headers=headers, timeout=15, ) resp.raise_for_status() data = resp.json() bookmarks = data.get("bookmarks", data if isinstance(data, list) else []) articles = [] for bm in bookmarks: created = bm.get("createdAt") or bm.get("created_at") or "" if created: try: dt = datetime.fromisoformat(created.replace("Z", "+00:00")) if dt < since: continue except ValueError: pass title = bm.get("title") or bm.get("content", {}).get("title", "") if title: articles.append({"title": title}) return articles except Exception as e: logger.error(f"Karakeep fetch failed: {e}") return [] def summarize_news(articles: list[dict]) -> list[str]: """LLM으로 뉴스 한줄 요약. 실패 시 원본 제목 fallback.""" if not articles: return [] titles = [a["title"] for a in articles[:5]] prompt = "다음 뉴스 제목들을 각각 한 줄로 짧게 한국어 요약하세요. 번호 없이 줄바꿈으로 구분.\n\n" + "\n".join(titles) try: resp = httpx.post( f"{GPU_OLLAMA_URL}/api/generate", json={ "model": "id-9b:latest", "system": "/no_think", "prompt": prompt, "stream": False, "think": False, }, timeout=15, ) lines = [l.strip() for l in resp.json().get("response", "").strip().split("\n") if l.strip()] if lines: return lines[:5] except Exception as e: logger.error(f"News summarize failed: {e}") return titles[:5] def format_briefing(events: list | None, mails: list | None, reports: list | None, news: list[str] | None) -> str | None: """브리핑 텍스트 조립. 전체 데이터 없으면 None.""" now = datetime.now(KST) weekday = WEEKDAYS[now.weekday()] header = f"[모닝 브리핑] {now.strftime('%Y-%m-%d')} ({weekday})" sections = [] # 일정 if events: limit = 10 lines = [] for e in events[:limit]: start = e.get("start", "") time_str = start[11:16] if len(start) >= 16 else "" title = e.get("summary") or e.get("title", "") location = e.get("location", "") entry = f"- {time_str} {title}" if time_str else f"- {title}" if location: entry += f" ({location})" lines.append(entry) count_str = f"{len(events)}건" if len(events) > limit: count_str = f"{limit}건, 외 {len(events) - limit}건" sections.append(f"[일정] 오늘 ({count_str})\n" + "\n".join(lines)) # 메일 if mails: limit = 5 lines = [] for m in mails[:limit]: sender = m.get("from", "").split("<")[0].strip().strip('"') lines.append(f"- {sender}: {m.get('subject', '')}") count_str = f"{len(mails)}건" if len(mails) > limit: count_str = f"{limit}건, 외 {len(mails) - limit}건" sections.append(f"[메일] 주요 ({count_str})\n" + "\n".join(lines)) # 보고 if reports: limit = 5 lines = [] for r in reports[:limit]: cat = r.get("category", "") desc = (r.get("summary") or "")[:50] created = r.get("created_at") date_str = "" if created: if isinstance(created, datetime): date_str = created.strftime("%m/%d") else: date_str = str(created)[:10] entry = f"- [{cat}] {desc}" if date_str: entry += f" -- {date_str} 접수" lines.append(entry) count_str = f"{len(reports)}건" if len(reports) > limit: count_str = f"{limit}건, 외 {len(reports) - limit}건" sections.append(f"[보고] 미해결 ({count_str})\n" + "\n".join(lines)) # 뉴스 if news: lines = [f"- {n}" for n in news[:5]] count_str = f"{len(news)}건" sections.append(f"[뉴스] ({count_str})\n" + "\n".join(lines)) if not sections: return None return header + "\n\n" + "\n\n".join(sections) def send_briefing(text: str) -> None: """Synology Chat 웹훅으로 브리핑 전송.""" if not SYNOLOGY_CHAT_WEBHOOK_URL: logger.warning("SYNOLOGY_CHAT_WEBHOOK_URL not set") return try: httpx.post( SYNOLOGY_CHAT_WEBHOOK_URL, data={"payload": json.dumps({"text": text})}, verify=False, timeout=10, ) logger.info("Briefing sent to Synology Chat") except Exception as e: logger.error(f"Chat send failed: {e}") def main(): logger.info("Morning briefing started") since = datetime.now(KST) - timedelta(hours=24) # 데이터 수집 (각각 독립, 실패해도 계속) events = fetch_today_events() or None mails = fetch_important_mails() or None reports = fetch_open_reports() or None news_articles = fetch_news(since) news_lines = summarize_news(news_articles) if news_articles else None text = format_briefing(events, mails, reports, news_lines) if not text: logger.info("No data for briefing — skipping") return send_briefing(text) logger.info("Morning briefing complete") if __name__ == "__main__": main()