added Modelfile
This commit is contained in:
109
Modelfile
Normal file
109
Modelfile
Normal 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.2–0.4)
|
||||
|
||||
Pravidla pro CONFIDENCE:
|
||||
- 0.0–0.39: nízká jistota
|
||||
- 0.4–0.69: střední jistota
|
||||
- 0.7–0.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.
|
||||
"""
|
||||
|
||||
@@ -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
125
main.py
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user