update
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
kubectl -n mailu delete secret mail-classifier-secret
|
||||
kubectl -n mailu create secret generic mail-classifier-secret \
|
||||
--from-literal=imap_user='martin@sukany.cz' \
|
||||
--from-literal=imap_pass='treasure-Hunter'
|
||||
--from-literal=imap_pass='treasure-Hunter' \
|
||||
--from-literal=openwebui_api_key='sk-5aec322c43f347549268b27779f12620'
|
||||
|
||||
|
||||
@@ -32,20 +32,44 @@ spec:
|
||||
secretKeyRef:
|
||||
name: mail-classifier-secret
|
||||
key: imap_pass
|
||||
- name: OLLAMA_URL
|
||||
value: "http://ollama-service.open-webui.svc:11434"
|
||||
|
||||
# ---------- LLM backend konfigurace ----------
|
||||
# Volba backendu: "ollama" nebo "openwebui"
|
||||
- name: LLM_BACKEND
|
||||
value: "openwebui"
|
||||
|
||||
# Pokud používáš OpenWebUI (OpenRouter connection),
|
||||
# v k8s máš service "open-webui-service" na portu 8080:
|
||||
- name: OPENWEBUI_URL
|
||||
value: "http://open-webui-service.open-webui.svc:8080"
|
||||
|
||||
# User API token / service token z OpenWebUI,
|
||||
# ulož ho do secretu mail-classifier-secret pod klíčem "openwebui_api_key"
|
||||
- name: OPENWEBUI_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: mail-classifier-secret
|
||||
key: openwebui_api_key
|
||||
|
||||
# Jméno modelu v OpenWebUI (OpenRouter)
|
||||
# Použij přesně to, které vidíš v UI, např. "openai/gpt-5-mini"
|
||||
- name: MODEL_NAME
|
||||
value: "mail-router"
|
||||
value: "openai/gpt-5-mini"
|
||||
|
||||
# ---------- Obecná konfigurace ----------
|
||||
- name: CHECK_INTERVAL
|
||||
value: "300" # 5 minut, klidně si zkrať
|
||||
value: "300" # 5 minut
|
||||
- name: MAX_BODY_CHARS
|
||||
value: "2000"
|
||||
- name: LOG_LEVEL
|
||||
value: "INFO" # na ladění DEBUG
|
||||
- name: OLLAMA_TIMEOUT
|
||||
value: "120" # první request může být delší kvůli warm-upu
|
||||
- name: OLLAMA_MAX_RETRIES
|
||||
|
||||
# Timeout / retry pro LLM (společné pro oba backendy)
|
||||
- name: LLM_TIMEOUT
|
||||
value: "120"
|
||||
- name: LLM_MAX_RETRIES
|
||||
value: "3"
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: "100m"
|
||||
|
||||
546
main.py
Executable file → Normal file
546
main.py
Executable file → Normal 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.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)."""
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user