This commit is contained in:
root
2025-11-26 12:59:49 +00:00
parent 444520651b
commit 59309502b5
3 changed files with 460 additions and 128 deletions

546
main.py Executable file → Normal file
View File

@@ -26,24 +26,44 @@ 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
OLLAMA_TIMEOUT = int(os.environ.get("OLLAMA_TIMEOUT", "120")) # read timeout v sekundách
OLLAMA_MAX_RETRIES = int(os.environ.get("OLLAMA_MAX_RETRIES", "3"))
# 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"))
# povolené složky (whitelist) MUSÍ odpovídat tomu, co je v Modelfile
# 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.Pracovni",
"INBOX.Osobni",
"INBOX.Finance",
"INBOX.Notifikace",
"INBOX.Zpravodaje",
@@ -51,28 +71,248 @@ ALLOWED_FOLDERS = {
"INBOX.Ukoly",
"INBOX.Nepodstatne",
"INBOX.ZTJ",
"INBOX.Potvrzeni",
}
# tvrdá pravidla podle subjectu Úkoly (faktury, vyúčtování)
HARDCODED_SUBJECT_RULES = [
(re.compile(r"\bfaktura\b", re.IGNORECASE), "INBOX.Ukoly"),
(re.compile(r"\bvyúčtován[íi]\b|\bvyuctovan[íi]\b", re.IGNORECASE), "INBOX.Ukoly"),
(re.compile(r"\bdaňov[ýy]\s+doklad\b", re.IGNORECASE), "INBOX.Ukoly"),
(re.compile(r"\binvoice\b", re.IGNORECASE), "INBOX.Ukoly"),
]
# ---------- System prompt ----------
# ZTJ klíčová slova v subjectu
HARDCODED_ZTJ_SUBJECT = re.compile(
r"\b(Život trochu jinak|zivot trochu jinak|ZTJ)\b",
re.IGNORECASE,
)
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č.
# marketing / newsletter hrubý pattern
HARDCODED_MARKETING_SUBJECT = re.compile(
r"\b(black friday|sleva|slevy|akce|speciální nabídka|newsletter|zpravodaj|unsubscribe)\b",
re.IGNORECASE,
)
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.81.0 = jsi si jistý, typický případ (newsletter, výpis, jasný úkol, ZTJ…)
* 0.40.7 = váháš mezi dvěma složkami, ale jednu vybereš
* 0.00.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 (13 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)."""
@@ -81,16 +321,19 @@ def log_config():
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"OLLAMA_TIMEOUT = {OLLAMA_TIMEOUT} s")
logger.info(f"OLLAMA_MAX_RETRIES = {OLLAMA_MAX_RETRIES}")
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("====================================")
@@ -112,20 +355,26 @@ def connect_imap():
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")
status, data = imap_conn.search(None, "UNSEEN", "UNKEYWORD", PROCESSED_FLAG)
if status != "OK":
logger.error(f"UNSEEN search failed: {status}")
logger.error(f"UNSEEN UNKEYWORD search failed: {status}")
return []
ids = data[0].split()
logger.info(f"Found {len(ids)} unseen messages in INBOX")
logger.info(
f"Found {len(ids)} unseen & unprocessed messages in INBOX "
f"(UNSEEN UNKEYWORD {PROCESSED_FLAG})"
)
if ids:
logger.debug(f"Unseen message IDs: {[i.decode(errors='ignore') for i in ids]}")
logger.debug(f"Unseen/unprocessed message IDs: {[i.decode(errors='ignore') for i in ids]}")
return ids
@@ -176,51 +425,67 @@ def build_prompt_from_email(msg):
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 Ollama natáhla model před prvním reálným mailem.
Jednoduchý warm-up dotaz, aby si backend natáhl model před prvním reálným mailem.
"""
logger.info("Warming up Ollama model...")
payload = {
"model": MODEL_NAME,
"stream": False,
"messages": [
{
"role": "user",
"content": (
"HEADERS:\nFrom: warmup@example.com\nSubject: warmup\n\n"
"BODY:\nThis is a warmup request. "
"Odpověz přesně ve formátu:\n"
"FOLDER: INBOX\nCONFIDENCE: 0.0\nREASON: warmup\nRULES:\n- warmup"
),
}
],
}
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:
r = requests.post(f"{OLLAMA_URL}/api/chat", json=payload, timeout=OLLAMA_TIMEOUT)
r.raise_for_status()
data = r.json()
content = data.get("message", {}).get("content", "")
logger.info(f"Warm-up response (first 200 chars): {content[:200].replace(chr(10), ' ')}")
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*(.+)$", re.MULTILINE)
CONF_RE = re.compile(r"^CONFIDENCE:\s*([0-9.]+)", re.MULTILINE)
REASON_RE = re.compile(r"^REASON:\s*(.+)$", re.MULTILINE)
RULES_RE = re.compile(r"^RULES:\s*(.*)$", re.MULTILINE)
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.Pracovni
FOLDER: INBOX.Zpravodaje
CONFIDENCE: 0.8
REASON: ...
RULES:
@@ -232,27 +497,27 @@ def parse_model_output(content: str) -> dict:
folder_match = FOLDER_RE.search(content)
conf_match = CONF_RE.search(content)
reason_match = REASON_RE.search(content)
rules_match = RULES_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 = reason_match.group(1).strip()
reason_raw = reason_match.group(1).strip()
reason = reason_raw.splitlines()[0].strip()
# rules: vezmeme vše od řádku po "RULES:" dál
rules_start = rules_match.end()
rules_block = content[rules_start:].strip()
rules_block = rules_match.group(1).strip()
rules = []
for line in rules_block.splitlines():
line = line.strip()
if line.startswith("- "):
rules.append(line[2:].strip())
l = line.strip()
if l.startswith("- "):
rules.append(l[2:].strip())
return {
"folder": folder,
@@ -262,93 +527,115 @@ def parse_model_output(content: str) -> dict:
}
# ---------- LLM call ----------
# ---------- LLM call backend-specific helpers ----------
def classify_email(prompt):
def _call_ollama_chat(messages) -> str:
payload = {
"model": MODEL_NAME,
"stream": False,
"messages": [
{"role": "user", "content": prompt}
],
"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, OLLAMA_MAX_RETRIES + 1):
for attempt in range(1, LLM_MAX_RETRIES + 1):
logger.info(
f"Calling model {MODEL_NAME} at {OLLAMA_URL}/api/chat "
f"(attempt {attempt}/{OLLAMA_MAX_RETRIES})"
f"Calling model {MODEL_NAME} via backend {LLM_BACKEND} "
f"(attempt {attempt}/{LLM_MAX_RETRIES})"
)
try:
r = requests.post(
f"{OLLAMA_URL}/api/chat",
json=payload,
timeout=OLLAMA_TIMEOUT,
)
r.raise_for_status()
data = r.json()
content = data.get("message", {}).get("content", "")
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(
f"Model returned content (first 300 chars): "
"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"Ollama request failed with {type(e).__name__}: {e}")
if attempt < OLLAMA_MAX_RETRIES:
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:
# ostatní chyby nemá smysl retryovat (rozbitý output apod.)
last_exc = e
logger.error(f"Ollama request failed (non-retryable): {e}")
logger.error(f"LLM request failed (non-retryable): {e}")
logger.debug(traceback.format_exc())
raise
logger.error(f"Ollama request failed after {OLLAMA_MAX_RETRIES} attempts: {last_exc}")
raise last_exc or RuntimeError("Ollama request failed")
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:
1) nejdřív tvrdá pravidla (ZTJ, faktury → Úkoly, marketing → Zpravodaje),
2) pak výsledek z modelu + threshold + whitelist.
Vrátí cílovou složku čistě podle modelu:
- pokud složka není v ALLOWED_FOLDERS -> INBOX
- pokud confidence pod prahem -> INBOX
"""
subject = msg.get("Subject", "") or ""
# ZTJ pokud je v subjectu klíčové slovo, vždy do INBOX.ZTJ
if HARDCODED_ZTJ_SUBJECT.search(subject):
logger.info("Hardcoded ZTJ rule matched subject, forcing folder=INBOX.ZTJ")
return "INBOX.ZTJ"
# marketing / newsletter → Zpravodaje
if HARDCODED_MARKETING_SUBJECT.search(subject):
logger.info("Hardcoded marketing rule matched subject, forcing folder=INBOX.Zpravodaje")
return "INBOX.Zpravodaje"
# faktury / vyúčtování → Úkoly
for pattern, folder in HARDCODED_SUBJECT_RULES:
if pattern.search(subject):
logger.info(
f"Hardcoded subject rule matched pattern {pattern.pattern}, "
f"forcing folder={folder}"
)
return folder
# jinak necháme rozhodnout model
folder = result.get("folder", "INBOX")
confidence = float(result.get("confidence", 0.0) or 0.0)
logger.info(f"Model suggested folder={folder}, confidence={confidence}")
# neznámá složka → INBOX
if folder not in ALLOWED_FOLDERS:
logger.warning(f"Folder {folder} not in ALLOWED_FOLDERS, using INBOX")
return "INBOX"
@@ -372,10 +659,6 @@ def normalize_folder(result, msg):
# ---------- IMAP folder operations ----------
def ensure_folder(imap_conn, folder):
"""
Zkontroluje existenci složky přes LIST a případně ji vytvoří + SUBSCRIBE.
Nemění aktuální mailbox.
"""
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:
@@ -388,7 +671,6 @@ def ensure_folder(imap_conn, folder):
logger.error(f"Failed to create folder {folder}: {data}")
else:
logger.info(f"Folder {folder} created successfully")
# pokusíme se složku i SUBSCRIBE-nout, aby ji klient (Roundcube) viděl
try:
st, dat = imap_conn.subscribe(folder)
if st == "OK":
@@ -399,16 +681,38 @@ def ensure_folder(imap_conn, folder):
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 message {msg_id_str} -> {target_folder}")
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 mažeme ze source folderu
typ, data = imap_conn.store(msg_id, "+FLAGS", "\\Deleted")
if typ != "OK":
@@ -449,7 +753,7 @@ def process_once():
except Exception as e:
logger.error(f"Error calling model for message {msg_id_str}: {e}")
logger.debug(traceback.format_exc())
# necháme zprávu v INBOXu, zpracuje se později
# neznačíme jako processed zkusí se to jindy znova
continue
target_folder = normalize_folder(result, msg)
@@ -458,6 +762,8 @@ def process_once():
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,
# takže se zpráva nebude dál klasifikovat
continue
finally: