From bbfd2dc45f8714370dabf53c8201beaf75d02d0b Mon Sep 17 00:00:00 2001 From: Corback Date: Tue, 16 Jun 2026 08:53:02 +0000 Subject: [PATCH] feat: PipBoy rename, /lore tab fix, run.py status check loop, crash test script --- src/engine/run.py | 19 ++- tools/llm_crash_test.py | 326 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 344 insertions(+), 1 deletion(-) create mode 100644 tools/llm_crash_test.py diff --git a/src/engine/run.py b/src/engine/run.py index 947bff2..ac41560 100644 --- a/src/engine/run.py +++ b/src/engine/run.py @@ -67,7 +67,10 @@ def run(config_path: str | None = None): # Initialiser le world_state si vide _init_world_state(SESSION_ID, day) + STATUS_CHECK_INTERVAL = 5 # vérifier le statut DB toutes les N ticks + try: + tick_count = 0 while True: tick_engine.process_tick(SESSION_ID, day, current_tick, effective_mode) db.update_session_tick(SESSION_ID, day, current_tick) @@ -75,9 +78,23 @@ def run(config_path: str | None = None): if tick_sleep > 0: time.sleep(tick_sleep) + # Vérification statut + reset DB (permet stop/pause/reset depuis le dashboard) + tick_count += 1 + if tick_count % STATUS_CHECK_INTERVAL == 0: + live = db.get_session(SESSION_ID) + if live and live["status"] != "active": + print(f"\n[DASH] Statut changé → '{live['status']}' — arrêt propre.") + print(f" Dernier état : Jour {day}, Tick {current_tick}h") + return + # Détecter reset jour/tick : si la DB est en arrière de >1 jour, c'est un reset intentionnel + if live and live["current_day"] < day - 1: + print(f"\n[DASH] Reset détecté (DB: J{live['current_day']} vs sim: J{day}) — relecture position.") + day = live["current_day"] + current_tick = live["current_tick"] + day, current_tick = tick_engine.next_tick(day, current_tick) - # Début d'un nouveau jour : hot-reload config depuis le JSON (permet les changements dashboard) + # Début d'un nouveau jour : hot-reload config depuis le JSON if current_tick == 0: cfg.load(config_path, session_id=SESSION_ID) effective_mode = cfg.mode() or db_mode diff --git a/tools/llm_crash_test.py b/tools/llm_crash_test.py new file mode 100644 index 0000000..d70768b --- /dev/null +++ b/tools/llm_crash_test.py @@ -0,0 +1,326 @@ +""" +LLM Crash Test — Fallout: Venice of Wasteland +Test de performance des deux LLM (MJ 14b + PNJ 7b) en conditions réelles. + +Modes: + --mode simultane : les deux LLM tournent en parallèle (threads) + --mode decale : push progressif PNJ → MJ (pipeline producteur/consommateur) + --mode solo-mj : seulement le 14b + --mode solo-pnj : seulement le 7b + +Usage: + python llm_crash_test.py --mode simultane --rounds 5 + python llm_crash_test.py --mode decale --rounds 10 --pnj-count 3 +""" + +import os, sys, time, json, argparse, threading, queue +import urllib.request +from datetime import datetime + +OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434") +MODEL_MJ = os.getenv("MODEL_MJ", "qwen2.5:14b") +MODEL_PNJ = os.getenv("MODEL_PNJ", "qwen2.5:7b") + +# --------------------------------------------------------------------------- +# Prompts de test réalistes (contexte Fallout Venice) +# --------------------------------------------------------------------------- + +PNJ_PROMPTS = [ + "Tu es Remy Tureaud, marchand créole de Pearl River. Réponds en 2 phrases à : 'T'as des piles ?'", + "Tu es Sœur Eulalie, soigneuse de L'Union. Réponds en 2 phrases à : 'J'ai une blessure par balle.'", + "Tu es Jacques-Henri, garde de la Régie. Réponds en 2 phrases à : 'Laisse-moi passer le checkpoint.'", + "Tu es Mama Voodoo, goule du Grand Krewe. Réponds en 2 phrases à : 'C'est quoi ce tatouage ?'", + "Tu es un Écumeur anonyme. Réponds en 2 phrases à : 'On veut juste passer.'", +] + +MJ_PROMPTS = [ + "Résume en 3 phrases la situation politique entre L'Union et la CdA à New Orleans post-apo.", + "Décris en 3 phrases une rencontre de nuit dans les bayous de Pearl River.", + "En 3 phrases, quelles sont les conséquences d'un blocus sur la route de Baton Rouge ?", + "Décris en 3 phrases l'ambiance d'un marché noir dans le Vieux Carré de NOLA.", + "En 3 phrases, comment réagit le Grand Krewe si des étrangers fouillent leurs ruines ?", +] + +# --------------------------------------------------------------------------- +# LLM call +# --------------------------------------------------------------------------- + +def call_llm(model: str, prompt: str, timeout: int = 60) -> dict: + """Appel Ollama, retourne {tokens, elapsed, error, text}.""" + t0 = time.time() + body = json.dumps({ + "model": model, + "prompt": prompt, + "stream": False, + "options": {"num_predict": 150, "temperature": 0.7}, + }).encode() + req = urllib.request.Request( + f"{OLLAMA_URL}/api/generate", data=body, method="POST", + headers={"Content-Type": "application/json"} + ) + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + data = json.loads(r.read()) + elapsed = time.time() - t0 + tokens = data.get("eval_count", 0) + return { + "model": model, + "tokens": tokens, + "elapsed": round(elapsed, 2), + "tok_s": round(tokens / elapsed, 1) if elapsed > 0 else 0, + "error": None, + "text": data.get("response", "")[:120], + } + except Exception as e: + return { + "model": model, + "tokens": 0, + "elapsed": round(time.time() - t0, 2), + "tok_s": 0, + "error": str(e)[:80], + "text": "", + } + +# --------------------------------------------------------------------------- +# Modes +# --------------------------------------------------------------------------- + +def run_solo(model: str, prompts: list[str], rounds: int) -> list[dict]: + results = [] + for i in range(rounds): + prompt = prompts[i % len(prompts)] + print(f" [{model}] Round {i+1}/{rounds}...", end="", flush=True) + r = call_llm(model, prompt) + print(f" {r['tok_s']} tok/s | {r['elapsed']}s | {'ERR: '+r['error'] if r['error'] else 'OK'}") + results.append(r) + return results + + +def run_simultane(rounds: int, pnj_count: int = 2) -> dict: + """Les deux LLM tournent en parallèle via threads.""" + print(f"\n[SIMULTANE] {MODEL_MJ} + {MODEL_PNJ} x{pnj_count} PNJ — {rounds} rounds\n") + all_results = {"mj": [], "pnj": []} + lock = threading.Lock() + + def mj_worker(round_idx: int): + prompt = MJ_PROMPTS[round_idx % len(MJ_PROMPTS)] + r = call_llm(MODEL_MJ, prompt) + with lock: + all_results["mj"].append(r) + status = f"ERR: {r['error']}" if r['error'] else f"{r['tok_s']} tok/s" + print(f" [MJ 14b] R{round_idx+1} → {status} ({r['elapsed']}s)") + + def pnj_worker(round_idx: int, pnj_idx: int): + prompt = PNJ_PROMPTS[(round_idx * pnj_count + pnj_idx) % len(PNJ_PROMPTS)] + r = call_llm(MODEL_PNJ, prompt) + with lock: + all_results["pnj"].append(r) + status = f"ERR: {r['error']}" if r['error'] else f"{r['tok_s']} tok/s" + print(f" [PNJ 7b] R{round_idx+1} PNJ{pnj_idx+1} → {status} ({r['elapsed']}s)") + + t_start = time.time() + for i in range(rounds): + threads = [] + t = threading.Thread(target=mj_worker, args=(i,)) + threads.append(t) + for j in range(pnj_count): + t = threading.Thread(target=pnj_worker, args=(i, j)) + threads.append(t) + for t in threads: + t.start() + for t in threads: + t.join() + print() + + total = time.time() - t_start + return {**all_results, "total_elapsed": round(total, 1)} + + +def run_decale(rounds: int, pnj_count: int = 3) -> dict: + """Pipeline producteur/consommateur : les PNJ accumulent des infos → poussé vers le MJ.""" + print(f"\n[DÉCALÉ] {pnj_count} PNJ 7b → buffer → MJ 14b — {rounds} rounds\n") + pnj_queue: queue.Queue = queue.Queue() + all_results = {"mj": [], "pnj": [], "latency_pnj_to_mj": []} + stop_flag = threading.Event() + + def pnj_producer(): + idx = 0 + while not stop_flag.is_set(): + prompt = PNJ_PROMPTS[idx % len(PNJ_PROMPTS)] + r = call_llm(MODEL_PNJ, prompt) + r["produced_at"] = time.time() + pnj_queue.put(r) + with threading.Lock(): + status = f"ERR: {r['error']}" if r['error'] else f"{r['tok_s']} tok/s" + print(f" [PNJ 7b ] prod #{idx+1} → {status} ({r['elapsed']}s) | queue={pnj_queue.qsize()}") + idx += 1 + + def mj_consumer(): + consumed = 0 + while consumed < rounds: + # Attendre qu'il y ait au moins 1 item dans la queue (ou timeout) + try: + pnj_result = pnj_queue.get(timeout=90) + except queue.Empty: + print(" [MJ 14b] Timeout attente PNJ") + break + + latency = time.time() - pnj_result["produced_at"] + # Construire le prompt MJ à partir du résultat PNJ + pnj_text = pnj_result.get("text", "(vide)") + mj_prompt = ( + f"Un PNJ vient de parler : \"{pnj_text}\"\n" + f"En tant que MJ, en 2 phrases, quelle est la conséquence narrative ?" + ) + r = call_llm(MODEL_MJ, mj_prompt) + r["latency_from_pnj"] = round(latency, 2) + all_results["mj"].append(r) + all_results["latency_pnj_to_mj"].append(round(latency + r["elapsed"], 2)) + status = f"ERR: {r['error']}" if r['error'] else f"{r['tok_s']} tok/s" + print(f" [MJ 14b ] cons #{consumed+1} → {status} ({r['elapsed']}s) | pipeline={round(latency+r['elapsed'],1)}s") + consumed += 1 + pnj_queue.task_done() + print() + + # Lancer N threads PNJ + 1 thread MJ + pnj_threads = [threading.Thread(target=pnj_producer, daemon=True) for _ in range(pnj_count)] + mj_thread = threading.Thread(target=mj_consumer) + + t_start = time.time() + for t in pnj_threads: + t.start() + mj_thread.start() + mj_thread.join() + stop_flag.set() + + # Récupérer tous les résultats PNJ dans la queue restante + while not pnj_queue.empty(): + all_results["pnj"].append(pnj_queue.get()) + + total = time.time() - t_start + return {**all_results, "total_elapsed": round(total, 1)} + +# --------------------------------------------------------------------------- +# Rapport +# --------------------------------------------------------------------------- + +def print_report(results: dict, mode: str): + def stats(lst: list[dict]) -> dict: + if not lst: + return {} + ok = [r for r in lst if not r.get("error")] + err = len(lst) - len(ok) + if not ok: + return {"calls": len(lst), "errors": err} + tok_s = [r["tok_s"] for r in ok] + elaps = [r["elapsed"] for r in ok] + return { + "calls": len(lst), + "errors": err, + "avg_tok_s": round(sum(tok_s) / len(tok_s), 1), + "min_tok_s": min(tok_s), + "max_tok_s": max(tok_s), + "avg_elapsed": round(sum(elaps) / len(elaps), 1), + "total_tokens": sum(r["tokens"] for r in ok), + } + + sep = "=" * 60 + print(f"\n{sep}") + print(f" RAPPORT — MODE {mode.upper()}") + print(f" {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print(sep) + + if "mj" in results and results["mj"]: + s = stats(results["mj"]) + print(f"\n MJ 14b ({MODEL_MJ})") + for k, v in s.items(): + print(f" {k:20s} : {v}") + + if "pnj" in results and results["pnj"]: + s = stats(results["pnj"]) + print(f"\n PNJ 7b ({MODEL_PNJ})") + for k, v in s.items(): + print(f" {k:20s} : {v}") + + if "latency_pnj_to_mj" in results and results["latency_pnj_to_mj"]: + lat = results["latency_pnj_to_mj"] + print(f"\n Pipeline PNJ→MJ (latence totale)") + print(f" avg_pipeline_s : {round(sum(lat)/len(lat), 1)}") + print(f" min_pipeline_s : {min(lat)}") + print(f" max_pipeline_s : {max(lat)}") + + print(f"\n Durée totale : {results.get('total_elapsed', '?')}s") + + # Score de viabilité + mj_ok = not any(r.get("error") for r in results.get("mj", [])) + pnj_ok = not any(r.get("error") for r in results.get("pnj", [])) + mj_speed = stats(results.get("mj", [])).get("avg_tok_s", 0) + pnj_speed = stats(results.get("pnj", [])).get("avg_tok_s", 0) + + print(f"\n DIAGNOSTIC") + print(f" MJ stable : {'✓' if mj_ok else '✗ ERREURS'}") + print(f" PNJ stable : {'✓' if pnj_ok else '✗ ERREURS'}") + if mj_speed: + viable = "✓ VIABLE" if mj_speed > 8 else "⚠ LENT (< 8 tok/s)" if mj_speed > 3 else "✗ TROP LENT" + print(f" MJ débit : {mj_speed} tok/s → {viable}") + if pnj_speed: + viable = "✓ VIABLE" if pnj_speed > 15 else "⚠ LENT" if pnj_speed > 5 else "✗ TROP LENT" + print(f" PNJ débit : {pnj_speed} tok/s → {viable}") + print(sep) + + # Export JSON + report_path = f"/tmp/crash_test_{mode}_{int(time.time())}.json" + try: + with open(report_path, "w") as f: + json.dump({"mode": mode, "results": results, + "stats": {"mj": stats(results.get("mj",[])), + "pnj": stats(results.get("pnj",[]))}}, f, indent=2) + print(f"\n Rapport JSON : {report_path}") + except Exception: + pass + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def main(): + parser = argparse.ArgumentParser(description="LLM Crash Test — Fallout Venice") + parser.add_argument("--mode", choices=["simultane","decale","solo-mj","solo-pnj"], + default="simultane") + parser.add_argument("--rounds", type=int, default=5, help="Nombre de rounds MJ") + parser.add_argument("--pnj-count", type=int, default=2, help="PNJ parallèles (modes simultane/decale)") + args = parser.parse_args() + + print(f"=== LLM CRASH TEST — {args.mode.upper()} ===") + print(f"Ollama : {OLLAMA_URL}") + print(f"MJ : {MODEL_MJ}") + print(f"PNJ : {MODEL_PNJ}") + print(f"Rounds : {args.rounds} | PNJ parallèles : {args.pnj_count}") + + # Vérifier que Ollama répond + try: + with urllib.request.urlopen(f"{OLLAMA_URL}/api/tags", timeout=5) as r: + models = [m["name"] for m in json.loads(r.read()).get("models", [])] + print(f"Modèles disponibles : {', '.join(models[:5])}") + except Exception as e: + print(f"[ERR] Ollama inaccessible : {e}") + sys.exit(1) + + t_global = time.time() + + if args.mode == "simultane": + results = run_simultane(args.rounds, args.pnj_count) + elif args.mode == "decale": + results = run_decale(args.rounds, args.pnj_count) + elif args.mode == "solo-mj": + r = run_solo(MODEL_MJ, MJ_PROMPTS, args.rounds) + results = {"mj": r, "total_elapsed": round(time.time() - t_global, 1)} + elif args.mode == "solo-pnj": + r = run_solo(MODEL_PNJ, PNJ_PROMPTS, args.rounds) + results = {"pnj": r, "total_elapsed": round(time.time() - t_global, 1)} + + print_report(results, args.mode) + + +if __name__ == "__main__": + main()