From ea8e24e2386b0c8814d11c5e45693e74105a21d4 Mon Sep 17 00:00:00 2001 From: Corback Date: Wed, 17 Jun 2026 14:10:01 +0000 Subject: [PATCH] refacto: dashboard Flask Blueprints + templates Jinja2, engine run/lore_enricher mis a jour --- .gitignore | 3 + dashboard/__init__.py | 0 dashboard/app.py | 1851 +----------------------- dashboard/chroma.py | 97 ++ dashboard/config.py | 26 + dashboard/db.py | 45 + dashboard/process_mgr.py | 152 ++ dashboard/routes/__init__.py | 0 dashboard/routes/lore.py | 95 ++ dashboard/routes/params.py | 83 ++ dashboard/routes/sim.py | 261 ++++ dashboard/routes/sim_proposals.py | 138 ++ dashboard/routes/tools.py | 150 ++ dashboard/start.sh | 3 +- dashboard/templates/base.html | 218 +++ dashboard/templates/lore.html | 141 ++ dashboard/templates/params.html | 173 +++ dashboard/templates/sim.html | 402 +++++ dashboard/templates/sim_proposals.html | 121 ++ dashboard/templates/tools.html | 329 +++++ docker-compose.yml | 10 +- requirements.txt | 1 + src/engine/lore_enricher.py | 6 +- src/engine/run.py | 2 +- 24 files changed, 2457 insertions(+), 1850 deletions(-) create mode 100644 dashboard/__init__.py create mode 100644 dashboard/chroma.py create mode 100644 dashboard/config.py create mode 100644 dashboard/db.py create mode 100644 dashboard/process_mgr.py create mode 100644 dashboard/routes/__init__.py create mode 100644 dashboard/routes/lore.py create mode 100644 dashboard/routes/params.py create mode 100644 dashboard/routes/sim.py create mode 100644 dashboard/routes/sim_proposals.py create mode 100644 dashboard/routes/tools.py create mode 100644 dashboard/templates/base.html create mode 100644 dashboard/templates/lore.html create mode 100644 dashboard/templates/params.html create mode 100644 dashboard/templates/sim.html create mode 100644 dashboard/templates/sim_proposals.html create mode 100644 dashboard/templates/tools.html create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore index 4601cd9..ed0ce6a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ __pycache__/ Thumbs.db INFRA_PRIVATE.md .env +*.bak +*.bak_* +docker-compose.yml.bak_pre_refacto diff --git a/dashboard/__init__.py b/dashboard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dashboard/app.py b/dashboard/app.py index 3dca479..0a5f66a 100644 --- a/dashboard/app.py +++ b/dashboard/app.py @@ -1,1852 +1,23 @@ """ PipBoy — Fallout: Venice of Wasteland Dashboard unifié : Simulation | Outils | Lore enrichment | Paramètres -(anciennement VAULTCOM) """ +from flask import Flask -import os, json, subprocess, threading, time -import psycopg2 -import psycopg2.extras -from flask import Flask, render_template_string, jsonify, request, redirect, url_for +from .routes import sim, tools, lore, sim_proposals, params -app = Flask(__name__) -# --------------------------------------------------------------------------- -# Stress test — état global (un seul test à la fois) -# --------------------------------------------------------------------------- -_stress_state = { - "running": False, - "started_at": None, - "mode": None, - "rounds": None, - "pnj_count": None, - "output": [], # lignes de stdout - "returncode": None, - "error": None, -} -_stress_lock = threading.Lock() +def create_app() -> Flask: + app = Flask(__name__, template_folder="templates") -def _run_stress_bg(mode: str, rounds: int, pnj_count: int): - global _stress_state - ollama_url = os.getenv("OLLAMA_URL", "http://host.docker.internal:11434") - model_mj = os.getenv("MODEL_MJ", "qwen2.5:14b") - model_pnj = os.getenv("MODEL_PNJ", "qwen2.5:7b") - results_path = os.path.join(SIM_CONFIGS_DIR, "crash_results.json") + app.register_blueprint(sim.bp) + app.register_blueprint(tools.bp) + app.register_blueprint(lore.bp) + app.register_blueprint(sim_proposals.bp) + app.register_blueprint(params.bp) - cmd = [ - "python3", "/app/tools/llm_crash_test.py", - "--mode", mode, - "--rounds", str(rounds), - "--pnj-count", str(pnj_count), - ] - env = {**os.environ, - "OLLAMA_URL": ollama_url, - "MODEL_MJ": model_mj, - "MODEL_PNJ": model_pnj, - "CRASH_RESULTS_PATH": results_path, - "PYTHONUNBUFFERED": "1"} - try: - proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - text=True, env=env) - for line in proc.stdout: - with _stress_lock: - _stress_state["output"].append(line.rstrip()) - proc.wait() - with _stress_lock: - _stress_state["returncode"] = proc.returncode - _stress_state["running"] = False - except Exception as e: - with _stress_lock: - _stress_state["error"] = str(e) - _stress_state["running"] = False + return app -DB_HOST = os.getenv("DB_HOST", "localhost") -DB_PORT = int(os.getenv("DB_PORT", 5432)) -DB_NAME = os.getenv("DB_NAME", "fallout") -DB_USER = os.getenv("DB_USER", "fallout") -DB_PASS = os.getenv("DB_PASS", "VeniceOfWasteland2026!") - -SIM_CONFIGS_DIR = os.getenv("SIM_CONFIGS_DIR", "/app/src/config") - -def get_conn(): - return psycopg2.connect( - host=DB_HOST, port=DB_PORT, dbname=DB_NAME, - user=DB_USER, password=DB_PASS - ) - -def query(sql, params=None): - conn = get_conn() - try: - with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: - cur.execute(sql, params) - return [dict(r) for r in cur.fetchall()] - finally: - conn.close() - -def query_one(sql, params=None): - rows = query(sql, params) - return rows[0] if rows else None - -def execute(sql, params=None): - conn = get_conn() - try: - with conn.cursor() as cur: - cur.execute(sql, params) - conn.commit() - finally: - conn.close() - -_REFERENCE_CONFIG_ID = 1 # sim_001.json = référence pour la liste des modes - -def load_sim_config(session_id: int) -> dict | None: - """Retourne le cfg JSON ou None si introuvable (None est falsy ET distinguable de {}).""" - path = os.path.join(SIM_CONFIGS_DIR, f"sim_{session_id:03d}.json") - try: - with open(path) as f: - return json.load(f) - except Exception as e: - return None - -def load_all_modes() -> dict: - """Charge les modes depuis sim_001.json (référence complète des 5 modes).""" - cfg = load_sim_config(_REFERENCE_CONFIG_ID) or {} - return cfg.get("modes", {}) - -def save_sim_config(session_id: int, cfg: dict): - path = os.path.join(SIM_CONFIGS_DIR, f"sim_{session_id:03d}.json") - with open(path, "w") as f: - json.dump(cfg, f, indent=2, ensure_ascii=False) - -def get_all_sessions(): - return query("SELECT * FROM sessions ORDER BY id") - -# --------------------------------------------------------------------------- -# HTML Template -# --------------------------------------------------------------------------- - -TEMPLATE = r""" - - - - -PipBoy — Fallout: Venice of Wasteland - - - - - -
- 📟 PIPBOY -
- {% for s in all_sessions %} - {% set is_active = s.status == 'active' %} - - - S{{ s.id }} - - {% endfor %} -
- - -
- -{% if flash_msg %} -
{{ flash_msg }}
-{% endif %} - -
- -{% if tab == 'sim' %} - - - - -{% if session %} -
- Session #{{ session.id }} — {{ session.name }} - Jour {{ session.current_day }} / Tick {{ session.current_tick }}h - {{ session.mode | upper }} - {% set st = session.status %} - - - {{ st | upper }} - - Seed: {{ session.seed_global }} - - ↻ Actualiser - - -
-{% endif %} - - -
-
-
PNJ Vivants
-
{{ alive_count }}
-
-
-
PNJ Morts
-
{{ stats.get('PNJ morts', 0) }}
-
-
-
Total PNJ
-
{{ total_count }}
-
-
-
Combats
-
{{ stats.get('Combats', 0) }}
-
-
-
Rencontres
-
{{ stats.get('Rencontres', 0) }}
-
-
-
Événements
-
{{ stats.get('Événements total', 0) }}
-
-
- -
- -
-

👥 PNJ ({{ alive_count }}/{{ total_count }})

-
- - - {% for c in characters %} - {% set pct = (c.hp / c.max_hp * 100) | int if c.max_hp else 0 %} - - - - - - - - - {% endfor %} -
TierNomPVFactionZoneCaps
- {% if c.tier == 0 %}★ BOSS - {% elif c.tier == 2 %}T2 - {% elif c.tier == 3 %}T3 - {% else %}T1{% endif %} - {{ c.name }} - {% if c.is_alive %} - {{ c.hp }}/{{ c.max_hp }} - {% else %}{% endif %} - - {% set f = c.faction_slug or 'sans' %} - {{ f[:14] }} - {{ (c.location_slug or '—')[:16] }}{{ c.caps }}¢
-
-
- - -
-

📡 Événements récents

-
- - - {% for ev in events %} - - - - - - {% endfor %} - {% if not events %}{% endif %} -
J/TTypeDescription
{{ ev.day }}/{{ ev.tick }}{{ ev.event_type }}{{ ev.description[:90] }}{% if ev.description|length > 90 %}…{% endif %}
Aucun événement
-
-
-
- -
- -
-

🗺 État du monde

-
- {% for ws in world_state %} -
- {{ ws.location_slug }} - - 🍖 {{ ws.food_level or 0 }}% - 💧 {{ ws.water_level or 0 }}% - 🛡 {{ ws.security_level or 0 }}% - 👤 {{ ws.pop_count or 0 }} - -
- {% endfor %} - {% if not world_state %}

Pas de données monde

{% endif %} -
-
- - -
-

⚔ Relations factions

-
- - - {% for r in faction_relations %} - {% set cls = 'rel-hostile' if r.relation_score < -20 else 'rel-allie' if r.relation_score > 20 else 'rel-neutral' %} - - - - - - - {% endfor %} - {% if not faction_relations %}{% endif %} -
Faction AFaction BScoreÉtat
{{ r.faction_a[:14] }}{{ r.faction_b[:14] }}{{ r.relation_score }}{{ r.relation_label or ('HOSTILE' if r.relation_score < -20 else 'ALLIÉ' if r.relation_score > 20 else 'NEUTRE') }}
Pas de données
-
-
- - -
-

🎭 PNJ par tier

- - - {% for row in tier_stats %} - - - - - - - {% endfor %} -
TierLabelVivantsMorts
{{ row.tier }}{{ row.label }}{{ row.alive }}{{ row.dead }}
-

🏙 PNJ par zone (top 5)

- - - {% for row in zone_stats %} - - - - - {% endfor %} -
ZonePNJ
{{ row.location_slug }}{{ row.cnt }}
-
-
- - -
-

⚡ Rencontres récentes

-
- - - {% for ev in encounters %} - - - - - - - - {% endfor %} - {% if not encounters %}{% endif %} -
J/TTypeActeurZoneDescription
{{ ev.day }}/{{ ev.tick }}{{ ev.event_type }}{{ ev.actor_name or '—' }}{{ (ev.location_slug or '—')[:16] }}{{ ev.description[:90] }}{% if ev.description|length > 90 %}…{% endif %}
Aucune rencontre
-
-
- - -{% if boss_inventory %} -
-

★ Équipement chefs de faction

- - - {% for row in boss_inventory %} - - - - - - - - {% endfor %} -
BossItemTypeQtéÉquipé
{{ row.char_name }}{{ row.item_name }}{{ row.item_type }}{{ row.quantity }}{{ '✓' if row.is_equipped else '' }}
-
-{% endif %} - - - -{% elif tab == 'tools' %} - - - - - - - -
-

🚀 Lancer une simulation

-
- -
- {% for s in all_sessions %} - - {% endfor %} -
-
-
- - -
-
- -
- - - - -
- -
-
- -
-
-
-

-    
-
-
- - -
-

📚 Lancer un enrichissement lore

-
- - -
-
- -
-
-
-

-    
-
-
- - -
-

⚡ Stress Test LLM

-
- -
- - - - -
-
-
- -
- - - -
-
-
- -
- - - -
-
-
- - - - -
- - - - - -
-
- - - -{% elif tab == 'lore' %} - - - - -
- 📜 Enrichissement Lore — Venice of Wasteland -
- En attente : {{ lore_stats.pending }} - Acceptées : {{ lore_stats.accepted }} - Rejetées : {{ lore_stats.rejected }} - Modifiées : {{ lore_stats.modified }} -
-
- -
- - - - - {{ proposals|length }} proposition(s) affichée(s) -
- -{% if not proposals %} -
- Aucune proposition lore.
- Lancer : python3 lore_enricher.py --faction grand_krewe sur Ampère -
-{% endif %} - -{% for p in proposals %} -
-
- #{{ p.id }} - {{ p.status | upper }} - {{ p.change_type }} - {{ p.faction_slug }} - {% if p.field_path %}{{ p.field_path }}{% endif %} - {{ p.created_at.strftime('%d/%m %H:%M') if p.created_at else '' }} -
- -

{{ p.rationale or '(pas de justification)' }}

- -
-
-
Original
-
{{ p.original_text or '(ajout — pas de texte original)' }}
-
-
-
Proposé
-
{{ p.proposed_text }}
-
-
- - {% if p.status == 'pending' %} -
-
- - - - - -
-
- - - - - -
-
- - - - - - -
-
- {% elif p.status == 'modified' and p.modified_text %} -
-
Texte modifié accepté
-
{{ p.modified_text }}
-
- {% endif %} -
-{% endfor %} - -{% elif tab == 'params' %} - - - - -{% if session %} -
- Session #{{ session.id }} — {{ session.name }} - {{ session.mode | upper }} - {% set st = session.status %} - - - {{ st | upper }} - - Jour {{ session.current_day }} / Tick {{ session.current_tick }}h -
-{% endif %} - -
- - -
-

⚙ Config sim_{{ '%03d' % current_sid }}.json

-
- {% if cfg is not none %} - -
-

Général

-
- - -
-
- - -
-
- - -
-
- -
-

Survie

-
- - -
-
- - -
-
- - -
-
- -
-

Rencontres

-
- - -
-
- - -
-
- -
-

Économie

-
- - -
-
- - -

- Prise en compte au prochain jour (J{{ (session.current_day or 0) + 1 }}). - Pour effet immédiat : STOP → relancer via terminal. -

- {% else %} -

- Fichier sim_{{ '%03d' % current_sid }}.json introuvable dans {{ sim_configs_dir }} -

-

- Vérifier le mount Docker : /home/ubuntu/fallout-venice/src/config → /app/src/config -

- {% endif %} -
-
- - -
-

🔧 Gestion session #{{ current_sid }}

- -
-

Statut DB

-
-
- - -
-
- - -
-
- - -
-
-

- STOP → la sim s'arrête dans les 5 ticks suivants. Puis RESET JOUR/TICK → relancer via terminal. -

-
- -
-

Reset jour/tick

-
-
- - -
-
- - -
- -
-
- -
-

Modes disponibles

- - - {% for m_name, m_vals in modes.items() %} - - - - - - - {% endfor %} -
ModeDrainEncountersCaps
- {{ m_name }}{% if m_name == cfg.get('mode','') %} ◀{% endif %}×{{ m_vals.get('survival',{}).get('drain_speed','—') }}×{{ m_vals.get('encounter',{}).get('global_rate_multiplier','—') }}×{{ m_vals.get('economy',{}).get('caps_global_multiplier','—') }}
-
- -
-

Commandes SSH (référence)

-

Copier-coller dans votre terminal SSH. Pour les commandes générées, utiliser l'onglet OUTILS.

-
-
tail -f ~/fallout_sim_s{{ current_sid }}.log
-
-
-
- -
- - - -{% endif %} - -
- - - -""" - -# --------------------------------------------------------------------------- -# Helpers data -# --------------------------------------------------------------------------- - -def get_sim_data(sid: int) -> dict: - session = query_one("SELECT * FROM sessions WHERE id = %s", (sid,)) - if not session: - session = query_one("SELECT * FROM sessions ORDER BY id DESC LIMIT 1") - - characters = query(""" - SELECT c.*, COALESCE(c.tier, 1) as tier - FROM characters c - WHERE c.session_id = %s - ORDER BY COALESCE(c.tier,1), c.is_alive DESC, c.name - """, (sid,)) - alive_count = sum(1 for c in characters if c["is_alive"]) - - events = query(""" - SELECT e.*, c.name as actor_name FROM events e - LEFT JOIN characters c ON c.id = e.actor_id - WHERE e.session_id = %s - AND e.event_type NOT IN ('encounter_lambda','encounter_group','encounter_actif','encounter_boss','cycle_tick') - ORDER BY e.day DESC, e.tick DESC, e.id DESC LIMIT 50 - """, (sid,)) - - encounters = query(""" - SELECT e.*, c.name as actor_name FROM events e - LEFT JOIN characters c ON c.id = e.actor_id - WHERE e.session_id = %s AND e.event_type LIKE 'encounter%%' - ORDER BY e.day DESC, e.tick DESC, e.id DESC LIMIT 30 - """, (sid,)) - - world_state = query(""" - SELECT DISTINCT ON (location_slug) * - FROM world_state WHERE session_id = %s - ORDER BY location_slug, day DESC - """, (sid,)) - - faction_relations = query(""" - SELECT * FROM faction_relations WHERE session_id = %s - ORDER BY relation_score ASC - """, (sid,)) - - tier_labels = {0: "BOSS", 1: "ACTIFS SIM", 2: "PNJ+", 3: "PASSAGE"} - tier_raw = query(""" - SELECT COALESCE(tier,1) as tier, - COUNT(*) FILTER (WHERE is_alive) as alive, - COUNT(*) FILTER (WHERE NOT is_alive) as dead - FROM characters WHERE session_id = %s - GROUP BY COALESCE(tier,1) ORDER BY COALESCE(tier,1) - """, (sid,)) - tier_stats = [{"tier": r["tier"], "label": tier_labels.get(r["tier"],"?"), - "alive": r["alive"], "dead": r["dead"]} for r in tier_raw] - - zone_stats = query(""" - SELECT location_slug, COUNT(*) as cnt FROM characters - WHERE session_id = %s AND is_alive = TRUE - GROUP BY location_slug ORDER BY cnt DESC LIMIT 5 - """, (sid,)) - - ev_total = query_one("SELECT COUNT(*) as n FROM events WHERE session_id=%s", (sid,)) - cb_total = query_one("SELECT COUNT(*) as n FROM events WHERE session_id=%s AND event_type='combat'", (sid,)) - dt_total = query_one("SELECT COUNT(*) as n FROM events WHERE session_id=%s AND event_type='death'", (sid,)) - enc_total = query_one("SELECT COUNT(*) as n FROM events WHERE session_id=%s AND event_type LIKE 'encounter%%'", (sid,)) - - stats = { - "Événements total": ev_total["n"] if ev_total else 0, - "Combats": cb_total["n"] if cb_total else 0, - "Morts": dt_total["n"] if dt_total else 0, - "Rencontres": enc_total["n"] if enc_total else 0, - "PNJ vivants": alive_count, - "PNJ morts": len(characters) - alive_count, - } - - boss_inventory = query(""" - SELECT c.name as char_name, i.item_name, i.item_type, i.quantity, i.is_equipped - FROM inventory i - JOIN characters c ON c.id = i.character_id - WHERE c.session_id = %s AND COALESCE(c.tier,1) = 0 - ORDER BY c.name, i.is_equipped DESC - """, (sid,)) - - return dict( - session=session, - characters=characters, - alive_count=alive_count, - total_count=len(characters), - events=events, - encounters=encounters, - world_state=world_state, - faction_relations=faction_relations, - tier_stats=tier_stats, - zone_stats=zone_stats, - stats=stats, - boss_inventory=boss_inventory, - ) - -# --------------------------------------------------------------------------- -# Routes -# --------------------------------------------------------------------------- - -@app.route("/") -def index(): - all_sessions = get_all_sessions() - sid = int(request.args.get("sid", all_sessions[-1]["id"] if all_sessions else 1)) - data = get_sim_data(sid) - return render_template_string(TEMPLATE, - tab="sim", - current_sid=sid, - all_sessions=all_sessions, - flash_msg=None, - flash_ok=True, - modes={}, - cfg={}, - sim_configs_dir=SIM_CONFIGS_DIR, - **data, - ) - -@app.route("/tools") -def tools(): - all_sessions = get_all_sessions() - sid = int(request.args.get("sid", all_sessions[-1]["id"] if all_sessions else 1)) - session = query_one("SELECT * FROM sessions WHERE id = %s", (sid,)) - return render_template_string(TEMPLATE, - tab="tools", - current_sid=sid, - all_sessions=all_sessions, - session=session, - flash_msg=None, - flash_ok=True, - cfg=None, modes={}, sim_configs_dir=SIM_CONFIGS_DIR, - proposals=[], lore_stats={"pending":0,"accepted":0,"rejected":0,"modified":0}, - all_factions=[], filt_faction="", filt_status="", - characters=[], alive_count=0, total_count=0, - events=[], encounters=[], world_state=[], - faction_relations=[], tier_stats=[], zone_stats=[], - stats={}, boss_inventory=[], - ) - -@app.route("/api/stress-test/results") -def api_stress_test_results(): - path = os.path.join(SIM_CONFIGS_DIR, "crash_results.json") - if not os.path.exists(path): - return jsonify({"error": "Aucun résultat. Lancez d'abord le test."}) - try: - import datetime - mtime = os.path.getmtime(path) - file_date = datetime.datetime.fromtimestamp(mtime).strftime("%d/%m/%Y %H:%M:%S") - with open(path) as f: - data = json.load(f) - data["file_date"] = file_date - return jsonify(data) - except Exception as e: - return jsonify({"error": f"Erreur lecture : {e}"}) - -@app.route("/api/stress-test/run", methods=["POST"]) -def api_stress_test_run(): - global _stress_state - with _stress_lock: - if _stress_state["running"]: - return jsonify({"error": "Un test est déjà en cours.", "running": True}) - mode = request.json.get("mode", "simultane") - rounds = int(request.json.get("rounds", 5)) - pnj_count = int(request.json.get("pnj_count", 2)) - _stress_state = { - "running": True, - "started_at": time.strftime("%H:%M:%S"), - "mode": mode, - "rounds": rounds, - "pnj_count": pnj_count, - "output": [], - "returncode": None, - "error": None, - } - t = threading.Thread(target=_run_stress_bg, args=(mode, rounds, pnj_count), daemon=True) - t.start() - return jsonify({"ok": True, "message": f"Test '{mode}' {rounds} rounds lancé."}) - -@app.route("/api/stress-test/status") -def api_stress_test_status(): - with _stress_lock: - return jsonify({ - "running": _stress_state["running"], - "started_at": _stress_state["started_at"], - "mode": _stress_state["mode"], - "rounds": _stress_state["rounds"], - "output": _stress_state["output"][-60:], # 60 dernières lignes - "returncode": _stress_state["returncode"], - "error": _stress_state["error"], - }) - -@app.route("/params") -def params(): - all_sessions = get_all_sessions() - sid = int(request.args.get("sid", all_sessions[-1]["id"] if all_sessions else 1)) - session = query_one("SELECT * FROM sessions WHERE id = %s", (sid,)) - cfg = load_sim_config(sid) - modes = load_all_modes() # toujours les 5 modes depuis sim_001 (référence) - flash_msg = request.args.get("msg") - flash_ok = request.args.get("ok", "1") == "1" - return render_template_string(TEMPLATE, - tab="params", - current_sid=sid, - all_sessions=all_sessions, - session=session, - cfg=cfg, - modes=modes, - sim_configs_dir=SIM_CONFIGS_DIR, - flash_msg=flash_msg, - flash_ok=flash_ok, - # unused in params tab but required by template - proposals=[], lore_stats={"pending":0,"accepted":0,"rejected":0,"modified":0}, - all_factions=[], filt_faction="", filt_status="", - characters=[], alive_count=0, total_count=0, - events=[], encounters=[], world_state=[], - faction_relations=[], tier_stats=[], zone_stats=[], - stats={}, boss_inventory=[], - ) - -@app.route("/params/save", methods=["POST"]) -def params_save(): - sid = int(request.args.get("sid", 1)) - cfg = load_sim_config(sid) - if cfg is None: - return redirect(url_for("params", sid=sid, msg=f"Fichier sim_{sid:03d}.json introuvable dans {SIM_CONFIGS_DIR}", ok=0)) - - f = request.form - - cfg["name"] = f.get("name", cfg.get("name", "")) - cfg["mode"] = f.get("mode", cfg.get("mode", "pacifiste")) - - cfg.setdefault("tick", {})["tick_sleep_sec"] = int(f.get("tick_sleep_sec", 60)) - - cfg.setdefault("survival", {}).update({ - "drain_speed": float(f.get("survival_drain_speed", 1.0)), - "fatigue_hp_per_2pts": int(f.get("survival_fatigue_hp", 2)), - "enabled": "survival_enabled" in f, - }) - - cfg.setdefault("encounter", {}).update({ - "global_rate_multiplier": float(f.get("encounter_rate", 1.0)), - "enabled": "encounter_enabled" in f, - }) - - cfg.setdefault("economy", {})["caps_global_multiplier"] = float(f.get("caps_multiplier", 1.0)) - - try: - save_sim_config(sid, cfg) - execute("UPDATE sessions SET mode=%s WHERE id=%s", (cfg["mode"], sid)) - msg = f"Config sim_{sid:03d}.json sauvegardée. Mode DB mis à jour → {cfg['mode']}." - ok = 1 - except Exception as e: - msg = f"Erreur: {e}" - ok = 0 - - return redirect(url_for("params", sid=sid, msg=msg, ok=ok)) - -@app.route("/params/setstatus", methods=["POST"]) -def params_setstatus(): - sid = int(request.args.get("sid", 1)) - status = request.form.get("status", "paused") - try: - execute("UPDATE sessions SET status=%s WHERE id=%s", (status, sid)) - msg = f"Session {sid} → status '{status}'. Le process run.py sortira au prochain tick si arrêté." - ok = 1 - except Exception as e: - msg = f"Erreur: {e}" - ok = 0 - return redirect(url_for("params", sid=sid, msg=msg, ok=ok)) - -@app.route("/params/resetday", methods=["POST"]) -def params_resetday(): - sid = int(request.args.get("sid", 1)) - day = int(request.form.get("day", 1)) - tick = int(request.form.get("tick", 0)) - try: - execute("UPDATE sessions SET current_day=%s, current_tick=%s WHERE id=%s", (day, tick, sid)) - msg = f"Session {sid} remise à J{day} T{tick}h." - ok = 1 - except Exception as e: - msg = f"Erreur: {e}" - ok = 0 - return redirect(url_for("params", sid=sid, msg=msg, ok=ok)) - -CHROMA_URL = os.getenv("CHROMA_URL", "http://localhost:8800") -CHROMA_COL_OUT = os.getenv("CHROMA_COL_OUT", "fallout_lore_enriched") -CHROMA_BASE = f"{CHROMA_URL}/api/v2/tenants/default_tenant/databases/default_database" - -def _chroma_post(path: str, body: dict): - import urllib.request - data = __import__("json").dumps(body).encode() - req = urllib.request.Request(f"{CHROMA_BASE}{path}", data=data, method="POST", - headers={"Content-Type": "application/json"}) - try: - with urllib.request.urlopen(req, timeout=10) as r: - return __import__("json").loads(r.read()) - except Exception: - return {} - -def _chroma_get_enriched_col_id(): - import urllib.request - try: - with urllib.request.urlopen(f"{CHROMA_BASE}/collections", timeout=5) as r: - cols = __import__("json").loads(r.read()) - for c in cols: - if c["name"] == CHROMA_COL_OUT: - return c["id"] - except Exception: - pass - # Créer la collection si absente - res = _chroma_post("/collections", {"name": CHROMA_COL_OUT, "metadata": {"hnsw:space": "cosine"}}) - return res.get("id") - -def _upsert_to_chroma_enriched(proposal: dict): - col_id = _chroma_get_enriched_col_id() - if not col_id: - return - text = proposal.get("modified_text") or proposal.get("proposed_text", "") - doc_id = f"lore_proposal_{proposal['id']}" - _chroma_post(f"/collections/{col_id}/upsert", { - "ids": [doc_id], - "documents": [text], - "metadatas": [{ - "faction_slug": proposal.get("faction_slug", ""), - "change_type": proposal.get("change_type", ""), - "field_path": proposal.get("field_path") or "", - "category": "lore_enriched", - "source": f"lore_proposal_{proposal['id']}", - }], - }) - -def get_lore_proposals(faction: str = "", status: str = "pending") -> list[dict]: - sql = "SELECT * FROM lore_proposals WHERE 1=1" - params = [] - if faction: - sql += " AND faction_slug = %s" - params.append(faction) - if status: - sql += " AND status = %s" - params.append(status) - sql += " ORDER BY id DESC LIMIT 100" - return query(sql, params or None) - -def get_lore_stats() -> dict: - rows = query("SELECT status, COUNT(*) as n FROM lore_proposals GROUP BY status") - m = {r["status"]: r["n"] for r in rows} - return {"pending": m.get("pending",0), "accepted": m.get("accepted",0), - "rejected": m.get("rejected",0), "modified": m.get("modified",0)} - -def get_all_factions() -> list[str]: - rows = query("SELECT DISTINCT faction_slug FROM lore_proposals ORDER BY faction_slug") - return [r["faction_slug"] for r in rows] - -@app.route("/lore") -def lore(): - all_sessions = get_all_sessions() - sid = int(request.args.get("sid", all_sessions[-1]["id"] if all_sessions else 1)) - session = query_one("SELECT * FROM sessions WHERE id = %s", (sid,)) - filt_faction = request.args.get("faction", "") - filt_status = request.args.get("status", "pending") - proposals = get_lore_proposals(filt_faction, filt_status) - flash_msg = request.args.get("msg") - flash_ok = request.args.get("ok", "1") == "1" - return render_template_string(TEMPLATE, - tab="lore", - current_sid=sid, - all_sessions=all_sessions, - session=session, - proposals=proposals, - lore_stats=get_lore_stats(), - all_factions=get_all_factions(), - filt_faction=filt_faction, - filt_status=filt_status, - flash_msg=flash_msg, - flash_ok=flash_ok, - cfg=None, modes={}, sim_configs_dir=SIM_CONFIGS_DIR, - characters=[], alive_count=0, total_count=0, - events=[], encounters=[], world_state=[], - faction_relations=[], tier_stats=[], zone_stats=[], - stats={}, boss_inventory=[], - ) - -@app.route("/lore/action", methods=["POST"]) -def lore_action(): - prop_id = int(request.form.get("id", 0)) - action = request.form.get("action", "") - modified_text = request.form.get("modified_text", "").strip() - filt_faction = request.form.get("filt_faction", "") - filt_status = request.form.get("filt_status", "pending") - - proposal = query_one("SELECT * FROM lore_proposals WHERE id = %s", (prop_id,)) - if not proposal: - return redirect(url_for("lore", msg="Proposition introuvable", ok=0, - faction=filt_faction, status=filt_status)) - try: - if action == "accept": - execute("UPDATE lore_proposals SET status='accepted', reviewed_at=NOW() WHERE id=%s", (prop_id,)) - _upsert_to_chroma_enriched({**proposal, "modified_text": None}) - msg = f"Proposition #{prop_id} acceptée et indexée dans {CHROMA_COL_OUT}." - elif action == "reject": - execute("UPDATE lore_proposals SET status='rejected', reviewed_at=NOW() WHERE id=%s", (prop_id,)) - msg = f"Proposition #{prop_id} rejetée." - elif action == "modify": - if not modified_text: - modified_text = proposal["proposed_text"] - execute("""UPDATE lore_proposals SET status='modified', modified_text=%s, reviewed_at=NOW() - WHERE id=%s""", (modified_text, prop_id)) - _upsert_to_chroma_enriched({**proposal, "modified_text": modified_text}) - msg = f"Proposition #{prop_id} modifiée et indexée dans {CHROMA_COL_OUT}." - else: - msg = "Action inconnue." - ok = 1 - except Exception as e: - msg = f"Erreur: {e}" - ok = 0 - - return redirect(url_for("lore", msg=msg, ok=ok, - faction=filt_faction, status=filt_status)) - -@app.route("/api/state") -def api_state(): - all_sessions = get_all_sessions() - sid = int(request.args.get("sid", all_sessions[-1]["id"] if all_sessions else 1)) - session = query_one("SELECT * FROM sessions WHERE id=%s", (sid,)) - alive = query(""" - SELECT id, name, hp, max_hp, tier, faction_slug, location_slug - FROM characters WHERE session_id=%s AND is_alive=TRUE - """, (sid,)) - return jsonify({"session": session, "alive_pnj": alive}) if __name__ == "__main__": - app.run(host="0.0.0.0", port=5000, debug=False) + create_app().run(host="0.0.0.0", port=5000, debug=False) diff --git a/dashboard/chroma.py b/dashboard/chroma.py new file mode 100644 index 0000000..48a249e --- /dev/null +++ b/dashboard/chroma.py @@ -0,0 +1,97 @@ +import os +import json +import urllib.request + +CHROMA_URL = os.getenv("CHROMA_URL", "http://localhost:8800") +CHROMA_COL_OUT = os.getenv("CHROMA_COL_OUT", "fallout_lore_enriched") +CHROMA_SIM_COL = os.getenv("CHROMA_SIM_COL", "fallout_sim_enriched") +CHROMA_BASE = f"{CHROMA_URL}/api/v2/tenants/default_tenant/databases/default_database" + + +def _chroma_post(path: str, body: dict) -> dict: + data = json.dumps(body).encode() + req = urllib.request.Request( + f"{CHROMA_BASE}{path}", data=data, method="POST", + headers={"Content-Type": "application/json"} + ) + try: + with urllib.request.urlopen(req, timeout=10) as r: + return json.loads(r.read()) + except Exception: + return {} + + +def _get_or_create_col(name: str) -> str | None: + try: + with urllib.request.urlopen(f"{CHROMA_BASE}/collections", timeout=5) as r: + cols = json.loads(r.read()) + for c in cols: + if c["name"] == name: + return c["id"] + except Exception: + pass + res = _chroma_post("/collections", {"name": name, "metadata": {"hnsw:space": "cosine"}}) + return res.get("id") + + +def upsert_lore_enriched(proposal: dict): + col_id = _get_or_create_col(CHROMA_COL_OUT) + if not col_id: + return + text = proposal.get("modified_text") or proposal.get("proposed_text", "") + doc_id = f"lore_proposal_{proposal['id']}" + _chroma_post(f"/collections/{col_id}/upsert", { + "ids": [doc_id], + "documents": [text], + "metadatas": [{ + "faction_slug": proposal.get("faction_slug", ""), + "change_type": proposal.get("change_type", ""), + "field_path": proposal.get("field_path") or "", + "category": "lore_enriched", + "source": doc_id, + }], + }) + + +def upsert_sim_enriched(proposal: dict): + col_id = _get_or_create_col(CHROMA_SIM_COL) + if not col_id: + return + patch_str = json.dumps(proposal.get("json_patch") or {}, ensure_ascii=False) + text = ( + f"{proposal.get('title','')}\n" + f"{proposal.get('description','')}\n" + f"Patch: {patch_str}\n" + f"Rationale: {proposal.get('rationale','')}" + ) + doc_id = f"sim_proposal_{proposal['id']}" + _chroma_post(f"/collections/{col_id}/upsert", { + "ids": [doc_id], + "documents": [text], + "metadatas": [{ + "proposal_type": proposal.get("proposal_type", ""), + "title": proposal.get("title", ""), + "category": "sim_enriched", + "source": doc_id, + }], + }) + + +def upsert_sim_reject(proposal: dict, reason: str): + col_id = _get_or_create_col(CHROMA_SIM_COL + "_rejects") + if not col_id: + return + neg_text = ( + f"REJET — {proposal.get('proposal_type','')}: {proposal.get('title','')}\n" + f"Motif: {reason}\n" + f"Description: {proposal.get('description','')}" + ) + _chroma_post(f"/collections/{col_id}/upsert", { + "ids": [f"reject_{proposal['id']}"], + "documents": [neg_text], + "metadatas": [{ + "proposal_type": proposal.get("proposal_type", ""), + "category": "negative_example", + "source": f"reject_{proposal['id']}", + }], + }) diff --git a/dashboard/config.py b/dashboard/config.py new file mode 100644 index 0000000..ccf3842 --- /dev/null +++ b/dashboard/config.py @@ -0,0 +1,26 @@ +import os +import json + +SIM_CONFIGS_DIR = os.getenv("SIM_CONFIGS_DIR", "/app/src/config") + +_REFERENCE_CONFIG_ID = 1 + + +def load_sim_config(session_id: int) -> dict | None: + path = os.path.join(SIM_CONFIGS_DIR, f"sim_{session_id:03d}.json") + try: + with open(path) as f: + return json.load(f) + except Exception: + return None + + +def save_sim_config(session_id: int, cfg: dict): + path = os.path.join(SIM_CONFIGS_DIR, f"sim_{session_id:03d}.json") + with open(path, "w") as f: + json.dump(cfg, f, indent=2, ensure_ascii=False) + + +def load_all_modes() -> dict: + cfg = load_sim_config(_REFERENCE_CONFIG_ID) or {} + return cfg.get("modes", {}) diff --git a/dashboard/db.py b/dashboard/db.py new file mode 100644 index 0000000..f14420e --- /dev/null +++ b/dashboard/db.py @@ -0,0 +1,45 @@ +import os +import psycopg2 +import psycopg2.extras + +DB_HOST = os.getenv("DB_HOST", "localhost") +DB_PORT = int(os.getenv("DB_PORT", 5432)) +DB_NAME = os.getenv("DB_NAME", "fallout") +DB_USER = os.getenv("DB_USER", "fallout") +DB_PASS = os.getenv("DB_PASSWORD", "VeniceOfWasteland2026!") + + +def get_conn(): + return psycopg2.connect( + host=DB_HOST, port=DB_PORT, dbname=DB_NAME, + user=DB_USER, password=DB_PASS + ) + + +def query(sql, params=None): + conn = get_conn() + try: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute(sql, params) + return [dict(r) for r in cur.fetchall()] + finally: + conn.close() + + +def query_one(sql, params=None): + rows = query(sql, params) + return rows[0] if rows else None + + +def execute(sql, params=None): + conn = get_conn() + try: + with conn.cursor() as cur: + cur.execute(sql, params) + conn.commit() + finally: + conn.close() + + +def get_all_sessions(): + return query("SELECT * FROM sessions ORDER BY id") diff --git a/dashboard/process_mgr.py b/dashboard/process_mgr.py new file mode 100644 index 0000000..86d1d86 --- /dev/null +++ b/dashboard/process_mgr.py @@ -0,0 +1,152 @@ +import os +import subprocess +import threading + +_procs = {} +_procs_lock = threading.Lock() + +_PROC_DEFS = { + "sim_enricher": { + "label": "Enrichissement règles sim (LLM → sim_proposals)", + "cmd": ["python3", "-u", "/opt/coyote/pipelines/sim_enricher.py", "--type", "all"], + "env_extra": {"CHROMA_URL": "http://chromadb:8000"}, + }, + "lore_enricher": { + "label": "Enrichissement lore (LLM → lore_proposals)", + "cmd": ["python3", "-u", "/app/src/engine/lore_enricher.py", "--faction", "all"], + "env_extra": {"CHROMA_URL": "http://chromadb:8000"}, + }, + "embed_fallout": { + "label": "Re-embed PDFs Fallout → fallout_lore", + "cmd": ["python3", "-u", "/opt/coyote/pipelines/embed_fallout.py"], + "env_extra": {"CHROMA_URL": "http://chromadb:8000"}, + }, + "embed_sim": { + "label": "Re-embed règles sim → fallout_sim_rules", + "cmd": ["python3", "-u", "/opt/coyote/pipelines/embed_sim.py"], + "env_extra": {"CHROMA_URL": "http://chromadb:8000"}, + }, +} + + +def _run_proc_loop_bg(name, cmd, env_extra=None, count=1): + env = {**os.environ, **(env_extra or {}), "PYTHONUNBUFFERED": "1"} + with _procs_lock: + _procs[name] = {"proc": None, "output": [], "running": True, + "returncode": None, "error": None, "iteration": 0, "total": count} + iteration = 0 + try: + while count == 0 or iteration < count: + iteration += 1 + sep = f"─── Itération {iteration}" + (f"/{count}" if count else "/∞") + " ───" + with _procs_lock: + _procs[name]["output"].append(sep) + _procs[name]["iteration"] = iteration + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, env=env) + with _procs_lock: + _procs[name]["proc"] = proc + for line in proc.stdout: + with _procs_lock: + _procs[name]["output"].append(line.rstrip()) + proc.wait() + if proc.returncode is not None and proc.returncode < 0: + break + with _procs_lock: + if not _procs[name]["running"]: + break + rc = proc.returncode + if rc != 0: + with _procs_lock: + _procs[name]["returncode"] = rc + break + with _procs_lock: + if _procs[name]["returncode"] is None: + _procs[name]["returncode"] = 0 + _procs[name]["running"] = False + except Exception as e: + with _procs_lock: + _procs[name]["error"] = str(e) + _procs[name]["running"] = False + + +def _run_mix_bg(count=10): + name = "mix" + sim_cmd = list(_PROC_DEFS["sim_enricher"]["cmd"]) + lore_cmd = list(_PROC_DEFS["lore_enricher"]["cmd"]) + env = {**os.environ, "CHROMA_URL": "http://chromadb:8000", "PYTHONUNBUFFERED": "1"} + with _procs_lock: + _procs[name] = {"proc": None, "output": [], "running": True, + "returncode": None, "error": None, "iteration": 0, "total": count * 2} + iteration = 0 + try: + for script_name, cmd in [("sim_enricher", sim_cmd), ("lore_enricher", lore_cmd)]: + i = 0 + while count == 0 or i < count: + i += 1 + iteration += 1 + sep = f"─── MIX {script_name} {i}" + (f"/{count}" if count else "/∞") + " ───" + with _procs_lock: + if not _procs[name]["running"]: + raise StopIteration + _procs[name]["output"].append(sep) + _procs[name]["iteration"] = iteration + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, env=env) + with _procs_lock: + _procs[name]["proc"] = proc + for line in proc.stdout: + with _procs_lock: + _procs[name]["output"].append(line.rstrip()) + proc.wait() + if proc.returncode is not None and proc.returncode < 0: + raise StopIteration + with _procs_lock: + if not _procs[name]["running"]: + raise StopIteration + with _procs_lock: + _procs[name]["returncode"] = 0 + _procs[name]["running"] = False + except StopIteration: + with _procs_lock: + _procs[name]["returncode"] = 0 + _procs[name]["running"] = False + except Exception as e: + with _procs_lock: + _procs[name]["error"] = str(e) + _procs[name]["running"] = False + + +def proc_start(name: str, cmd: list, env_extra: dict, count: int): + t = threading.Thread(target=_run_proc_loop_bg, + args=(name, cmd, env_extra, count), daemon=True) + t.start() + + +def proc_mix_start(count: int): + t = threading.Thread(target=_run_mix_bg, args=(count,), daemon=True) + t.start() + + +def proc_get_state(name: str) -> dict: + with _procs_lock: + state = dict(_procs.get(name, {"running": False, "output": [], "returncode": None, "error": None})) + state.pop("proc", None) + return state + + +def proc_is_running(name: str) -> bool: + with _procs_lock: + return _procs.get(name, {}).get("running", False) + + +def proc_stop(name: str): + with _procs_lock: + p = _procs.get(name, {}).get("proc") + if name in _procs: + _procs[name]["running"] = False + if p: + try: + p.terminate() + except Exception: + pass diff --git a/dashboard/routes/__init__.py b/dashboard/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dashboard/routes/lore.py b/dashboard/routes/lore.py new file mode 100644 index 0000000..7aa468f --- /dev/null +++ b/dashboard/routes/lore.py @@ -0,0 +1,95 @@ +from flask import Blueprint, render_template, jsonify, request, redirect, url_for + +from ..db import query, query_one, execute, get_all_sessions +from ..chroma import upsert_lore_enriched, CHROMA_COL_OUT + +bp = Blueprint("lore", __name__) + + +def _get_proposals(faction: str = "", status: str = "pending") -> list: + sql = "SELECT * FROM lore_proposals WHERE 1=1" + params = [] + if faction: + sql += " AND faction_slug = %s" + params.append(faction) + if status: + sql += " AND status = %s" + params.append(status) + sql += " ORDER BY id DESC LIMIT 100" + return query(sql, params or None) + + +def _get_stats() -> dict: + rows = query("SELECT status, COUNT(*) as n FROM lore_proposals GROUP BY status") + m = {r["status"]: r["n"] for r in rows} + return {"pending": m.get("pending", 0), "accepted": m.get("accepted", 0), + "rejected": m.get("rejected", 0), "modified": m.get("modified", 0)} + + +def _get_factions() -> list: + rows = query("SELECT DISTINCT faction_slug FROM lore_proposals ORDER BY faction_slug") + return [r["faction_slug"] for r in rows] + + +@bp.route("/lore") +def lore(): + all_sessions = get_all_sessions() + sid = int(request.args.get("sid", all_sessions[-1]["id"] if all_sessions else 1)) + session = query_one("SELECT * FROM sessions WHERE id = %s", (sid,)) + filt_faction = request.args.get("faction", "") + filt_status = request.args.get("status", "pending") + proposals = _get_proposals(filt_faction, filt_status) + flash_msg = request.args.get("msg") + flash_ok = request.args.get("ok", "1") == "1" + + # Threads de discussion + threads_raw = query("SELECT * FROM lore_proposal_threads ORDER BY created_at ASC") if proposals else [] + proposal_threads = {} + for row in (threads_raw or []): + pid = row["proposal_id"] + proposal_threads.setdefault(pid, []).append(row) + + return render_template("lore.html", + tab="lore", current_sid=sid, all_sessions=all_sessions, session=session, + proposals=proposals, lore_stats=_get_stats(), + all_factions=_get_factions(), filt_faction=filt_faction, filt_status=filt_status, + proposal_threads=proposal_threads, flash_msg=flash_msg, flash_ok=flash_ok, + ) + + +@bp.route("/lore/action", methods=["POST"]) +def lore_action(): + prop_id = int(request.form.get("id", 0)) + action = request.form.get("action", "") + modified_text = request.form.get("modified_text", "").strip() + filt_faction = request.form.get("filt_faction", "") + filt_status = request.form.get("filt_status", "pending") + + proposal = query_one("SELECT * FROM lore_proposals WHERE id = %s", (prop_id,)) + if not proposal: + return redirect(url_for("lore.lore", msg="Proposition introuvable", ok=0, + faction=filt_faction, status=filt_status)) + try: + if action == "accept": + execute("UPDATE lore_proposals SET status='accepted', reviewed_at=NOW() WHERE id=%s", (prop_id,)) + upsert_lore_enriched({**proposal, "modified_text": None}) + msg = f"Proposition #{prop_id} acceptée et indexée dans {CHROMA_COL_OUT}." + elif action == "reject": + execute("UPDATE lore_proposals SET status='rejected', reviewed_at=NOW() WHERE id=%s", (prop_id,)) + msg = f"Proposition #{prop_id} rejetée." + elif action == "modify": + if not modified_text: + modified_text = proposal["proposed_text"] + execute("""UPDATE lore_proposals SET status='modified', modified_text=%s, reviewed_at=NOW() + WHERE id=%s""", (modified_text, prop_id)) + upsert_lore_enriched({**proposal, "modified_text": modified_text}) + msg = f"Proposition #{prop_id} modifiée et indexée dans {CHROMA_COL_OUT}." + else: + msg = "Action inconnue." + ok = 1 + except Exception as e: + msg = f"Erreur: {e}" + ok = 0 + + return redirect(url_for("lore.lore", msg=msg, ok=ok, + faction=filt_faction, status=filt_status)) diff --git a/dashboard/routes/params.py b/dashboard/routes/params.py new file mode 100644 index 0000000..8a9fd33 --- /dev/null +++ b/dashboard/routes/params.py @@ -0,0 +1,83 @@ +from flask import Blueprint, render_template, request, redirect, url_for + +from ..db import query_one, execute, get_all_sessions +from ..config import SIM_CONFIGS_DIR, load_sim_config, save_sim_config, load_all_modes + +bp = Blueprint("params", __name__) + + +@bp.route("/params") +def params(): + all_sessions = get_all_sessions() + sid = int(request.args.get("sid", all_sessions[-1]["id"] if all_sessions else 1)) + session = query_one("SELECT * FROM sessions WHERE id = %s", (sid,)) + cfg = load_sim_config(sid) + modes = load_all_modes() + flash_msg = request.args.get("msg") + flash_ok = request.args.get("ok", "1") == "1" + return render_template("params.html", + tab="params", current_sid=sid, all_sessions=all_sessions, + session=session, cfg=cfg, modes=modes, sim_configs_dir=SIM_CONFIGS_DIR, + flash_msg=flash_msg, flash_ok=flash_ok, + ) + + +@bp.route("/params/save", methods=["POST"]) +def params_save(): + sid = int(request.args.get("sid", 1)) + cfg = load_sim_config(sid) + if cfg is None: + return redirect(url_for("params.params", sid=sid, + msg=f"Fichier sim_{sid:03d}.json introuvable dans {SIM_CONFIGS_DIR}", ok=0)) + f = request.form + cfg["name"] = f.get("name", cfg.get("name", "")) + cfg["mode"] = f.get("mode", cfg.get("mode", "pacifiste")) + cfg.setdefault("tick", {})["tick_sleep_sec"] = int(f.get("tick_sleep_sec", 60)) + cfg.setdefault("survival", {}).update({ + "drain_speed": float(f.get("survival_drain_speed", 1.0)), + "fatigue_hp_per_2pts": int(f.get("survival_fatigue_hp", 2)), + "enabled": "survival_enabled" in f, + }) + cfg.setdefault("encounter", {}).update({ + "global_rate_multiplier": float(f.get("encounter_rate", 1.0)), + "enabled": "encounter_enabled" in f, + }) + cfg.setdefault("economy", {})["caps_global_multiplier"] = float(f.get("caps_multiplier", 1.0)) + try: + save_sim_config(sid, cfg) + execute("UPDATE sessions SET mode=%s WHERE id=%s", (cfg["mode"], sid)) + msg = f"Config sim_{sid:03d}.json sauvegardée. Mode DB mis à jour → {cfg['mode']}." + ok = 1 + except Exception as e: + msg = f"Erreur: {e}" + ok = 0 + return redirect(url_for("params.params", sid=sid, msg=msg, ok=ok)) + + +@bp.route("/params/setstatus", methods=["POST"]) +def params_setstatus(): + sid = int(request.args.get("sid", 1)) + status = request.form.get("status", "paused") + try: + execute("UPDATE sessions SET status=%s WHERE id=%s", (status, sid)) + msg = f"Session {sid} → status '{status}'." + ok = 1 + except Exception as e: + msg = f"Erreur: {e}" + ok = 0 + return redirect(url_for("params.params", sid=sid, msg=msg, ok=ok)) + + +@bp.route("/params/resetday", methods=["POST"]) +def params_resetday(): + sid = int(request.args.get("sid", 1)) + day = int(request.form.get("day", 1)) + tick = int(request.form.get("tick", 0)) + try: + execute("UPDATE sessions SET current_day=%s, current_tick=%s WHERE id=%s", (day, tick, sid)) + msg = f"Session {sid} remise à J{day} T{tick}h." + ok = 1 + except Exception as e: + msg = f"Erreur: {e}" + ok = 0 + return redirect(url_for("params.params", sid=sid, msg=msg, ok=ok)) diff --git a/dashboard/routes/sim.py b/dashboard/routes/sim.py new file mode 100644 index 0000000..6272a34 --- /dev/null +++ b/dashboard/routes/sim.py @@ -0,0 +1,261 @@ +import os +import subprocess +import threading + +from flask import Blueprint, render_template, jsonify, request, send_file + +from ..db import query, query_one, execute, get_all_sessions +from ..config import SIM_CONFIGS_DIR + +bp = Blueprint("sim", __name__) + +_sim_procs = { + "truth": {"proc": None, "output": [], "running": False, "returncode": None, "error": None, "sid": None}, + "enriched": {"proc": None, "output": [], "running": False, "returncode": None, "error": None, "sid": None}, +} +_sim_lock = threading.Lock() + + +def _run_sim_bg(mode, sid, tick_speed_s): + env = {**os.environ, + "SESSION_ID": str(sid), + "TICK_SLEEP_SEC": str(tick_speed_s), + "PYTHONUNBUFFERED": "1"} + if mode == "enriched": + env["USE_ENRICHED"] = "1" + try: + row = query_one( + "INSERT INTO sim_runs (mode,session_id,tick_speed_s,status,started_at) " + "VALUES (%s,%s,%s,'running',NOW()) RETURNING id", + (mode, sid, tick_speed_s)) + run_id = row["id"] if row else None + except Exception: + run_id = None + with _sim_lock: + _sim_procs[mode].update({"output": [], "running": True, "returncode": None, "error": None, "sid": sid}) + try: + proc = subprocess.Popen( + ["python3", "-u", "/app/src/engine/run.py"], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, env=env) + with _sim_lock: + _sim_procs[mode]["proc"] = proc + for line in proc.stdout: + with _sim_lock: + _sim_procs[mode]["output"].append(line.rstrip()) + proc.wait() + with _sim_lock: + _sim_procs[mode]["returncode"] = proc.returncode + _sim_procs[mode]["running"] = False + if run_id: + try: + execute("UPDATE sim_runs SET status='stopped',stopped_at=NOW() WHERE id=%s", (run_id,)) + except Exception: + pass + except Exception as e: + with _sim_lock: + _sim_procs[mode]["error"] = str(e) + _sim_procs[mode]["running"] = False + + +def _get_sim_data(sid: int) -> dict: + session = query_one("SELECT * FROM sessions WHERE id = %s", (sid,)) + if not session: + session = query_one("SELECT * FROM sessions ORDER BY id DESC LIMIT 1") + + characters = query(""" + SELECT c.*, COALESCE(c.tier, 1) as tier + FROM characters c + WHERE c.session_id = %s + ORDER BY COALESCE(c.tier,1), c.is_alive DESC, c.name + """, (sid,)) + alive_count = sum(1 for c in characters if c["is_alive"]) + + events = query(""" + SELECT e.*, c.name as actor_name FROM events e + LEFT JOIN characters c ON c.id = e.actor_id + WHERE e.session_id = %s + AND e.event_type NOT IN ('encounter_lambda','encounter_group','encounter_actif','encounter_boss','cycle_tick') + ORDER BY e.day DESC, e.tick DESC, e.id DESC LIMIT 50 + """, (sid,)) + + encounters = query(""" + SELECT e.*, c.name as actor_name FROM events e + LEFT JOIN characters c ON c.id = e.actor_id + WHERE e.session_id = %s AND e.event_type LIKE 'encounter%%' + ORDER BY e.day DESC, e.tick DESC, e.id DESC LIMIT 30 + """, (sid,)) + + world_state = query(""" + SELECT DISTINCT ON (location_slug) * + FROM world_state WHERE session_id = %s + ORDER BY location_slug, day DESC + """, (sid,)) + + faction_relations = query(""" + SELECT * FROM faction_relations WHERE session_id = %s + ORDER BY relation_score ASC + """, (sid,)) + + tier_labels = {0: "BOSS", 1: "ACTIFS SIM", 2: "PNJ+", 3: "PASSAGE"} + tier_raw = query(""" + SELECT COALESCE(tier,1) as tier, + COUNT(*) FILTER (WHERE is_alive) as alive, + COUNT(*) FILTER (WHERE NOT is_alive) as dead + FROM characters WHERE session_id = %s + GROUP BY COALESCE(tier,1) ORDER BY COALESCE(tier,1) + """, (sid,)) + tier_stats = [{"tier": r["tier"], "label": tier_labels.get(r["tier"], "?"), + "alive": r["alive"], "dead": r["dead"]} for r in tier_raw] + + zone_stats = query(""" + SELECT location_slug, COUNT(*) as cnt FROM characters + WHERE session_id = %s AND is_alive = TRUE + GROUP BY location_slug ORDER BY cnt DESC LIMIT 5 + """, (sid,)) + + ev_total = query_one("SELECT COUNT(*) as n FROM events WHERE session_id=%s", (sid,)) + cb_total = query_one("SELECT COUNT(*) as n FROM events WHERE session_id=%s AND event_type='combat'", (sid,)) + dt_total = query_one("SELECT COUNT(*) as n FROM events WHERE session_id=%s AND event_type='death'", (sid,)) + enc_total = query_one("SELECT COUNT(*) as n FROM events WHERE session_id=%s AND event_type LIKE 'encounter%%'", (sid,)) + + stats = { + "Événements total": ev_total["n"] if ev_total else 0, + "Combats": cb_total["n"] if cb_total else 0, + "Morts": dt_total["n"] if dt_total else 0, + "Rencontres": enc_total["n"] if enc_total else 0, + "PNJ vivants": alive_count, + "PNJ morts": len(characters) - alive_count, + } + + boss_inventory = query(""" + SELECT c.name as char_name, i.item_name, i.item_type, i.quantity, i.is_equipped + FROM inventory i + JOIN characters c ON c.id = i.character_id + WHERE c.session_id = %s AND COALESCE(c.tier,1) = 0 + ORDER BY c.name, i.is_equipped DESC + """, (sid,)) + + return dict( + session=session, + characters=characters, + alive_count=alive_count, + total_count=len(characters), + events=events, + encounters=encounters, + world_state=world_state, + faction_relations=faction_relations, + tier_stats=tier_stats, + zone_stats=zone_stats, + stats=stats, + boss_inventory=boss_inventory, + ) + + +@bp.route("/") +def index(): + all_sessions = get_all_sessions() + sid = int(request.args.get("sid", all_sessions[-1]["id"] if all_sessions else 1)) + data = _get_sim_data(sid) + return render_template("sim.html", tab="sim", current_sid=sid, + all_sessions=all_sessions, flash_msg=None, flash_ok=True, **data) + + +@bp.route("/api/sim//run", methods=["POST"]) +def sim_run(mode): + if mode not in ("truth", "enriched"): + return jsonify({"error": "mode invalide"}), 400 + with _sim_lock: + if _sim_procs[mode].get("running"): + return jsonify({"error": f"Simulation {mode} déjà en cours"}), 400 + data = request.get_json(silent=True) or {} + sid = int(data.get("sid", 1)) + tick_speed_s = int(data.get("tick_speed_s", 5)) + t = threading.Thread(target=_run_sim_bg, args=(mode, sid, tick_speed_s), daemon=True) + t.start() + return jsonify({"ok": True, "sid": sid, "mode": mode}) + + +@bp.route("/api/sim//status") +def sim_status(mode): + if mode not in ("truth", "enriched"): + return jsonify({"error": "mode invalide"}), 400 + with _sim_lock: + state = {k: v for k, v in _sim_procs[mode].items() if k != "proc"} + return jsonify(state) + + +@bp.route("/api/sim//stop", methods=["POST"]) +def sim_stop(mode): + if mode not in ("truth", "enriched"): + return jsonify({"error": "mode invalide"}), 400 + with _sim_lock: + p = _sim_procs[mode].get("proc") + if p: + try: + p.terminate() + except Exception: + pass + return jsonify({"ok": True}) + + +@bp.route("/api/sim//log") +def sim_log(mode): + if mode not in ("truth", "enriched"): + return jsonify({"error": "mode invalide"}), 400 + import io + import json as _json + import datetime as _dt + run = query_one("SELECT * FROM sim_runs WHERE mode=%s ORDER BY id DESC LIMIT 1", (mode,)) + if not run: + return jsonify({"error": "Aucun run trouvé — lancez d'abord une simulation"}), 404 + sid = run["session_id"] + session = query_one("SELECT * FROM sessions WHERE id=%s", (sid,)) + events = query("SELECT * FROM events WHERE session_id=%s ORDER BY id DESC LIMIT 500", (sid,)) + with _sim_lock: + output_lines = list(_sim_procs[mode].get("output", [])) + log_data = { + "meta": { + "sim_mode": mode, + "session_id": sid, + "session_name": session.get("name", "") if session else "", + "world": "Venice of Wasteland, Louisiane post-apo", + "tick_speed_s": run["tick_speed_s"], + "started_at": run["started_at"].isoformat() if run.get("started_at") else None, + "stopped_at": run["stopped_at"].isoformat() if run.get("stopped_at") else None, + "status": run["status"], + "exported_at": _dt.datetime.now().isoformat(), + }, + "console_output": output_lines, + "recent_events": [dict(e) for e in events], + } + buf = io.BytesIO(_json.dumps(log_data, ensure_ascii=False, indent=2, default=str).encode("utf-8")) + return send_file(buf, mimetype="application/json", as_attachment=True, + download_name=f"sim_{mode}_{sid}.json") + + +@bp.route("/api/session/set-mode", methods=["POST"]) +def session_set_mode(): + data = request.get_json(silent=True) or {} + sid = int(data.get("sid", 1)) + mode = data.get("mode", "").strip() + valid = {"pacifiste", "politique", "guerre_commerciale", "guerre", "survie_extreme"} + if mode not in valid: + return jsonify({"error": f"Mode invalide: {mode}"}), 400 + try: + execute("UPDATE sessions SET mode=%s WHERE id=%s", (mode, sid)) + return jsonify({"ok": True, "sid": sid, "mode": mode}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@bp.route("/api/state") +def api_state(): + all_sessions = get_all_sessions() + sid = int(request.args.get("sid", all_sessions[-1]["id"] if all_sessions else 1)) + session = query_one("SELECT * FROM sessions WHERE id=%s", (sid,)) + alive = query(""" + SELECT id, name, hp, max_hp, tier, faction_slug, location_slug + FROM characters WHERE session_id=%s AND is_alive=TRUE + """, (sid,)) + return jsonify({"session": session, "alive_pnj": alive}) diff --git a/dashboard/routes/sim_proposals.py b/dashboard/routes/sim_proposals.py new file mode 100644 index 0000000..36b654a --- /dev/null +++ b/dashboard/routes/sim_proposals.py @@ -0,0 +1,138 @@ +import os +import json +import urllib.request + +from flask import Blueprint, render_template, jsonify, request, redirect, url_for + +from ..db import query, query_one, execute, get_all_sessions +from ..chroma import upsert_sim_enriched, upsert_sim_reject, CHROMA_SIM_COL + +bp = Blueprint("sim_proposals", __name__) + + +def _get_proposals(ptype: str = "", status: str = "pending") -> list: + sql = "SELECT * FROM sim_proposals WHERE 1=1" + params = [] + if ptype: + sql += " AND proposal_type = %s" + params.append(ptype) + if status: + sql += " AND status = %s" + params.append(status) + sql += " ORDER BY id DESC LIMIT 100" + return query(sql, params or None) + + +def _get_stats() -> dict: + rows = query("SELECT status, COUNT(*) as n FROM sim_proposals GROUP BY status") + m = {r["status"]: r["n"] for r in rows} + return {"pending": m.get("pending", 0), "accepted": m.get("accepted", 0), + "rejected": m.get("rejected", 0)} + + +def _get_types() -> list: + rows = query("SELECT DISTINCT proposal_type FROM sim_proposals ORDER BY proposal_type") + return [r["proposal_type"] for r in rows] + + +@bp.route("/sim-proposals") +def sim_proposals_view(): + all_sessions = get_all_sessions() + sid = int(request.args.get("sid", all_sessions[-1]["id"] if all_sessions else 1)) + filt_type = request.args.get("type", "") + filt_status = request.args.get("status", "pending") + proposals = _get_proposals(filt_type, filt_status) + flash_msg = request.args.get("msg") + flash_ok = request.args.get("ok", "1") == "1" + + threads_raw = query("SELECT * FROM sim_proposal_threads ORDER BY created_at ASC") if proposals else [] + proposal_threads = {} + for row in (threads_raw or []): + pid = row["proposal_id"] + proposal_threads.setdefault(pid, []).append(row) + + return render_template("sim_proposals.html", + tab="sim_proposals", current_sid=sid, all_sessions=all_sessions, + proposals=proposals, sim_stats=_get_stats(), sim_types=_get_types(), + filt_type=filt_type, filt_status=filt_status, + proposal_threads=proposal_threads, flash_msg=flash_msg, flash_ok=flash_ok, + ) + + +@bp.route("/sim-proposals/action", methods=["POST"]) +def sim_proposals_action(): + prop_id = int(request.form.get("id", 0)) + action = request.form.get("action", "") + filt_type = request.form.get("filt_type", "") + filt_status = request.form.get("filt_status", "pending") + + proposal = query_one("SELECT * FROM sim_proposals WHERE id = %s", (prop_id,)) + if not proposal: + return redirect(url_for("sim_proposals.sim_proposals_view", msg="Proposition introuvable", ok=0)) + try: + if action == "accept": + execute("UPDATE sim_proposals SET status='accepted', reviewed_at=NOW() WHERE id=%s", (prop_id,)) + upsert_sim_enriched(dict(proposal)) + msg = f"Proposition #{prop_id} acceptée → indexée dans {CHROMA_SIM_COL}." + elif action == "reject": + reason = request.form.get("reject_reason", "").strip() + execute("UPDATE sim_proposals SET status='rejected', reviewed_at=NOW(), reject_reason=%s WHERE id=%s", + (reason or None, prop_id)) + if reason: + try: + upsert_sim_reject(dict(proposal), reason) + except Exception: + pass + msg = f"Proposition #{prop_id} rejetée." + (" Motif indexé." if reason else "") + else: + msg = "Action inconnue." + ok = 1 + except Exception as e: + msg = f"Erreur: {e}" + ok = 0 + + return redirect(url_for("sim_proposals.sim_proposals_view", msg=msg, ok=ok, + type=filt_type, status=filt_status)) + + +@bp.route("/sim-proposals//discuss", methods=["POST"]) +def sim_proposals_discuss(prop_id): + proposal = query_one("SELECT * FROM sim_proposals WHERE id=%s", (prop_id,)) + if not proposal: + return jsonify({"error": "Proposition introuvable"}), 404 + data = request.get_json(silent=True) or {} + user_msg = data.get("message", "").strip() + temperature = float(data.get("temperature", 0.75)) + num_predict = int(data.get("num_predict", 512)) + if not user_msg: + return jsonify({"error": "Message vide"}), 400 + + execute("INSERT INTO sim_proposal_threads (proposal_id,role,content,temperature,num_predict) VALUES (%s,'user',%s,%s,%s)", + (prop_id, user_msg, temperature, num_predict)) + + context = [ + "Tu es un assistant de game design pour un JDR Fallout post-apocalyptique (Louisiane, 'Venice of Wasteland').", + f"Proposition de règle : [{proposal.get('proposal_type','')}] {proposal.get('title','')}", + f"Description : {proposal.get('description','')}", + f"Rationale : {proposal.get('rationale','')}", + "", + f"Le maître de jeu demande : {user_msg}", + "", + "Réponds de façon concise et pratique. Si tu proposes une version modifiée, structure-la clairement.", + ] + ollama_url = os.getenv("OLLAMA_URL", "http://10.8.0.2:11434") + model = os.getenv("MODEL_MJ", "qwen2.5:14b") + try: + payload = json.dumps({"model": model, "prompt": "\n".join(context), "stream": False, + "options": {"temperature": temperature, "num_predict": num_predict}}).encode() + req = urllib.request.Request(f"{ollama_url}/api/generate", + data=payload, headers={"Content-Type": "application/json"}, method="POST") + with urllib.request.urlopen(req, timeout=120) as resp: + result = json.loads(resp.read()) + llm_response = result.get("response", "").strip() + except Exception as e: + return jsonify({"error": f"Erreur LLM : {e}"}), 500 + + execute("INSERT INTO sim_proposal_threads (proposal_id,role,content,temperature,num_predict) VALUES (%s,'llm',%s,%s,%s)", + (prop_id, llm_response, temperature, num_predict)) + return jsonify({"ok": True, "response": llm_response}) diff --git a/dashboard/routes/tools.py b/dashboard/routes/tools.py new file mode 100644 index 0000000..b274fbb --- /dev/null +++ b/dashboard/routes/tools.py @@ -0,0 +1,150 @@ +import os +import json +import time +import threading +import subprocess + +from flask import Blueprint, render_template, jsonify, request + +from ..db import query_one, get_all_sessions +from ..config import SIM_CONFIGS_DIR +from .. import process_mgr as pm + +bp = Blueprint("tools", __name__) + +# ── Stress test state ───────────────────────────────────────────────────────── +_stress_state = { + "running": False, "started_at": None, "mode": None, + "rounds": None, "pnj_count": None, + "output": [], "returncode": None, "error": None, +} +_stress_lock = threading.Lock() + + +def _run_stress_bg(mode: str, rounds: int, pnj_count: int): + global _stress_state + ollama_url = os.getenv("OLLAMA_URL", "http://host.docker.internal:11434") + model_mj = os.getenv("MODEL_MJ", "qwen2.5:14b") + model_pnj = os.getenv("MODEL_PNJ", "qwen2.5:7b") + results_path = os.path.join(SIM_CONFIGS_DIR, "crash_results.json") + cmd = [ + "python3", "/app/tools/llm_crash_test.py", + "--mode", mode, "--rounds", str(rounds), "--pnj-count", str(pnj_count), + ] + env = {**os.environ, + "OLLAMA_URL": ollama_url, "MODEL_MJ": model_mj, "MODEL_PNJ": model_pnj, + "CRASH_RESULTS_PATH": results_path, "PYTHONUNBUFFERED": "1"} + try: + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, env=env) + for line in proc.stdout: + with _stress_lock: + _stress_state["output"].append(line.rstrip()) + proc.wait() + with _stress_lock: + _stress_state["returncode"] = proc.returncode + _stress_state["running"] = False + except Exception as e: + with _stress_lock: + _stress_state["error"] = str(e) + _stress_state["running"] = False + + +# ── Routes ──────────────────────────────────────────────────────────────────── + +@bp.route("/tools") +def tools(): + all_sessions = get_all_sessions() + sid = int(request.args.get("sid", all_sessions[-1]["id"] if all_sessions else 1)) + session = query_one("SELECT * FROM sessions WHERE id = %s", (sid,)) + return render_template("tools.html", tab="tools", current_sid=sid, + all_sessions=all_sessions, session=session, + flash_msg=None, flash_ok=True) + + +@bp.route("/api/stress-test/run", methods=["POST"]) +def api_stress_run(): + global _stress_state + with _stress_lock: + if _stress_state["running"]: + return jsonify({"error": "Un test est déjà en cours.", "running": True}) + mode = request.json.get("mode", "simultane") + rounds = int(request.json.get("rounds", 5)) + pnj_count = int(request.json.get("pnj_count", 2)) + _stress_state = { + "running": True, "started_at": time.strftime("%H:%M:%S"), + "mode": mode, "rounds": rounds, "pnj_count": pnj_count, + "output": [], "returncode": None, "error": None, + } + t = threading.Thread(target=_run_stress_bg, args=(mode, rounds, pnj_count), daemon=True) + t.start() + return jsonify({"ok": True, "message": f"Test '{mode}' {rounds} rounds lancé."}) + + +@bp.route("/api/stress-test/status") +def api_stress_status(): + with _stress_lock: + return jsonify({ + "running": _stress_state["running"], + "started_at": _stress_state["started_at"], + "mode": _stress_state["mode"], + "rounds": _stress_state["rounds"], + "output": _stress_state["output"][-60:], + "returncode": _stress_state["returncode"], + "error": _stress_state["error"], + }) + + +@bp.route("/api/stress-test/results") +def api_stress_results(): + import datetime + path = os.path.join(SIM_CONFIGS_DIR, "crash_results.json") + if not os.path.exists(path): + return jsonify({"error": "Aucun résultat. Lancez d'abord le test."}) + try: + mtime = os.path.getmtime(path) + file_date = datetime.datetime.fromtimestamp(mtime).strftime("%d/%m/%Y %H:%M:%S") + with open(path) as f: + data = json.load(f) + data["file_date"] = file_date + return jsonify(data) + except Exception as e: + return jsonify({"error": f"Erreur lecture : {e}"}) + + +@bp.route("/api/proc//run", methods=["POST"]) +def proc_run(name): + if name == "mix": + if pm.proc_is_running("mix"): + return jsonify({"error": "Mix déjà en cours"}), 400 + data = request.get_json(silent=True) or {} + count = int(data.get("count", 10)) + pm.proc_mix_start(count) + return jsonify({"ok": True, "label": f"Mode MIX — {count if count else '∞'}× chaque"}) + if name not in pm._PROC_DEFS: + return jsonify({"error": f"Processus inconnu: {name}"}), 400 + if pm.proc_is_running(name): + return jsonify({"error": "Déjà en cours"}), 400 + defn = pm._PROC_DEFS[name] + data = request.get_json(silent=True) or {} + count = int(data.get("count", 1)) + cmd = list(defn["cmd"]) + if name == "sim_enricher" and data.get("type"): + try: cmd[cmd.index("all")] = data["type"] + except ValueError: pass + if name == "lore_enricher" and data.get("faction"): + try: cmd[cmd.index("all")] = data["faction"] + except ValueError: pass + pm.proc_start(name, cmd, defn.get("env_extra", {}), count) + return jsonify({"ok": True, "label": defn["label"]}) + + +@bp.route("/api/proc//status") +def proc_status(name): + return jsonify(pm.proc_get_state(name)) + + +@bp.route("/api/proc//stop", methods=["POST"]) +def proc_stop_route(name): + pm.proc_stop(name) + return jsonify({"ok": True}) diff --git a/dashboard/start.sh b/dashboard/start.sh index 5b0cf38..6b2a020 100755 --- a/dashboard/start.sh +++ b/dashboard/start.sh @@ -1,11 +1,10 @@ #!/bin/sh set -e -# Tunnel SSH vers Vigile (DB) cp /ssh/vigile.key /tmp/vigile.key chmod 600 /tmp/vigile.key ssh -fN -L 15432:localhost:5432 -i /tmp/vigile.key -o StrictHostKeyChecking=no -o ServerAliveInterval=30 ubuntu@79.72.30.231 echo '[start.sh] Tunnel SSH vers Vigile établi sur 127.0.0.1:15432' -exec python app.py +exec python -m dashboard.app diff --git a/dashboard/templates/base.html b/dashboard/templates/base.html new file mode 100644 index 0000000..4f40a0a --- /dev/null +++ b/dashboard/templates/base.html @@ -0,0 +1,218 @@ + + + + + +PipBoy — Fallout: Venice of Wasteland + + + + +
+ 📟 PIPBOY +
+ {% for s in all_sessions %} + {% set is_active = s.status == 'active' %} + + + S{{ s.id }} + + {% endfor %} +
+ + +
+ +{% if flash_msg %} +
{{ flash_msg }}
+{% endif %} + +
+{% block content %}{% endblock %} +
+ + + + diff --git a/dashboard/templates/lore.html b/dashboard/templates/lore.html new file mode 100644 index 0000000..e48d64a --- /dev/null +++ b/dashboard/templates/lore.html @@ -0,0 +1,141 @@ +{% extends "base.html" %} +{% block content %} + +
+ 📜 Enrichissement Lore — Venice of Wasteland +
+ En attente : {{ lore_stats.pending }} + Acceptées : {{ lore_stats.accepted }} + Rejetées : {{ lore_stats.rejected }} + Modifiées : {{ lore_stats.modified }} +
+
+ +
+ + + + + {{ proposals|length }} proposition(s) affichée(s) +
+ +{% if not proposals %} +
+ Aucune proposition lore.
+ Lancer : python3 lore_enricher.py --faction grand_krewe sur Ampère +
+{% endif %} + +{% for p in proposals %} +
+
+ #{{ p.id }} + {{ p.status | upper }} + {{ p.change_type }} + {{ p.faction_slug }} + {% if p.field_path %}{{ p.field_path }}{% endif %} + {{ p.created_at.strftime('%d/%m %H:%M') if p.created_at else '' }} +
+ +

{{ p.rationale or '(pas de justification)' }}

+ +
+
+
Original
+
{{ p.original_text or '(ajout — pas de texte original)' }}
+
+
+
Proposé
+
{{ p.proposed_text }}
+
+
+ + {% if p.status == 'pending' %} +
+
+ + + + + +
+
+ + + + + +
+
+ + + + + + +
+
+ {% elif p.status == 'modified' and p.modified_text %} +
+
Texte modifié accepté
+
{{ p.modified_text }}
+
+ {% endif %} + + {% if p.status in ('modified', 'accepted') %} + + + {% endif %} +
+{% endfor %} + + +{% endblock %} diff --git a/dashboard/templates/params.html b/dashboard/templates/params.html new file mode 100644 index 0000000..f7e7d0f --- /dev/null +++ b/dashboard/templates/params.html @@ -0,0 +1,173 @@ +{% extends "base.html" %} +{% block content %} + +{% if session %} +
+ Session #{{ session.id }} — {{ session.name }} + {{ session.mode | upper }} + {% set st = session.status %} + + + {{ st | upper }} + + Jour {{ session.current_day }} / Tick {{ session.current_tick }}h +
+{% endif %} + +
+ + +
+

⚙ Config sim_{{ '%03d' % current_sid }}.json

+
+ {% if cfg is not none %} + +
+

Général

+
+ + +
+
+ + +
+
+ + +
+
+ +
+

Survie

+
+ + +
+
+ + +
+
+ + +
+
+ +
+

Rencontres

+
+ + +
+
+ + +
+
+ +
+

Économie

+
+ + +
+
+ + +

+ Prise en compte au prochain jour (J{{ (session.current_day or 0) + 1 if session else '?' }}). + Pour effet immédiat : STOP → relancer via terminal. +

+ {% else %} +

+ Fichier sim_{{ '%03d' % current_sid }}.json introuvable dans {{ sim_configs_dir }} +

+

+ Vérifier le mount Docker : /home/ubuntu/fallout-venice/src/config → /app/src/config +

+ {% endif %} +
+
+ + +
+

🔧 Gestion session #{{ current_sid }}

+ +
+

Statut DB

+
+
+ + +
+
+ + +
+
+ + +
+
+

+ STOP → la sim s'arrête dans les 5 ticks suivants. +

+
+ +
+

Reset jour/tick

+
+
+ + +
+
+ + +
+ +
+
+ +
+

Modes disponibles

+ + + {% for m_name, m_vals in modes.items() %} + + + + + + + {% endfor %} +
ModeDrainEncountersCaps
+ {{ m_name }}{% if cfg and m_name == cfg.get('mode','') %} ◀{% endif %}×{{ m_vals.get('survival',{}).get('drain_speed','—') }}×{{ m_vals.get('encounter',{}).get('global_rate_multiplier','—') }}×{{ m_vals.get('economy',{}).get('caps_global_multiplier','—') }}
+
+ +
+

Commandes SSH (référence)

+
+
tail -f ~/fallout_sim_s{{ current_sid }}.log
+ +
+
+
+ +
+ + +{% endblock %} diff --git a/dashboard/templates/sim.html b/dashboard/templates/sim.html new file mode 100644 index 0000000..1957eb7 --- /dev/null +++ b/dashboard/templates/sim.html @@ -0,0 +1,402 @@ +{% extends "base.html" %} +{% block content %} + +{% if session %} +
+ Session #{{ session.id }} — {{ session.name }} + Jour {{ session.current_day }} / Tick {{ session.current_tick }}h + {{ session.mode | upper }} + {% set st = session.status %} + + + {{ st | upper }} + + Seed: {{ session.seed_global }} + + ↻ Actualiser + + +
+{% endif %} + +
+
PNJ Vivants
{{ alive_count }}
+
PNJ Morts
{{ stats.get('PNJ morts', 0) }}
+
Total PNJ
{{ total_count }}
+
Combats
{{ stats.get('Combats', 0) }}
+
Rencontres
{{ stats.get('Rencontres', 0) }}
+
Événements
{{ stats.get('Événements total', 0) }}
+
+ +
+
+

👥 PNJ ({{ alive_count }}/{{ total_count }})

+
+ + + {% for c in characters %} + {% set pct = (c.hp / c.max_hp * 100) | int if c.max_hp else 0 %} + + + + + + + + + {% endfor %} +
TierNomPVFactionZoneCaps
+ {% if c.tier == 0 %}★ BOSS + {% elif c.tier == 2 %}T2 + {% elif c.tier == 3 %}T3 + {% else %}T1{% endif %} + {{ c.name }} + {% if c.is_alive %} + {{ c.hp }}/{{ c.max_hp }} + {% else %}{% endif %} + + {% set f = c.faction_slug or 'sans' %} + {{ f[:14] }} + {{ (c.location_slug or '—')[:16] }}{{ c.caps }}¢
+
+
+ +
+

📡 Événements récents

+
+ + + {% for ev in events %} + + + + + + {% endfor %} + {% if not events %}{% endif %} +
J/TTypeDescription
{{ ev.day }}/{{ ev.tick }}{{ ev.event_type }}{{ ev.description[:90] }}{% if ev.description|length > 90 %}…{% endif %}
Aucun événement
+
+
+
+ +
+
+

🗺 État du monde

+
+ {% for ws in world_state %} +
+ {{ ws.location_slug }} + + 🍖 {{ ws.food_level or 0 }}% + 💧 {{ ws.water_level or 0 }}% + 🛡 {{ ws.security_level or 0 }}% + 👤 {{ ws.pop_count or 0 }} + +
+ {% endfor %} + {% if not world_state %}

Pas de données monde

{% endif %} +
+
+ +
+

⚔ Relations factions

+
+ + + {% for r in faction_relations %} + {% set cls = 'rel-hostile' if r.relation_score < -20 else 'rel-allie' if r.relation_score > 20 else 'rel-neutral' %} + + + + + + + {% endfor %} + {% if not faction_relations %}{% endif %} +
Faction AFaction BScoreÉtat
{{ r.faction_a[:14] }}{{ r.faction_b[:14] }}{{ r.relation_score }}{{ r.relation_label or ('HOSTILE' if r.relation_score < -20 else 'ALLIÉ' if r.relation_score > 20 else 'NEUTRE') }}
Pas de données
+
+
+ +
+

🎭 PNJ par tier

+ + + {% for row in tier_stats %} + + + + + + + {% endfor %} +
TierLabelVivantsMorts
{{ row.tier }}{{ row.label }}{{ row.alive }}{{ row.dead }}
+

🏙 PNJ par zone (top 5)

+ + + {% for row in zone_stats %} + + + + + {% endfor %} +
ZonePNJ
{{ row.location_slug }}{{ row.cnt }}
+
+
+ +
+

⚡ Rencontres récentes

+
+ + + {% for ev in encounters %} + + + + + + + + {% endfor %} + {% if not encounters %}{% endif %} +
J/TTypeActeurZoneDescription
{{ ev.day }}/{{ ev.tick }}{{ ev.event_type }}{{ ev.actor_name or '—' }}{{ (ev.location_slug or '—')[:16] }}{{ ev.description[:90] }}{% if ev.description|length > 90 %}…{% endif %}
Aucune rencontre
+
+
+ +{% if boss_inventory %} +
+

★ Équipement chefs de faction

+ + + {% for row in boss_inventory %} + + + + + + + + {% endfor %} +
BossItemTypeQtéÉquipé
{{ row.char_name }}{{ row.item_name }}{{ row.item_type }}{{ row.quantity }}{{ '✓' if row.is_equipped else '' }}
+
+{% endif %} + + +
+
+ + + +
+ Tick global : +
+ + + + + +
+
+
+ +
+
+
+ + 📚 Sim Vérité + sources : fallout_lore + fallout_sim_rules uniquement +
+
+ Session : #{{ current_sid }} + +
+
Arrêtée
+
+ + + +
+ +
+
+ + + + +
+ + +{% endblock %} diff --git a/dashboard/templates/sim_proposals.html b/dashboard/templates/sim_proposals.html new file mode 100644 index 0000000..97c7e3b --- /dev/null +++ b/dashboard/templates/sim_proposals.html @@ -0,0 +1,121 @@ +{% extends "base.html" %} +{% block content %} + +
+ 🎲 Règles Sim — Propositions LLM +
+ En attente : {{ sim_stats.pending }} + Acceptées : {{ sim_stats.accepted }} + Rejetées : {{ sim_stats.rejected }} +
+
+ +
+ + + + + {{ proposals|length }} proposition(s) +
+ +{% if not proposals %} +
+ Aucune proposition.
+ Lancer : python3 sim_enricher.py --type all sur Ampère +
+{% endif %} + +{% for p in proposals %} +
+
+ #{{ p.id }} + {{ p.status | upper }} + {{ p.proposal_type }} + {{ p.created_at.strftime('%d/%m %H:%M') if p.created_at else '' }} +
+ +

{{ p.title }}

+

{{ p.description }}

+ + {% if p.rationale %} +

💡 {{ p.rationale }}

+ {% endif %} + + {% if p.json_patch %} +
{{ p.json_patch | tojson(indent=2) }}
+ {% endif %} + + {% if p.status == 'pending' %} +
+
+ + + + + +
+
+ + + + + + +
+
+ {% endif %} + + + +
+{% endfor %} + + +{% endblock %} diff --git a/dashboard/templates/tools.html b/dashboard/templates/tools.html new file mode 100644 index 0000000..9a95cf1 --- /dev/null +++ b/dashboard/templates/tools.html @@ -0,0 +1,329 @@ +{% extends "base.html" %} +{% block content %} + + + + +
+

⚡ Stress Test LLM

+
+ +
+ + + + +
+
+
+ +
+ + + +
+
+
+ +
+ + + +
+
+
+ + + + +
+ + + +
+
+ + +
+

🧠 Enrichissement LLM

+
+ +
+
+ 🎲 Règles sim → sim_proposals + + + + + +
+ + +
+ +
+
+ 📜 Lore → lore_proposals + + + + + +
+ + +
+ +
+
+ 🔄 Mode MIX — sim + lore en alternance + + + + +
+ + +
+
+
+ + +
+

🔁 Re-embedding Opération rare — seulement si nouveaux PDFs ou configs modifiées

+
+ +
+
+ PDFs Fallout → fallout_lore + + + + +
+ + +
+ +
+
+ Configs sim → fallout_sim_rules + + + + +
+ + +
+
+
+ +{% endblock %} diff --git a/docker-compose.yml b/docker-compose.yml index c58877d..7715eb6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,12 +5,11 @@ services: restart: unless-stopped working_dir: /app volumes: - - ./dashboard/app.py:/app/app.py:ro - - ./dashboard/start.sh:/app/start.sh:ro + - /opt/coyote/fallout/src/dashboard:/app/dashboard:ro - /home/ubuntu/.ssh/vigile_backup:/ssh/vigile.key:ro - - ./src/config:/app/src/config - - ./tools:/app/tools:ro - command: sh -c 'apk add --no-cache openssh-client && pip install flask psycopg2-binary --quiet && sh /app/start.sh' + - /opt/coyote/fallout/src/config:/app/src/config + - /opt/coyote/fallout/tools:/app/tools:ro + command: sh -c 'apk add --no-cache openssh-client && pip install flask psycopg2-binary --quiet && sh /app/dashboard/start.sh' networks: - proxy-nw extra_hosts: @@ -28,6 +27,7 @@ services: - OLLAMA_URL=${OLLAMA_URL} - MODEL_MJ=${MODEL_MJ} - MODEL_PNJ=${MODEL_PNJ} + - CHROMA_URL=http://chromadb:8000 networks: proxy-nw: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..eee9252 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +pymupdf diff --git a/src/engine/lore_enricher.py b/src/engine/lore_enricher.py index 03cc606..08b1168 100644 --- a/src/engine/lore_enricher.py +++ b/src/engine/lore_enricher.py @@ -77,9 +77,11 @@ def _get_collection_id(name: str) -> str | None: return c["id"] return None -def chroma_query(collection_id: str, query_text: str, n: int = 8, +def chroma_query(collection_id, query_text: str, n: int = 8, where: dict | None = None) -> list[dict]: """Recherche sémantique dans une collection Chroma.""" + if not collection_id: + return [] body = { "query_texts": [query_text], "n_results": n, @@ -98,7 +100,7 @@ def chroma_query(collection_id: str, query_text: str, n: int = 8, print(f" [chroma] Erreur query: {e}") return [] -def chroma_get_by_category(collection_id: str, category: str) -> list[dict]: +def chroma_get_by_category(collection_id, category: str) -> list[dict]: """Récupère tous les chunks d'une catégorie.""" body = { "where": {"category": {"$eq": category}}, diff --git a/src/engine/run.py b/src/engine/run.py index ac41560..ffdcb35 100644 --- a/src/engine/run.py +++ b/src/engine/run.py @@ -99,7 +99,7 @@ def run(config_path: str | None = None): cfg.load(config_path, session_id=SESSION_ID) effective_mode = cfg.mode() or db_mode tick_sleep = float(os.getenv("TICK_SLEEP_SEC", - cfg.get("tick", {}).get("tick_sleep_sec", tick_sleep))) + cfg.get("tick", "tick_sleep_sec", tick_sleep))) _init_world_state(SESSION_ID, day) except KeyboardInterrupt: