798 lines
28 KiB
Python
798 lines
28 KiB
Python
import os
|
||
import time
|
||
import imaplib
|
||
import email
|
||
import requests
|
||
import logging
|
||
import sys
|
||
import traceback
|
||
import re
|
||
|
||
# ---------- Logging setup ----------
|
||
|
||
LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO").upper()
|
||
|
||
logging.basicConfig(
|
||
level=getattr(logging, LOG_LEVEL, logging.INFO),
|
||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||
handlers=[logging.StreamHandler(sys.stdout)],
|
||
)
|
||
logger = logging.getLogger("mail-classifier")
|
||
|
||
# ---------- Env variables ----------
|
||
|
||
IMAP_HOST = os.environ.get("IMAP_HOST", "mailu-front.mailu.svc")
|
||
IMAP_PORT = int(os.environ.get("IMAP_PORT", "993"))
|
||
IMAP_USER = os.environ.get("IMAP_USER")
|
||
IMAP_PASS = os.environ.get("IMAP_PASS")
|
||
|
||
# LLM backend:
|
||
# - "ollama" -> volá Ollamu na OLLAMA_URL /api/chat
|
||
# - "openwebui" -> volá OpenWebUI na OPENWEBUI_URL /api/chat/completions
|
||
LLM_BACKEND = os.environ.get("LLM_BACKEND", "ollama").lower()
|
||
|
||
# Ollama endpoint (použije se jen pokud LLM_BACKEND == "ollama")
|
||
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://ollama-service.open-webui.svc:11434")
|
||
|
||
# OpenWebUI endpoint (použije se jen pokud LLM_BACKEND == "openwebui")
|
||
OPENWEBUI_URL = os.environ.get(
|
||
"OPENWEBUI_URL",
|
||
"http://open-webui-service.open-webui.svc:8080",
|
||
)
|
||
|
||
# API key pro OpenWebUI (User API token / service token), pokud je potřeba auth
|
||
OPENWEBUI_API_KEY = os.environ.get("OPENWEBUI_API_KEY")
|
||
|
||
# Jméno modelu – pro Ollamu to může být např. "mail-router",
|
||
# pro OpenWebUI+OpenRouter např. "openai/gpt-4.1-mini"
|
||
MODEL_NAME = os.environ.get("MODEL_NAME", "mail-router")
|
||
|
||
MAX_BODY_CHARS = int(os.environ.get("MAX_BODY_CHARS", "2000"))
|
||
CHECK_INTERVAL = int(os.environ.get("CHECK_INTERVAL", "300")) # v sekundách
|
||
|
||
# Timeouty / retry s aliasem na staré proměnné
|
||
LLM_TIMEOUT = int(os.environ.get("LLM_TIMEOUT", os.environ.get("OLLAMA_TIMEOUT", "120")))
|
||
LLM_MAX_RETRIES = int(os.environ.get("LLM_MAX_RETRIES", os.environ.get("OLLAMA_MAX_RETRIES", "3")))
|
||
|
||
# prahy jistoty
|
||
MIN_CONFIDENCE_DEFAULT = float(os.environ.get("MIN_CONFIDENCE_DEFAULT", "0.4"))
|
||
MIN_CONFIDENCE_RELAXED = float(os.environ.get("MIN_CONFIDENCE_RELAXED", "0.3"))
|
||
|
||
# IMAP flag pro označení již zpracovaných zpráv
|
||
PROCESSED_FLAG = "ProcessedByClassifier"
|
||
|
||
# povolené složky – musí sedět se systémovým promptem
|
||
ALLOWED_FOLDERS = {
|
||
"INBOX",
|
||
"INBOX.Finance",
|
||
"INBOX.Notifikace",
|
||
"INBOX.Zpravodaje",
|
||
"INBOX.SocialniSite",
|
||
"INBOX.Ukoly",
|
||
"INBOX.Nepodstatne",
|
||
"INBOX.ZTJ",
|
||
"INBOX.Potvrzeni",
|
||
}
|
||
|
||
# ---------- System prompt ----------
|
||
|
||
SYSTEM_PROMPT = """
|
||
Jsi klasifikátor e-mailů pro uživatele Martina. Tvoje jediná práce je
|
||
zařadit každý e-mail do jedné IMAP složky a stručně vysvětlit proč.
|
||
|
||
Vstup dostaneš ve formátu:
|
||
HEADERS:
|
||
From: ...
|
||
To: ...
|
||
Cc: ...
|
||
Subject: ...
|
||
Date: ...
|
||
BODY:
|
||
<text e-mailu, prvních N znaků>
|
||
|
||
POVOLENÉ SLOŽKY – FOLDER MUSÍ být přesně jedna z TĚCHTO hodnot:
|
||
INBOX
|
||
INBOX.Finance
|
||
INBOX.Notifikace
|
||
INBOX.Zpravodaje
|
||
INBOX.SocialniSite
|
||
INBOX.Ukoly
|
||
INBOX.Nepodstatne
|
||
INBOX.ZTJ
|
||
INBOX.Potvrzeni
|
||
|
||
ŽÁDNÉ JINÉ NÁZVY SLOŽEK NESMÍŠ POUŽÍT.
|
||
Výslovně NESMÍŠ použít složky jako:
|
||
INBOX.Osobni, INBOX.Pracovni ani žádné jiné varianty s tečkou.
|
||
|
||
CÍL: Mít v INBOXU co nejméně zpráv.
|
||
Pokud váháš mezi INBOX a nějakou specializovanou složkou, VŽDY zvol specializovanou složku
|
||
(Ukoly, Finance, Notifikace, Zpravodaje, SocialniSite, ZTJ, Potvrzeni, Nepodstatne).
|
||
|
||
----------------------------------------------------------------------
|
||
JASNÉ VYMEZENÍ SLOŽEK
|
||
----------------------------------------------------------------------
|
||
|
||
1) INBOX.ZTJ
|
||
– cokoliv, co souvisí s organizací „Život trochu jinak“ (ZTJ):
|
||
* „Život trochu jinak“, „ZTJ“ v Subject nebo v těle
|
||
* cokoliv z domény @zivotjinak.cz
|
||
* zážitkové kurzy, tábory, akce pro mládež, příprava programů, faktury pro ZTJ,
|
||
komunikace s účastníky, rodiči, partnery ohledně ZTJ
|
||
→ Pokud splňuje ZTJ, vždy INBOX.ZTJ, i kdyby to byla faktura nebo e-mail s úkolem.
|
||
|
||
2) INBOX.Ukoly
|
||
– hlavní smysl e-mailu je, že Martin MUSÍ něco udělat:
|
||
* odpovědět, potvrdit, kliknout na odkaz, doplnit údaje
|
||
* zaplatit, rezervovat, domluvit termín, vyplnit formulář
|
||
– typické fráze:
|
||
* „Prosíme o úhradu…“
|
||
* „Potvrď prosím účast“
|
||
* „Je potřeba doplnit…“
|
||
* „Klikni zde pro dokončení…“
|
||
* „Prosím o odpověď do…“
|
||
– NA VŠECHNY TODO dávej přednost INBOX.Ukoly před ostatními složkami
|
||
(kromě ZTJ, ta má prioritu).
|
||
– Patří sem i úkoly z banky, úřadů, služeb, doktorů, školky atd., pokud je jasná akce.
|
||
|
||
3) INBOX.Notifikace
|
||
– automatická technická/systemová/bezpečnostní upozornění:
|
||
* monitoring (alerty, výpadky)
|
||
* 2FA kódy, bezpečnostní kódy, „your login code is…“
|
||
* potvrzení přihlášení, upozornění na podezřelou aktivitu
|
||
* upozornění na aktivitu zařízení (např. Apple „Najít“, přihlášení z nového zařízení)
|
||
* systémové logy, cron, servery, aplikace
|
||
– zprávy jsou často krátké, strohé, většinou bez „Ahoj Martine“, spíše technický jazyk.
|
||
|
||
4) INBOX.Finance
|
||
– finanční věci, kde hlavní účel je INFORMOVAT, ne úkol:
|
||
* bankovní výpisy, potvrzení o platbě, přehledy transakcí
|
||
* faktury a vyúčtování, kde už je zaplaceno a není potřeba akce
|
||
* pojišťovny, finanční úřad, účetnictví, pokud po Martinovi nic konkrétního nechtějí
|
||
* potvrzení platby, účtenky a faktury od Apple/Google/PayPal atd., pokud už je hotovo
|
||
– Pokud e-mail z banky/pojišťovny/platební služby obsahuje ÚKOL (zaplať, doplň, potvrď),
|
||
použij raději INBOX.Ukoly.
|
||
|
||
5) INBOX.Potvrzeni
|
||
– potvrzení rezervací, objednávek, registrací, termínů a vstupenek, kde hlavní
|
||
smysl je: „všechno je zařízené / zarezervované / domluvené“:
|
||
* potvrzení rezervace hotelu / ubytování / dovolené / letenek
|
||
* potvrzení rezervace v restauraci nebo na akci
|
||
* potvrzení registrace na kurz, webinář, konferenci
|
||
* potvrzení objednávky v e-shopu (objednávka přijata, čeká se na odeslání)
|
||
* potvrzení vstupenek na koncert, divadlo, kino, akci
|
||
– Pokud je hlavním účelem INFORMOVAT o tom, že něco proběhlo / proběhne
|
||
(a ty už nic dělat nemusíš), ale nejde primárně o čisté finance, dej INBOX.Potvrzeni.
|
||
– Pokud e-mail obsahuje zároveň jasný úkol (např. „dokonči platbu“, „potvrď účast“),
|
||
dej INBOX.Ukoly (Ukoly má přednost před Potvrzeni).
|
||
|
||
6) INBOX.Zpravodaje
|
||
– newslettery, marketing, promo, slevy, akce:
|
||
* e-shopy, služby, aplikace (Freeletics, Decathlon, Alza, Garmin, atd.)
|
||
* „Black Friday“, „% off“, „sleva“, „akce“, „speciální nabídka“, „promo“
|
||
* „novinky“, „zpravodaj“, „newsletter“
|
||
– PATŘÍ SEM i personalizované newslettery začínající např. „Ahoj Martine“,
|
||
pokud jde primárně o marketing/propagaci.
|
||
– Typické znaky: tlačítka „Zjistit více“, „Koupit“, odhlašovací odkaz, dlouhý formát.
|
||
– Nikdy neházej marketing do INBOX nebo INBOX.Ukoly, pokud neobsahuje
|
||
opravdu jasný konkrétní úkol.
|
||
|
||
7) INBOX.SocialniSite
|
||
– e-maily od sociálních sítí a komunit:
|
||
* Facebook, Instagram, X/Twitter, Discord, GitHub, GitLab, LinkedIn, fóra, komunity
|
||
* „someone mentioned you“, „new follower“, „pull request“, „issue“, „comment“
|
||
– Patří sem i komunitní notifikace z projektů, pokud to není čistý marketing
|
||
(marketingové kampaně těchto služeb dej do INBOX.Zpravodaje).
|
||
|
||
8) INBOX.Nepodstatne
|
||
– zjevný spam a věci s minimální hodnotou:
|
||
* náhodné soutěže, podivné nabídky, kryptoměny, pochybná schémata
|
||
* nesouvisející „výhodné nabídky“ od neznámých subjektů
|
||
– Pokud je e-mail podezřelý, nesrozumitelný nebo evidentně nerelevantní,
|
||
použij INBOX.Nepodstatne.
|
||
|
||
9) INBOX
|
||
– osobní nebo běžná konverzace od lidí:
|
||
* rodina, přátelé, běžná domluva (pokud z toho není jasný úkol)
|
||
* individuální e-maily, kde tě někdo normálně oslovuje a píše vlastní text,
|
||
ne automatický newsletter
|
||
– také e-maily, které nejde jednoznačně přiřadit do ostatních složek
|
||
– INBOX by neměl být výchozí pro všechno. Pokud e-mail jasně spadá
|
||
do jiné složky, použij tu jinou složku.
|
||
|
||
----------------------------------------------------------------------
|
||
ROZHODOVACÍ POSTUP (DŮLEŽITÉ – ŽÁDNÉ PŘEKRYVY)
|
||
----------------------------------------------------------------------
|
||
|
||
Postupuj VŽDY podle tohoto pořadí. Jakmile některý bod sedí, použij danou složku
|
||
a přestaň hledat dál:
|
||
|
||
1) Je to ZTJ (doména @zivotjinak.cz nebo obsahuje „Život trochu jinak“/„ZTJ“
|
||
nebo je zjevně o zážitkovém kurzu/táboře/akci pro mládež)?
|
||
→ INBOX.ZTJ
|
||
|
||
2) Je hlavní pointa, že Martin MUSÍ něco konkrétně udělat?
|
||
(zaplatit, potvrdit, odpovědět, vyplnit, rezervovat, kliknout, dokončit registraci…)
|
||
→ INBOX.Ukoly
|
||
|
||
3) Je to technická / bezpečnostní / systémová notifikace?
|
||
(monitoring, alert, 2FA, login upozornění, server logy, „Find My“ apod.)?
|
||
→ INBOX.Notifikace
|
||
|
||
4) Je to čistě finanční informace bez nutnosti akce?
|
||
(výpis, potvrzení, přehled, už zaplacená faktura…)
|
||
→ INBOX.Finance
|
||
|
||
5) Je to potvrzení rezervace / objednávky / registrace / vstupenek / termínu?
|
||
(hotel, ubytování, letenky, restaurace, akce, e-shop, služba)?
|
||
→ INBOX.Potvrzeni
|
||
|
||
6) Je to newsletter / marketing / promo / sleva / nabídka?
|
||
→ INBOX.Zpravodaje
|
||
|
||
7) Je to notifikace ze sociální sítě nebo vývojářské/komunitní platformy?
|
||
→ INBOX.SocialniSite
|
||
|
||
8) Je to zjevný spam nebo velmi málo užitečné / náhodná nabídka?
|
||
→ INBOX.Nepodstatne
|
||
|
||
9) Jinak:
|
||
– osobní korespondence, normální domluva bez tasku
|
||
– cokoliv, co nepasuje výše
|
||
→ INBOX
|
||
|
||
Pokud váháš mezi INBOX a nějakou specializovanou složkou, vyber raději
|
||
specializovanou složku (Ukoly, Zpravodaje, Finance, Notifikace, SocialniSite, ZTJ, Potvrzeni, Nepodstatne).
|
||
|
||
----------------------------------------------------------------------
|
||
PRAVIDLA PRO CONFIDENCE A FORMÁT
|
||
----------------------------------------------------------------------
|
||
|
||
– CONFIDENCE vybírej takto:
|
||
* 0.8–1.0 = jsi si jistý, typický případ (newsletter, výpis, jasný úkol, ZTJ…)
|
||
* 0.4–0.7 = váháš mezi dvěma složkami, ale jednu vybereš
|
||
* 0.0–0.3 = použij jen pokud je e-mail opravdu zmatený/nečitelný
|
||
– Pokud e-mail zařadíš do nějaké složky s jasným důvodem, NEPOUŽÍVEJ 0.0.
|
||
|
||
– V odpovědi SMÍŠ napsat slova „FOLDER:“ a „CONFIDENCE:“ POUZE jednou
|
||
na prvních dvou řádcích.
|
||
* Nikdy nepřidávej druhý blok „FOLDER: … CONFIDENCE: …“ do REASON ani do RULES.
|
||
* V REASON a RULES NEPIŠ, že e-mail „by měl být v jiné složce“ – rovnou vyber správnou složku.
|
||
|
||
– RULES má jen stručně shrnout, proč jsi vybral danou složku (1–3 krátké řádky),
|
||
a nesmí obsahovat žádná další FOLDER/CONFIDENCE ani zakazující věty
|
||
typu „nikdy nepoužívej INBOX.Finance“ (kromě toho, co je uvedeno výše v instrukcích).
|
||
|
||
----------------------------------------------------------------------
|
||
PÁR ZKRÁCENÝCH PŘÍKLADŮ
|
||
----------------------------------------------------------------------
|
||
|
||
1) From: news@decathlon.cz
|
||
Subject: „Ahoj Martine, 20 % sleva na běžecké boty jen tento týden“
|
||
→ FOLDER: INBOX.Zpravodaje
|
||
|
||
2) From: banka@neco.cz
|
||
Subject: „Výpis z účtu za měsíc listopad“
|
||
→ FOLDER: INBOX.Finance
|
||
|
||
3) From: noreply@security.google.com
|
||
Subject: „Your Google verification code“
|
||
→ FOLDER: INBOX.Notifikace
|
||
|
||
4) From: info@zivotjinak.cz
|
||
Subject: „Informace k letnímu kurzu Život trochu jinak“
|
||
→ FOLDER: INBOX.ZTJ
|
||
|
||
5) From: nekdo@nekde.cz
|
||
Subject: „Prosím o potvrzení termínu schůzky“
|
||
→ FOLDER: INBOX.Ukoly
|
||
|
||
6) From: reservations@hotel.com
|
||
Subject: „Potvrzení rezervace ubytování“
|
||
→ FOLDER: INBOX.Potvrzeni
|
||
|
||
7) From: kamarád@neco.cz
|
||
Subject: „Co děláte o víkendu?“
|
||
→ FOLDER: INBOX
|
||
|
||
----------------------------------------------------------------------
|
||
FORMÁT VÝSTUPU
|
||
----------------------------------------------------------------------
|
||
|
||
Odpověz POUZE v tomto formátu, bez dalšího textu před ani po:
|
||
|
||
FOLDER: <jedna ze složek výše>
|
||
CONFIDENCE: <číslo 0.0 až 1.0>
|
||
REASON: <stručné vysvětlení v jedné větě>
|
||
RULES:
|
||
- <stručné pravidlo 1>
|
||
- <stručné pravidlo 2>
|
||
|
||
Nepoužívej JSON, nepoužívej markdown, nepiš žádné shrnutí e-mailu.
|
||
Nepřidávej další řádky, hlavičky ani komentáře mimo uvedený formát.
|
||
"""
|
||
|
||
# ---------- Config logging ----------
|
||
|
||
def log_config():
|
||
"""Vypíše aktuální konfiguraci (bez hesla)."""
|
||
logger.info("=== mail-classifier configuration ===")
|
||
logger.info(f"IMAP_HOST = {IMAP_HOST}")
|
||
logger.info(f"IMAP_PORT = {IMAP_PORT}")
|
||
logger.info(f"IMAP_USER = {IMAP_USER}")
|
||
logger.info("IMAP_PASS = **** (hidden)")
|
||
logger.info(f"LLM_BACKEND = {LLM_BACKEND}")
|
||
logger.info(f"OLLAMA_URL = {OLLAMA_URL}")
|
||
logger.info(f"OPENWEBUI_URL = {OPENWEBUI_URL}")
|
||
logger.info(f"MODEL_NAME = {MODEL_NAME}")
|
||
logger.info(f"MAX_BODY_CHARS = {MAX_BODY_CHARS}")
|
||
logger.info(f"CHECK_INTERVAL = {CHECK_INTERVAL} s")
|
||
logger.info(f"LLM_TIMEOUT = {LLM_TIMEOUT} s")
|
||
logger.info(f"LLM_MAX_RETRIES = {LLM_MAX_RETRIES}")
|
||
logger.info(f"MIN_CONF_DEFAULT = {MIN_CONFIDENCE_DEFAULT}")
|
||
logger.info(f"MIN_CONF_RELAXED = {MIN_CONFIDENCE_RELAXED}")
|
||
logger.info(f"LOG_LEVEL = {LOG_LEVEL}")
|
||
logger.info(f"ALLOWED_FOLDERS = {sorted(ALLOWED_FOLDERS)}")
|
||
logger.info(f"PROCESSED_FLAG = {PROCESSED_FLAG}")
|
||
logger.info("====================================")
|
||
|
||
|
||
# ---------- IMAP helpers ----------
|
||
|
||
def connect_imap():
|
||
logger.info(f"Connecting to IMAP {IMAP_HOST}:{IMAP_PORT} as {IMAP_USER}")
|
||
if not IMAP_USER or not IMAP_PASS:
|
||
logger.error("IMAP_USER or IMAP_PASS is not set! Exiting.")
|
||
raise RuntimeError("Missing IMAP credentials")
|
||
|
||
m = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT)
|
||
typ, data = m.login(IMAP_USER, IMAP_PASS)
|
||
if typ != "OK":
|
||
logger.error(f"IMAP login failed: {typ} {data}")
|
||
raise RuntimeError("IMAP login failed")
|
||
logger.info("IMAP login successful")
|
||
return m
|
||
|
||
|
||
def get_unseen_messages(imap_conn):
|
||
# Bereme jen zprávy v INBOXu, které jsou:
|
||
# - UNSEEN (nepřečtené)
|
||
# - NEMAJÍ flag ProcessedByClassifier
|
||
typ, _ = imap_conn.select("INBOX")
|
||
if typ != "OK":
|
||
logger.error(f"Cannot select INBOX, got: {typ}")
|
||
return []
|
||
|
||
status, data = imap_conn.search(None, "UNSEEN", "UNKEYWORD", PROCESSED_FLAG)
|
||
if status != "OK":
|
||
logger.error(f"UNSEEN UNKEYWORD search failed: {status}")
|
||
return []
|
||
|
||
ids = data[0].split()
|
||
logger.info(
|
||
f"Found {len(ids)} unseen & unprocessed messages in INBOX "
|
||
f"(UNSEEN UNKEYWORD {PROCESSED_FLAG})"
|
||
)
|
||
if ids:
|
||
logger.debug(f"Unseen/unprocessed message IDs: {[i.decode(errors='ignore') for i in ids]}")
|
||
return ids
|
||
|
||
|
||
# ---------- Email to prompt ----------
|
||
|
||
def build_prompt_from_email(msg):
|
||
headers = []
|
||
for h in ["From", "To", "Cc", "Subject", "Date"]:
|
||
value = msg.get(h, "")
|
||
headers.append(f"{h}: {value}")
|
||
headers_text = "\n".join(headers)
|
||
|
||
body_text = ""
|
||
if msg.is_multipart():
|
||
for part in msg.walk():
|
||
content_type = part.get_content_type()
|
||
disp = str(part.get("Content-Disposition") or "")
|
||
if content_type == "text/plain" and "attachment" not in disp.lower():
|
||
try:
|
||
part_bytes = part.get_payload(decode=True)
|
||
if part_bytes is None:
|
||
continue
|
||
body_text += part_bytes.decode(
|
||
part.get_content_charset() or "utf-8",
|
||
errors="ignore",
|
||
)
|
||
except Exception as e:
|
||
logger.debug(f"Error decoding multipart part: {e}")
|
||
continue
|
||
else:
|
||
try:
|
||
part_bytes = msg.get_payload(decode=True)
|
||
if part_bytes is not None:
|
||
body_text = part_bytes.decode(
|
||
msg.get_content_charset() or "utf-8",
|
||
errors="ignore",
|
||
)
|
||
except Exception as e:
|
||
logger.debug(f"Error decoding singlepart message: {e}")
|
||
body_text = ""
|
||
|
||
if len(body_text) > MAX_BODY_CHARS:
|
||
logger.debug(f"Body truncated from {len(body_text)} to {MAX_BODY_CHARS} chars")
|
||
body_text = body_text[:MAX_BODY_CHARS]
|
||
|
||
prompt = f"HEADERS:\n{headers_text}\n\nBODY:\n{body_text}"
|
||
logger.debug(f"Built prompt (first 500 chars): {prompt[:500].replace(chr(10), ' ')}")
|
||
return prompt
|
||
|
||
|
||
def build_llm_input(email_prompt: str) -> str:
|
||
"""
|
||
LLM dostane jako user obsah e-mailu v podobě HEADERS/BODY.
|
||
Všechny instrukce jsou v SYSTEM_PROMPT.
|
||
"""
|
||
return email_prompt
|
||
|
||
|
||
# ---------- LLM warm-up ----------
|
||
|
||
def warmup_model():
|
||
"""
|
||
Jednoduchý warm-up dotaz, aby si backend natáhl model před prvním reálným mailem.
|
||
"""
|
||
logger.info(f"Warming up model {MODEL_NAME} using backend {LLM_BACKEND}...")
|
||
|
||
warmup_email = (
|
||
"HEADERS:\n"
|
||
"From: warmup@example.com\n"
|
||
"To: martin@example.com\n"
|
||
"Subject: Warmup\n"
|
||
"Date: Thu, 01 Jan 1970 00:00:00 +0000\n\n"
|
||
"BODY:\n"
|
||
"Toto je testovací e-mail pouze pro warmup modelu.\n"
|
||
)
|
||
|
||
messages = [
|
||
{"role": "system", "content": SYSTEM_PROMPT},
|
||
{"role": "user", "content": warmup_email},
|
||
]
|
||
|
||
try:
|
||
if LLM_BACKEND == "ollama":
|
||
content = _call_ollama_chat(messages)
|
||
elif LLM_BACKEND == "openwebui":
|
||
content = _call_openwebui_chat(messages)
|
||
else:
|
||
logger.warning(f"Unknown LLM_BACKEND={LLM_BACKEND}, skipping warm-up")
|
||
return
|
||
|
||
logger.info(
|
||
"Warm-up response (first 200 chars): "
|
||
f"{str(content)[:200].replace(chr(10), ' ')}"
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"Warm-up failed (will continue anyway): {e}")
|
||
|
||
|
||
# ---------- Parsing model output ----------
|
||
|
||
FOLDER_RE = re.compile(r"FOLDER:\s*([A-Za-z0-9_.]+)")
|
||
CONF_RE = re.compile(r"CONFIDENCE:\s*([0-9.]+)")
|
||
REASON_RE = re.compile(r"REASON:\s*(.*?)(?:RULES:|$)", re.DOTALL)
|
||
RULES_BLOCK_RE = re.compile(r"RULES:\s*(.*)$", re.DOTALL)
|
||
|
||
|
||
def parse_model_output(content: str) -> dict:
|
||
"""
|
||
Očekávaný formát:
|
||
|
||
FOLDER: INBOX.Zpravodaje
|
||
CONFIDENCE: 0.8
|
||
REASON: ...
|
||
RULES:
|
||
- ...
|
||
- ...
|
||
|
||
Vrací dict {folder, confidence, reason, rules} nebo vyhodí výjimku.
|
||
"""
|
||
folder_match = FOLDER_RE.search(content)
|
||
conf_match = CONF_RE.search(content)
|
||
reason_match = REASON_RE.search(content)
|
||
rules_match = RULES_BLOCK_RE.search(content)
|
||
|
||
if not folder_match or not conf_match or not reason_match or not rules_match:
|
||
raise ValueError("Missing one of FOLDER/CONFIDENCE/REASON/RULES in model output")
|
||
|
||
folder = folder_match.group(1).strip()
|
||
|
||
try:
|
||
confidence = float(conf_match.group(1))
|
||
except Exception:
|
||
confidence = 0.0
|
||
|
||
reason_raw = reason_match.group(1).strip()
|
||
reason = reason_raw.splitlines()[0].strip()
|
||
|
||
rules_block = rules_match.group(1).strip()
|
||
rules = []
|
||
for line in rules_block.splitlines():
|
||
l = line.strip()
|
||
if l.startswith("- "):
|
||
rules.append(l[2:].strip())
|
||
|
||
return {
|
||
"folder": folder,
|
||
"confidence": confidence,
|
||
"reason": reason,
|
||
"rules": rules,
|
||
}
|
||
|
||
|
||
# ---------- LLM call backend-specific helpers ----------
|
||
|
||
def _call_ollama_chat(messages) -> str:
|
||
payload = {
|
||
"model": MODEL_NAME,
|
||
"stream": False,
|
||
"messages": messages,
|
||
}
|
||
r = requests.post(
|
||
f"{OLLAMA_URL}/api/chat",
|
||
json=payload,
|
||
timeout=LLM_TIMEOUT,
|
||
)
|
||
r.raise_for_status()
|
||
data = r.json()
|
||
content = data.get("message", {}).get("content", "")
|
||
return content
|
||
|
||
|
||
def _call_openwebui_chat(messages) -> str:
|
||
payload = {
|
||
"model": MODEL_NAME,
|
||
"stream": False,
|
||
"temperature": 0.1,
|
||
"messages": messages,
|
||
}
|
||
headers = {}
|
||
if OPENWEBUI_API_KEY:
|
||
headers["Authorization"] = f"Bearer {OPENWEBUI_API_KEY}"
|
||
|
||
r = requests.post(
|
||
f"{OPENWEBUI_URL}/api/chat/completions",
|
||
json=payload,
|
||
headers=headers,
|
||
timeout=LLM_TIMEOUT,
|
||
)
|
||
r.raise_for_status()
|
||
data = r.json()
|
||
choices = data.get("choices", [])
|
||
if not choices:
|
||
raise RuntimeError("OpenWebUI returned no choices")
|
||
|
||
message = choices[0].get("message", {})
|
||
content = message.get("content", "")
|
||
if not content:
|
||
raise RuntimeError("OpenWebUI choice[0].message.content is empty")
|
||
return content
|
||
|
||
|
||
# ---------- LLM call (common) ----------
|
||
|
||
def classify_email(prompt):
|
||
email_input = build_llm_input(prompt)
|
||
|
||
messages = [
|
||
{"role": "system", "content": SYSTEM_PROMPT},
|
||
{"role": "user", "content": email_input},
|
||
]
|
||
|
||
last_exc = None
|
||
for attempt in range(1, LLM_MAX_RETRIES + 1):
|
||
logger.info(
|
||
f"Calling model {MODEL_NAME} via backend {LLM_BACKEND} "
|
||
f"(attempt {attempt}/{LLM_MAX_RETRIES})"
|
||
)
|
||
try:
|
||
if LLM_BACKEND == "ollama":
|
||
content = _call_ollama_chat(messages)
|
||
elif LLM_BACKEND == "openwebui":
|
||
content = _call_openwebui_chat(messages)
|
||
else:
|
||
raise RuntimeError(f"Unsupported LLM_BACKEND: {LLM_BACKEND}")
|
||
|
||
logger.info(
|
||
"Model returned content (first 300 chars): "
|
||
f"{content[:300].replace(chr(10), ' ')}"
|
||
)
|
||
result = parse_model_output(content)
|
||
logger.info(f"Parsed model result: {result}")
|
||
return result
|
||
|
||
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
|
||
last_exc = e
|
||
logger.warning(f"LLM request failed with {type(e).__name__}: {e}")
|
||
if attempt < LLM_MAX_RETRIES:
|
||
backoff = 5 * attempt
|
||
logger.info(f"Retrying in {backoff} seconds...")
|
||
time.sleep(backoff)
|
||
except Exception as e:
|
||
last_exc = e
|
||
logger.error(f"LLM request failed (non-retryable): {e}")
|
||
logger.debug(traceback.format_exc())
|
||
raise
|
||
|
||
logger.error(f"LLM request failed after {LLM_MAX_RETRIES} attempts: {last_exc}")
|
||
raise last_exc or RuntimeError("LLM request failed")
|
||
|
||
|
||
def normalize_folder(result, msg):
|
||
"""
|
||
Vrátí cílovou složku čistě podle modelu:
|
||
- pokud složka není v ALLOWED_FOLDERS -> INBOX
|
||
- pokud confidence pod prahem -> INBOX
|
||
"""
|
||
folder = result.get("folder", "INBOX")
|
||
confidence = float(result.get("confidence", 0.0) or 0.0)
|
||
|
||
logger.info(f"Model suggested folder={folder}, confidence={confidence}")
|
||
|
||
if folder not in ALLOWED_FOLDERS:
|
||
logger.warning(f"Folder {folder} not in ALLOWED_FOLDERS, using INBOX")
|
||
return "INBOX"
|
||
|
||
# pro spam/newsletter buďme benevolentnější
|
||
if folder in ("INBOX.Nepodstatne", "INBOX.Zpravodaje"):
|
||
threshold = MIN_CONFIDENCE_RELAXED
|
||
else:
|
||
threshold = MIN_CONFIDENCE_DEFAULT
|
||
|
||
if confidence < threshold:
|
||
logger.info(
|
||
f"Low confidence ({confidence}) for folder {folder} "
|
||
f"(threshold {threshold}), using INBOX as fallback"
|
||
)
|
||
return "INBOX"
|
||
|
||
return folder
|
||
|
||
|
||
# ---------- IMAP folder operations ----------
|
||
|
||
def ensure_folder(imap_conn, folder):
|
||
logger.debug(f"Ensuring folder exists: {folder}")
|
||
typ, mailboxes = imap_conn.list('""', f'"{folder}"')
|
||
if typ == "OK" and mailboxes and mailboxes[0] is not None:
|
||
logger.debug(f"Folder {folder} already exists")
|
||
return
|
||
|
||
logger.info(f"Folder {folder} does not exist, creating...")
|
||
typ, data = imap_conn.create(folder)
|
||
if typ != "OK":
|
||
logger.error(f"Failed to create folder {folder}: {data}")
|
||
else:
|
||
logger.info(f"Folder {folder} created successfully")
|
||
try:
|
||
st, dat = imap_conn.subscribe(folder)
|
||
if st == "OK":
|
||
logger.info(f"Folder {folder} subscribed successfully")
|
||
else:
|
||
logger.warning(f"Failed to subscribe folder {folder}: {dat}")
|
||
except Exception as e:
|
||
logger.warning(f"IMAP server does not support SUBSCRIBE or it failed: {e}")
|
||
|
||
|
||
def mark_processed(imap_conn, msg_id):
|
||
"""
|
||
Přidá vlastní flag, aby se zpráva už nikdy znovu neklasifikovala.
|
||
"""
|
||
typ, data = imap_conn.store(msg_id, "+FLAGS", PROCESSED_FLAG)
|
||
if typ != "OK":
|
||
logger.warning(f"Failed to set {PROCESSED_FLAG} on message {msg_id}: {data}")
|
||
|
||
|
||
def move_message(imap_conn, msg_id, target_folder):
|
||
msg_id_str = msg_id.decode(errors="ignore")
|
||
logger.info(f"Moving/marking message {msg_id_str} -> {target_folder}")
|
||
|
||
# Pokud složka je INBOX, necháme zprávu v INBOXu, ale označíme ji jako zpracovanou
|
||
# (aby se už nikdy neklasifikovala znova)
|
||
if target_folder == "INBOX":
|
||
logger.info(f"Target folder is INBOX, not moving message {msg_id_str}, only marking processed")
|
||
mark_processed(imap_conn, msg_id)
|
||
return
|
||
|
||
ensure_folder(imap_conn, target_folder)
|
||
|
||
typ, data = imap_conn.copy(msg_id, target_folder)
|
||
if typ != "OK":
|
||
logger.error(f"Failed to copy message {msg_id_str} to {target_folder}: {data}")
|
||
# i tak označíme jako processed, ať ji nezpracováváme dokola – jinak by to žralo tokeny
|
||
mark_processed(imap_conn, msg_id)
|
||
return
|
||
|
||
# označíme původní zprávu jako zpracovanou
|
||
mark_processed(imap_conn, msg_id)
|
||
|
||
# neoznačujeme jako \Seen, jen přidáme \Deleted
|
||
typ, data = imap_conn.store(msg_id, "+FLAGS", "\\Deleted")
|
||
if typ != "OK":
|
||
logger.error(f"Failed to mark message {msg_id_str} as deleted: {data}")
|
||
return
|
||
|
||
logger.info(f"Message {msg_id_str} marked as deleted in INBOX (will be expunged later)")
|
||
|
||
|
||
# ---------- Main processing ----------
|
||
|
||
def process_once():
|
||
logger.info("Starting one processing iteration")
|
||
imap_conn = connect_imap()
|
||
try:
|
||
ids = get_unseen_messages(imap_conn)
|
||
for msg_id in ids:
|
||
msg_id_str = msg_id.decode(errors="ignore")
|
||
logger.info(f"Processing message ID {msg_id_str}")
|
||
|
||
# BODY.PEEK[] – neznačí zprávu jako \Seen
|
||
typ, data = imap_conn.fetch(msg_id, "(BODY.PEEK[])")
|
||
if typ != "OK":
|
||
logger.error(f"Fetch failed for {msg_id_str}: {data}")
|
||
continue
|
||
|
||
raw_email = data[0][1]
|
||
msg = email.message_from_bytes(raw_email)
|
||
|
||
prompt = build_prompt_from_email(msg)
|
||
try:
|
||
result = classify_email(prompt)
|
||
except Exception as e:
|
||
logger.error(f"Error calling model for message {msg_id_str}: {e}")
|
||
logger.debug(traceback.format_exc())
|
||
# neznačíme jako processed – zkusí se to jindy znova
|
||
continue
|
||
|
||
target_folder = normalize_folder(result, msg)
|
||
try:
|
||
move_message(imap_conn, msg_id, target_folder)
|
||
except Exception as e:
|
||
logger.error(f"Error moving message {msg_id_str} to {target_folder}: {e}")
|
||
logger.debug(traceback.format_exc())
|
||
# move selhal, ale mark_processed už uvnitř běžel (nebo proběhne příště),
|
||
# takže se zpráva nebude dál klasifikovat
|
||
continue
|
||
|
||
# Po zpracování všech zpráv provedeme jedno společné EXPUNGE
|
||
logger.info("Running single EXPUNGE for all deleted messages in INBOX")
|
||
try:
|
||
typ, data = imap_conn.expunge()
|
||
if typ != "OK":
|
||
logger.error(f"EXPUNGE failed: {data}")
|
||
except Exception as e:
|
||
logger.warning(f"EXPUNGE raised an exception: {e}")
|
||
|
||
finally:
|
||
logger.info("Logging out from IMAP")
|
||
try:
|
||
imap_conn.logout()
|
||
except Exception as e:
|
||
logger.warning(f"Error during IMAP logout: {e}")
|
||
|
||
|
||
def main():
|
||
logger.info("mail-classifier starting up...")
|
||
log_config()
|
||
warmup_model()
|
||
while True:
|
||
try:
|
||
process_once()
|
||
except Exception as e:
|
||
logger.error(f"Error in main loop: {e}")
|
||
logger.debug(traceback.format_exc())
|
||
logger.info(f"Sleeping for {CHECK_INTERVAL} seconds")
|
||
time.sleep(CHECK_INTERVAL)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|
||
|