failure-forensics 0.1.1__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,5 @@
1
+ from .trace import trace
2
+ from .dashboard import render as dashboard
3
+ from .regression_guard import check_regression as regression_guard
4
+
5
+ __all__ = ["trace", "dashboard", "regression_guard"]
@@ -0,0 +1,116 @@
1
+ """
2
+ ab_report.py — İki prompt versiyonunu karşılaştıran A/B raporu üretir.
3
+ Adım bazında breakdown, kazanan versiyon, fark yüzdesi.
4
+ data/ab_report.json'a kaydeder ve terminale tablo olarak basar.
5
+ """
6
+ import json
7
+ import os
8
+ import sys
9
+
10
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
11
+ from failure_forensics.versioning import compare_versions, version_stats
12
+ from failure_forensics.logger import read_logs
13
+ from failure_forensics.config import AB_REPORT_FILE
14
+
15
+ STEP_ORDER = ["retrieval", "reranking", "generation", "citation"]
16
+
17
+
18
+ def generate_ab_report(v1: str = "v1", v2: str = "v2") -> dict:
19
+ """
20
+ v1 ve v2 versiyonları için A/B raporu üretir.
21
+ Raporu AB_REPORT_FILE'a kaydeder.
22
+
23
+ Returns:
24
+ Tam rapor dict'i
25
+ """
26
+ logs = read_logs()
27
+ comparison = compare_versions(v1, v2, logs)
28
+ stats = version_stats(logs)
29
+
30
+ v1_data = stats.get(v1, {})
31
+ v2_data = stats.get(v2, {})
32
+
33
+ # Adım bazında karşılaştırma
34
+ step_comparison = {}
35
+ all_steps = set(
36
+ list(v1_data.get("steps", {}).keys()) +
37
+ list(v2_data.get("steps", {}).keys())
38
+ )
39
+ for step in all_steps:
40
+ s1 = v1_data.get("steps", {}).get(step, {"total": 0, "failed": 0, "rate": 0.0})
41
+ s2 = v2_data.get("steps", {}).get(step, {"total": 0, "failed": 0, "rate": 0.0})
42
+ delta = s1["rate"] - s2["rate"]
43
+ step_winner = v1 if s1["rate"] < s2["rate"] else (v2 if s2["rate"] < s1["rate"] else "tie")
44
+ step_comparison[step] = {
45
+ v1: s1,
46
+ v2: s2,
47
+ "delta_pct": round(delta * 100, 2),
48
+ "step_winner": step_winner,
49
+ }
50
+
51
+ report = {
52
+ "summary": {
53
+ "v1": v1,
54
+ "v2": v2,
55
+ "winner": comparison["winner"],
56
+ "v1_failure_rate": comparison["v1_rate"],
57
+ "v2_failure_rate": comparison["v2_rate"],
58
+ "improvement_pct": comparison["improvement_pct"],
59
+ "v1_runs": comparison["v1_runs"],
60
+ "v2_runs": comparison["v2_runs"],
61
+ },
62
+ "step_breakdown": step_comparison,
63
+ "raw_stats": {v1: v1_data, v2: v2_data},
64
+ }
65
+
66
+ # Kaydet
67
+ os.makedirs(os.path.dirname(AB_REPORT_FILE), exist_ok=True)
68
+ with open(AB_REPORT_FILE, "w", encoding="utf-8") as f:
69
+ json.dump(report, f, indent=2, ensure_ascii=False)
70
+
71
+ return report
72
+
73
+
74
+ def print_ab_report(v1: str = "v1", v2: str = "v2"):
75
+ """Terminale formatlanmış A/B raporu tablo olarak basar."""
76
+ report = generate_ab_report(v1, v2)
77
+ summary = report["summary"]
78
+ steps = report["step_breakdown"]
79
+
80
+ print("\n" + "=" * 62)
81
+ print(" 📊 A/B RAPORU — Prompt Versiyon Karşılaştırması")
82
+ print("=" * 62)
83
+
84
+ # Özet
85
+ winner = summary["winner"]
86
+ winner_icon = "🏆"
87
+ print(f"\n Karşılaştırma : {v1} vs {v2}")
88
+ print(f" {v1} çalışma sayısı : {summary['v1_runs']}")
89
+ print(f" {v2} çalışma sayısı : {summary['v2_runs']}")
90
+ print(f"\n {'Metrik':<30} {v1:>10} {v2:>10} {'Fark':>10}")
91
+ print(f" {'-'*60}")
92
+ v1r = summary["v1_failure_rate"]
93
+ v2r = summary["v2_failure_rate"]
94
+ diff = (v1r - v2r) * 100
95
+ print(f" {'Genel Failure Rate':<30} {v1r:>9.1%} {v2r:>10.1%} {diff:>+9.1f}pp")
96
+
97
+ # Adım bazında tablo
98
+ print(f"\n {'Adım':<16} {v1+' fail':>12} {v2+' fail':>12} {'Kazanan':>10}")
99
+ print(f" {'-'*55}")
100
+
101
+ for step in STEP_ORDER:
102
+ if step not in steps:
103
+ continue
104
+ sc = steps[step]
105
+ r1 = sc[v1]["rate"]
106
+ r2 = sc[v2]["rate"]
107
+ sw = sc["step_winner"]
108
+ icon = winner_icon if sw != "tie" else "="
109
+ print(f" {step:<16} {r1:>11.1%} {r2:>12.1%} {icon} {sw:>6}")
110
+
111
+ print(f"\n {winner_icon} KAZANAN: {winner.upper()} "
112
+ f"— {summary['improvement_pct']:.1f} pp iyileşme")
113
+ print(f"\n 📁 Detaylı rapor: {AB_REPORT_FILE}")
114
+ print("=" * 62)
115
+
116
+ return report
@@ -0,0 +1,100 @@
1
+ """
2
+ alerts.py — Slack webhook bildirimleri.
3
+ Tetiklenme koşulları:
4
+ 1. Failure rate eşiği aşıldığında
5
+ 2. Anomali tespit edildiğinde
6
+ 3. Belirli bir adım 3 üst üste başarısız olduğunda
7
+
8
+ Webhook URL boşsa konsola yazar.
9
+ """
10
+ import os
11
+ import sys
12
+ import json
13
+ from datetime import datetime, timezone
14
+
15
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
16
+ from failure_forensics.config import SLACK_WEBHOOK_URL, FAILURE_RATE_THRESHOLD, CONSECUTIVE_FAILURE_THRESHOLD
17
+
18
+ try:
19
+ import requests as _requests
20
+ _HAS_REQUESTS = True
21
+ except ImportError:
22
+ _HAS_REQUESTS = False
23
+
24
+
25
+ def _send(message: str, level: str = "WARNING"):
26
+ """Mesajı Slack'e veya konsola gönderir."""
27
+ icon = {"WARNING": "⚠️", "CRITICAL": "🔴", "INFO": "ℹ️"}.get(level, "⚠️")
28
+ full_msg = f"{icon} *[Failure Forensics]* {message}"
29
+
30
+ if SLACK_WEBHOOK_URL and _HAS_REQUESTS:
31
+ try:
32
+ _requests.post(
33
+ SLACK_WEBHOOK_URL,
34
+ json={"text": full_msg},
35
+ timeout=5,
36
+ )
37
+ print(f"[ALERT → Slack] {full_msg}")
38
+ except Exception as e:
39
+ print(f"[ALERT → Console (Slack failed: {e})] {full_msg}")
40
+ else:
41
+ ts = datetime.now(timezone.utc).strftime("%H:%M:%S")
42
+ print(f"[ALERT {ts}] {full_msg}")
43
+
44
+
45
+ def check_failure_rate(failure_rate: float, context: str = ""):
46
+ """Failure rate eşiği aşılırsa alert gönderir."""
47
+ if failure_rate > FAILURE_RATE_THRESHOLD:
48
+ _send(
49
+ f"Failure rate eşiği aşıldı: {failure_rate:.1%} "
50
+ f"(eşik: {FAILURE_RATE_THRESHOLD:.1%}). {context}",
51
+ level="CRITICAL",
52
+ )
53
+ return True
54
+ return False
55
+
56
+
57
+ def check_anomaly(anomaly_result: dict):
58
+ """Anomali tespit edildiyse alert gönderir."""
59
+ if anomaly_result.get("anomaly"):
60
+ today = anomaly_result["today_rate"]
61
+ avg = anomaly_result["avg_7d"]
62
+ delta = anomaly_result["delta"]
63
+ _send(
64
+ f"Anomali tespit edildi! Bugün: {today:.1%}, "
65
+ f"7g ort: {avg:.1%}, fark: +{delta:.1%}",
66
+ level="CRITICAL",
67
+ )
68
+ return True
69
+ return False
70
+
71
+
72
+ def check_consecutive_failures(logs: list[dict], step_name: str = None) -> bool:
73
+ """
74
+ Belirli bir adımda (veya herhangi bir adımda) arka arkaya
75
+ CONSECUTIVE_FAILURE_THRESHOLD kadar hata varsa alert gönderir.
76
+ """
77
+ if not logs:
78
+ return False
79
+
80
+ # İsteğe bağlı filtre
81
+ filtered = [r for r in logs if step_name is None or r.get("step_name") == step_name]
82
+
83
+ # Son N kaydı kontrol et
84
+ recent = filtered[-CONSECUTIVE_FAILURE_THRESHOLD:]
85
+ if len(recent) < CONSECUTIVE_FAILURE_THRESHOLD:
86
+ return False
87
+
88
+ if all(not r.get("success", True) for r in recent):
89
+ step_label = step_name or "herhangi bir adım"
90
+ _send(
91
+ f"'{step_label}' adımı {CONSECUTIVE_FAILURE_THRESHOLD} üst üste başarısız!",
92
+ level="CRITICAL",
93
+ )
94
+ return True
95
+ return False
96
+
97
+
98
+ def send_summary(title: str, body: str):
99
+ """Genel özet bildirimi gönderir."""
100
+ _send(f"*{title}*\n{body}", level="INFO")
@@ -0,0 +1,91 @@
1
+ """
2
+ baseline.py — Günlük failure rate'i kaydeder, 7 günlük hareketli ortalama ve trend hesaplar.
3
+ Trend: IMPROVING / STABLE / DEGRADING
4
+ """
5
+ import json
6
+ import os
7
+ import sys
8
+ from datetime import datetime, timezone
9
+
10
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
11
+ from failure_forensics.config import BASELINE_FILE, TREND_IMPROVING_THRESHOLD, TREND_DEGRADING_THRESHOLD
12
+
13
+
14
+ def _load() -> dict:
15
+ os.makedirs(os.path.dirname(BASELINE_FILE), exist_ok=True)
16
+ if not os.path.exists(BASELINE_FILE):
17
+ return {}
18
+ with open(BASELINE_FILE, "r", encoding="utf-8") as f:
19
+ try:
20
+ return json.load(f)
21
+ except json.JSONDecodeError:
22
+ return {}
23
+
24
+
25
+ def _save(data: dict):
26
+ os.makedirs(os.path.dirname(BASELINE_FILE), exist_ok=True)
27
+ with open(BASELINE_FILE, "w", encoding="utf-8") as f:
28
+ json.dump(data, f, indent=2)
29
+
30
+
31
+ def record_daily_rate(date: str, failure_rate: float):
32
+ """Bir günün failure rate'ini baseline dosyasına kaydeder."""
33
+ data = _load()
34
+ data[date] = round(failure_rate, 6)
35
+ _save(data)
36
+
37
+
38
+ def get_moving_average(days: int = 7) -> dict:
39
+ """
40
+ Son N günün hareketli ortalamasını döner.
41
+
42
+ Returns:
43
+ {
44
+ "dates": [...],
45
+ "rates": [...],
46
+ "moving_avg": float,
47
+ "trend": "IMPROVING" | "STABLE" | "DEGRADING",
48
+ "trend_delta": float
49
+ }
50
+ """
51
+ data = _load()
52
+ sorted_dates = sorted(data.keys())[-days:]
53
+ rates = [data[d] for d in sorted_dates]
54
+
55
+ moving_avg = sum(rates) / len(rates) if rates else 0.0
56
+
57
+ # Trend: ilk yarı ile ikinci yarıyı karşılaştır
58
+ if len(rates) >= 4:
59
+ mid = len(rates) // 2
60
+ first_half_avg = sum(rates[:mid]) / mid
61
+ second_half_avg = sum(rates[mid:]) / (len(rates) - mid)
62
+ trend_delta = second_half_avg - first_half_avg
63
+ elif len(rates) >= 2:
64
+ trend_delta = rates[-1] - rates[0]
65
+ else:
66
+ trend_delta = 0.0
67
+
68
+ if trend_delta <= TREND_IMPROVING_THRESHOLD:
69
+ trend = "IMPROVING"
70
+ elif trend_delta >= TREND_DEGRADING_THRESHOLD:
71
+ trend = "DEGRADING"
72
+ else:
73
+ trend = "STABLE"
74
+
75
+ return {
76
+ "dates": sorted_dates,
77
+ "rates": [round(r, 4) for r in rates],
78
+ "moving_avg": round(moving_avg, 4),
79
+ "trend": trend,
80
+ "trend_delta": round(trend_delta, 4),
81
+ }
82
+
83
+
84
+ def sync_from_pattern(daily_rates: dict):
85
+ """
86
+ pattern.py'den gelen günlük failure rate'leri baseline'a yazar.
87
+ Her gün için record_daily_rate çağırır.
88
+ """
89
+ for date, day_data in daily_rates.items():
90
+ rate = day_data.get("failure_rate", 0.0)
91
+ record_daily_rate(date, rate)
@@ -0,0 +1,36 @@
1
+ """
2
+ config.py — Eşik değerleri, Slack webhook URL, adım bazında kalite eşikleri.
3
+ """
4
+
5
+ # Failure rate eşiği — bu değeri aşarsa alert tetiklenir
6
+ FAILURE_RATE_THRESHOLD = 0.25
7
+
8
+ # Anomali eşiği — bugün 7 günlük ortalamayı bu kadar aşarsa ANOMALY flag'i
9
+ ANOMALY_THRESHOLD = 0.20
10
+
11
+ # Slack webhook URL — boş bırakılırsa konsola yazılır
12
+ SLACK_WEBHOOK_URL = ""
13
+
14
+ # Log dosyası
15
+ LOG_FILE = "data/logs/requests.jsonl"
16
+
17
+ # A/B rapor çıktısı
18
+ AB_REPORT_FILE = "data/ab_report.json"
19
+
20
+ # Baseline geçmiş dosyası
21
+ BASELINE_FILE = "data/baseline.json"
22
+
23
+ # Adım bazında kalite eşikleri (maksimum kabul edilebilir failure rate)
24
+ STEP_THRESHOLDS = {
25
+ "retrieval": 0.20,
26
+ "reranking": 0.15,
27
+ "generation": 0.15,
28
+ "citation": 0.10,
29
+ }
30
+
31
+ # Trend hesabı için eşikler
32
+ TREND_IMPROVING_THRESHOLD = -0.05 # %5 iyileşme → IMPROVING
33
+ TREND_DEGRADING_THRESHOLD = 0.05 # %5 kötüleşme → DEGRADING
34
+
35
+ # Kaç üst üste hata ALERT tetikler
36
+ CONSECUTIVE_FAILURE_THRESHOLD = 3
@@ -0,0 +1,104 @@
1
+ """
2
+ dashboard.py — Terminal ASCII dashboard.
3
+ Gösterir:
4
+ - Son 7 günün failure rate grafiği (ASCII bar chart)
5
+ - Adım bazında hata dağılımı
6
+ - Aktif anomaliler
7
+ - Son 5 başarısız run ve kök nedenleri
8
+ - Trend durumu (IMPROVING / STABLE / DEGRADING)
9
+ """
10
+ import os
11
+ import sys
12
+
13
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
14
+ from failure_forensics.pattern import daily_failure_rates, detect_anomaly, step_breakdown_report
15
+ from failure_forensics.baseline import get_moving_average, sync_from_pattern
16
+ from failure_forensics.forensics import get_failed_runs
17
+
18
+ BAR_WIDTH = 30
19
+ TREND_ICONS = {
20
+ "IMPROVING": "📈 IMPROVING ↓ (failure azalıyor)",
21
+ "STABLE": "➡️ STABLE — (kararlı)",
22
+ "DEGRADING": "📉 DEGRADING ↑ (failure artıyor)",
23
+ }
24
+
25
+
26
+ def _bar(rate: float, width: int = BAR_WIDTH) -> str:
27
+ """0.0-1.0 arası failure rate'i ASCII bar'a dönüştürür."""
28
+ filled = int(rate * width)
29
+ empty = width - filled
30
+ color = "█" * filled + "░" * empty
31
+ return f"[{color}] {rate:.1%}"
32
+
33
+
34
+ def render(clear: bool = False):
35
+ """Terminal dashboard'ı render eder."""
36
+ if clear:
37
+ os.system("cls" if os.name == "nt" else "clear")
38
+
39
+ daily = daily_failure_rates(7)
40
+ sync_from_pattern(daily)
41
+ anomaly = detect_anomaly(daily)
42
+ step_report = step_breakdown_report(daily)
43
+ baseline = get_moving_average(7)
44
+ failed_runs = get_failed_runs(5)
45
+
46
+ W = 65
47
+ print("\n" + "═" * W)
48
+ print(" 🔬 FAILURE FORENSICS — Terminal Dashboard")
49
+ print("═" * W)
50
+
51
+ # ── 1. 7 Günlük Failure Rate Grafiği ─────────────────────────
52
+ print("\n 📅 SON 7 GÜNÜN FAILURE RATE GRAFİĞİ")
53
+ print(" " + "─" * (W - 2))
54
+ if not daily:
55
+ print(" (henüz veri yok)")
56
+ else:
57
+ for date, data in sorted(daily.items()):
58
+ rate = data["failure_rate"]
59
+ bar = _bar(rate)
60
+ marker = " ⚠️ " if rate > 0.25 else " "
61
+ print(f" {date} {bar}{marker}")
62
+
63
+ # ── 2. Adım Bazında Hata Dağılımı ────────────────────────────
64
+ print(f"\n 🔍 ADIM BAZINDA HATA DAĞILIMI (son 7 gün)")
65
+ print(" " + "─" * (W - 2))
66
+ if not step_report:
67
+ print(" (veri yok)")
68
+ else:
69
+ for step, sd in sorted(step_report.items(), key=lambda x: -x[1]["rate"]):
70
+ rate = sd["rate"]
71
+ bar = _bar(rate, width=20)
72
+ print(f" {step:<12} {bar} ({sd['failed']}/{sd['total']} hatalı)")
73
+
74
+ # ── 3. Anomali Durumu ─────────────────────────────────────────
75
+ print(f"\n ⚡ ANOMALİ DURUMU")
76
+ print(" " + "─" * (W - 2))
77
+ print(f" {anomaly['message']}")
78
+ if anomaly["anomaly"]:
79
+ print(f" → Bugün: {anomaly['today_rate']:.1%} | 7g ort: {anomaly['avg_7d']:.1%} | Delta: +{anomaly['delta']:.1%}")
80
+
81
+ # ── 4. Trend ──────────────────────────────────────────────────
82
+ print(f"\n 📊 TREND & BASELINE (7 günlük hareketli ort.)")
83
+ print(" " + "─" * (W - 2))
84
+ trend = baseline.get("trend", "STABLE")
85
+ avg = baseline.get("moving_avg", 0.0)
86
+ delta = baseline.get("trend_delta", 0.0)
87
+ print(f" {TREND_ICONS.get(trend, trend)}")
88
+ print(f" Hareketli Ort: {avg:.1%} | Trend Delta: {delta:+.1%}")
89
+
90
+ # ── 5. Son 5 Başarısız Run ────────────────────────────────────
91
+ print(f"\n ❌ SON 5 BAŞARISIZ RUN & KÖK NEDENLER")
92
+ print(" " + "─" * (W - 2))
93
+ if not failed_runs:
94
+ print(" Başarısız run bulunamadı. 🎉")
95
+ else:
96
+ for i, run in enumerate(failed_runs[:5], 1):
97
+ rid = run["run_id"]
98
+ cat = run["category"]
99
+ steps = ", ".join(run["failed_steps"]) or "-"
100
+ print(f" {i}. run_id={rid} [{cat}]")
101
+ print(f" Başarısız adımlar: {steps}")
102
+ print(f" → {run['description'][:80]}")
103
+
104
+ print("\n" + "═" * W + "\n")
@@ -0,0 +1,113 @@
1
+ """
2
+ Katman 3 — eval_collector.py
3
+ Başarısız sorgulardan otomatik eval seti büyütme.
4
+ """
5
+ import os
6
+ import sys
7
+ import json
8
+ from datetime import datetime, timezone, timedelta
9
+ from collections import defaultdict
10
+
11
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
12
+ from failure_forensics.logger import read_logs
13
+ from failure_forensics.forensics import ERROR_CATEGORY_MAP, STEP_CATEGORY_MAP
14
+
15
+ EVAL_CANDIDATES_FILE = "data/eval_candidates/candidates.jsonl"
16
+
17
+ def _ensure_dir():
18
+ os.makedirs(os.path.dirname(EVAL_CANDIDATES_FILE), exist_ok=True)
19
+
20
+ def collect_candidates():
21
+ """Başarısız sorguları frekanslarına göre toplar ve candidates.jsonl dosyasına yazar."""
22
+ logs = read_logs()
23
+
24
+ # query -> { failure_count, root_cause_category, first_seen, last_seen }
25
+ candidates = {}
26
+
27
+ # run_id -> category mapping
28
+ # Hızlıca kategori bulmak için
29
+ run_categories = {}
30
+
31
+ # Önce tüm başarısız run'ların kategorilerini bul
32
+ runs = defaultdict(list)
33
+ for record in logs:
34
+ runs[record.get("run_id", "unknown")].append(record)
35
+
36
+ for rid, steps in runs.items():
37
+ failed_steps = [s for s in steps if not s.get("success", True)]
38
+ if failed_steps:
39
+ first_failure = failed_steps[0]
40
+ step_name = first_failure.get("step_name", "")
41
+ error_type = (first_failure.get("error_type") or "").lower()
42
+
43
+ category = "UNKNOWN"
44
+ for key, cat in ERROR_CATEGORY_MAP.items():
45
+ if key in error_type:
46
+ category = cat
47
+ break
48
+ if category == "UNKNOWN":
49
+ category = STEP_CATEGORY_MAP.get(step_name, "UNKNOWN")
50
+
51
+ run_categories[rid] = category
52
+
53
+ # Input summary'den query al
54
+ query = first_failure.get("input_summary", "").strip()
55
+ if not query or query.startswith("Simulated"): # Simulate'deki fake query'leri atla
56
+ continue
57
+
58
+ ts = first_failure.get("timestamp", "")
59
+
60
+ # Query deduplication
61
+ if query not in candidates:
62
+ candidates[query] = {
63
+ "query": query,
64
+ "failure_count": 1,
65
+ "root_cause_category": category,
66
+ "first_seen": ts,
67
+ "last_seen": ts,
68
+ "auto_add_eligible": False
69
+ }
70
+ else:
71
+ candidates[query]["failure_count"] += 1
72
+ candidates[query]["last_seen"] = ts
73
+ if candidates[query]["failure_count"] >= 3:
74
+ candidates[query]["auto_add_eligible"] = True
75
+
76
+ # Dosyaya yaz
77
+ _ensure_dir()
78
+ with open(EVAL_CANDIDATES_FILE, "w", encoding="utf-8") as f:
79
+ for query, data in candidates.items():
80
+ if data["failure_count"] >= 2: # En az 2 kez başarısız olanları kaydet
81
+ f.write(json.dumps(data, ensure_ascii=False) + "\n")
82
+
83
+ def weekly_summary() -> dict:
84
+ """Haftalık eval adayı özeti döner."""
85
+ collect_candidates() # Verileri güncelle
86
+
87
+ if not os.path.exists(EVAL_CANDIDATES_FILE):
88
+ return {"total_candidates": 0, "eligible_count": 0, "message": "Aday bulunamadı."}
89
+
90
+ candidates = []
91
+ with open(EVAL_CANDIDATES_FILE, "r", encoding="utf-8") as f:
92
+ for line in f:
93
+ if line.strip():
94
+ candidates.append(json.loads(line))
95
+
96
+ one_week_ago = datetime.now(timezone.utc) - timedelta(days=7)
97
+
98
+ recent_candidates = []
99
+ for c in candidates:
100
+ try:
101
+ ts = datetime.fromisoformat(c["last_seen"].replace("Z", "+00:00"))
102
+ if ts > one_week_ago:
103
+ recent_candidates.append(c)
104
+ except Exception:
105
+ pass
106
+
107
+ eligible_count = sum(1 for c in recent_candidates if c.get("auto_add_eligible"))
108
+
109
+ return {
110
+ "total_candidates": len(recent_candidates),
111
+ "eligible_count": eligible_count,
112
+ "message": f"Bu hafta {len(recent_candidates)} yeni eval adayı birikirdi, onaylamak ister misiniz? ({eligible_count} tanesi >=3 kez başarısız)"
113
+ }