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.
@@ -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
+ )