From eb34f354b3dff500a3eb9620c94fb74f756cc9e5 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 9 Mar 2026 15:29:43 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20LLM-gest=C3=BCtzte=20Mail-Klassifizieru?= =?UTF-8?q?ng=20(Wichtig/Aktion/Newsletter/Spam)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- homelab-ai-bot/context.py | 9 ++ .../__pycache__/mail_client.cpython-313.pyc | Bin 13308 -> 20308 bytes homelab-ai-bot/core/mail_client.py | 140 +++++++++++++++++- homelab-ai-bot/llm.py | 14 ++ 4 files changed, 162 insertions(+), 1 deletion(-) diff --git a/homelab-ai-bot/context.py b/homelab-ai-bot/context.py index dd45234d..a79d23ee 100644 --- a/homelab-ai-bot/context.py +++ b/homelab-ai-bot/context.py @@ -221,6 +221,14 @@ def _tool_get_todays_mails() -> str: return "\n".join(lines) +def _tool_get_smart_mail_digest(hours: int = 24) -> str: + cfg = _load_config() + mail_client.init(cfg) + api_key = cfg.api_keys.get("openrouter_key", "") + digest = mail_client.get_smart_digest(hours=hours, api_key=api_key) + return mail_client.format_smart_digest(digest) + + def _tool_get_feed_stats() -> str: cfg = _load_config() ct_109 = config.get_container(cfg, vmid=109) @@ -262,4 +270,5 @@ def get_tool_handlers() -> dict: "get_mail_count": lambda: _tool_get_mail_count(), "search_mail": lambda query, days=30: _tool_search_mail(query, days=days), "get_todays_mails": lambda: _tool_get_todays_mails(), + "get_smart_mail_digest": lambda hours=24: _tool_get_smart_mail_digest(hours=hours), } diff --git a/homelab-ai-bot/core/__pycache__/mail_client.cpython-313.pyc b/homelab-ai-bot/core/__pycache__/mail_client.cpython-313.pyc index e6fd8722f385716fc85e712e18814a1b7cdd6006..7f87041d6c6fbef51ed9e9c42a789cbc5b8c5e5d 100644 GIT binary patch delta 6687 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 E0<2rVL;wH) diff --git a/homelab-ai-bot/core/mail_client.py b/homelab-ai-bot/core/mail_client.py index db9daf28..7ba649d6 100644 --- a/homelab-ai-bot/core/mail_client.py +++ b/homelab-ai-bot/core/mail_client.py @@ -1,7 +1,13 @@ -"""IMAP Mail Client — Liest E-Mails vom All-Inkl Spiegel-Postfach (Read-Only).""" +"""IMAP Mail Client — Liest E-Mails vom All-Inkl Spiegel-Postfach (Read-Only). + +Stufe 1: Keyword-Filter (IMPORTANT_SENDERS) +Stufe 2: LLM-Klassifizierung (classify_mails) — trennt Spam/Newsletter von Wichtigem. +""" import imaplib import email +import json +import requests as _req from email.header import decode_header from email.utils import parsedate_to_datetime from datetime import datetime, timedelta, timezone @@ -204,6 +210,138 @@ def get_important_mails(hours: int = 24) -> list[dict]: m.logout() +CLASSIFY_PROMPT = """Du bekommst eine Liste von E-Mails (Absender + Betreff). +Klassifiziere JEDE Mail in genau eine Kategorie: +- "wichtig": Rechnungen, Sicherheitswarnungen, Server-Alerts, Bank, Behörden, persönliche Nachrichten +- "aktion": Erfordert eine Handlung (Passwort ändern, Zahlung fällig, Termin bestätigen) +- "info": Nützliche Info aber keine Handlung nötig (Versandbestätigung, Status-Update) +- "newsletter": Newsletter, Marketing, Angebote, Werbung +- "spam": Offensichtlicher Spam, Phishing, unseriös + +Antworte NUR mit einem JSON-Array. Pro Mail ein Objekt mit "idx" (0-basiert) und "cat" (Kategorie). +Beispiel: [{"idx":0,"cat":"newsletter"},{"idx":1,"cat":"wichtig"}]""" + + +def classify_mails(mails: list[dict], api_key: str) -> list[dict]: + """LLM-gestützte Klassifizierung von Mails nach Wichtigkeit.""" + if not mails or not api_key: + return mails + + mail_text = "\n".join( + f"{i}. Von: {m['from'][:50]} | Betreff: {m['subject'][:80]}" + for i, m in enumerate(mails) + ) + + try: + r = _req.post( + "https://openrouter.ai/api/v1/chat/completions", + headers={"Authorization": f"Bearer {api_key}"}, + json={ + "model": "openai/gpt-4o-mini", + "messages": [ + {"role": "system", "content": CLASSIFY_PROMPT}, + {"role": "user", "content": mail_text}, + ], + "max_tokens": 400, + "temperature": 0, + }, + timeout=30, + ) + r.raise_for_status() + content = r.json()["choices"][0]["message"]["content"] + content = content.strip() + if content.startswith("```"): + content = content.split("\n", 1)[-1].rsplit("```", 1)[0] + classifications = json.loads(content) + + cat_map = {c["idx"]: c["cat"] for c in classifications} + for i, m in enumerate(mails): + m["category"] = cat_map.get(i, "unknown") + return mails + except Exception: + for m in mails: + m["category"] = "unknown" + return mails + + +def get_smart_digest(hours: int = 24, api_key: str = "") -> dict: + """Intelligente Mail-Zusammenfassung: holt Mails, klassifiziert per LLM, gruppiert.""" + m = _connect() + if not m: + return {"error": "IMAP-Verbindung fehlgeschlagen"} + try: + m.select("INBOX", readonly=True) + since = (datetime.now() - timedelta(hours=hours)).strftime("%d-%b-%Y") + _, data = m.search(None, f'(SINCE "{since}")') + ids = data[0].split() if data[0] else [] + + mails = [] + for mid in ids[-50:]: + _, msg_data = m.fetch(mid, "(BODY.PEEK[HEADER])") + parsed = _parse_mail(msg_data) + if parsed: + mails.append(parsed) + mails.reverse() + except Exception as e: + return {"error": str(e)} + finally: + m.logout() + + if not mails: + return {"total": 0, "mails": [], "summary": {}} + + if api_key: + mails = classify_mails(mails, api_key) + + summary = {} + for m_item in mails: + cat = m_item.get("category", "unknown") + summary.setdefault(cat, []).append(m_item) + + return {"total": len(mails), "mails": mails, "summary": summary} + + +def format_smart_digest(digest: dict) -> str: + """Formatiert den intelligenten Digest für Telegram.""" + if "error" in digest: + return f"Mail-Fehler: {digest['error']}" + if digest.get("total", 0) == 0: + return "Keine neuen Mails im gewählten Zeitraum." + + lines = [f"📧 Mail-Digest ({digest['total']} Mails)\n"] + summary = digest.get("summary", {}) + + cat_labels = { + "wichtig": "🔴 Wichtig", + "aktion": "⚡ Aktion nötig", + "info": "ℹ️ Info", + "newsletter": "📰 Newsletter", + "spam": "🗑️ Spam", + "unknown": "❓ Unkategorisiert", + } + cat_order = ["aktion", "wichtig", "info", "newsletter", "spam", "unknown"] + + for cat in cat_order: + cat_mails = summary.get(cat, []) + if not cat_mails: + continue + label = cat_labels.get(cat, cat) + lines.append(f"{label} ({len(cat_mails)}):") + show = cat_mails if cat in ("wichtig", "aktion", "info") else cat_mails[:3] + for m_item in show: + lines.append(f" {m_item['date_str']} | {m_item['from'][:30]}") + lines.append(f" → {m_item['subject'][:65]}") + if len(cat_mails) > len(show): + lines.append(f" ... und {len(cat_mails) - len(show)} weitere") + lines.append("") + + wichtig = len(summary.get("wichtig", [])) + len(summary.get("aktion", [])) + noise = len(summary.get("newsletter", [])) + len(summary.get("spam", [])) + lines.append(f"Fazit: {wichtig} relevante, {noise} ignorierbare Mails") + + return "\n".join(lines) + + def format_summary() -> str: """Komplett-Übersicht: Counts + letzte Mails + wichtige.""" counts = get_mail_count() diff --git a/homelab-ai-bot/llm.py b/homelab-ai-bot/llm.py index 80d23153..c4b644e0 100644 --- a/homelab-ai-bot/llm.py +++ b/homelab-ai-bot/llm.py @@ -214,6 +214,20 @@ TOOLS = [ "parameters": {"type": "object", "properties": {}, "required": []}, }, }, + { + "type": "function", + "function": { + "name": "get_smart_mail_digest", + "description": "Intelligente Mail-Zusammenfassung: KI klassifiziert Mails in Wichtig/Aktion/Info/Newsletter/Spam. Nutze dies wenn der User nach 'wichtigen Mails' fragt oder wissen will ob etwas Relevantes dabei ist.", + "parameters": { + "type": "object", + "properties": { + "hours": {"type": "integer", "description": "Zeitraum in Stunden (default: 24)", "default": 24}, + }, + "required": [], + }, + }, + }, ]