File size: 11,403 Bytes
52b0da0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
"""
benchmark/continuous_benchmark.py — VORTEX GOD v1.2
Benchmark continu avec détection de régression.

Tourne en thread daemon et :
  - Évalue le système toutes les N heures sur un suite de tâches fixes
  - Détecte les régressions vs le meilleur score historique
  - Enregistre les résultats dans benchmark_history.jsonl
  - Expose une API simple pour l'onglet Gradio
"""

from __future__ import annotations

import asyncio
import json
import logging
import os
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional

log = logging.getLogger("vortex.benchmark")

DATA_DIR       = Path(os.environ.get("DATA_DIR",   "/app/data"))
BENCH_LOG      = DATA_DIR / "benchmark_history.jsonl"
BENCH_INTERVAL = float(os.environ.get("BENCH_INTERVAL_HOURS", "6.0"))
REGRESSION_THR = float(os.environ.get("REGRESSION_THRESHOLD", "0.05"))   # -5% = régression


@dataclass
class BenchmarkRun:
    run_id:     str
    ts:         float = field(default_factory=time.time)
    scores:     Dict[str, float] = field(default_factory=dict)
    global_score: float = 0.0
    duration_s: float = 0.0
    regression: bool  = False
    regression_details: List[str] = field(default_factory=list)

    def to_dict(self) -> Dict:
        return {
            "run_id":      self.run_id,
            "ts":          self.ts,
            "scores":      self.scores,
            "global_score": round(self.global_score, 4),
            "duration_s":  round(self.duration_s, 1),
            "regression":  self.regression,
            "regression_details": self.regression_details,
        }


# ─────────────────────────────────────────────
# Suite de tâches benchmark (déterministes)
# ─────────────────────────────────────────────

BENCHMARK_SUITE = {
    "engineer": [
        {
            "user": "Écris une fonction Python `fibonacci(n)` qui retourne le n-ième nombre de Fibonacci.",
            "checks": [
                lambda r: "def fibonacci" in r,
                lambda r: "return" in r,
                lambda r: any(w in r for w in ["n-1", "n - 1", "n-2", "n - 2", "memo", "cache"]),
            ],
        },
        {
            "user": "Écris une fonction `is_palindrome(s)` qui vérifie si une chaîne est un palindrome.",
            "checks": [
                lambda r: "def is_palindrome" in r,
                lambda r: "return" in r,
                lambda r: any(w in r for w in ["reverse", "[::-1]", "lower", "==", "!="]),
            ],
        },
        {
            "user": "Écris un générateur Python qui yield les nombres premiers jusqu'à N.",
            "checks": [
                lambda r: "def " in r,
                lambda r: "yield" in r,
                lambda r: any(w in r for w in ["prime", "premier", "divisible", "sqrt", "%"]),
            ],
        },
    ],
    "planner": [
        {
            "user": "Planifie le déploiement d'une application Flask sur un serveur Ubuntu.",
            "checks": [
                lambda r: any(w in r.lower() for w in ["étape", "step", "install", "nginx", "gunicorn", "systemd"]),
                lambda r: len(r) > 100,
            ],
        },
        {
            "user": "Planifie la mise en place d'un pipeline de données ETL.",
            "checks": [
                lambda r: any(w in r.lower() for w in ["extract", "transform", "load", "source", "étape"]),
                lambda r: len(r) > 80,
            ],
        },
    ],
    "critic": [
        {
            "user": "Audite : import pickle; data = pickle.loads(user_input)",
            "checks": [
                lambda r: any(w in r.lower() for w in ["pickle", "dangereux", "dangerous", "injection", "unsafe"]),
            ],
        },
        {
            "user": "Audite : cursor.execute('DELETE FROM users WHERE id=' + user_id)",
            "checks": [
                lambda r: any(w in r.lower() for w in ["injection", "sql", "paramètre", "parameter", "dangereux"]),
            ],
        },
    ],
    "researcher": [
        {
            "user": "Qu'est-ce que le RAG (Retrieval-Augmented Generation) ?",
            "checks": [
                lambda r: any(w in r.lower() for w in ["retrieval", "récupération", "vectoriel", "contexte", "llm"]),
                lambda r: len(r) > 100,
            ],
        },
    ],
    "optimizer": [
        {
            "user": "Optimise : for i in range(len(lst)): if lst[i] in target: result.append(lst[i])",
            "checks": [
                lambda r: any(w in r.lower() for w in ["set", "intersection", "comprehension", "filter", "o(1)"]),
            ],
        },
    ],
}

AGENT_SYSTEMS = {
    "engineer":   "Tu es un ingénieur Python. Génère uniquement du code Python valide.",
    "planner":    "Tu es un planificateur. Décris les étapes principales en 5 lignes max.",
    "critic":     "Tu es un expert sécurité. Identifie les problèmes critiques.",
    "researcher": "Tu es un chercheur. Explique en 3-5 phrases.",
    "optimizer":  "Tu es un optimiseur Python. Propose une version améliorée.",
}


class ContinuousBenchmark:
    """
    Benchmark continu qui évalue le système en appelant directement le LLM engine.
    Ne dépend pas des agents spécialisés (pour isoler les régressions du LLM seul).
    """

    def __init__(self, llm_engine):
        self._engine   = llm_engine
        self.history:  List[BenchmarkRun] = self._load_history()
        self._best:    float = max((r.global_score for r in self.history), default=0.0)
        self._running: bool  = False

    def _load_history(self) -> List[BenchmarkRun]:
        runs = []
        if BENCH_LOG.exists():
            with open(BENCH_LOG) as f:
                for line in f:
                    try:
                        d = json.loads(line)
                        runs.append(BenchmarkRun(
                            run_id       = d["run_id"],
                            ts           = d["ts"],
                            scores       = d["scores"],
                            global_score = d["global_score"],
                            duration_s   = d["duration_s"],
                            regression   = d.get("regression", False),
                        ))
                    except Exception:
                        pass
        return runs

    # ── Évaluation ───────────────────────────────────────────────────────────

    async def _eval_task(self, task_type: str, task: Dict) -> float:
        """Évalue une tâche individuelle. Retourne 0.0–1.0."""
        system = AGENT_SYSTEMS.get(task_type, "Tu es un assistant.")
        try:
            resp = await self._engine.call(
                agent       = None,
                system      = system,
                user        = task["user"],
                max_tokens  = 400,
                temperature = 0.1,
                use_cache   = False,
            )
            content = resp.content
            checks  = task.get("checks", [])
            if not checks:
                return 0.5
            passed = sum(1 for c in checks if c(content))
            return round(passed / len(checks), 3)
        except Exception as exc:
            log.debug(f"[Bench] Tâche {task_type} échouée : {exc}")
            return 0.0

    async def run_once(self) -> BenchmarkRun:
        """Lance un passage complet du benchmark. Retourne le BenchmarkRun."""
        import uuid
        run     = BenchmarkRun(run_id=str(uuid.uuid4())[:8])
        t0      = time.time()
        scores  = {}
        regs    = []

        for task_type, tasks in BENCHMARK_SUITE.items():
            task_scores = await asyncio.gather(
                *[self._eval_task(task_type, t) for t in tasks],
                return_exceptions=True,
            )
            valid  = [s for s in task_scores if isinstance(s, float)]
            avg    = round(sum(valid) / len(valid), 4) if valid else 0.0
            scores[task_type] = avg

            # Détecter régression par rapport au meilleur historique
            best_for_type = max(
                (r.scores.get(task_type, 0) for r in self.history),
                default=0.0,
            )
            if best_for_type > 0.1 and avg < best_for_type - REGRESSION_THR:
                regs.append(f"{task_type}: {avg:.3f} vs best {best_for_type:.3f} (Δ={avg-best_for_type:+.3f})")

        run.scores      = scores
        run.global_score = round(sum(scores.values()) / len(scores), 4) if scores else 0.0
        run.duration_s  = time.time() - t0
        run.regression  = bool(regs)
        run.regression_details = regs

        if run.regression:
            log.warning(f"[Bench] ⚠️ RÉGRESSION détectée : {regs}")
        else:
            log.info(f"[Bench] Score global : {run.global_score:.4f} (best={self._best:.4f})")

        if run.global_score > self._best:
            self._best = run.global_score

        self.history.append(run)
        self._save_run(run)
        return run

    def _save_run(self, run: BenchmarkRun):
        DATA_DIR.mkdir(parents=True, exist_ok=True)
        with open(BENCH_LOG, "a") as f:
            f.write(json.dumps(run.to_dict(), ensure_ascii=False) + "\n")

    # ── Boucle daemon ────────────────────────────────────────────────────────

    async def run_forever(self, interval_hours: float = BENCH_INTERVAL):
        self._running = True
        log.info(f"[Bench] Benchmark continu démarré (intervalle={interval_hours}h)")
        # Premier run immédiat au démarrage
        await asyncio.sleep(60)
        while self._running:
            try:
                await self.run_once()
            except Exception as exc:
                log.error(f"[Bench] Erreur : {exc}")
            await asyncio.sleep(interval_hours * 3600)

    def stop(self):
        self._running = False

    # ── API pour Gradio ───────────────────────────────────────────────────────

    def get_dashboard(self) -> Dict[str, Any]:
        """Données pour l'onglet Gradio."""
        if not self.history:
            return {"status": "Aucun benchmark exécuté", "history": []}

        last = self.history[-1]
        trend = []
        for r in self.history[-20:]:
            trend.append({"ts": r.ts, "score": r.global_score, "regression": r.regression})

        return {
            "last_run": {
                "run_id":      last.run_id,
                "global_score": last.global_score,
                "scores":      last.scores,
                "duration_s":  last.duration_s,
                "regression":  last.regression,
                "regression_details": last.regression_details,
            },
            "best_score": self._best,
            "total_runs": len(self.history),
            "regressions": sum(1 for r in self.history if r.regression),
            "trend":       trend,
        }