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 \
|
kubectl -n mailu create secret generic mail-classifier-secret \
|
||||||
--from-literal=imap_user='martin@sukany.cz' \
|
--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:
|
secretKeyRef:
|
||||||
name: mail-classifier-secret
|
name: mail-classifier-secret
|
||||||
key: imap_pass
|
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
|
- name: MODEL_NAME
|
||||||
value: "mail-router"
|
value: "openai/gpt-5-mini"
|
||||||
|
|
||||||
|
# ---------- Obecná konfigurace ----------
|
||||||
- name: CHECK_INTERVAL
|
- name: CHECK_INTERVAL
|
||||||
value: "300" # 5 minut, klidně si zkrať
|
value: "300" # 5 minut
|
||||||
- name: MAX_BODY_CHARS
|
- name: MAX_BODY_CHARS
|
||||||
value: "2000"
|
value: "2000"
|
||||||
- name: LOG_LEVEL
|
- name: LOG_LEVEL
|
||||||
value: "INFO" # na ladění DEBUG
|
value: "INFO" # na ladění DEBUG
|
||||||
- name: OLLAMA_TIMEOUT
|
|
||||||
value: "120" # první request může být delší kvůli warm-upu
|
# Timeout / retry pro LLM (společné pro oba backendy)
|
||||||
- name: OLLAMA_MAX_RETRIES
|
- name: LLM_TIMEOUT
|
||||||
|
value: "120"
|
||||||
|
- name: LLM_MAX_RETRIES
|
||||||
value: "3"
|
value: "3"
|
||||||
|
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
cpu: "100m"
|
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_USER = os.environ.get("IMAP_USER")
|
||||||
IMAP_PASS = os.environ.get("IMAP_PASS")
|
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")
|
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")
|
MODEL_NAME = os.environ.get("MODEL_NAME", "mail-router")
|
||||||
|
|
||||||
MAX_BODY_CHARS = int(os.environ.get("MAX_BODY_CHARS", "2000"))
|
MAX_BODY_CHARS = int(os.environ.get("MAX_BODY_CHARS", "2000"))
|
||||||
CHECK_INTERVAL = int(os.environ.get("CHECK_INTERVAL", "300")) # v sekundách
|
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
|
# Timeouty / retry s aliasem na staré proměnné
|
||||||
OLLAMA_MAX_RETRIES = int(os.environ.get("OLLAMA_MAX_RETRIES", "3"))
|
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
|
# prahy jistoty
|
||||||
MIN_CONFIDENCE_DEFAULT = float(os.environ.get("MIN_CONFIDENCE_DEFAULT", "0.4"))
|
MIN_CONFIDENCE_DEFAULT = float(os.environ.get("MIN_CONFIDENCE_DEFAULT", "0.4"))
|
||||||
MIN_CONFIDENCE_RELAXED = float(os.environ.get("MIN_CONFIDENCE_RELAXED", "0.3"))
|
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 = {
|
ALLOWED_FOLDERS = {
|
||||||
"INBOX",
|
"INBOX",
|
||||||
"INBOX.Pracovni",
|
|
||||||
"INBOX.Osobni",
|
|
||||||
"INBOX.Finance",
|
"INBOX.Finance",
|
||||||
"INBOX.Notifikace",
|
"INBOX.Notifikace",
|
||||||
"INBOX.Zpravodaje",
|
"INBOX.Zpravodaje",
|
||||||
@@ -51,28 +71,248 @@ ALLOWED_FOLDERS = {
|
|||||||
"INBOX.Ukoly",
|
"INBOX.Ukoly",
|
||||||
"INBOX.Nepodstatne",
|
"INBOX.Nepodstatne",
|
||||||
"INBOX.ZTJ",
|
"INBOX.ZTJ",
|
||||||
|
"INBOX.Potvrzeni",
|
||||||
}
|
}
|
||||||
|
|
||||||
# tvrdá pravidla podle subjectu – Úkoly (faktury, vyúčtování)
|
# ---------- System prompt ----------
|
||||||
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"),
|
|
||||||
]
|
|
||||||
|
|
||||||
# ZTJ – klíčová slova v subjectu
|
SYSTEM_PROMPT = """
|
||||||
HARDCODED_ZTJ_SUBJECT = re.compile(
|
Jsi klasifikátor e-mailů pro uživatele Martina. Tvoje jediná práce je
|
||||||
r"\b(Život trochu jinak|zivot trochu jinak|ZTJ)\b",
|
zařadit každý e-mail do jedné IMAP složky a stručně vysvětlit proč.
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
# marketing / newsletter – hrubý pattern
|
Vstup dostaneš ve formátu:
|
||||||
HARDCODED_MARKETING_SUBJECT = re.compile(
|
HEADERS:
|
||||||
r"\b(black friday|sleva|slevy|akce|speciální nabídka|newsletter|zpravodaj|unsubscribe)\b",
|
From: ...
|
||||||
re.IGNORECASE,
|
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():
|
def log_config():
|
||||||
"""Vypíše aktuální konfiguraci (bez hesla)."""
|
"""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_PORT = {IMAP_PORT}")
|
||||||
logger.info(f"IMAP_USER = {IMAP_USER}")
|
logger.info(f"IMAP_USER = {IMAP_USER}")
|
||||||
logger.info("IMAP_PASS = **** (hidden)")
|
logger.info("IMAP_PASS = **** (hidden)")
|
||||||
|
logger.info(f"LLM_BACKEND = {LLM_BACKEND}")
|
||||||
logger.info(f"OLLAMA_URL = {OLLAMA_URL}")
|
logger.info(f"OLLAMA_URL = {OLLAMA_URL}")
|
||||||
|
logger.info(f"OPENWEBUI_URL = {OPENWEBUI_URL}")
|
||||||
logger.info(f"MODEL_NAME = {MODEL_NAME}")
|
logger.info(f"MODEL_NAME = {MODEL_NAME}")
|
||||||
logger.info(f"MAX_BODY_CHARS = {MAX_BODY_CHARS}")
|
logger.info(f"MAX_BODY_CHARS = {MAX_BODY_CHARS}")
|
||||||
logger.info(f"CHECK_INTERVAL = {CHECK_INTERVAL} s")
|
logger.info(f"CHECK_INTERVAL = {CHECK_INTERVAL} s")
|
||||||
logger.info(f"OLLAMA_TIMEOUT = {OLLAMA_TIMEOUT} s")
|
logger.info(f"LLM_TIMEOUT = {LLM_TIMEOUT} s")
|
||||||
logger.info(f"OLLAMA_MAX_RETRIES = {OLLAMA_MAX_RETRIES}")
|
logger.info(f"LLM_MAX_RETRIES = {LLM_MAX_RETRIES}")
|
||||||
logger.info(f"MIN_CONF_DEFAULT = {MIN_CONFIDENCE_DEFAULT}")
|
logger.info(f"MIN_CONF_DEFAULT = {MIN_CONFIDENCE_DEFAULT}")
|
||||||
logger.info(f"MIN_CONF_RELAXED = {MIN_CONFIDENCE_RELAXED}")
|
logger.info(f"MIN_CONF_RELAXED = {MIN_CONFIDENCE_RELAXED}")
|
||||||
logger.info(f"LOG_LEVEL = {LOG_LEVEL}")
|
logger.info(f"LOG_LEVEL = {LOG_LEVEL}")
|
||||||
logger.info(f"ALLOWED_FOLDERS = {sorted(ALLOWED_FOLDERS)}")
|
logger.info(f"ALLOWED_FOLDERS = {sorted(ALLOWED_FOLDERS)}")
|
||||||
|
logger.info(f"PROCESSED_FLAG = {PROCESSED_FLAG}")
|
||||||
logger.info("====================================")
|
logger.info("====================================")
|
||||||
|
|
||||||
|
|
||||||
@@ -112,20 +355,26 @@ def connect_imap():
|
|||||||
|
|
||||||
|
|
||||||
def get_unseen_messages(imap_conn):
|
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")
|
typ, _ = imap_conn.select("INBOX")
|
||||||
if typ != "OK":
|
if typ != "OK":
|
||||||
logger.error(f"Cannot select INBOX, got: {typ}")
|
logger.error(f"Cannot select INBOX, got: {typ}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
status, data = imap_conn.search(None, "UNSEEN")
|
status, data = imap_conn.search(None, "UNSEEN", "UNKEYWORD", PROCESSED_FLAG)
|
||||||
if status != "OK":
|
if status != "OK":
|
||||||
logger.error(f"UNSEEN search failed: {status}")
|
logger.error(f"UNSEEN UNKEYWORD search failed: {status}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
ids = data[0].split()
|
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:
|
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
|
return ids
|
||||||
|
|
||||||
|
|
||||||
@@ -176,51 +425,67 @@ def build_prompt_from_email(msg):
|
|||||||
return prompt
|
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 ----------
|
# ---------- LLM warm-up ----------
|
||||||
|
|
||||||
def warmup_model():
|
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...")
|
logger.info(f"Warming up model {MODEL_NAME} using backend {LLM_BACKEND}...")
|
||||||
payload = {
|
|
||||||
"model": MODEL_NAME,
|
warmup_email = (
|
||||||
"stream": False,
|
"HEADERS:\n"
|
||||||
"messages": [
|
"From: warmup@example.com\n"
|
||||||
{
|
"To: martin@example.com\n"
|
||||||
"role": "user",
|
"Subject: Warmup\n"
|
||||||
"content": (
|
"Date: Thu, 01 Jan 1970 00:00:00 +0000\n\n"
|
||||||
"HEADERS:\nFrom: warmup@example.com\nSubject: warmup\n\n"
|
"BODY:\n"
|
||||||
"BODY:\nThis is a warmup request. "
|
"Toto je testovací e-mail pouze pro warmup modelu.\n"
|
||||||
"Odpověz přesně ve formátu:\n"
|
)
|
||||||
"FOLDER: INBOX\nCONFIDENCE: 0.0\nREASON: warmup\nRULES:\n- warmup"
|
|
||||||
),
|
messages = [
|
||||||
}
|
{"role": "system", "content": SYSTEM_PROMPT},
|
||||||
],
|
{"role": "user", "content": warmup_email},
|
||||||
}
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
r = requests.post(f"{OLLAMA_URL}/api/chat", json=payload, timeout=OLLAMA_TIMEOUT)
|
if LLM_BACKEND == "ollama":
|
||||||
r.raise_for_status()
|
content = _call_ollama_chat(messages)
|
||||||
data = r.json()
|
elif LLM_BACKEND == "openwebui":
|
||||||
content = data.get("message", {}).get("content", "")
|
content = _call_openwebui_chat(messages)
|
||||||
logger.info(f"Warm-up response (first 200 chars): {content[:200].replace(chr(10), ' ')}")
|
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:
|
except Exception as e:
|
||||||
logger.warning(f"Warm-up failed (will continue anyway): {e}")
|
logger.warning(f"Warm-up failed (will continue anyway): {e}")
|
||||||
|
|
||||||
|
|
||||||
# ---------- Parsing model output ----------
|
# ---------- Parsing model output ----------
|
||||||
|
|
||||||
FOLDER_RE = re.compile(r"^FOLDER:\s*(.+)$", re.MULTILINE)
|
FOLDER_RE = re.compile(r"FOLDER:\s*([A-Za-z0-9_.]+)")
|
||||||
CONF_RE = re.compile(r"^CONFIDENCE:\s*([0-9.]+)", re.MULTILINE)
|
CONF_RE = re.compile(r"CONFIDENCE:\s*([0-9.]+)")
|
||||||
REASON_RE = re.compile(r"^REASON:\s*(.+)$", re.MULTILINE)
|
REASON_RE = re.compile(r"REASON:\s*(.*?)(?:RULES:|$)", re.DOTALL)
|
||||||
RULES_RE = re.compile(r"^RULES:\s*(.*)$", re.MULTILINE)
|
RULES_BLOCK_RE = re.compile(r"RULES:\s*(.*)$", re.DOTALL)
|
||||||
|
|
||||||
|
|
||||||
def parse_model_output(content: str) -> dict:
|
def parse_model_output(content: str) -> dict:
|
||||||
"""
|
"""
|
||||||
Očekávaný formát:
|
Očekávaný formát:
|
||||||
|
|
||||||
FOLDER: INBOX.Pracovni
|
FOLDER: INBOX.Zpravodaje
|
||||||
CONFIDENCE: 0.8
|
CONFIDENCE: 0.8
|
||||||
REASON: ...
|
REASON: ...
|
||||||
RULES:
|
RULES:
|
||||||
@@ -232,27 +497,27 @@ def parse_model_output(content: str) -> dict:
|
|||||||
folder_match = FOLDER_RE.search(content)
|
folder_match = FOLDER_RE.search(content)
|
||||||
conf_match = CONF_RE.search(content)
|
conf_match = CONF_RE.search(content)
|
||||||
reason_match = REASON_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:
|
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")
|
raise ValueError("Missing one of FOLDER/CONFIDENCE/REASON/RULES in model output")
|
||||||
|
|
||||||
folder = folder_match.group(1).strip()
|
folder = folder_match.group(1).strip()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
confidence = float(conf_match.group(1))
|
confidence = float(conf_match.group(1))
|
||||||
except Exception:
|
except Exception:
|
||||||
confidence = 0.0
|
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_block = rules_match.group(1).strip()
|
||||||
rules_start = rules_match.end()
|
|
||||||
rules_block = content[rules_start:].strip()
|
|
||||||
rules = []
|
rules = []
|
||||||
for line in rules_block.splitlines():
|
for line in rules_block.splitlines():
|
||||||
line = line.strip()
|
l = line.strip()
|
||||||
if line.startswith("- "):
|
if l.startswith("- "):
|
||||||
rules.append(line[2:].strip())
|
rules.append(l[2:].strip())
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"folder": folder,
|
"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 = {
|
payload = {
|
||||||
"model": MODEL_NAME,
|
"model": MODEL_NAME,
|
||||||
"stream": False,
|
"stream": False,
|
||||||
"messages": [
|
"messages": messages,
|
||||||
{"role": "user", "content": prompt}
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
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
|
last_exc = None
|
||||||
for attempt in range(1, OLLAMA_MAX_RETRIES + 1):
|
for attempt in range(1, LLM_MAX_RETRIES + 1):
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Calling model {MODEL_NAME} at {OLLAMA_URL}/api/chat "
|
f"Calling model {MODEL_NAME} via backend {LLM_BACKEND} "
|
||||||
f"(attempt {attempt}/{OLLAMA_MAX_RETRIES})"
|
f"(attempt {attempt}/{LLM_MAX_RETRIES})"
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
r = requests.post(
|
if LLM_BACKEND == "ollama":
|
||||||
f"{OLLAMA_URL}/api/chat",
|
content = _call_ollama_chat(messages)
|
||||||
json=payload,
|
elif LLM_BACKEND == "openwebui":
|
||||||
timeout=OLLAMA_TIMEOUT,
|
content = _call_openwebui_chat(messages)
|
||||||
)
|
else:
|
||||||
r.raise_for_status()
|
raise RuntimeError(f"Unsupported LLM_BACKEND: {LLM_BACKEND}")
|
||||||
data = r.json()
|
|
||||||
content = data.get("message", {}).get("content", "")
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Model returned content (first 300 chars): "
|
"Model returned content (first 300 chars): "
|
||||||
f"{content[:300].replace(chr(10), ' ')}"
|
f"{content[:300].replace(chr(10), ' ')}"
|
||||||
)
|
)
|
||||||
|
|
||||||
result = parse_model_output(content)
|
result = parse_model_output(content)
|
||||||
logger.info(f"Parsed model result: {result}")
|
logger.info(f"Parsed model result: {result}")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
|
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
|
||||||
last_exc = e
|
last_exc = e
|
||||||
logger.warning(f"Ollama request failed with {type(e).__name__}: {e}")
|
logger.warning(f"LLM request failed with {type(e).__name__}: {e}")
|
||||||
if attempt < OLLAMA_MAX_RETRIES:
|
if attempt < LLM_MAX_RETRIES:
|
||||||
backoff = 5 * attempt
|
backoff = 5 * attempt
|
||||||
logger.info(f"Retrying in {backoff} seconds...")
|
logger.info(f"Retrying in {backoff} seconds...")
|
||||||
time.sleep(backoff)
|
time.sleep(backoff)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# ostatní chyby nemá smysl retryovat (rozbitý output apod.)
|
|
||||||
last_exc = e
|
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())
|
logger.debug(traceback.format_exc())
|
||||||
raise
|
raise
|
||||||
|
|
||||||
logger.error(f"Ollama request failed after {OLLAMA_MAX_RETRIES} attempts: {last_exc}")
|
logger.error(f"LLM request failed after {LLM_MAX_RETRIES} attempts: {last_exc}")
|
||||||
raise last_exc or RuntimeError("Ollama request failed")
|
raise last_exc or RuntimeError("LLM request failed")
|
||||||
|
|
||||||
|
|
||||||
def normalize_folder(result, msg):
|
def normalize_folder(result, msg):
|
||||||
"""
|
"""
|
||||||
Vrátí cílovou složku:
|
Vrátí cílovou složku čistě podle modelu:
|
||||||
1) nejdřív tvrdá pravidla (ZTJ, faktury → Úkoly, marketing → Zpravodaje),
|
- pokud složka není v ALLOWED_FOLDERS -> INBOX
|
||||||
2) pak výsledek z modelu + threshold + whitelist.
|
- 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")
|
folder = result.get("folder", "INBOX")
|
||||||
confidence = float(result.get("confidence", 0.0) or 0.0)
|
confidence = float(result.get("confidence", 0.0) or 0.0)
|
||||||
|
|
||||||
logger.info(f"Model suggested folder={folder}, confidence={confidence}")
|
logger.info(f"Model suggested folder={folder}, confidence={confidence}")
|
||||||
|
|
||||||
# neznámá složka → INBOX
|
|
||||||
if folder not in ALLOWED_FOLDERS:
|
if folder not in ALLOWED_FOLDERS:
|
||||||
logger.warning(f"Folder {folder} not in ALLOWED_FOLDERS, using INBOX")
|
logger.warning(f"Folder {folder} not in ALLOWED_FOLDERS, using INBOX")
|
||||||
return "INBOX"
|
return "INBOX"
|
||||||
@@ -372,10 +659,6 @@ def normalize_folder(result, msg):
|
|||||||
# ---------- IMAP folder operations ----------
|
# ---------- IMAP folder operations ----------
|
||||||
|
|
||||||
def ensure_folder(imap_conn, folder):
|
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}")
|
logger.debug(f"Ensuring folder exists: {folder}")
|
||||||
typ, mailboxes = imap_conn.list('""', f'"{folder}"')
|
typ, mailboxes = imap_conn.list('""', f'"{folder}"')
|
||||||
if typ == "OK" and mailboxes and mailboxes[0] is not None:
|
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}")
|
logger.error(f"Failed to create folder {folder}: {data}")
|
||||||
else:
|
else:
|
||||||
logger.info(f"Folder {folder} created successfully")
|
logger.info(f"Folder {folder} created successfully")
|
||||||
# pokusíme se složku i SUBSCRIBE-nout, aby ji klient (Roundcube) viděl
|
|
||||||
try:
|
try:
|
||||||
st, dat = imap_conn.subscribe(folder)
|
st, dat = imap_conn.subscribe(folder)
|
||||||
if st == "OK":
|
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}")
|
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):
|
def move_message(imap_conn, msg_id, target_folder):
|
||||||
msg_id_str = msg_id.decode(errors="ignore")
|
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)
|
ensure_folder(imap_conn, target_folder)
|
||||||
|
|
||||||
typ, data = imap_conn.copy(msg_id, target_folder)
|
typ, data = imap_conn.copy(msg_id, target_folder)
|
||||||
if typ != "OK":
|
if typ != "OK":
|
||||||
logger.error(f"Failed to copy message {msg_id_str} to {target_folder}: {data}")
|
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
|
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
|
# neoznačujeme jako \Seen, jen mažeme ze source folderu
|
||||||
typ, data = imap_conn.store(msg_id, "+FLAGS", "\\Deleted")
|
typ, data = imap_conn.store(msg_id, "+FLAGS", "\\Deleted")
|
||||||
if typ != "OK":
|
if typ != "OK":
|
||||||
@@ -449,7 +753,7 @@ def process_once():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error calling model for message {msg_id_str}: {e}")
|
logger.error(f"Error calling model for message {msg_id_str}: {e}")
|
||||||
logger.debug(traceback.format_exc())
|
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
|
continue
|
||||||
|
|
||||||
target_folder = normalize_folder(result, msg)
|
target_folder = normalize_folder(result, msg)
|
||||||
@@ -458,6 +762,8 @@ def process_once():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error moving message {msg_id_str} to {target_folder}: {e}")
|
logger.error(f"Error moving message {msg_id_str} to {target_folder}: {e}")
|
||||||
logger.debug(traceback.format_exc())
|
logger.debug(traceback.format_exc())
|
||||||
|
# move selhal, ale mark_processed už uvnitř běžel,
|
||||||
|
# takže se zpráva nebude dál klasifikovat
|
||||||
continue
|
continue
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
Reference in New Issue
Block a user