mailshift 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mailshift/__init__.py +1 -0
- mailshift/config/__init__.py +1 -0
- mailshift/config/config.py +279 -0
- mailshift/core/__init__.py +1 -0
- mailshift/core/analyzers/__init__.py +1 -0
- mailshift/core/analyzers/base.py +8 -0
- mailshift/core/analyzers/fast.py +123 -0
- mailshift/core/analyzers/pro.py +529 -0
- mailshift/core/engine.py +842 -0
- mailshift/core/session.py +268 -0
- mailshift/db/__init__.py +1 -0
- mailshift/db/database.py +199 -0
- mailshift/main.py +902 -0
- mailshift/models/__init__.py +1 -0
- mailshift/models/models.py +47 -0
- mailshift/ui/__init__.py +1 -0
- mailshift/ui/cli.py +919 -0
- mailshift/ui/styles.py +96 -0
- mailshift/utils/__init__.py +1 -0
- mailshift/utils/hardware.py +572 -0
- mailshift/utils/history.py +358 -0
- mailshift/utils/logger.py +78 -0
- mailshift/utils/paths.py +8 -0
- mailshift/utils/power_user_settings.py +85 -0
- mailshift/utils/unsubscribe.py +119 -0
- mailshift/utils/updater.py +136 -0
- mailshift/utils/worker_profile_store.py +284 -0
- mailshift-1.0.0.dist-info/METADATA +258 -0
- mailshift-1.0.0.dist-info/RECORD +32 -0
- mailshift-1.0.0.dist-info/WHEEL +4 -0
- mailshift-1.0.0.dist-info/entry_points.txt +2 -0
- mailshift-1.0.0.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pro_analyzer.py | LLM-based email analysis for MailShift.
|
|
3
|
+
|
|
4
|
+
Supports two backends:
|
|
5
|
+
- Ollama (recommended for NVIDIA / Apple Silicon)
|
|
6
|
+
- LM Studio (recommended for AMD / Intel GPU users, OpenAI-compatible API)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import unicodedata
|
|
13
|
+
import threading
|
|
14
|
+
from typing import Optional, Union, Tuple
|
|
15
|
+
|
|
16
|
+
import requests
|
|
17
|
+
from requests.adapters import HTTPAdapter
|
|
18
|
+
from urllib3.util.retry import Retry
|
|
19
|
+
from abc import ABC, abstractmethod
|
|
20
|
+
|
|
21
|
+
from ...config.config import OllamaConfig, LMStudioConfig
|
|
22
|
+
from ...utils.hardware import detect_model_size, get_system_info
|
|
23
|
+
from ...models.models import MailMeta, ScanResult
|
|
24
|
+
from ...utils.logger import log
|
|
25
|
+
|
|
26
|
+
# ── Module-level connection pool & locks ─────────────────────────────────
|
|
27
|
+
_session: Optional[requests.Session] = None
|
|
28
|
+
_session_lock = threading.Lock()
|
|
29
|
+
|
|
30
|
+
_provider_cache: dict[str, "LLMProvider"] = {}
|
|
31
|
+
_provider_cache_lock = threading.Lock()
|
|
32
|
+
|
|
33
|
+
# ── Compiled patterns for reason extraction ──────────────────────────────
|
|
34
|
+
REASON_PATTERNS = [
|
|
35
|
+
re.compile(r'çünkü\s+(.+?)(?:\.|$)', re.IGNORECASE),
|
|
36
|
+
re.compile(r'nedeni[:\s]\s*(.+?)(?:\.|$)', re.IGNORECASE),
|
|
37
|
+
re.compile(r'sebebi[:\s]\s*(.+?)(?:\.|$)', re.IGNORECASE),
|
|
38
|
+
re.compile(r'because\s+(.+?)(?:\.|$)', re.IGNORECASE),
|
|
39
|
+
re.compile(r'since\s+(.+?)(?:\.|$)', re.IGNORECASE),
|
|
40
|
+
re.compile(r'reason:\s*(.+?)(?:\.|$)', re.IGNORECASE),
|
|
41
|
+
re.compile(r'it is\s+(a\s+\w+)\s+', re.IGNORECASE),
|
|
42
|
+
re.compile(r'this is\s+(a\s+\w+)\s+', re.IGNORECASE),
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_llm_timeout_reason(reason: str) -> bool:
|
|
47
|
+
"""Return True when a result reason indicates timeout-like LLM failure."""
|
|
48
|
+
normalized = (reason or "").strip().lower()
|
|
49
|
+
if not normalized:
|
|
50
|
+
return False
|
|
51
|
+
return (
|
|
52
|
+
normalized.startswith("llm-timeout")
|
|
53
|
+
or " timed out" in normalized
|
|
54
|
+
or "timeout" in normalized
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def is_llm_error_reason(reason: str) -> bool:
|
|
59
|
+
"""Return True when a result reason indicates non-timeout LLM/API failure."""
|
|
60
|
+
normalized = (reason or "").strip().lower()
|
|
61
|
+
if not normalized:
|
|
62
|
+
return False
|
|
63
|
+
return normalized.startswith("llm-error")
|
|
64
|
+
|
|
65
|
+
def _parse_bool_env(value: str) -> Optional[bool]:
|
|
66
|
+
lowered = (value or "").strip().lower()
|
|
67
|
+
if lowered in {"1", "true", "yes", "on"}:
|
|
68
|
+
return True
|
|
69
|
+
if lowered in {"0", "false", "no", "off"}:
|
|
70
|
+
return False
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
def _select_ollama_runtime_options(model_name: str) -> dict[str, Union[int, float, bool]]:
|
|
74
|
+
"""Pick Ollama runtime options based on current machine capabilities."""
|
|
75
|
+
options: dict[str, Union[int, float, bool]] = {
|
|
76
|
+
"num_predict": 96,
|
|
77
|
+
"temperature": 0.0,
|
|
78
|
+
"num_thread": 6,
|
|
79
|
+
"num_gpu": 32,
|
|
80
|
+
"use_flash_attn": True,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
system_info = get_system_info()
|
|
85
|
+
model_size_b = detect_model_size(model_name)
|
|
86
|
+
|
|
87
|
+
cpu_threads = max(2, min(12, max(1, system_info.cpu_count - 1)))
|
|
88
|
+
options["num_thread"] = cpu_threads
|
|
89
|
+
|
|
90
|
+
if system_info.has_gpu and system_info.vram_available_gb > 0.5:
|
|
91
|
+
estimated_model_vram = max(1.0, model_size_b * 0.9)
|
|
92
|
+
fit_ratio = system_info.vram_available_gb / estimated_model_vram
|
|
93
|
+
|
|
94
|
+
if fit_ratio >= 1.5:
|
|
95
|
+
options["num_gpu"] = 32
|
|
96
|
+
elif fit_ratio >= 1.0:
|
|
97
|
+
options["num_gpu"] = 24
|
|
98
|
+
elif fit_ratio >= 0.6:
|
|
99
|
+
options["num_gpu"] = 16
|
|
100
|
+
else:
|
|
101
|
+
options["num_gpu"] = 8
|
|
102
|
+
|
|
103
|
+
gpu_name = (system_info.gpu_name or "").lower()
|
|
104
|
+
options["use_flash_attn"] = any(token in gpu_name for token in ("nvidia", "rtx", "gtx", "apple"))
|
|
105
|
+
options["num_thread"] = max(2, min(int(options["num_thread"]), 8))
|
|
106
|
+
else:
|
|
107
|
+
options["num_gpu"] = 0
|
|
108
|
+
options["use_flash_attn"] = False
|
|
109
|
+
|
|
110
|
+
except Exception as exc:
|
|
111
|
+
log.warning(f"Hardware-based Ollama tuning failed, defaults will be used: {exc}")
|
|
112
|
+
|
|
113
|
+
# Environment variable overrides
|
|
114
|
+
env_num_thread = os.getenv("MAILSHIFT_OLLAMA_NUM_THREAD", "").strip()
|
|
115
|
+
if env_num_thread.isdigit() and int(env_num_thread) > 0:
|
|
116
|
+
options["num_thread"] = int(env_num_thread)
|
|
117
|
+
|
|
118
|
+
env_num_gpu = os.getenv("MAILSHIFT_OLLAMA_NUM_GPU", "").strip()
|
|
119
|
+
if env_num_gpu.isdigit() and int(env_num_gpu) >= 0:
|
|
120
|
+
options["num_gpu"] = int(env_num_gpu)
|
|
121
|
+
|
|
122
|
+
env_flash = _parse_bool_env(os.getenv("MAILSHIFT_OLLAMA_USE_FLASH_ATTN", ""))
|
|
123
|
+
if env_flash is not None:
|
|
124
|
+
options["use_flash_attn"] = env_flash
|
|
125
|
+
|
|
126
|
+
return options
|
|
127
|
+
|
|
128
|
+
def _get_session() -> requests.Session:
|
|
129
|
+
"""Return (or lazily create) a thread-safe module-level requests.Session."""
|
|
130
|
+
global _session
|
|
131
|
+
if _session is None:
|
|
132
|
+
with _session_lock:
|
|
133
|
+
if _session is None:
|
|
134
|
+
_session = requests.Session()
|
|
135
|
+
# Configured Retry to include POST methods explicitly
|
|
136
|
+
retry_strategy = Retry(
|
|
137
|
+
total=2,
|
|
138
|
+
backoff_factor=0.3,
|
|
139
|
+
status_forcelist=[429, 500, 502, 503, 504],
|
|
140
|
+
allowed_methods=["GET", "POST"]
|
|
141
|
+
)
|
|
142
|
+
adapter = HTTPAdapter(
|
|
143
|
+
pool_connections=10,
|
|
144
|
+
pool_maxsize=50,
|
|
145
|
+
max_retries=retry_strategy,
|
|
146
|
+
)
|
|
147
|
+
_session.mount("http://", adapter)
|
|
148
|
+
_session.mount("https://", adapter)
|
|
149
|
+
return _session
|
|
150
|
+
|
|
151
|
+
def close_ollama_session():
|
|
152
|
+
"""Close the persistent HTTP session if it exists."""
|
|
153
|
+
global _session
|
|
154
|
+
with _session_lock:
|
|
155
|
+
if _session is not None:
|
|
156
|
+
_session.close()
|
|
157
|
+
_session = None
|
|
158
|
+
|
|
159
|
+
# ── Health Checks & Memory Management ────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
def check_ollama_health(base_url: str = "http://localhost:11434", model: str = "") -> Tuple[bool, str]:
|
|
162
|
+
try:
|
|
163
|
+
resp = _get_session().get(f"{base_url}/api/tags", timeout=5)
|
|
164
|
+
resp.raise_for_status()
|
|
165
|
+
except requests.ConnectionError:
|
|
166
|
+
return False, "Ollama'ya bağlanılamıyor. Çalıştığından emin olun."
|
|
167
|
+
except Exception as exc:
|
|
168
|
+
return False, f"Ollama bağlantı hatası: {exc}"
|
|
169
|
+
|
|
170
|
+
if not model:
|
|
171
|
+
return True, "Ollama çalışıyor."
|
|
172
|
+
|
|
173
|
+
available = [m["name"].lower() for m in resp.json().get("models", [])]
|
|
174
|
+
model_lower = model.lower()
|
|
175
|
+
if any(model_lower in m or m in model_lower for m in available):
|
|
176
|
+
return True, f"Ollama çalışıyor, model '{model}' mevcut."
|
|
177
|
+
|
|
178
|
+
return False, f"Ollama çalışıyor fakat '{model}' modeli bulunamadı."
|
|
179
|
+
|
|
180
|
+
def check_lm_studio_health(base_url: str = "http://localhost:1234", model: str = "") -> Tuple[bool, str]:
|
|
181
|
+
try:
|
|
182
|
+
resp = _get_session().get(f"{base_url}/v1/models", timeout=5)
|
|
183
|
+
resp.raise_for_status()
|
|
184
|
+
except requests.ConnectionError:
|
|
185
|
+
return False, "LM Studio'ya bağlanılamıyor. Local Server'ın açık olduğundan emin olun."
|
|
186
|
+
except Exception as exc:
|
|
187
|
+
return False, f"LM Studio bağlantı hatası: {exc}"
|
|
188
|
+
|
|
189
|
+
available = [m["id"] for m in resp.json().get("data", [])]
|
|
190
|
+
if not model:
|
|
191
|
+
if available:
|
|
192
|
+
return True, f"LM Studio çalışıyor, {len(available)} model yüklü."
|
|
193
|
+
return False, "LM Studio çalışıyor fakat yüklü model yok."
|
|
194
|
+
|
|
195
|
+
if any(model.lower() in m.lower() or m.lower() in model.lower() for m in available):
|
|
196
|
+
return True, f"LM Studio çalışıyor, model '{model}' mevcut."
|
|
197
|
+
|
|
198
|
+
return False, f"LM Studio çalışıyor fakat '{model}' modeli yüklü değil."
|
|
199
|
+
|
|
200
|
+
def unload_lm_studio_models(base_url: str = "http://localhost:1234", model_id: Optional[str] = None) -> list[str]:
|
|
201
|
+
unloaded = []
|
|
202
|
+
try:
|
|
203
|
+
resp = _get_session().get(f"{base_url}/v1/models", timeout=3)
|
|
204
|
+
if resp.status_code != 200:
|
|
205
|
+
return []
|
|
206
|
+
|
|
207
|
+
for m in resp.json().get("data", []):
|
|
208
|
+
m_id = m.get("id")
|
|
209
|
+
if not m_id or (model_id and model_id.lower() not in m_id.lower()):
|
|
210
|
+
continue
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
unload_resp = _get_session().post(
|
|
214
|
+
f"{base_url}/api/v1/models/unload",
|
|
215
|
+
json={"instance_id": m_id},
|
|
216
|
+
timeout=3
|
|
217
|
+
)
|
|
218
|
+
if unload_resp.status_code == 200:
|
|
219
|
+
unloaded.append(m_id)
|
|
220
|
+
except Exception:
|
|
221
|
+
continue
|
|
222
|
+
except Exception as exc:
|
|
223
|
+
log.debug(f"LM Studio model tahliye işlemi başarısız: {exc}")
|
|
224
|
+
|
|
225
|
+
return unloaded
|
|
226
|
+
|
|
227
|
+
def unload_ollama_model(base_url: str = "http://localhost:11434", model: str = ""):
|
|
228
|
+
if not model:
|
|
229
|
+
return
|
|
230
|
+
try:
|
|
231
|
+
_get_session().post(
|
|
232
|
+
f"{base_url}/api/chat",
|
|
233
|
+
json={"model": model, "messages": [], "keep_alive": 0},
|
|
234
|
+
timeout=3
|
|
235
|
+
)
|
|
236
|
+
except Exception as exc:
|
|
237
|
+
log.debug(f"Ollama model tahliye işlemi başarısız: {exc}")
|
|
238
|
+
|
|
239
|
+
# ── LLM Provider Abstraction ─────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
class LLMProvider(ABC):
|
|
242
|
+
"""Base interface for LLM-based email analysis."""
|
|
243
|
+
|
|
244
|
+
@abstractmethod
|
|
245
|
+
def analyze(
|
|
246
|
+
self,
|
|
247
|
+
meta: MailMeta,
|
|
248
|
+
fast_reason: str = "",
|
|
249
|
+
fast_category: str = "",
|
|
250
|
+
cancel_event: Optional[threading.Event] = None,
|
|
251
|
+
) -> ScanResult:
|
|
252
|
+
"""Analyze an email and return a ScanResult."""
|
|
253
|
+
|
|
254
|
+
def _build_user_prompt(
|
|
255
|
+
self,
|
|
256
|
+
meta: MailMeta,
|
|
257
|
+
max_body_chars: int,
|
|
258
|
+
fast_reason: str = "",
|
|
259
|
+
fast_category: str = "",
|
|
260
|
+
) -> str:
|
|
261
|
+
"""Shared prompt builder moved to Base Class (DRY Principle)."""
|
|
262
|
+
body = (meta.body_preview or "")[:max_body_chars]
|
|
263
|
+
hint = ""
|
|
264
|
+
if fast_reason:
|
|
265
|
+
hint = f"\nÖn Analiz (heuristik): Bu e-posta '{fast_reason}' kuralıyla SIL olarak işaretlendi."
|
|
266
|
+
if fast_category:
|
|
267
|
+
hint += f" Kategori: {fast_category}."
|
|
268
|
+
hint += "\n"
|
|
269
|
+
|
|
270
|
+
return (
|
|
271
|
+
"Aşağıdaki e-postayı sınıflandır. Sadece karar ver.\n\n"
|
|
272
|
+
f"Konu: {meta.subject}\n"
|
|
273
|
+
f"Gönderen: {meta.sender}\n"
|
|
274
|
+
f"İçerik: {body}\n"
|
|
275
|
+
f"{hint}"
|
|
276
|
+
).strip()
|
|
277
|
+
|
|
278
|
+
def _parse_llm_response(self, response: str) -> Tuple[str, str]:
|
|
279
|
+
response = (response or "").strip()
|
|
280
|
+
if not response:
|
|
281
|
+
return ("TUT", "invalid-response")
|
|
282
|
+
|
|
283
|
+
raw_thinking = ""
|
|
284
|
+
think_match = re.search(r"<think>([\s\S]*?)</think>", response, flags=re.IGNORECASE)
|
|
285
|
+
if think_match:
|
|
286
|
+
raw_thinking = think_match.group(1).strip()
|
|
287
|
+
response = response.replace(think_match.group(0), "").strip()
|
|
288
|
+
|
|
289
|
+
prefix = ""
|
|
290
|
+
if raw_thinking:
|
|
291
|
+
sanitized = re.sub(r"\s+", " ", raw_thinking.replace("\n", " ").replace("\r", " ")).strip()
|
|
292
|
+
if len(sanitized) > 100:
|
|
293
|
+
sanitized = sanitized[:97] + "..."
|
|
294
|
+
prefix = f"({sanitized}) "
|
|
295
|
+
|
|
296
|
+
try:
|
|
297
|
+
parsed = json.loads(response)
|
|
298
|
+
if isinstance(parsed, dict) and "decision" in parsed:
|
|
299
|
+
decision = str(parsed["decision"]).upper()
|
|
300
|
+
if decision in {"SIL", "TUT"}:
|
|
301
|
+
reason = str(parsed.get("reason", "no reason provided"))
|
|
302
|
+
return (decision, f"{prefix}{reason}")
|
|
303
|
+
except json.JSONDecodeError:
|
|
304
|
+
pass
|
|
305
|
+
|
|
306
|
+
decision = self._extract_decision_from_json(response)
|
|
307
|
+
if decision is None:
|
|
308
|
+
normalized = self._normalize_for_decision_parse(response)
|
|
309
|
+
matches = list(re.finditer(r"\b(sil|tut)\b", normalized, flags=re.IGNORECASE))
|
|
310
|
+
if not matches:
|
|
311
|
+
return ("TUT", "invalid-response")
|
|
312
|
+
decision = "SIL" if matches[0].group(1).lower() == "sil" else "TUT"
|
|
313
|
+
|
|
314
|
+
reason = self._extract_reason(response, decision)
|
|
315
|
+
return (decision, f"{prefix}{reason}")
|
|
316
|
+
|
|
317
|
+
def _extract_decision_from_json(self, response: str) -> Optional[str]:
|
|
318
|
+
candidates = [response]
|
|
319
|
+
# Regex optimized for performance (non-greedy structural match)
|
|
320
|
+
block_match = re.search(r"\{[\s\S]*\}", response)
|
|
321
|
+
if block_match:
|
|
322
|
+
candidates.insert(0, block_match.group(0))
|
|
323
|
+
|
|
324
|
+
for candidate in candidates:
|
|
325
|
+
try:
|
|
326
|
+
parsed = json.loads(candidate)
|
|
327
|
+
except json.JSONDecodeError:
|
|
328
|
+
continue
|
|
329
|
+
|
|
330
|
+
values_to_check: list[str] = []
|
|
331
|
+
if isinstance(parsed, dict):
|
|
332
|
+
values_to_check.extend(
|
|
333
|
+
str(v) for k, v in parsed.items()
|
|
334
|
+
if k in {"decision", "karar", "label", "result"} and isinstance(v, str)
|
|
335
|
+
)
|
|
336
|
+
elif isinstance(parsed, str):
|
|
337
|
+
values_to_check.append(parsed)
|
|
338
|
+
|
|
339
|
+
for value in values_to_check:
|
|
340
|
+
normalized = self._normalize_for_decision_parse(value)
|
|
341
|
+
if re.search(r"\bsil\b", normalized, flags=re.IGNORECASE):
|
|
342
|
+
return "SIL"
|
|
343
|
+
if re.search(r"\btut\b", normalized, flags=re.IGNORECASE):
|
|
344
|
+
return "TUT"
|
|
345
|
+
return None
|
|
346
|
+
|
|
347
|
+
@staticmethod
|
|
348
|
+
def _normalize_for_decision_parse(text: str) -> str:
|
|
349
|
+
lowered = text.lower().replace("ı", "i")
|
|
350
|
+
decomposed = unicodedata.normalize("NFKD", lowered)
|
|
351
|
+
return "".join(ch for ch in decomposed if unicodedata.category(ch) != "Mn")
|
|
352
|
+
|
|
353
|
+
def _extract_reason(self, response: str, decision: str) -> str:
|
|
354
|
+
response_lower = response.lower()
|
|
355
|
+
for pattern in REASON_PATTERNS:
|
|
356
|
+
match = pattern.search(response_lower)
|
|
357
|
+
if match:
|
|
358
|
+
reason = match.group(1).strip()
|
|
359
|
+
if 3 < len(reason) < 150:
|
|
360
|
+
return reason
|
|
361
|
+
return "newsletter/spam" if decision == "SIL" else "personal/important"
|
|
362
|
+
|
|
363
|
+
class OllamaProvider(LLMProvider):
|
|
364
|
+
def __init__(self, cfg: OllamaConfig):
|
|
365
|
+
self.cfg = cfg
|
|
366
|
+
self.runtime_options = _select_ollama_runtime_options(cfg.model)
|
|
367
|
+
|
|
368
|
+
def _resolve_num_predict(self) -> int:
|
|
369
|
+
if self.cfg.use_think:
|
|
370
|
+
return 512
|
|
371
|
+
|
|
372
|
+
# qwen3.5 small models can return empty content when budget is too low.
|
|
373
|
+
model_name = (self.cfg.model or "").lower()
|
|
374
|
+
if "qwen3.5:2b" in model_name or "qwen3.5:4b" in model_name:
|
|
375
|
+
return 256
|
|
376
|
+
return 96
|
|
377
|
+
|
|
378
|
+
@staticmethod
|
|
379
|
+
def _extract_ollama_response_text(body: dict) -> str:
|
|
380
|
+
message = body.get("message")
|
|
381
|
+
message_content = ""
|
|
382
|
+
if isinstance(message, dict):
|
|
383
|
+
content_value = message.get("content", "")
|
|
384
|
+
if isinstance(content_value, str):
|
|
385
|
+
message_content = content_value.strip()
|
|
386
|
+
elif content_value is not None:
|
|
387
|
+
message_content = str(content_value).strip()
|
|
388
|
+
|
|
389
|
+
if message_content:
|
|
390
|
+
return message_content
|
|
391
|
+
|
|
392
|
+
# Some Ollama builds can return empty message.content while a top-level
|
|
393
|
+
# textual response still exists.
|
|
394
|
+
response_value = body.get("response", "")
|
|
395
|
+
if isinstance(response_value, str):
|
|
396
|
+
return response_value.strip()
|
|
397
|
+
if response_value is not None:
|
|
398
|
+
return str(response_value).strip()
|
|
399
|
+
|
|
400
|
+
return ""
|
|
401
|
+
|
|
402
|
+
def analyze(
|
|
403
|
+
self,
|
|
404
|
+
meta: MailMeta,
|
|
405
|
+
fast_reason: str = "",
|
|
406
|
+
fast_category: str = "",
|
|
407
|
+
cancel_event: Optional[threading.Event] = None,
|
|
408
|
+
) -> ScanResult:
|
|
409
|
+
if cancel_event and cancel_event.is_set():
|
|
410
|
+
return ScanResult(mail=meta, decision="TUT", reason="cancelled")
|
|
411
|
+
|
|
412
|
+
user_prompt = self._build_user_prompt(meta, self.cfg.max_body_chars, fast_reason, fast_category)
|
|
413
|
+
|
|
414
|
+
payload = {
|
|
415
|
+
"model": self.cfg.model,
|
|
416
|
+
"messages": [
|
|
417
|
+
{"role": "system", "content": self.cfg.system_prompt},
|
|
418
|
+
{"role": "user", "content": user_prompt},
|
|
419
|
+
],
|
|
420
|
+
"stream": False,
|
|
421
|
+
"think": self.cfg.use_think,
|
|
422
|
+
"keep_alive": -1,
|
|
423
|
+
"format": "json",
|
|
424
|
+
"options": {
|
|
425
|
+
**self.runtime_options,
|
|
426
|
+
"num_predict": self._resolve_num_predict(),
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
# Requests Session allows max_retries natively, but we keep explicit timeout catching for logging.
|
|
431
|
+
try:
|
|
432
|
+
resp = _get_session().post(
|
|
433
|
+
f"{self.cfg.base_url}/api/chat",
|
|
434
|
+
json=payload,
|
|
435
|
+
timeout=self.cfg.timeout,
|
|
436
|
+
)
|
|
437
|
+
resp.raise_for_status()
|
|
438
|
+
body = resp.json()
|
|
439
|
+
|
|
440
|
+
raw_response = self._extract_ollama_response_text(body)
|
|
441
|
+
|
|
442
|
+
decision, reason = self._parse_llm_response(raw_response)
|
|
443
|
+
return ScanResult(mail=meta, decision=decision, reason=f"llm:{decision} - {reason}")
|
|
444
|
+
|
|
445
|
+
except requests.Timeout:
|
|
446
|
+
log.warning(f"Ollama timeout for UID {meta.uid}")
|
|
447
|
+
return ScanResult(mail=meta, decision="TUT", reason="llm-timeout")
|
|
448
|
+
except Exception as exc:
|
|
449
|
+
log.warning(f"Ollama API error: {exc}")
|
|
450
|
+
return ScanResult(mail=meta, decision="TUT", reason=f"llm-error:{exc}")
|
|
451
|
+
|
|
452
|
+
class LMStudioProvider(LLMProvider):
|
|
453
|
+
def __init__(self, cfg: LMStudioConfig):
|
|
454
|
+
self.cfg = cfg
|
|
455
|
+
|
|
456
|
+
def analyze(
|
|
457
|
+
self,
|
|
458
|
+
meta: MailMeta,
|
|
459
|
+
fast_reason: str = "",
|
|
460
|
+
fast_category: str = "",
|
|
461
|
+
cancel_event: Optional[threading.Event] = None,
|
|
462
|
+
) -> ScanResult:
|
|
463
|
+
if cancel_event and cancel_event.is_set():
|
|
464
|
+
return ScanResult(mail=meta, decision="TUT", reason="cancelled")
|
|
465
|
+
|
|
466
|
+
user_prompt = self._build_user_prompt(meta, self.cfg.max_body_chars, fast_reason, fast_category)
|
|
467
|
+
|
|
468
|
+
payload = {
|
|
469
|
+
"model": self.cfg.model,
|
|
470
|
+
"messages": [
|
|
471
|
+
{"role": "system", "content": self.cfg.system_prompt},
|
|
472
|
+
{"role": "user", "content": user_prompt},
|
|
473
|
+
],
|
|
474
|
+
"stream": False,
|
|
475
|
+
"temperature": 0.0,
|
|
476
|
+
"max_tokens": 512 if self.cfg.use_think else 256,
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
try:
|
|
480
|
+
resp = _get_session().post(
|
|
481
|
+
f"{self.cfg.base_url}/v1/chat/completions",
|
|
482
|
+
json=payload,
|
|
483
|
+
timeout=self.cfg.timeout,
|
|
484
|
+
)
|
|
485
|
+
resp.raise_for_status()
|
|
486
|
+
|
|
487
|
+
choices = resp.json().get("choices", [])
|
|
488
|
+
raw_response = ""
|
|
489
|
+
if choices and isinstance(choices[0], dict):
|
|
490
|
+
raw_response = str(choices[0].get("message", {}).get("content", "")).strip()
|
|
491
|
+
|
|
492
|
+
decision, reason = self._parse_llm_response(raw_response)
|
|
493
|
+
return ScanResult(mail=meta, decision=decision, reason=f"llm:{decision} - {reason}")
|
|
494
|
+
|
|
495
|
+
except requests.Timeout:
|
|
496
|
+
log.warning(f"LM Studio timeout for UID {meta.uid}")
|
|
497
|
+
return ScanResult(mail=meta, decision="TUT", reason="llm-timeout")
|
|
498
|
+
except Exception as exc:
|
|
499
|
+
log.warning(f"LM Studio API error: {exc}")
|
|
500
|
+
return ScanResult(mail=meta, decision="TUT", reason=f"llm-error:{exc}")
|
|
501
|
+
|
|
502
|
+
# ── Main Entry ───────────────────────────────────────────────────────────
|
|
503
|
+
|
|
504
|
+
def pro_analyze(
|
|
505
|
+
meta: MailMeta,
|
|
506
|
+
cfg: Union[OllamaConfig, LMStudioConfig],
|
|
507
|
+
backend: str = "ollama",
|
|
508
|
+
fast_reason: str = "",
|
|
509
|
+
fast_category: str = "",
|
|
510
|
+
cancel_event: Optional[threading.Event] = None,
|
|
511
|
+
) -> ScanResult:
|
|
512
|
+
"""Analyze a single email via thread-safe cached LLM provider."""
|
|
513
|
+
cache_key = f"{backend}|{cfg.base_url}|{cfg.model}"
|
|
514
|
+
|
|
515
|
+
with _provider_cache_lock:
|
|
516
|
+
if cache_key not in _provider_cache:
|
|
517
|
+
if backend == "lm_studio":
|
|
518
|
+
_provider_cache[cache_key] = LMStudioProvider(cfg)
|
|
519
|
+
else:
|
|
520
|
+
_provider_cache[cache_key] = OllamaProvider(cfg)
|
|
521
|
+
|
|
522
|
+
provider = _provider_cache[cache_key]
|
|
523
|
+
|
|
524
|
+
return provider.analyze(
|
|
525
|
+
meta,
|
|
526
|
+
fast_reason=fast_reason,
|
|
527
|
+
fast_category=fast_category,
|
|
528
|
+
cancel_event=cancel_event,
|
|
529
|
+
)
|