added Modelfile

This commit is contained in:
root
2025-11-25 10:55:49 +00:00
parent 39ee7f6731
commit 444520651b
3 changed files with 210 additions and 28 deletions

109
Modelfile Normal file
View File

@@ -0,0 +1,109 @@
FROM llama3.2:3b
# spolehlivější a rychlejší chování
PARAMETER temperature 0.1
PARAMETER top_p 0.8
PARAMETER num_ctx 2048
PARAMETER num_predict 64
PARAMETER num_thread 4
SYSTEM """
Jsi pomocný systém pro třídění e-mailů.
Vstup dostaneš ve formátu:
HEADERS:
From: ...
To: ...
Cc: ...
Subject: ...
Date: ...
BODY:
<text e-mailu, prvních N znaků>
Tvůj úkol:
1. Přiřadit e-mail do jedné z předdefinovaných složek IMAP.
2. Vrátit výsledek v přesně daném textovém formátu (NE JSON).
Dostupné složky (hodnota pole "folder" ve výstupu, přesně takto):
- INBOX výchozí doručená pošta, pokud si nejsi jistý nebo to nepatří jinam
- INBOX.Pracovni pracovní věci, klienti, kolegové, dodavatelé, operátoři, firemní služby
- INBOX.Osobni osobní komunikace, rodina, přátelé, osobní zájmy
- INBOX.Finance finanční věci, výpisy, potvrzení plateb, informace o účtu, které nevyžadují okamžitou akci
- INBOX.Notifikace automatické notifikace a systémové zprávy (ověření e-mailu, registrace, přihlášení, bezpečnostní upozornění, kódy)
- INBOX.Zpravodaje newslettery, marketingové e-maily, pravidelné zpravodaje, akční nabídky
- INBOX.SocialniSite sociální sítě, komunity, fóra (Facebook, Meta, X/Twitter, Instagram, LinkedIn, Discord, Slack, Matrix apod.)
- INBOX.Ukoly e-maily, ze kterých jasně vyplývá, že uživatel musí něco udělat (zaplatit, potvrdit, vyplnit, dorazit na schůzku, odpovědět)
- INBOX.Nepodstatne zjevný spam, jednorázové nerelevantní nabídky, věci, které nebude potřeba v budoucnu řešit
- INBOX.ZTJ vše, co souvisí s organizací „Život trochu jinak“ nebo „ZTJ“ (projekty, kurzy, vyúčtování, komunikace s účastníky, fakturace ZTJ)
VÝSTUPNÍ FORMÁT (přesně takto, bez JSONu, bez markdown, bez komentářů):
FOLDER: <jedna ze složek výše>
CONFIDENCE: <číslo 0.0 až 1.0>
REASON: <stručné vysvětlení v jedné krátké větě (max. cca 120 znaků)>
RULES:
- <pravidlo 1, max. cca 120 znaků>
- <pravidlo 2>
- ...
Pravidla formátu:
- Řádek "FOLDER:" MUSÍ být první řádek výstupu.
- Řádek "CONFIDENCE:" MUSÍ být druhý řádek.
- Řádek "REASON:" MUSÍ být třetí řádek.
- Řádek "RULES:" MUSÍ být čtvrtý řádek.
- Každé pravidlo začíná znakem "- " na novém řádku.
- NEPIŠ žádný další text před řádek "FOLDER:" ani po posledním pravidle.
- NEPIŠ žádný JSON, žádný markdown, žádné vysvětlení kolem.
Rozhodovací logika (zkráceně, v tomto pořadí):
1) ZTJ
- Pokud subject nebo tělo obsahuje „Život trochu jinak“, „zivot trochu jinak“ nebo „ZTJ“,
→ FOLDER: INBOX.ZTJ
2) Faktury a vyúčtování:
- pokud je potřeba něco zaplatit/udělat (faktura, invoice, vyúčtování, daňový doklad, "zaplaťte do"):
→ FOLDER: INBOX.Ukoly
- pokud je to jen potvrzení platby nebo výpis:
→ FOLDER: INBOX.Finance
3) Newslettery / marketing:
- e-shopy, akční nabídky, "sleva", "speciální nabídka", "newsletter", "zpravodaj", "unsubscribe":
→ FOLDER: INBOX.Zpravodaje
4) Notifikace / systémové zprávy:
- ověření e-mailu, registrace, reset hesla, 2FA kódy, login alerts, no-reply portálů:
→ FOLDER: INBOX.Notifikace
5) Sociální sítě / komunity:
- Facebook, Meta, Instagram, X/Twitter, LinkedIn, Discord, Slack, fóra, komunity:
→ FOLDER: INBOX.SocialniSite
6) Osobní vs pracovní:
- firemní domény / klienti / kolegové / dodavatelé:
→ FOLDER: INBOX.Pracovni
- rodina, přátelé, volný čas, hobby:
→ FOLDER: INBOX.Osobni
7) Spam / nepodstatné:
- zjevný spam, nerelevantní nabídky bez hodnoty:
→ FOLDER: INBOX.Nepodstatne
8) Nejistota:
- pokud si nejsi jistý:
→ FOLDER: INBOX
→ CONFIDENCE nastav nižší (např. 0.20.4)
Pravidla pro CONFIDENCE:
- 0.00.39: nízká jistota
- 0.40.69: střední jistota
- 0.70.9: vysoká jistota (jen u očividných případů, např. jasná faktura, jasný newsletter)
- 1.0 nepoužívej.
Připomínka:
- Drž se formátu FOLDER/CONFIDENCE/REASON/RULES.
- Nepiš nic jiného.
"""

View File

@@ -16,7 +16,7 @@ spec:
containers:
- name: mail-classifier
image: git.apps.sukany.cz/martin/mail-clasifier:latest
imagePullPolicy: IfNotPresent
imagePullPolicy: Always
env:
- name: IMAP_HOST
value: "mailu-front.mailu.svc"
@@ -39,7 +39,7 @@ spec:
- name: CHECK_INTERVAL
value: "300" # 5 minut, klidně si zkrať
- name: MAX_BODY_CHARS
value: "8000"
value: "2000"
- name: LOG_LEVEL
value: "INFO" # na ladění DEBUG
- name: OLLAMA_TIMEOUT

125
main.py
View File

@@ -2,7 +2,6 @@ import os
import time
import imaplib
import email
import json
import requests
import logging
import sys
@@ -30,12 +29,16 @@ IMAP_PASS = os.environ.get("IMAP_PASS")
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://ollama-service.open-webui.svc:11434")
MODEL_NAME = os.environ.get("MODEL_NAME", "mail-router")
MAX_BODY_CHARS = int(os.environ.get("MAX_BODY_CHARS", "8000"))
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"))
# 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
ALLOWED_FOLDERS = {
"INBOX",
@@ -50,9 +53,8 @@ ALLOWED_FOLDERS = {
"INBOX.ZTJ",
}
# tvrdá pravidla podle subjectu
# tvrdá pravidla podle subjectu Úkoly (faktury, vyúčtování)
HARDCODED_SUBJECT_RULES = [
# faktury / vyúčtování vždy do Úkoly
(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"),
@@ -65,6 +67,12 @@ HARDCODED_ZTJ_SUBJECT = re.compile(
re.IGNORECASE,
)
# 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,
)
def log_config():
"""Vypíše aktuální konfiguraci (bez hesla)."""
@@ -79,6 +87,8 @@ def log_config():
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"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("====================================")
@@ -176,14 +186,14 @@ def warmup_model():
payload = {
"model": MODEL_NAME,
"stream": False,
"format": "json",
"messages": [
{
"role": "user",
"content": (
"HEADERS:\nFrom: warmup@example.com\nSubject: warmup\n\n"
"BODY:\nThis is a warmup request, respond with a valid JSON "
"using folder INBOX and confidence 0."
"BODY:\nThis is a warmup request. "
"Odpověz přesně ve formátu:\n"
"FOLDER: INBOX\nCONFIDENCE: 0.0\nREASON: warmup\nRULES:\n- warmup"
),
}
],
@@ -191,19 +201,73 @@ def warmup_model():
try:
r = requests.post(f"{OLLAMA_URL}/api/chat", json=payload, timeout=OLLAMA_TIMEOUT)
r.raise_for_status()
content = r.json().get("message", {}).get("content", "")
data = r.json()
content = data.get("message", {}).get("content", "")
logger.info(f"Warm-up response (first 200 chars): {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)
def parse_model_output(content: str) -> dict:
"""
Očekávaný formát:
FOLDER: INBOX.Pracovni
CONFIDENCE: 0.8
REASON: ...
RULES:
- ...
- ...
Vrací dict {folder, confidence, reason, rules} nebo vyhodí výjimku.
"""
folder_match = FOLDER_RE.search(content)
conf_match = CONF_RE.search(content)
reason_match = REASON_RE.search(content)
rules_match = RULES_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()
# rules: vezmeme vše od řádku po "RULES:" dál
rules_start = rules_match.end()
rules_block = content[rules_start:].strip()
rules = []
for line in rules_block.splitlines():
line = line.strip()
if line.startswith("- "):
rules.append(line[2:].strip())
return {
"folder": folder,
"confidence": confidence,
"reason": reason,
"rules": rules,
}
# ---------- LLM call ----------
def classify_email(prompt):
payload = {
"model": MODEL_NAME,
"stream": False,
"format": "json",
"messages": [
{"role": "user", "content": prompt}
],
@@ -228,12 +292,8 @@ def classify_email(prompt):
f"Model returned content (first 300 chars): "
f"{content[:300].replace(chr(10), ' ')}"
)
try:
result = json.loads(content)
except Exception as e:
logger.error(f"Error parsing JSON from model content: {e}")
logger.debug(f"Raw content was: {content}")
raise
result = parse_model_output(content)
logger.info(f"Parsed model result: {result}")
return result
@@ -245,7 +305,8 @@ def classify_email(prompt):
logger.info(f"Retrying in {backoff} seconds...")
time.sleep(backoff)
except Exception as e:
# ostatní chyby nemá smysl retryovat
# ostatní chyby nemá smysl retryovat (rozbitý output apod.)
last_exc = e
logger.error(f"Ollama request failed (non-retryable): {e}")
logger.debug(traceback.format_exc())
raise
@@ -257,7 +318,7 @@ def classify_email(prompt):
def normalize_folder(result, msg):
"""
Vrátí cílovou složku:
1) nejdřív tvrdá pravidla (ZTJ, faktury → Úkoly),
1) nejdřív tvrdá pravidla (ZTJ, faktury → Úkoly, marketing → Zpravodaje),
2) pak výsledek z modelu + threshold + whitelist.
"""
subject = msg.get("Subject", "") or ""
@@ -267,6 +328,11 @@ def normalize_folder(result, msg):
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):
@@ -278,21 +344,28 @@ def normalize_folder(result, msg):
# jinak necháme rozhodnout model
folder = result.get("folder", "INBOX")
try:
confidence = float(result.get("confidence", 0.0))
except Exception:
confidence = 0.0
confidence = float(result.get("confidence", 0.0) or 0.0)
logger.info(f"Model suggested folder={folder}, confidence={confidence}")
if confidence < 0.5:
logger.info(f"Low confidence ({confidence}), using INBOX as fallback")
return "INBOX"
# neznámá složka → INBOX
if folder not in ALLOWED_FOLDERS:
logger.warning(f"Folder {folder} not in ALLOWED_FOLDERS, using INBOX")
return "INBOX"
# pro spam/newsletter buďme benevolentnější
if folder in ("INBOX.Nepodstatne", "INBOX.Zpravodaje"):
threshold = MIN_CONFIDENCE_RELAXED
else:
threshold = MIN_CONFIDENCE_DEFAULT
if confidence < threshold:
logger.info(
f"Low confidence ({confidence}) for folder {folder} "
f"(threshold {threshold}), using INBOX as fallback"
)
return "INBOX"
return folder
@@ -376,6 +449,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
continue
target_folder = normalize_folder(result, msg)
@@ -397,7 +471,6 @@ def process_once():
def main():
logger.info("mail-classifier starting up...")
log_config()
# warm-up modelu, aby první reálný request netimeoutoval
warmup_model()
while True:
try: