From 59309502b5e68fc58812f20f39d55662be70405d Mon Sep 17 00:00:00 2001 From: root Date: Wed, 26 Nov 2025 12:59:49 +0000 Subject: [PATCH] update --- kubernetes/credentials.sh | 4 +- kubernetes/deployment.yaml | 38 ++- main.py | 546 +++++++++++++++++++++++++++++-------- 3 files changed, 460 insertions(+), 128 deletions(-) mode change 100755 => 100644 main.py diff --git a/kubernetes/credentials.sh b/kubernetes/credentials.sh index b5070eb..46da6a2 100755 --- a/kubernetes/credentials.sh +++ b/kubernetes/credentials.sh @@ -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' diff --git a/kubernetes/deployment.yaml b/kubernetes/deployment.yaml index 373b95c..997d918 100644 --- a/kubernetes/deployment.yaml +++ b/kubernetes/deployment.yaml @@ -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" diff --git a/main.py b/main.py old mode 100755 new mode 100644 index 3ae2e46..1356e99 --- a/main.py +++ b/main.py @@ -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: + +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: +CONFIDENCE: <číslo 0.0 až 1.0> +REASON: +RULES: +- +- + +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: