Compare commits

...

5 Commits

26 changed files with 2789 additions and 2067 deletions
+7 -1
View File
@@ -7,4 +7,10 @@ __pycache__/
.DS_Store
Thumbs.db
INFRA_PRIVATE.md
.env
CLAUDE.md
ROADMAP.md
PDF/
deploy.sh
*.bak
*.bak_*
docker-compose.yml.bak_pre_refacto
-216
View File
@@ -1,216 +0,0 @@
# ROADMAP — Fallout: Venice of Wasteland
État au **16 juin 2026**. Projet : moteur de simulation JDR Fallout 2D20 autonome, Louisiane post-nucléaire.
---
## ✅ Phase 1 — Moteur de base (TERMINÉ)
- [x] Moteur de tick Python (survie, économie, rencontres)
- [x] Système PNJ 4 tiers (Tier 0 Boss / Tier 1 Actifs / Tier 2 PNJ+ / Tier 3 Passage)
- [x] Combats 2D20 (combat_engine.py)
- [x] Sessions multiples isolées (session_id sur toutes les tables)
- [x] Config JSON par session avec deep-merge par mode
- [x] Hot-reload config au début de chaque jour (sans redémarrer)
- [x] Vérification statut DB dans la boucle principale (stop depuis dashboard)
- [x] Zones sûres (safe zones) — pas de rencontres lambda, commerce PNJ-à-PNJ
- [x] Système de raids de faction dans les zones sûres (faible probabilité)
- [x] Relations factions avec score de volatilité
---
## ✅ Phase 2 — RAG & Lore (TERMINÉ)
- [x] ChromaDB avec 1403 chunks (règles 2D20 + lore canon Venice of Wasteland)
- [x] Bible Venice of Wasteland v1.1 & v2.0 indexée (lore_canon, 32 chunks)
- [x] Correction des 299 chunks avec métadonnée `type``category`
- [x] Pipeline d'enrichissement lore (lore_enricher.py)
- RAG depuis fallout_lore (lore_canon + regles_core + lore_inspiration)
- Appel qwen2.5:14b avec prompt structuré JDR
- Propositions stockées dans `lore_proposals` (PostgreSQL)
- Validation manuelle depuis le PipBoy
- Propositions acceptées → `fallout_lore_enriched` (Chroma)
---
## ✅ Phase 3 — Dashboard PipBoy (TERMINÉ)
- [x] Dashboard Flask accessible sur fallout.coyoteos.ovh
- [x] Onglet **SIM LIBRE** : état temps réel (PNJ, events, world state, factions, rencontres)
- [x] Onglet **OUTILS** : génération commandes + stress test LLM avec console live
- [x] Onglet **ENRICHISSEMENT** : validation propositions lore (accept/reject/modifier)
- [x] Onglet **PARAMÈTRES** : config session, modes, reset jour/tick, statut DB
- [x] Switcher de sessions (S1/S2)
- [x] Auto-refresh 30s sur l'onglet simulation
- [x] Métriques cards (PNJ vivants/morts, combats, rencontres)
- [x] Bouton "Exécuter" stress test avec console live (polling 1.5s)
- [x] Accès Ollama depuis le container Docker (iptables + host.docker.internal)
---
## ✅ Phase 4 — Tests de performance (TERMINÉ)
- [x] Script stress test LLM (llm_crash_test.py) — modes simultane/decale/solo
- [x] Script capacity test (capacity_test.py) — simulation N joueurs simultanés
- [x] Résultats mesurés : 1/5/10/50 joueurs, tick 35s → 3m32
- [x] Conclusion : 10-15 joueurs actifs simultanés recommandés sur l'infra actuelle
---
## 🔄 Phase 5 — LLM intégré à la simulation (EN COURS / PROCHAIN)
### 5a — Animation PNJ par LLM (priorité haute)
- [ ] Appel LLM 7b uniquement sur interaction joueur directe avec Tier 0/1
- [ ] Contexte RAG injecté : lore_canon faction + règles pertinentes
- [ ] Mémoire courte terme : 3 derniers échanges du PNJ en contexte
- [ ] Réponse stockée en DB (events table) avec attribution actor_id
- [ ] Limit : max N appels LLM PNJ simultanés par tick (configurable)
### 5b — MJ narrateur par LLM (priorité haute)
- [ ] Synthèse narrative du tick par le 14b (résumé de ce qui s'est passé)
- [ ] Stockée comme événement `narrative_tick` en DB
- [ ] Affichage dans PipBoy onglet SIM
- [ ] Contexte : world_state + top 10 événements du tick + relations factions
### 5c — Onglet SIM LLM dans PipBoy
- [ ] Vue dédiée pour les sessions avec LLM actif
- [ ] Feed narratif du MJ en temps réel
- [ ] Indicateur de charge LLM (requêtes en queue)
---
## 🔮 Phase 6 — Arbitre & Cohérence (CONÇU, NON IMPLÉMENTÉ)
Système de vérification automatique entre les ticks, utilisant la marge de 6m30 disponible.
### Architecture
```
Tick N (3m30) → Résultats en DB
Arbitre LLM (3m00) → Vérification cohérence + règles
├── Auto-correction (patterns connus)
└── File manuelle → PipBoy validation
Tick N+1 → Démarre sur une base cohérente
```
### Tables à créer
- `tick_validations` — anomalies détectées par tick
- `correction_patterns` — patterns appris, score de confiance
### Logique d'apprentissage
- Chaque validation manuelle augmente la confiance du pattern (+delta)
- Chaque rejet diminue la confiance (-delta)
- Seuil configurable (défaut 0.8) → bascule en auto-correction
- Types de patterns : `stat_check` | `location` | `economy` | `narrative`
### Ce que l'arbitre vérifie
- **Auto (confiance rapide)** : caps négatifs, HP > max, PNJ mort qui agit, localisation impossible
- **Manuel (confiance lente)** : cohérence narrative, comportement faction vs lore, conflits de traités
---
## 🔮 Phase 7 — Ticks temps réel (IDÉE VALIDÉE, NON IMPLÉMENTÉ)
Synchroniser les ticks sur l'heure réelle de la Louisiane (UTC-5/UTC-6).
### Principe
- 1 tick = 1 heure réelle (timezone `America/Chicago`)
- `run.py` calcule le tick courant depuis `datetime.now(tz)` au lieu d'incrémenter
- Résout la dérive en cas de redémarrage
- Nuit en jeu = vraiment la nuit (20h-8h Louisiana = 2h-14h France)
### Impact joueur
- Action possible toutes les X minutes réelles
- Le MJ et les PNJ "vivent" même quand le joueur est absent
- Journées réelles = journées en jeu
### Config à ajouter dans sim_XXX.json
```json
"tick": {
"mode": "realtime",
"realtime_timezone": "America/Chicago",
"fixed_tick_sleep_sec": 60
}
```
### Considération fuseau horaire
- France UTC+2 vs Louisiane UTC-5/6 → 7-8h de décalage
- Midi à NOLA = 18h-19h en France → créneau jouable en soirée
- Nuit à NOLA se passe pendant les heures de travail en France → cohérent narrativement
---
## 🔮 Phase 8 — Météo procédurale (IDÉE, NON IMPLÉMENTÉ)
Système météo calé sur la météo réelle de la Louisiane.
- API météo New Orleans → injectée dans le contexte LLM chaque jour
- Impact mécanique : pluie = -visibilité, chaleur extrême = drain eau ×1.5
- Événements saisonniers : saison des ouragans (juin-novembre), Mardi Gras
- Stocké dans `world_state` par zone avec colonne `weather_json`
---
## 🔮 Phase 9 — Interfaces multiples (VISION LONG TERME)
Trois interfaces distinctes, même backend, accès différenciés.
### 9a — Interface Admin (PipBoy v2, évolution de l'actuel)
- Accès restreint (auth basique ou token)
- Tout ce que fait le PipBoy actuel + logs bruts + contrôle fin des sessions
- Gestion des joueurs : création de compte, reset de personnage, ban
- Vue globale multi-sessions en temps réel
### 9b — Interface Joueur
- Flask léger ou SvelteKit (si JS est inévitable)
- Un joueur = un personnage = une session
- Actions possibles : se déplacer, interagir avec un PNJ (→ déclenche LLM 7b), commercer
- Réponse PNJ via LLM 7b dans la foulée (<10s)
- Résumé MJ 14b à la fin du tick (feed narratif)
- Statut du personnage : HP, inventaire, position, derniers events
- Gestion des absences : le PNJ joueur survit en mode automatique (Tier 1)
- **Brique à préparer dès Phase 5** : API REST `POST /player/action` + `GET /player/state`
### 9c — Interface Bug / Rapport
- Formulaire simple accessible depuis l'interface joueur
- Champ : description du bug + tick/session automatiquement attachés
- Stocké en table `bug_reports` (PostgreSQL) avec statut open/closed
- Visible depuis l'interface Admin
### Architecture cible
```
┌──────────────┐
│ API centrale │ (Flask ou FastAPI)
│ /admin/* │← Admin (PipBoy évolué)
│ /player/* │← Interface Joueur
│ /bug/* │← Rapports bugs
└──────┬───────┘
┌────────────┼────────────┐
DB Vigile Ollama ChromaDB
```
**Décision clé à anticiper** : séparer Admin et Joueur en deux Flask (ports différents, auth différente) ou un seul Flask avec blueprints + décorateur `@require_role`. La deuxième option est plus simple à l'échelle actuelle.
---
## Backlog technique (bugs connus / dettes)
- [x] `fallout-visu/app.py` et `dashboard/app.py` dupliqués → ✅ unifié en juin 2026 (docker-compose monte directement `dashboard/app.py`)
- [ ] Le container PipBoy se réinstalle à chaque restart (pip install au démarrage) → créer une image Docker dédiée avec les dépendances pré-installées
- [ ] `capacity_test.py` écrit en root depuis le container (permission denied) → monter le volume avec le bon uid
- [ ] 8 timeouts sur 50 joueurs simultanés → augmenter timeout à 300s ou implémenter une queue avec backpressure
- [ ] Pas de persistance de l'iptables après reboot complet du host → vérifier que `netfilter-persistent` est installé
---
## Décisions d'architecture à retenir
| Décision | Raison |
|----------|--------|
| LLM PNJ déclenché sur interaction seulement (pas chaque tick) | Évite de saturer Ollama sur les 100+ PNJ de la sim |
| `fallout_lore` en lecture seule | Source de vérité immuable — les enrichissements vont dans `fallout_lore_enriched` |
| Validation manuelle avant auto (Phase 6) | Constituer un corpus de corrections avant d'automatiser |
| Ticks temps réel en option (pas par défaut) | Handicap pour les joueurs français si activé sans réflexion |
| PostgreSQL sur Vigile (OVH) séparé d'Ampère | Résilience — si Ampère tombe, les données persistent |
| Sessions isolées par `session_id` | Permet tests en parallèle sans collision de données |
View File
+11 -1840
View File
File diff suppressed because it is too large Load Diff
+97
View File
@@ -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']}",
}],
})
+26
View File
@@ -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", {})
+45
View File
@@ -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")
+152
View File
@@ -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
View File
+95
View File
@@ -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))
+83
View File
@@ -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))
+261
View File
@@ -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/<mode>/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/<mode>/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/<mode>/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/<mode>/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})
+138
View File
@@ -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/<int:prop_id>/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})
+150
View File
@@ -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/<name>/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/<name>/status")
def proc_status(name):
return jsonify(pm.proc_get_state(name))
@bp.route("/api/proc/<name>/stop", methods=["POST"])
def proc_stop_route(name):
pm.proc_stop(name)
return jsonify({"ok": True})
+1 -2
View File
@@ -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
+218
View File
@@ -0,0 +1,218 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PipBoy — Fallout: Venice of Wasteland</title>
<style>
:root {
--green: #4ade80;
--red: #f87171;
--amber: #fbbf24;
--blue: #60a5fa;
--bg: #0f1117;
--panel: #161b27;
--panel2: #1a1f2e;
--border: #2a3040;
--text: #e8e8e8;
--muted: #8892a4;
--dim: #4a5568;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: var(--bg); color: var(--text); font-family: system-ui, 'Segoe UI', sans-serif; font-size: 14px; }
.topbar { display: flex; align-items: center; gap: 8px; padding: 10px 16px; background: #0a0d14; border-bottom: 1px solid var(--border); }
.topbar-title { font-size: 15px; font-weight: 600; color: var(--green); letter-spacing: 1px; margin-right: 8px; }
.session-pills { display: flex; gap: 6px; }
.session-pill { padding: 4px 12px; border-radius: 20px; border: 1px solid var(--border); color: var(--muted); text-decoration: none; font-size: 12px; font-weight: 500; display: inline-flex; align-items: center; gap: 5px; transition: all .15s; }
.session-pill:hover { border-color: var(--dim); color: var(--text); }
.session-pill.active { border-color: var(--green); color: var(--green); background: rgba(74,222,128,.08); }
.session-pill.stopped { border-color: var(--red); color: var(--red); }
.session-pill .dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; }
.dot-green { background: var(--green); }
.dot-red { background: var(--red); }
.dot-amber { background: var(--amber); }
.nav-sep { flex-grow: 1; }
.nav-tabs { display: flex; gap: 2px; }
.nav-tabs a { padding: 6px 14px; text-decoration: none; color: var(--muted); font-size: 13px; font-weight: 500; border-radius: 6px 6px 0 0; border: 1px solid transparent; border-bottom: none; transition: all .15s; }
.nav-tabs a:hover:not(.active) { color: var(--text); background: var(--panel2); }
.nav-tabs a.active { color: var(--amber); background: var(--panel); border-color: var(--border); border-bottom-color: var(--panel); }
.main { padding: 16px; }
.flash { padding: 8px 12px; margin: 0 16px 12px; border-radius: 6px; font-size: 13px; }
.flash-ok { background: rgba(74,222,128,.1); border: 1px solid rgba(74,222,128,.3); color: var(--green); }
.flash-err { background: rgba(248,113,113,.1); border: 1px solid rgba(248,113,113,.3); color: var(--red); }
.info-bar { display: flex; align-items: center; gap: 16px; padding: 10px 14px; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; margin-bottom: 16px; font-size: 13px; flex-wrap: wrap; }
.info-bar .label { color: var(--muted); }
.info-bar .val { color: var(--text); font-weight: 500; }
.status-badge { display: inline-flex; align-items: center; gap: 5px; padding: 2px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; }
.status-active { background: rgba(74,222,128,.12); color: var(--green); }
.status-stopped { background: rgba(248,113,113,.12); color: var(--red); }
.status-paused { background: rgba(251,191,36,.12); color: var(--amber); }
.mode-tag { padding: 2px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; background: rgba(96,165,250,.1); color: var(--blue); border: 1px solid rgba(96,165,250,.2); }
.metric-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 10px; margin-bottom: 16px; }
.metric-card { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 12px 14px; }
.metric-card .metric-label { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; margin-bottom: 4px; }
.metric-card .metric-value { font-size: 26px; font-weight: 700; line-height: 1; }
.metric-green { color: var(--green); }
.metric-red { color: var(--red); }
.metric-amber { color: var(--amber); }
.metric-blue { color: var(--blue); }
.metric-muted { color: var(--muted); }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.grid3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; margin-top: 12px; }
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 12px; }
.panel h2 { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .8px; color: var(--amber); border-bottom: 1px solid var(--border); padding-bottom: 8px; margin-bottom: 10px; }
table { width: 100%; border-collapse: collapse; }
th { color: var(--muted); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .5px; padding: 6px 10px; text-align: left; border-bottom: 1px solid var(--border); }
td { padding: 6px 10px; font-size: 13px; border-bottom: 1px solid rgba(42,48,64,.5); vertical-align: middle; }
tr:nth-child(even) td { background: #1a1a2e; }
tr:hover td { background: rgba(74,222,128,.04) !important; }
.dead td { opacity: .45; text-decoration: line-through; }
.tier0-text { color: #fb923c; }
.tier1-text { color: var(--green); }
.tier2-text { color: var(--blue); }
.tier3-text { color: var(--muted); }
.hp-ok { color: var(--green); font-weight: 600; }
.hp-low { color: var(--amber); font-weight: 600; }
.hp-crit { color: var(--red); font-weight: 600; }
.faction-union { color: #60a5fa; }
.faction-cda { color: #f87171; }
.faction-ecumeurs { color: #c084fc; }
.faction-syndicat { color: #facc15; }
.faction-krewe { color: #34d399; }
.faction-oak { color: #fb923c; }
.faction-regie { color: #e2e8f0; }
.faction-default { color: var(--muted); }
.ev-combat { color: var(--red); }
.ev-encounter { color: var(--amber); }
.ev-survival { color: #86efac; }
.ev-death { color: #ff0000; font-weight: 700; }
.rel-hostile { color: var(--red); }
.rel-neutral { color: var(--muted); }
.rel-allie { color: var(--green); }
.scrollable { max-height: 300px; overflow-y: auto; }
.world-zone { display: flex; justify-content: space-between; align-items: center; padding: 5px 0; border-bottom: 1px solid rgba(42,48,64,.5); font-size: 13px; }
.zone-name { color: var(--amber); min-width: 150px; }
.zone-stats { display: flex; gap: 10px; font-size: 12px; color: var(--muted); }
.zone-stats span { color: var(--text); }
.cfg-block { margin-bottom: 16px; }
.cfg-block h3 { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .8px; color: var(--amber); border-bottom: 1px solid var(--border); padding-bottom: 6px; margin-bottom: 10px; }
.cfg-row { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.cfg-row label { min-width: 230px; font-size: 13px; color: var(--muted); }
.cfg-row input[type=number], .cfg-row input[type=text], .cfg-row select { background: #0a0d14; color: var(--text); border: 1px solid var(--border); border-radius: 5px; padding: 5px 10px; font-family: inherit; font-size: 13px; width: 140px; }
.cfg-row input[type=checkbox] { accent-color: var(--green); width: auto; }
.cfg-row select:focus, .cfg-row input:focus { outline: none; border-color: var(--amber); }
.btn { padding: 7px 18px; border: 1px solid var(--border); background: var(--panel2); color: var(--text); font-family: inherit; font-size: 13px; font-weight: 500; border-radius: 6px; cursor: pointer; transition: all .15s; }
.btn:hover { border-color: var(--dim); background: #222736; }
.btn-green { border-color: var(--green); color: var(--green); background: rgba(74,222,128,.08); }
.btn-green:hover { background: rgba(74,222,128,.15); }
.btn-red { border-color: var(--red); color: var(--red); background: rgba(248,113,113,.08); }
.btn-red:hover { background: rgba(248,113,113,.15); }
.btn-amber { border-color: var(--amber); color: var(--amber); background: rgba(251,191,36,.08); }
.btn-amber:hover { background: rgba(251,191,36,.15); }
.code-block { position: relative; margin: 8px 0; }
.code-block pre { background: #0a0d14; border: 1px solid var(--border); border-radius: 6px; padding: 10px 14px; font-family: 'Consolas', 'Courier New', monospace; font-size: 12px; color: #86efac; white-space: pre-wrap; word-break: break-all; margin: 0; padding-right: 80px; }
.copy-btn { position: absolute; top: 6px; right: 8px; padding: 3px 10px; font-size: 11px; border: 1px solid var(--border); background: var(--panel2); color: var(--muted); border-radius: 4px; cursor: pointer; font-family: inherit; }
.copy-btn:hover { color: var(--text); border-color: var(--dim); }
.lore-card { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 14px; margin-bottom: 10px; }
.lore-card h3 { font-size: 13px; color: var(--text); margin-bottom: 8px; font-weight: 500; }
.lore-diff { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 10px; }
.diff-old { background: rgba(248,113,113,.06); border: 1px solid rgba(248,113,113,.2); border-radius:5px; padding: 8px; font-size: 12px; color: #fca5a5; white-space: pre-wrap; font-family: 'Consolas', monospace; }
.diff-new { background: rgba(74,222,128,.06); border: 1px solid rgba(74,222,128,.2); border-radius:5px; padding: 8px; font-size: 12px; color: #86efac; white-space: pre-wrap; font-family: 'Consolas', monospace; }
.diff-label { font-size: 11px; color: var(--muted); margin-bottom: 4px; text-transform: uppercase; letter-spacing: .5px; }
.lore-meta { font-size: 12px; color: var(--muted); margin-bottom: 8px; display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.lore-actions { display: flex; gap: 8px; align-items: flex-start; flex-wrap: wrap; }
.lore-actions textarea { background: #0a0d14; color: var(--text); border: 1px solid var(--border); border-radius: 5px; font-family: 'Consolas', monospace; font-size: 12px; padding: 6px 8px; width: 100%; min-height: 60px; resize: vertical; }
.lore-filters { display: flex; gap: 10px; margin-bottom: 12px; align-items: center; flex-wrap: wrap; }
.lore-filters label { font-size: 13px; color: var(--muted); }
.lore-filters select { background: #0a0d14; color: var(--text); border: 1px solid var(--border); border-radius: 5px; font-family: inherit; font-size: 13px; padding: 4px 8px; }
.lore-stat-bar { display: flex; gap: 16px; margin-bottom: 12px; font-size: 13px; flex-wrap: wrap; }
.lore-stat { color: var(--muted); }
.lore-stat span { font-weight: 600; }
.badge-pending { background: rgba(251,191,36,.12); color: var(--amber); padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
.badge-accepted { background: rgba(74,222,128,.12); color: var(--green); padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
.badge-rejected { background: rgba(248,113,113,.12); color: var(--red); padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
.badge-modified { background: rgba(96,165,250,.12); color: var(--blue); padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
.change-type-badge { font-size: 11px; color: var(--dim); border: 1px solid var(--border); padding: 1px 7px; border-radius: 4px; }
.tools-section { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 16px; margin-bottom: 14px; }
.tools-section h2 { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: .8px; color: var(--amber); border-bottom: 1px solid var(--border); padding-bottom: 8px; margin-bottom: 14px; }
.tool-row { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; }
.tool-row label { font-size: 13px; color: var(--muted); min-width: 100px; }
.tool-row select, .tool-row input[type=number] { background: #0a0d14; color: var(--text); border: 1px solid var(--border); border-radius: 5px; padding: 5px 10px; font-family: inherit; font-size: 13px; }
.radio-group { display: flex; gap: 6px; flex-wrap: wrap; }
.radio-group label { display: flex; align-items: center; gap: 5px; padding: 4px 12px; border: 1px solid var(--border); border-radius: 5px; cursor: pointer; font-size: 13px; color: var(--muted); min-width: auto; transition: all .15s; }
.radio-group input[type=radio] { display: none; }
.radio-group input[type=radio]:checked + span { color: var(--amber); }
.radio-group label:has(input:checked) { border-color: var(--amber); background: rgba(251,191,36,.08); color: var(--amber); }
.enrich-card { background:var(--panel);border:1px solid var(--border);border-radius:6px;padding:12px 14px; }
.mini-console { background:#020408;border:1px solid var(--border);padding:8px 10px;max-height:200px;overflow-y:auto;font-size:11px;color:#a8d5a2;white-space:pre-wrap;border-radius:4px;margin-top:8px; }
.results-area { margin-top: 12px; }
.results-meta { font-size: 12px; color: var(--muted); margin-bottom: 8px; }
.diag-good { color: var(--green); font-weight: 600; }
.diag-bad { color: var(--red); font-weight: 600; }
.sim-tab-btn { background:none;border:none;border-bottom:2px solid transparent;color:var(--dim);padding:8px 18px;cursor:pointer;font-size:13px;font-family:inherit;transition:color .15s; }
.sim-tab-btn:hover { color:var(--text); }
.sim-tab-btn.active { color:var(--amber);border-bottom-color:var(--amber);font-weight:600; }
.refresh-note { font-size: 12px; color: var(--dim); margin-left: auto; }
.refresh-note a { color: var(--muted); text-decoration: none; }
.refresh-note a:hover { color: var(--text); }
.footer { text-align: center; font-size: 11px; color: var(--dim); padding: 16px; border-top: 1px solid var(--border); margin-top: 20px; }
</style>
</head>
<body>
<div class="topbar">
<span class="topbar-title">📟 PIPBOY</span>
<div class="session-pills">
{% for s in all_sessions %}
{% set is_active = s.status == 'active' %}
<a href="/?sid={{ s.id }}"
class="session-pill {{ 'active' if s.id == current_sid and tab == 'sim' else ('stopped' if not is_active else '') }}">
<span class="dot {{ 'dot-green' if is_active else 'dot-red' }}"></span>
S{{ s.id }}
</a>
{% endfor %}
</div>
<span class="nav-sep"></span>
<div class="nav-tabs">
<a href="/?sid={{ current_sid }}" class="{{ 'active' if tab=='sim' }}">📡 SIM</a>
<a href="/tools?sid={{ current_sid }}" class="{{ 'active' if tab=='tools' }}">🔧 OUTILS</a>
<a href="/lore" class="{{ 'active' if tab=='lore' }}">📜 LORE</a>
<a href="/sim-proposals" class="{{ 'active' if tab=='sim_proposals' }}">🎲 RÈGLES SIM</a>
<a href="/params?sid={{ current_sid }}" class="{{ 'active' if tab=='params' }}">⚙ PARAMS</a>
</div>
</div>
{% if flash_msg %}
<div class="flash {{ 'flash-ok' if flash_ok else 'flash-err' }}">{{ flash_msg }}</div>
{% endif %}
<div class="main">
{% block content %}{% endblock %}
</div>
<div class="footer">PipBoy v5.1 | SIM LIBRE • OUTILS • ENRICHISSEMENT • PARAMÈTRES</div>
</body>
</html>
+141
View File
@@ -0,0 +1,141 @@
{% extends "base.html" %}
{% block content %}
<div class="info-bar">
<span style="font-weight:600">📜 Enrichissement Lore — Venice of Wasteland</span>
<div class="lore-stat-bar" style="margin:0">
<span class="lore-stat">En attente : <span style="color:var(--amber)">{{ lore_stats.pending }}</span></span>
<span class="lore-stat">Acceptées : <span style="color:var(--green)">{{ lore_stats.accepted }}</span></span>
<span class="lore-stat">Rejetées : <span style="color:var(--red)">{{ lore_stats.rejected }}</span></span>
<span class="lore-stat">Modifiées : <span style="color:var(--blue)">{{ lore_stats.modified }}</span></span>
</div>
</div>
<div class="lore-filters">
<label>Faction :</label>
<select onchange="location.href='/lore?faction='+this.value+'&status='+document.getElementById('filt-status').value">
<option value="" {{ 'selected' if not filt_faction else '' }}>— toutes —</option>
{% for f in all_factions %}
<option value="{{ f }}" {{ 'selected' if filt_faction==f else '' }}>{{ f }}</option>
{% endfor %}
</select>
<label>Statut :</label>
<select id="filt-status" onchange="location.href='/lore?faction='+document.querySelector('[onchange]').value+'&status='+this.value">
<option value="pending" {{ 'selected' if filt_status=='pending' else '' }}>pending</option>
<option value="accepted" {{ 'selected' if filt_status=='accepted' else '' }}>accepted</option>
<option value="rejected" {{ 'selected' if filt_status=='rejected' else '' }}>rejected</option>
<option value="modified" {{ 'selected' if filt_status=='modified' else '' }}>modified</option>
<option value="" {{ 'selected' if not filt_status else '' }}>— tous —</option>
</select>
<span style="font-size:12px;color:var(--dim)">{{ proposals|length }} proposition(s) affichée(s)</span>
</div>
{% if not proposals %}
<div class="panel" style="color:var(--dim);text-align:center;padding:24px">
Aucune proposition lore.<br>
<small style="color:var(--border)">Lancer : <code>python3 lore_enricher.py --faction grand_krewe</code> sur Ampère</small>
</div>
{% endif %}
{% for p in proposals %}
<div class="lore-card">
<div class="lore-meta">
<span>#{{ p.id }}</span>
<span class="badge-{{ p.status }}">{{ p.status | upper }}</span>
<span class="change-type-badge">{{ p.change_type }}</span>
<span style="color:var(--amber)">{{ p.faction_slug }}</span>
{% if p.field_path %}<span>{{ p.field_path }}</span>{% endif %}
<span style="color:var(--dim)">{{ p.created_at.strftime('%d/%m %H:%M') if p.created_at else '' }}</span>
</div>
<h3>{{ p.rationale or '(pas de justification)' }}</h3>
<div class="lore-diff">
<div>
<div class="diff-label">Original</div>
<div class="diff-old">{{ p.original_text or '(ajout — pas de texte original)' }}</div>
</div>
<div>
<div class="diff-label">Proposé</div>
<div class="diff-new">{{ p.proposed_text }}</div>
</div>
</div>
{% if p.status == 'pending' %}
<div class="lore-actions">
<form method="POST" action="/lore/action" style="display:inline">
<input type="hidden" name="id" value="{{ p.id }}">
<input type="hidden" name="action" value="accept">
<input type="hidden" name="filt_faction" value="{{ filt_faction }}">
<input type="hidden" name="filt_status" value="{{ filt_status }}">
<button type="submit" class="btn btn-green" onclick="return confirm('Accepter cette proposition ?')">✓ Accepter</button>
</form>
<form method="POST" action="/lore/action" style="display:inline">
<input type="hidden" name="id" value="{{ p.id }}">
<input type="hidden" name="action" value="reject">
<input type="hidden" name="filt_faction" value="{{ filt_faction }}">
<input type="hidden" name="filt_status" value="{{ filt_status }}">
<button type="submit" class="btn btn-red">✗ Rejeter</button>
</form>
<form method="POST" action="/lore/action" style="width:100%;margin-top:6px">
<input type="hidden" name="id" value="{{ p.id }}">
<input type="hidden" name="action" value="modify">
<input type="hidden" name="filt_faction" value="{{ filt_faction }}">
<input type="hidden" name="filt_status" value="{{ filt_status }}">
<textarea name="modified_text" placeholder="Texte modifié…">{{ p.proposed_text }}</textarea>
<button type="submit" class="btn" style="margin-top:6px">✎ Modifier & Accepter</button>
</form>
</div>
{% elif p.status == 'modified' and p.modified_text %}
<div style="margin-top:8px">
<div class="diff-label">Texte modifié accepté</div>
<div class="diff-new" style="border-color:rgba(96,165,250,.3)">{{ p.modified_text }}</div>
</div>
{% endif %}
{% if p.status in ('modified', 'accepted') %}
<button type="button" class="btn" onclick="toggleDiscuss({{ p.id }})" style="font-size:12px;margin-top:8px">💬 Discuter / Modifier</button>
<div id="discuss-{{ p.id }}" style="display:none;margin-top:12px;border-top:1px solid var(--border);padding-top:10px">
{% for msg in proposal_threads.get(p.id, []) %}
<div style="margin-bottom:8px;padding:6px 10px;border-radius:4px;{% if msg.role=='user' %}background:#0d1117;border-left:2px solid var(--amber){% else %}background:#050d05;border-left:2px solid var(--green){% endif %}">
<span style="font-size:10px;color:var(--dim);display:block;margin-bottom:3px">{{ '👤 Toi' if msg.role=='user' else '🤖 LLM' }} — {{ msg.created_at.strftime('%d/%m %H:%M') if msg.created_at else '' }}</span>
<span style="font-size:12px;white-space:pre-wrap">{{ msg.content }}</span>
</div>
{% endfor %}
<div style="display:flex;flex-direction:column;gap:6px;margin-top:8px">
<textarea id="discuss-msg-{{ p.id }}" placeholder="Ex: Garde l'idée mais réduis l'impact de 50%…" style="background:var(--bg);border:1px solid var(--border);color:var(--text);font-size:12px;padding:6px;border-radius:3px;height:54px;resize:vertical"></textarea>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
<span style="font-size:11px;color:var(--dim)">temp: <input type="range" id="discuss-temp-{{ p.id }}" min="0" max="1" step="0.05" value="0.75" style="width:70px;vertical-align:middle" oninput="document.getElementById('discuss-temp-val-{{ p.id }}').textContent=this.value"> <span id="discuss-temp-val-{{ p.id }}">0.75</span></span>
<span style="font-size:11px;color:var(--dim)">tokens: <input type="number" id="discuss-np-{{ p.id }}" value="512" min="128" max="2048" step="128" style="width:58px;background:var(--bg);border:1px solid var(--border);color:var(--text);font-size:11px;padding:2px 4px"></span>
<button class="btn btn-green" onclick="submitDiscuss({{ p.id }})" style="font-size:12px">▶ Envoyer</button>
<span id="discuss-spinner-{{ p.id }}" style="display:none;color:var(--amber);font-size:12px">⏳ Génération…</span>
</div>
</div>
</div>
{% endif %}
</div>
{% endfor %}
<script>
function toggleDiscuss(id) {
var el = document.getElementById('discuss-' + id);
el.style.display = el.style.display === 'none' ? 'block' : 'none';
}
function submitDiscuss(id) {
var msg = document.getElementById('discuss-msg-' + id).value.trim();
var temp = parseFloat(document.getElementById('discuss-temp-' + id).value);
var np = parseInt(document.getElementById('discuss-np-' + id).value);
if (!msg) { alert('Message vide'); return; }
var spin = document.getElementById('discuss-spinner-' + id);
spin.style.display = 'inline';
fetch('/lore/' + id + '/discuss', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({message: msg, temperature: temp, num_predict: np})
}).then(function(r){ return r.json(); }).then(function(d){
spin.style.display = 'none';
if (d.error) { alert(d.error); return; }
location.reload();
}).catch(function(e){ spin.style.display = 'none'; alert('Erreur réseau : ' + e); });
}
</script>
{% endblock %}
+173
View File
@@ -0,0 +1,173 @@
{% extends "base.html" %}
{% block content %}
{% if session %}
<div class="info-bar">
<span><span class="label">Session</span> <strong class="val">#{{ session.id }} — {{ session.name }}</strong></span>
<span class="mode-tag">{{ session.mode | upper }}</span>
{% set st = session.status %}
<span class="status-badge {{ 'status-active' if st=='active' else 'status-stopped' if st=='stopped' else 'status-paused' }}">
<span class="dot {{ 'dot-green' if st=='active' else 'dot-red' if st=='stopped' else 'dot-amber' }}"></span>
{{ st | upper }}
</span>
<span><span class="label">Jour</span> <strong class="val">{{ session.current_day }}</strong> / <span class="label">Tick</span> <strong class="val">{{ session.current_tick }}h</strong></span>
</div>
{% endif %}
<div class="grid">
<!-- Config JSON -->
<div class="panel">
<h2>⚙ Config sim_{{ '%03d' % current_sid }}.json</h2>
<form method="POST" action="/params/save?sid={{ current_sid }}">
{% if cfg is not none %}
<div class="cfg-block">
<h3>Général</h3>
<div class="cfg-row">
<label>Nom de la session</label>
<input type="text" name="name" value="{{ cfg.get('name','') }}" style="width:220px">
</div>
<div class="cfg-row">
<label>Mode actif</label>
<select name="mode">
{% for m in ['pacifiste','politique','guerre_commerciale','guerre','survie_extreme'] %}
<option value="{{ m }}" {{ 'selected' if cfg.get('mode')==m else '' }}>{{ m }}</option>
{% endfor %}
</select>
</div>
<div class="cfg-row">
<label>Tick sleep (secondes)</label>
<input type="number" name="tick_sleep_sec" value="{{ cfg.get('tick',{}).get('tick_sleep_sec',60) }}" step="1" min="0">
</div>
</div>
<div class="cfg-block">
<h3>Survie</h3>
<div class="cfg-row">
<label>drain_speed (multiplicateur)</label>
<input type="number" name="survival_drain_speed" value="{{ cfg.get('survival',{}).get('drain_speed',1.0) }}" step="0.1" min="0.1" max="5">
</div>
<div class="cfg-row">
<label>fatigue_hp_per_2pts</label>
<input type="number" name="survival_fatigue_hp" value="{{ cfg.get('survival',{}).get('fatigue_hp_per_2pts',2) }}" step="1" min="0">
</div>
<div class="cfg-row">
<label>Survie activée</label>
<input type="checkbox" name="survival_enabled" {{ 'checked' if cfg.get('survival',{}).get('enabled',True) else '' }}>
</div>
</div>
<div class="cfg-block">
<h3>Rencontres</h3>
<div class="cfg-row">
<label>global_rate_multiplier</label>
<input type="number" name="encounter_rate" value="{{ cfg.get('encounter',{}).get('global_rate_multiplier',1.0) }}" step="0.1" min="0" max="5">
</div>
<div class="cfg-row">
<label>Rencontres activées</label>
<input type="checkbox" name="encounter_enabled" {{ 'checked' if cfg.get('encounter',{}).get('enabled',True) else '' }}>
</div>
</div>
<div class="cfg-block">
<h3>Économie</h3>
<div class="cfg-row">
<label>caps_global_multiplier</label>
<input type="number" name="caps_multiplier" value="{{ cfg.get('economy',{}).get('caps_global_multiplier',1.0) }}" step="0.1" min="0.1">
</div>
</div>
<button type="submit" class="btn btn-green">💾 Sauvegarder config</button>
<p style="margin-top:8px;font-size:12px;color:var(--muted)">
Prise en compte au prochain jour (J{{ (session.current_day or 0) + 1 if session else '?' }}).
Pour effet immédiat : STOP → relancer via terminal.
</p>
{% else %}
<p style="color:var(--red);font-size:13px">
Fichier <code>sim_{{ '%03d' % current_sid }}.json</code> introuvable dans <code>{{ sim_configs_dir }}</code>
</p>
<p style="color:var(--muted);font-size:12px;margin-top:6px">
Vérifier le mount Docker : /home/ubuntu/fallout-venice/src/config → /app/src/config
</p>
{% endif %}
</form>
</div>
<!-- Actions session -->
<div class="panel">
<h2>🔧 Gestion session #{{ current_sid }}</h2>
<div class="cfg-block">
<h3>Statut DB</h3>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<form method="POST" action="/params/setstatus?sid={{ current_sid }}" style="display:inline">
<input type="hidden" name="status" value="active">
<button type="submit" class="btn btn-green">▶ Activer</button>
</form>
<form method="POST" action="/params/setstatus?sid={{ current_sid }}" style="display:inline">
<input type="hidden" name="status" value="paused">
<button type="submit" class="btn">⏸ Pause</button>
</form>
<form method="POST" action="/params/setstatus?sid={{ current_sid }}" style="display:inline">
<input type="hidden" name="status" value="stopped">
<button type="submit" class="btn btn-red">■ Stop</button>
</form>
</div>
<p style="margin-top:8px;font-size:12px;color:var(--muted)">
STOP → la sim s'arrête dans les 5 ticks suivants.
</p>
</div>
<div class="cfg-block">
<h3>Reset jour/tick</h3>
<form method="POST" action="/params/resetday?sid={{ current_sid }}">
<div class="cfg-row">
<label>Remettre au Jour</label>
<input type="number" name="day" value="1" min="1">
</div>
<div class="cfg-row">
<label>Tick</label>
<input type="number" name="tick" value="0" min="0" max="23">
</div>
<button type="submit" class="btn">⏮ Reset position</button>
</form>
</div>
<div class="cfg-block">
<h3>Modes disponibles</h3>
<table>
<tr><th>Mode</th><th>Drain</th><th>Encounters</th><th>Caps</th></tr>
{% for m_name, m_vals in modes.items() %}
<tr>
<td class="{{ 'tier1-text' if cfg and m_name == cfg.get('mode','') else '' }}">
{{ m_name }}{% if cfg and m_name == cfg.get('mode','') %} ◀{% endif %}</td>
<td style="color:var(--muted);font-size:12px">×{{ m_vals.get('survival',{}).get('drain_speed','—') }}</td>
<td style="color:var(--muted);font-size:12px">×{{ m_vals.get('encounter',{}).get('global_rate_multiplier','—') }}</td>
<td style="color:var(--muted);font-size:12px">×{{ m_vals.get('economy',{}).get('caps_global_multiplier','—') }}</td>
</tr>
{% endfor %}
</table>
</div>
<div class="cfg-block">
<h3>Commandes SSH (référence)</h3>
<div class="code-block">
<pre id="ssh_logs_cmd">tail -f ~/fallout_sim_s{{ current_sid }}.log</pre>
<button class="copy-btn" onclick="copyText('ssh_logs_cmd')">Copier</button>
</div>
</div>
</div>
</div>
<script>
function copyText(elementId) {
var el = document.getElementById(elementId);
navigator.clipboard.writeText(el.textContent.trim()).then(function() {
var btn = el.nextElementSibling;
if (btn) { btn.textContent = '✓ Copié'; setTimeout(function(){ btn.textContent = 'Copier'; }, 2000); }
});
}
</script>
{% endblock %}
+402
View File
@@ -0,0 +1,402 @@
{% extends "base.html" %}
{% block content %}
{% if session %}
<div class="info-bar">
<span><span class="label">Session</span> <strong class="val">#{{ session.id }} — {{ session.name }}</strong></span>
<span><span class="label">Jour</span> <strong class="val">{{ session.current_day }}</strong> <span class="label">/ Tick</span> <strong class="val">{{ session.current_tick }}h</strong></span>
<span class="mode-tag">{{ session.mode | upper }}</span>
{% set st = session.status %}
<span class="status-badge {{ 'status-active' if st=='active' else 'status-stopped' if st=='stopped' else 'status-paused' }}">
<span class="dot {{ 'dot-green' if st=='active' else 'dot-red' if st=='stopped' else 'dot-amber' }}"></span>
{{ st | upper }}
</span>
<span class="label">Seed: <span class="val">{{ session.seed_global }}</span></span>
<span class="refresh-note">
<a href="?sid={{ session.id }}">↻ Actualiser</a>
<span id="countdown"></span>
</span>
</div>
{% endif %}
<div class="metric-row">
<div class="metric-card"><div class="metric-label">PNJ Vivants</div><div class="metric-value metric-green">{{ alive_count }}</div></div>
<div class="metric-card"><div class="metric-label">PNJ Morts</div><div class="metric-value metric-red">{{ stats.get('PNJ morts', 0) }}</div></div>
<div class="metric-card"><div class="metric-label">Total PNJ</div><div class="metric-value metric-muted">{{ total_count }}</div></div>
<div class="metric-card"><div class="metric-label">Combats</div><div class="metric-value metric-amber">{{ stats.get('Combats', 0) }}</div></div>
<div class="metric-card"><div class="metric-label">Rencontres</div><div class="metric-value metric-blue">{{ stats.get('Rencontres', 0) }}</div></div>
<div class="metric-card"><div class="metric-label">Événements</div><div class="metric-value metric-muted">{{ stats.get('Événements total', 0) }}</div></div>
</div>
<div class="grid">
<div class="panel">
<h2>👥 PNJ ({{ alive_count }}/{{ total_count }})</h2>
<div class="scrollable">
<table>
<tr><th>Tier</th><th>Nom</th><th>PV</th><th>Faction</th><th>Zone</th><th>Caps</th></tr>
{% for c in characters %}
{% set pct = (c.hp / c.max_hp * 100) | int if c.max_hp else 0 %}
<tr class="{{ 'dead' if not c.is_alive else '' }}">
<td>
{% if c.tier == 0 %}<span class="tier0-text">★ BOSS</span>
{% elif c.tier == 2 %}<span class="tier2-text">T2</span>
{% elif c.tier == 3 %}<span class="tier3-text">T3</span>
{% else %}<span class="tier1-text">T1</span>{% endif %}
</td>
<td>{{ c.name }}</td>
<td>
{% if c.is_alive %}
<span class="{{ 'hp-crit' if pct < 25 else 'hp-low' if pct < 50 else 'hp-ok' }}">{{ c.hp }}/{{ c.max_hp }}</span>
{% else %}<span class="tier3-text"></span>{% endif %}
</td>
<td>
{% set f = c.faction_slug or 'sans' %}
<span class="faction-{{ 'union' if 'union' in f else 'cda' if 'cda' in f else 'ecumeurs' if 'ecumeur' in f else 'krewe' if 'krewe' in f else 'oak' if 'oak' in f else 'regie' if 'regie' in f else 'syndicat' if 'syndicat' in f else 'default' }}">{{ f[:14] }}</span>
</td>
<td style="font-size:12px;color:var(--muted)">{{ (c.location_slug or '—')[:16] }}</td>
<td style="color:var(--amber)">{{ c.caps }}¢</td>
</tr>
{% endfor %}
</table>
</div>
</div>
<div class="panel">
<h2>📡 Événements récents</h2>
<div class="scrollable">
<table>
<tr><th>J/T</th><th>Type</th><th>Description</th></tr>
{% for ev in events %}
<tr>
<td style="color:var(--muted);white-space:nowrap">{{ ev.day }}/{{ ev.tick }}</td>
<td class="ev-{{ ev.event_type }}" style="white-space:nowrap"><small>{{ ev.event_type }}</small></td>
<td style="font-size:12px">{{ ev.description[:90] }}{% if ev.description|length > 90 %}…{% endif %}</td>
</tr>
{% endfor %}
{% if not events %}<tr><td colspan="3" style="color:var(--dim);text-align:center;padding:16px">Aucun événement</td></tr>{% endif %}
</table>
</div>
</div>
</div>
<div class="grid3">
<div class="panel">
<h2>🗺 État du monde</h2>
<div class="scrollable">
{% for ws in world_state %}
<div class="world-zone">
<span class="zone-name">{{ ws.location_slug }}</span>
<span class="zone-stats">
<span>🍖 <span>{{ ws.food_level or 0 }}%</span></span>
<span>💧 <span>{{ ws.water_level or 0 }}%</span></span>
<span>🛡 <span>{{ ws.security_level or 0 }}%</span></span>
<span>👤 <span>{{ ws.pop_count or 0 }}</span></span>
</span>
</div>
{% endfor %}
{% if not world_state %}<p style="color:var(--dim);padding:12px 0">Pas de données monde</p>{% endif %}
</div>
</div>
<div class="panel">
<h2>⚔ Relations factions</h2>
<div class="scrollable">
<table>
<tr><th>Faction A</th><th>Faction B</th><th>Score</th><th>État</th></tr>
{% 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' %}
<tr>
<td style="font-size:12px">{{ r.faction_a[:14] }}</td>
<td style="font-size:12px">{{ r.faction_b[:14] }}</td>
<td class="{{ cls }}">{{ r.relation_score }}</td>
<td class="{{ cls }}" style="font-size:12px">{{ r.relation_label or ('HOSTILE' if r.relation_score < -20 else 'ALLIÉ' if r.relation_score > 20 else 'NEUTRE') }}</td>
</tr>
{% endfor %}
{% if not faction_relations %}<tr><td colspan="4" style="color:var(--dim)">Pas de données</td></tr>{% endif %}
</table>
</div>
</div>
<div class="panel">
<h2>🎭 PNJ par tier</h2>
<table style="margin-bottom:12px">
<tr><th>Tier</th><th>Label</th><th>Vivants</th><th>Morts</th></tr>
{% for row in tier_stats %}
<tr>
<td class="tier{{ row.tier }}-text">{{ row.tier }}</td>
<td>{{ row.label }}</td>
<td class="metric-green">{{ row.alive }}</td>
<td style="color:var(--muted)">{{ row.dead }}</td>
</tr>
{% endfor %}
</table>
<h2 style="margin-top:4px">🏙 PNJ par zone (top 5)</h2>
<table>
<tr><th>Zone</th><th>PNJ</th></tr>
{% for row in zone_stats %}
<tr>
<td style="font-size:12px">{{ row.location_slug }}</td>
<td class="metric-green">{{ row.cnt }}</td>
</tr>
{% endfor %}
</table>
</div>
</div>
<div class="panel" style="margin-top:12px">
<h2>⚡ Rencontres récentes</h2>
<div class="scrollable" style="max-height:180px">
<table>
<tr><th>J/T</th><th>Type</th><th>Acteur</th><th>Zone</th><th>Description</th></tr>
{% for ev in encounters %}
<tr>
<td style="color:var(--muted);white-space:nowrap;font-size:12px">{{ ev.day }}/{{ ev.tick }}</td>
<td class="ev-encounter" style="font-size:12px;white-space:nowrap">{{ ev.event_type }}</td>
<td style="font-size:12px">{{ ev.actor_name or '—' }}</td>
<td style="font-size:12px;color:var(--muted)">{{ (ev.location_slug or '—')[:16] }}</td>
<td style="font-size:12px">{{ ev.description[:90] }}{% if ev.description|length > 90 %}…{% endif %}</td>
</tr>
{% endfor %}
{% if not encounters %}<tr><td colspan="5" style="color:var(--dim);text-align:center;padding:12px">Aucune rencontre</td></tr>{% endif %}
</table>
</div>
</div>
{% if boss_inventory %}
<div class="panel" style="margin-top:12px">
<h2>★ Équipement chefs de faction</h2>
<table>
<tr><th>Boss</th><th>Item</th><th>Type</th><th>Qté</th><th>Équipé</th></tr>
{% for row in boss_inventory %}
<tr>
<td class="tier0-text">{{ row.char_name }}</td>
<td>{{ row.item_name }}</td>
<td style="color:var(--muted);font-size:12px">{{ row.item_type }}</td>
<td>{{ row.quantity }}</td>
<td style="color:var(--green)">{{ '✓' if row.is_equipped else '' }}</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
<!-- Contrôles simulation -->
<div style="margin-top:16px">
<div style="display:flex;align-items:center;gap:0;border-bottom:1px solid var(--border);flex-wrap:wrap">
<button class="sim-tab-btn active" id="stab-truth" onclick="showSimTab('truth')">&#128218; Vérité</button>
<button class="sim-tab-btn" id="stab-enriched" onclick="showSimTab('enriched')">&#10024; Enrichie</button>
<button class="sim-tab-btn" id="stab-mix" onclick="showSimTab('mix')">&#9889; MIX</button>
<div style="margin-left:auto;display:flex;align-items:center;gap:8px;padding:4px 6px;flex-wrap:wrap">
<span style="font-size:11px;color:var(--dim)">Tick&nbsp;global&nbsp;:</span>
<div class="radio-group" style="margin:0">
<label><input type="radio" name="sim_tick" value="0"><span>INSTANT</span></label>
<label><input type="radio" name="sim_tick" value="5" checked><span>5s</span></label>
<label><input type="radio" name="sim_tick" value="30"><span>30s</span></label>
<label><input type="radio" name="sim_tick" value="60"><span>60s</span></label>
<label><input type="radio" name="sim_tick" value="3600"><span>1h</span></label>
</div>
</div>
</div>
<div id="simtab-truth" class="sim-tab-content" style="display:block">
<div class="panel" style="margin-top:12px;padding:16px">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px">
<span class="dot dot-red" id="dot-sim-truth"></span>
<span style="font-weight:600">&#128218; Sim Vérité</span>
<span style="font-size:10px;color:var(--dim)">sources : fallout_lore + fallout_sim_rules uniquement</span>
</div>
<div style="display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:12px">
<span style="font-size:12px;color:var(--dim)">Session : <strong style="color:var(--text)">#{{ current_sid }}</strong></span>
<label style="font-size:12px;color:var(--dim)">Mode :
<select id="truth-mode" onchange="setSessionMode(this.value)" style="background:var(--bg);border:1px solid var(--border);color:var(--text);font-size:12px;padding:2px 8px;margin-left:4px">
{% for m in ['pacifiste','politique','guerre_commerciale','guerre','survie_extreme'] %}
<option value="{{ m }}" {{ 'selected' if session and session.mode==m else '' }}>{{ m }}</option>
{% endfor %}
</select>
</label>
</div>
<div id="sim-truth-status" style="font-size:12px;color:var(--dim);margin-bottom:10px">Arrêtée</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn btn-green" id="btn-truth-start" onclick="simStart('truth')">&#9654; Démarrer</button>
<button class="btn btn-red" id="btn-truth-stop" onclick="simStop('truth')">&#9632; Arrêter</button>
<button class="btn" onclick="simDownloadLog('truth')" style="font-size:11px">&#11015; Log JSON</button>
</div>
<div id="console-sim-truth" class="mini-console" style="display:none;margin-top:12px;max-height:320px"></div>
</div>
</div>
<div id="simtab-enriched" class="sim-tab-content" style="display:none">
<div class="panel" style="margin-top:12px;padding:16px">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px">
<span class="dot dot-red" id="dot-sim-enriched"></span>
<span style="font-weight:600;color:var(--amber)">&#10024; Sim Enrichie</span>
<span style="font-size:10px;color:var(--dim)">+ fallout_lore_enriched + fallout_sim_enriched</span>
</div>
<div id="sim-enriched-status" style="font-size:12px;color:var(--dim);margin-bottom:10px">Arrêtée</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn btn-green" id="btn-enriched-start" onclick="simStart('enriched')">&#9654; Démarrer</button>
<button class="btn btn-red" id="btn-enriched-stop" onclick="simStop('enriched')">&#9632; Arrêter</button>
<button class="btn" onclick="simDownloadLog('enriched')" style="font-size:11px">&#11015; Log JSON</button>
</div>
<div id="console-sim-enriched" class="mini-console" style="display:none;margin-top:12px;max-height:320px"></div>
</div>
</div>
<div id="simtab-mix" class="sim-tab-content" style="display:none">
<div class="panel" style="margin-top:12px;padding:16px">
<div style="margin-bottom:12px">
<span style="font-weight:600;color:var(--green)">&#9889; Mode MIX — Comparaison simultanée</span>
<p style="font-size:12px;color:var(--dim);margin:6px 0 0">Lance les deux simulations en même temps.</p>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px">
<button class="btn btn-green" id="btn-mix-start" onclick="simMixStart()">&#9654; Lancer les 2</button>
<button class="btn btn-red" id="btn-mix-stop" onclick="simMixStop()">&#9632; Arrêter les 2</button>
<button class="btn" onclick="simDownloadLog('truth')" style="font-size:11px">&#11015; Log Vérité</button>
<button class="btn" onclick="simDownloadLog('enriched')" style="font-size:11px">&#11015; Log Enrichie</button>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div>
<div style="font-size:11px;color:var(--dim);margin-bottom:6px;display:flex;align-items:center;gap:6px">
<span class="dot dot-red" id="dot-mix-truth"></span>&#128218; Vérité
</div>
<div id="console-mix-truth" class="mini-console" style="max-height:260px"></div>
</div>
<div>
<div style="font-size:11px;color:var(--amber);margin-bottom:6px;display:flex;align-items:center;gap:6px">
<span class="dot dot-red" id="dot-mix-enriched"></span>&#10024; Enrichie
</div>
<div id="console-mix-enriched" class="mini-console" style="max-height:260px"></div>
</div>
</div>
</div>
</div>
</div>
<script>
var _simPolls = {truth: null, enriched: null};
var _simLastLines = {truth: 0, enriched: 0};
var _mixLastLines = {truth: 0, enriched: 0};
function showSimTab(tab) {
['truth','enriched','mix'].forEach(function(t) {
document.getElementById('simtab-' + t).style.display = (t === tab) ? 'block' : 'none';
document.getElementById('stab-' + t).classList.toggle('active', t === tab);
});
}
function simStart(mode) {
var tickEl = document.querySelector('input[name="sim_tick"]:checked');
var tickSpeed = tickEl ? parseInt(tickEl.value) : 5;
fetch('/api/sim/' + mode + '/run', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({sid: {{ current_sid }}, tick_speed_s: tickSpeed})
}).then(function(r){ return r.json(); }).then(function(d){
if (d.error) { alert(d.error); return; }
_simLastLines[mode] = 0;
var cons = document.getElementById('console-sim-' + mode);
cons.textContent = '';
cons.style.display = 'block';
document.getElementById('dot-sim-' + mode).className = 'dot dot-green';
document.getElementById('sim-' + mode + '-status').textContent = 'En cours — ' + (tickSpeed === 0 ? 'INSTANT' : tickSpeed + 's') + '/tick';
document.getElementById('btn-' + mode + '-start').disabled = true;
if (_simPolls[mode]) clearInterval(_simPolls[mode]);
_simPolls[mode] = setInterval(function(){ simPoll(mode); }, 2000);
});
}
function simPoll(mode) {
fetch('/api/sim/' + mode + '/status').then(function(r){ return r.json(); }).then(function(d){
var cons = document.getElementById('console-sim-' + mode);
var lines = d.output || [];
if (lines.length > _simLastLines[mode]) {
cons.textContent += lines.slice(_simLastLines[mode]).join('\n') + '\n';
cons.scrollTop = cons.scrollHeight;
_simLastLines[mode] = lines.length;
}
if (!d.running) {
clearInterval(_simPolls[mode]);
document.getElementById('dot-sim-' + mode).className = 'dot ' + (d.returncode === 0 ? 'dot-amber' : 'dot-red');
document.getElementById('sim-' + mode + '-status').textContent = d.error || ('Arrêtée (code ' + d.returncode + ')');
document.getElementById('btn-' + mode + '-start').disabled = false;
}
});
}
function simStop(mode) {
fetch('/api/sim/' + mode + '/stop', {method:'POST'}).then(function(){ simPoll(mode); });
}
function simDownloadLog(mode) { window.location.href = '/api/sim/' + mode + '/log'; }
function simMixStart() {
var tickEl = document.querySelector('input[name="sim_tick"]:checked');
var tickSpeed = tickEl ? parseInt(tickEl.value) : 5;
['truth','enriched'].forEach(function(mode) {
fetch('/api/sim/' + mode + '/run', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({sid: {{ current_sid }}, tick_speed_s: tickSpeed})
}).then(function(r){ return r.json(); }).then(function(d){
if (d.error) { console.warn('[MIX] ' + mode + ':', d.error); return; }
_mixLastLines[mode] = 0;
document.getElementById('console-mix-' + mode).textContent = '';
document.getElementById('dot-mix-' + mode).className = 'dot dot-green';
document.getElementById('btn-mix-start').disabled = true;
if (_simPolls[mode]) clearInterval(_simPolls[mode]);
_simPolls[mode] = setInterval(function(){ simMixPoll(mode); }, 2000);
});
});
}
function simMixPoll(mode) {
fetch('/api/sim/' + mode + '/status').then(function(r){ return r.json(); }).then(function(d){
var cons = document.getElementById('console-mix-' + mode);
var lines = d.output || [];
if (lines.length > _mixLastLines[mode]) {
cons.textContent += lines.slice(_mixLastLines[mode]).join('\n') + '\n';
cons.scrollTop = cons.scrollHeight;
_mixLastLines[mode] = lines.length;
}
if (!d.running) {
clearInterval(_simPolls[mode]);
document.getElementById('dot-mix-' + mode).className = 'dot ' + (d.returncode === 0 ? 'dot-amber' : 'dot-red');
}
});
}
function simMixStop() { ['truth','enriched'].forEach(function(mode){ simStop(mode); }); }
function setSessionMode(mode) {
fetch('/api/session/set-mode', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({sid: {{ current_sid }}, mode: mode})
}).then(function(r){ return r.json(); }).then(function(d){
if (d.error) alert('Erreur : ' + d.error);
});
}
(function(){
['truth','enriched'].forEach(function(mode){
fetch('/api/sim/' + mode + '/status').then(function(r){ return r.json(); }).then(function(d){
if (d.running) {
document.getElementById('dot-sim-' + mode).className = 'dot dot-green';
document.getElementById('sim-' + mode + '-status').textContent = 'En cours';
document.getElementById('btn-' + mode + '-start').disabled = true;
document.getElementById('console-sim-' + mode).style.display = 'block';
_simLastLines[mode] = (d.output||[]).length;
_simPolls[mode] = setInterval(function(){ simPoll(mode); }, 2000);
}
});
});
})();
var t = 30;
var el = document.getElementById('countdown');
if (el) {
setInterval(function() {
t--;
el.textContent = ' (' + t + 's)';
if (t <= 0) { location.reload(); }
}, 1000);
}
</script>
{% endblock %}
+121
View File
@@ -0,0 +1,121 @@
{% extends "base.html" %}
{% block content %}
<div class="info-bar">
<span style="font-weight:600">🎲 Règles Sim — Propositions LLM</span>
<div class="lore-stat-bar" style="margin:0">
<span class="lore-stat">En attente : <span style="color:var(--amber)">{{ sim_stats.pending }}</span></span>
<span class="lore-stat">Acceptées : <span style="color:var(--green)">{{ sim_stats.accepted }}</span></span>
<span class="lore-stat">Rejetées : <span style="color:var(--red)">{{ sim_stats.rejected }}</span></span>
</div>
</div>
<div class="lore-filters">
<label>Type :</label>
<select onchange="location.href='/sim-proposals?type='+this.value+'&status='+document.getElementById('sp-status').value">
<option value="" {{ 'selected' if not filt_type else '' }}>— tous —</option>
{% for t in sim_types %}
<option value="{{ t }}" {{ 'selected' if filt_type==t else '' }}>{{ t }}</option>
{% endfor %}
</select>
<label>Statut :</label>
<select id="sp-status" onchange="location.href='/sim-proposals?type='+document.querySelector('[onchange]').value+'&status='+this.value">
<option value="pending" {{ 'selected' if filt_status=='pending' else '' }}>pending</option>
<option value="accepted" {{ 'selected' if filt_status=='accepted' else '' }}>accepted</option>
<option value="rejected" {{ 'selected' if filt_status=='rejected' else '' }}>rejected</option>
<option value="" {{ 'selected' if not filt_status else '' }}>— tous —</option>
</select>
<span style="font-size:12px;color:var(--dim)">{{ proposals|length }} proposition(s)</span>
</div>
{% if not proposals %}
<div class="panel" style="color:var(--dim);text-align:center;padding:24px">
Aucune proposition.<br>
<small style="color:var(--border)">Lancer : <code>python3 sim_enricher.py --type all</code> sur Ampère</small>
</div>
{% endif %}
{% for p in proposals %}
<div class="lore-card">
<div class="lore-meta">
<span>#{{ p.id }}</span>
<span class="badge-{{ p.status }}">{{ p.status | upper }}</span>
<span class="change-type-badge">{{ p.proposal_type }}</span>
<span style="color:var(--dim)">{{ p.created_at.strftime('%d/%m %H:%M') if p.created_at else '' }}</span>
</div>
<h3>{{ p.title }}</h3>
<p style="color:var(--text);margin:6px 0;font-size:13px">{{ p.description }}</p>
{% if p.rationale %}
<p style="color:var(--dim);font-size:12px;font-style:italic">💡 {{ p.rationale }}</p>
{% endif %}
{% if p.json_patch %}
<div class="diff-new" style="font-size:11px;white-space:pre-wrap;max-height:200px;overflow:auto">{{ p.json_patch | tojson(indent=2) }}</div>
{% endif %}
{% if p.status == 'pending' %}
<div class="lore-actions" style="margin-top:10px">
<form method="POST" action="/sim-proposals/action" style="display:inline">
<input type="hidden" name="id" value="{{ p.id }}">
<input type="hidden" name="action" value="accept">
<input type="hidden" name="filt_type" value="{{ filt_type }}">
<input type="hidden" name="filt_status" value="{{ filt_status }}">
<button type="submit" class="btn btn-green" onclick="return confirm('Accepter et indexer dans fallout_sim_enriched ?')">✓ Accepter</button>
</form>
<form method="POST" action="/sim-proposals/action" style="display:inline-block;vertical-align:top">
<input type="hidden" name="id" value="{{ p.id }}">
<input type="hidden" name="action" value="reject">
<input type="hidden" name="filt_type" value="{{ filt_type }}">
<input type="hidden" name="filt_status" value="{{ filt_status }}">
<textarea name="reject_reason" placeholder="Motif du rejet (optionnel — améliore le feedback futur)" style="display:block;width:260px;height:38px;margin-bottom:4px;background:var(--bg);border:1px solid var(--border);color:var(--text);font-size:11px;padding:4px 6px;border-radius:3px;resize:vertical"></textarea>
<button type="submit" class="btn btn-red">✗ Rejeter</button>
</form>
</div>
{% endif %}
<button type="button" class="btn" onclick="toggleDiscuss({{ p.id }})" style="font-size:12px;margin-top:8px">💬 Discuter</button>
<div id="discuss-{{ p.id }}" style="display:none;margin-top:12px;border-top:1px solid var(--border);padding-top:10px">
{% for msg in proposal_threads.get(p.id, []) %}
<div style="margin-bottom:8px;padding:6px 10px;border-radius:4px;{% if msg.role=='user' %}background:#0d1117;border-left:2px solid var(--amber){% else %}background:#050d05;border-left:2px solid var(--green){% endif %}">
<span style="font-size:10px;color:var(--dim);display:block;margin-bottom:3px">{{ '👤 Toi' if msg.role=='user' else '🤖 LLM' }} — {{ msg.created_at.strftime('%d/%m %H:%M') if msg.created_at else '' }}</span>
<span style="font-size:12px;white-space:pre-wrap">{{ msg.content }}</span>
</div>
{% endfor %}
<div style="display:flex;flex-direction:column;gap:6px;margin-top:8px">
<textarea id="discuss-msg-{{ p.id }}" placeholder="Ex: Réduis l'impact de 50%…" style="background:var(--bg);border:1px solid var(--border);color:var(--text);font-size:12px;padding:6px;border-radius:3px;height:54px;resize:vertical"></textarea>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
<span style="font-size:11px;color:var(--dim)">temp: <input type="range" id="discuss-temp-{{ p.id }}" min="0" max="1" step="0.05" value="0.75" style="width:70px;vertical-align:middle" oninput="document.getElementById('discuss-temp-val-{{ p.id }}').textContent=this.value"> <span id="discuss-temp-val-{{ p.id }}">0.75</span></span>
<span style="font-size:11px;color:var(--dim)">tokens: <input type="number" id="discuss-np-{{ p.id }}" value="512" min="128" max="2048" step="128" style="width:58px;background:var(--bg);border:1px solid var(--border);color:var(--text);font-size:11px;padding:2px 4px"></span>
<button class="btn btn-green" onclick="submitDiscuss({{ p.id }})" style="font-size:12px">▶ Envoyer</button>
<span id="discuss-spinner-{{ p.id }}" style="display:none;color:var(--amber);font-size:12px">⏳ Génération…</span>
</div>
</div>
</div>
</div>
{% endfor %}
<script>
function toggleDiscuss(id) {
var el = document.getElementById('discuss-' + id);
el.style.display = el.style.display === 'none' ? 'block' : 'none';
}
function submitDiscuss(id) {
var msg = document.getElementById('discuss-msg-' + id).value.trim();
var temp = parseFloat(document.getElementById('discuss-temp-' + id).value);
var np = parseInt(document.getElementById('discuss-np-' + id).value);
if (!msg) { alert('Message vide'); return; }
var spin = document.getElementById('discuss-spinner-' + id);
spin.style.display = 'inline';
fetch('/sim-proposals/' + id + '/discuss', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({message: msg, temperature: temp, num_predict: np})
}).then(function(r){ return r.json(); }).then(function(d){
spin.style.display = 'none';
if (d.error) { alert(d.error); return; }
location.reload();
}).catch(function(e){ spin.style.display = 'none'; alert('Erreur réseau : ' + e); });
}
</script>
{% endblock %}
+329
View File
@@ -0,0 +1,329 @@
{% extends "base.html" %}
{% block content %}
<script>
function copyText(elementId) {
var el = document.getElementById(elementId);
navigator.clipboard.writeText(el.textContent.trim()).then(function() {
var btn = el.nextElementSibling;
if (btn) { btn.textContent = '✓ Copié'; setTimeout(function(){ btn.textContent = 'Copier'; }, 2000); }
});
}
function genStressCmd() {
var mode = document.querySelector('input[name="stress_mode"]:checked').value;
var rounds = document.querySelector('input[name="stress_rounds"]:checked').value;
var pnjRow = document.getElementById('stress_pnj_row');
var pnj = '1';
if (pnjRow.style.display !== 'none') {
pnj = document.querySelector('input[name="stress_pnj"]:checked').value;
}
var cmd = 'cd /home/ubuntu/fallout-venice/tools && python3 llm_crash_test.py --mode ' + mode + ' --rounds ' + rounds;
if (mode !== 'solo-mj' && mode !== 'solo-pnj') cmd += ' --pnj-count ' + pnj;
document.getElementById('stress_cmd_text').textContent = cmd;
document.getElementById('stress_cmd_out').style.display = 'block';
}
function togglePnjRow() {
var mode = document.querySelector('input[name="stress_mode"]:checked').value;
document.getElementById('stress_pnj_row').style.display =
(mode === 'solo-mj' || mode === 'solo-pnj') ? 'none' : 'flex';
}
function loadStressResults() {
var area = document.getElementById('stress_results');
area.innerHTML = '<p style="color:var(--muted);padding:8px 0">Chargement…</p>';
fetch('/api/stress-test/results')
.then(function(r){ return r.json(); })
.then(function(data) {
if (data.error) { area.innerHTML = '<p style="color:var(--red);padding:8px 0">' + data.error + '</p>'; return; }
renderStressResults(area, data);
});
}
function renderStressResults(area, data) {
var html = '<div class="results-meta">Fichier du ' + (data.file_date || '?') + '</div>';
html += '<table><tr><th>Modèle</th><th>Appels</th><th>Erreurs</th><th>Moy tok/s</th><th>Min tok/s</th><th>Max tok/s</th><th>Moy durée</th></tr>';
var models = data.models || {};
for (var model in models) {
var m = models[model];
var avgTok = parseFloat(m.avg_tok_s || 0);
var threshold = model.indexOf('14b') !== -1 ? 8 : 15;
var cls = avgTok >= threshold ? 'diag-good' : 'diag-bad';
html += '<tr><td class="' + cls + '">' + model + '</td><td>' + (m.calls||0) + '</td>';
html += '<td style="color:' + ((m.errors||0) > 0 ? 'var(--red)' : 'var(--muted)') + '">' + (m.errors||0) + '</td>';
html += '<td class="' + cls + '">' + avgTok.toFixed(1) + '</td>';
html += '<td style="color:var(--muted)">' + parseFloat(m.min_tok_s||0).toFixed(1) + '</td>';
html += '<td style="color:var(--muted)">' + parseFloat(m.max_tok_s||0).toFixed(1) + '</td>';
html += '<td style="color:var(--muted)">' + parseFloat(m.avg_duration||0).toFixed(2) + 's</td></tr>';
}
html += '</table>';
if (data.pipeline) {
var p = data.pipeline;
html += '<div style="margin-top:12px"><strong style="color:var(--amber)">Pipeline (décalé)</strong></div>';
html += '<table><tr><th>Latence moy</th><th>Min</th><th>Max</th></tr>';
html += '<tr><td>' + (p.avg_latency||'—') + '</td><td>' + (p.min_latency||'—') + '</td><td>' + (p.max_latency||'—') + '</td></tr></table>';
}
area.innerHTML = html;
}
var _stressPollTimer = null;
var _stressLastLine = 0;
function runStress() {
var mode = document.querySelector('input[name="stress_mode"]:checked').value;
var rounds = document.querySelector('input[name="stress_rounds"]:checked').value;
var pnj = 2;
if (mode !== 'solo-mj' && mode !== 'solo-pnj')
pnj = parseInt(document.querySelector('input[name="stress_pnj"]:checked').value);
fetch('/api/stress-test/run', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({mode:mode, rounds:parseInt(rounds), pnj_count:pnj})
}).then(function(r){ return r.json(); }).then(function(d) {
if (d.error) { alert(d.error); return; }
_stressLastLine = 0;
document.getElementById('stress_console').textContent = '';
document.getElementById('stress_console_wrap').style.display = 'block';
document.getElementById('stress_status_bar').style.display = 'block';
document.getElementById('stress_status_text').textContent = 'Test ' + mode + ' en cours (' + rounds + ' rounds)…';
document.getElementById('btn-run-stress').disabled = true;
document.getElementById('btn-stop-stress').style.display = 'inline-block';
document.getElementById('stress_results').innerHTML = '';
_stressPollTimer = setInterval(function() {
fetch('/api/stress-test/status').then(function(r){ return r.json(); }).then(function(d) {
var cons = document.getElementById('stress_console');
var lines = d.output || [];
if (lines.length > _stressLastLine) {
cons.textContent += lines.slice(_stressLastLine).join('\n') + '\n';
cons.scrollTop = cons.scrollHeight;
_stressLastLine = lines.length;
}
if (!d.running) {
clearInterval(_stressPollTimer);
document.getElementById('stress_status_text').textContent =
d.returncode === 0 ? '✓ Terminé avec succès' : '✗ Erreur (code ' + d.returncode + ')';
document.getElementById('stress_spinner').textContent = d.returncode === 0 ? '' : '⚠';
document.getElementById('btn-run-stress').disabled = false;
document.getElementById('btn-stop-stress').style.display = 'none';
if (d.returncode === 0) loadStressResults();
}
});
}, 1500);
});
}
var _procPolls = {};
var _procLastLine = {};
function procRun(name, extra) {
fetch('/api/proc/' + name + '/run', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify(extra)
}).then(function(r){ return r.json(); }).then(function(d){
if (d.error) { alert(d.error); return; }
_procLastLine[name] = 0;
document.getElementById('console-' + name).textContent = '';
document.getElementById('console-' + name).style.display = 'block';
document.getElementById('btn-run-' + name).disabled = true;
document.getElementById('btn-stop-' + name).style.display = 'inline-block';
document.getElementById('dot-' + name).className = 'dot dot-green';
if (_procPolls[name]) clearInterval(_procPolls[name]);
_procPolls[name] = setInterval(function(){ procPoll(name); }, 1500);
});
}
function procPoll(name) {
fetch('/api/proc/' + name + '/status').then(function(r){ return r.json(); }).then(function(d){
var cons = document.getElementById('console-' + name);
var lines = d.output || [];
if (lines.length > (_procLastLine[name]||0)) {
cons.textContent += lines.slice(_procLastLine[name]||0).join('\n') + '\n';
cons.scrollTop = cons.scrollHeight;
_procLastLine[name] = lines.length;
}
var iterEl = document.getElementById('iter-' + name);
if (iterEl && d.iteration) {
iterEl.textContent = 'Itération ' + d.iteration + ' / ' + (d.total === 0 ? '∞' : d.total);
iterEl.style.display = 'block';
}
if (!d.running) {
clearInterval(_procPolls[name]);
document.getElementById('btn-run-' + name).disabled = false;
document.getElementById('btn-stop-' + name).style.display = 'none';
document.getElementById('dot-' + name).className = 'dot ' + (d.returncode === 0 ? 'dot-green' : 'dot-red');
if (iterEl) iterEl.style.display = 'none';
}
});
}
function procStop(name) {
fetch('/api/proc/' + name + '/stop', {method:'POST'}).then(function(){ procPoll(name); });
}
(function(){
['sim_enricher','lore_enricher','embed_fallout','embed_sim','mix'].forEach(function(name){
fetch('/api/proc/' + name + '/status').then(function(r){ return r.json(); }).then(function(d){
if (d.running) {
document.getElementById('btn-run-' + name).disabled = true;
document.getElementById('btn-stop-' + name).style.display = 'inline-block';
document.getElementById('dot-' + name).className = 'dot dot-green';
document.getElementById('console-' + name).style.display = 'block';
_procLastLine[name] = (d.output||[]).length;
_procPolls[name] = setInterval(function(){ procPoll(name); }, 1500);
}
});
});
})();
</script>
<!-- Stress Test -->
<div class="tools-section">
<h2>⚡ Stress Test LLM</h2>
<div class="tool-row">
<label>Mode</label>
<div class="radio-group">
<label><input type="radio" name="stress_mode" value="simultane" checked onchange="togglePnjRow()"><span>simultane</span></label>
<label><input type="radio" name="stress_mode" value="decale" onchange="togglePnjRow()"><span>decale</span></label>
<label><input type="radio" name="stress_mode" value="solo-mj" onchange="togglePnjRow()"><span>solo-mj</span></label>
<label><input type="radio" name="stress_mode" value="solo-pnj" onchange="togglePnjRow()"><span>solo-pnj</span></label>
</div>
</div>
<div class="tool-row">
<label>Rounds</label>
<div class="radio-group">
<label><input type="radio" name="stress_rounds" value="3" checked><span>3</span></label>
<label><input type="radio" name="stress_rounds" value="5"><span>5</span></label>
<label><input type="radio" name="stress_rounds" value="10"><span>10</span></label>
</div>
</div>
<div class="tool-row" id="stress_pnj_row">
<label>PNJ count</label>
<div class="radio-group">
<label><input type="radio" name="stress_pnj" value="1" checked><span>1</span></label>
<label><input type="radio" name="stress_pnj" value="2"><span>2</span></label>
<label><input type="radio" name="stress_pnj" value="3"><span>3</span></label>
</div>
</div>
<div class="tool-row" style="gap:8px;flex-wrap:wrap">
<button class="btn btn-green" id="btn-run-stress" onclick="runStress()">▶ Exécuter maintenant</button>
<button class="btn btn-amber" onclick="genStressCmd()">Voir commande SSH</button>
<button class="btn" onclick="loadStressResults()">Charger derniers résultats</button>
<button class="btn btn-red" id="btn-stop-stress" style="display:none" onclick="clearInterval(_stressPollTimer);document.getElementById('btn-run-stress').disabled=false;this.style.display='none'">■ Arrêter le suivi</button>
</div>
<div id="stress_status_bar" style="display:none;margin-top:8px;padding:6px 10px;background:#0d1117;border:1px solid var(--border);border-radius:4px;font-size:13px">
<span id="stress_status_text" style="color:var(--amber)">En cours…</span>
<span id="stress_spinner" style="margin-left:8px"></span>
</div>
<div class="cmd-output" id="stress_cmd_out" style="display:none">
<div class="code-block"><pre id="stress_cmd_text"></pre><button class="copy-btn" onclick="copyText('stress_cmd_text')">Copier</button></div>
</div>
<div id="stress_console_wrap" style="display:none;margin-top:10px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px">
<span style="font-size:12px;font-weight:600;color:var(--dim)">SORTIE EN DIRECT</span>
<button class="btn" style="padding:2px 8px;font-size:11px" onclick="document.getElementById('stress_console').innerHTML=''">Effacer</button>
</div>
<pre id="stress_console" style="background:#020408;border:1px solid var(--border);padding:10px;max-height:320px;overflow-y:auto;font-size:12px;color:#a8d5a2;white-space:pre-wrap;border-radius:4px"></pre>
</div>
<div class="results-area" id="stress_results"></div>
</div>
<!-- Enrichissement LLM -->
<div class="tools-section">
<h2>🧠 Enrichissement LLM</h2>
<div id="enrich-cards" style="display:flex;flex-direction:column;gap:12px">
<div class="enrich-card" id="card-sim_enricher">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<span style="flex:1;font-weight:600;color:var(--amber)">🎲 Règles sim → sim_proposals</span>
<select id="sim_enricher_type" style="width:148px">
<option value="all">all</option>
<option value="nouveau_mode">nouveau_mode</option>
<option value="evenement_special">evenement_special</option>
<option value="regle_maison">regle_maison</option>
<option value="scenario">scenario</option>
</select>
<select id="count-sim_enricher" style="width:62px">
<option value="1">1×</option><option value="5">5×</option>
<option value="10" selected>10×</option><option value="50">50×</option>
<option value="100">100×</option><option value="0"></option>
</select>
<button class="btn btn-green" id="btn-run-sim_enricher" onclick="procRun('sim_enricher',{type:document.getElementById('sim_enricher_type').value,count:parseInt(document.getElementById('count-sim_enricher').value)})">▶ Lancer</button>
<button class="btn btn-red" id="btn-stop-sim_enricher" style="display:none" onclick="procStop('sim_enricher')">■ Stop</button>
<span class="dot dot-red" id="dot-sim_enricher" style="margin-left:4px"></span>
</div>
<div id="iter-sim_enricher" style="font-size:11px;color:var(--dim);margin-top:4px;display:none"></div>
<div id="console-sim_enricher" class="mini-console" style="display:none"></div>
</div>
<div class="enrich-card" id="card-lore_enricher">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<span style="flex:1;font-weight:600;color:var(--amber)">📜 Lore → lore_proposals</span>
<select id="lore_enricher_faction" style="width:148px">
<option value="all">all</option>
<option value="union">union</option><option value="cda">cda</option>
<option value="ecumeurs">ecumeurs</option><option value="grand_krewe">grand_krewe</option>
<option value="dynaste_oak">dynaste_oak</option><option value="regie">regie</option>
<option value="syndicat_capitole">syndicat_capitole</option><option value="consortium">consortium</option>
</select>
<select id="count-lore_enricher" style="width:62px">
<option value="1">1×</option><option value="5">5×</option>
<option value="10" selected>10×</option><option value="50">50×</option>
<option value="100">100×</option><option value="0"></option>
</select>
<button class="btn btn-green" id="btn-run-lore_enricher" onclick="procRun('lore_enricher',{faction:document.getElementById('lore_enricher_faction').value,count:parseInt(document.getElementById('count-lore_enricher').value)})">▶ Lancer</button>
<button class="btn btn-red" id="btn-stop-lore_enricher" style="display:none" onclick="procStop('lore_enricher')">■ Stop</button>
<span class="dot dot-red" id="dot-lore_enricher" style="margin-left:4px"></span>
</div>
<div id="iter-lore_enricher" style="font-size:11px;color:var(--dim);margin-top:4px;display:none"></div>
<div id="console-lore_enricher" class="mini-console" style="display:none"></div>
</div>
<div class="enrich-card" id="card-mix" style="border-color:var(--green)">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<span style="flex:1;font-weight:600;color:var(--green)">🔄 Mode MIX — sim + lore en alternance</span>
<select id="count-mix" style="width:80px">
<option value="5">5× chaque</option><option value="10" selected>10× chaque</option>
<option value="50">50× chaque</option><option value="0">∞ chaque</option>
</select>
<button class="btn btn-green" id="btn-run-mix" onclick="procRun('mix',{count:parseInt(document.getElementById('count-mix').value)})">▶ Lancer</button>
<button class="btn btn-red" id="btn-stop-mix" style="display:none" onclick="procStop('mix')">■ Stop</button>
<span class="dot dot-red" id="dot-mix" style="margin-left:4px"></span>
</div>
<div id="iter-mix" style="font-size:11px;color:var(--dim);margin-top:4px;display:none"></div>
<div id="console-mix" class="mini-console" style="display:none"></div>
</div>
</div>
</div>
<!-- Re-embedding -->
<div class="tools-section" style="opacity:0.82">
<h2 style="color:var(--dim)">🔁 Re-embedding <small style="font-size:11px;font-weight:400;margin-left:8px">Opération rare — seulement si nouveaux PDFs ou configs modifiées</small></h2>
<div style="display:flex;flex-direction:column;gap:10px">
<div class="enrich-card" id="card-embed_fallout" style="border-color:var(--border)">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<span style="flex:1;font-weight:600;color:var(--dim)">PDFs Fallout → fallout_lore</span>
<select id="count-embed_fallout" style="width:62px"><option value="1" selected>1×</option><option value="5">5×</option></select>
<button class="btn btn-green" id="btn-run-embed_fallout" onclick="procRun('embed_fallout',{count:parseInt(document.getElementById('count-embed_fallout').value)})">▶ Lancer</button>
<button class="btn btn-red" id="btn-stop-embed_fallout" style="display:none" onclick="procStop('embed_fallout')">■ Stop</button>
<span class="dot dot-red" id="dot-embed_fallout" style="margin-left:4px"></span>
</div>
<div id="iter-embed_fallout" style="font-size:11px;color:var(--dim);margin-top:4px;display:none"></div>
<div id="console-embed_fallout" class="mini-console" style="display:none"></div>
</div>
<div class="enrich-card" id="card-embed_sim" style="border-color:var(--border)">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<span style="flex:1;font-weight:600;color:var(--dim)">Configs sim → fallout_sim_rules</span>
<select id="count-embed_sim" style="width:62px"><option value="1" selected>1×</option><option value="5">5×</option></select>
<button class="btn btn-green" id="btn-run-embed_sim" onclick="procRun('embed_sim',{count:parseInt(document.getElementById('count-embed_sim').value)})">▶ Lancer</button>
<button class="btn btn-red" id="btn-stop-embed_sim" style="display:none" onclick="procStop('embed_sim')">■ Stop</button>
<span class="dot dot-red" id="dot-embed_sim" style="margin-left:4px"></span>
</div>
<div id="iter-embed_sim" style="font-size:11px;color:var(--dim);margin-top:4px;display:none"></div>
<div id="console-embed_sim" class="mini-console" style="display:none"></div>
</div>
</div>
</div>
{% endblock %}
+5 -5
View File
@@ -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:
+328
View File
@@ -0,0 +1,328 @@
"""
embed_fallout.py — Ingestion PDFs Fallout JdR → ChromaDB (v3)
Fusion :
- Chunking structurel par paragraphes (fallout_ingest_v2, 15/06)
- Routing multi-collections selon README priorités (16/06)
Collections :
fallout_lore_canon ← lore_post_guerre/ (canonique, priorité max)
fallout_regles ← regles/core|supplements|fiches_de_jeu/
fallout_lore_contexte ← lore_pre_guerre/ + aventures/ + ambiance/radio/
Chemins container : PDF_DIR=/pdfs/jdr STATE=/pdfs/fallout_embed_state.json
Env vars : CHROMA_URL, OLLAMA_URL
"""
import json, os, re, subprocess, time, urllib.request
from pathlib import Path
CHROMA_URL = os.getenv("CHROMA_URL", "http://chromadb:8000")
CHROMA_BASE = CHROMA_URL + "/api/v2/tenants/default_tenant/databases/default_database"
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://172.18.0.1:11434")
EMBED_MODEL = "nomic-embed-text"
PDF_DIR = Path(os.getenv("PDF_DIR", "/pdfs/jdr"))
STATE_FILE = Path(os.getenv("STATE_FILE", "/pdfs/fallout_embed_state.json"))
BATCH_SIZE = 50
ROUTING = [
("lore_post_guerre", "fallout_lore_canon", "lore_post_guerre", 450, 80, True),
("regles/core", "fallout_regles", "regles_core", 350, 60, False),
("regles/supplements", "fallout_regles", "regles_supplement", 350, 60, False),
("regles/fiches_de_jeu", "fallout_regles", "fiches_jeu", 350, 60, False),
("lore_pre_guerre", "fallout_lore_contexte", "lore_pre_guerre", 450, 80, False),
("aventures", "fallout_lore_contexte", "aventures", 450, 80, False),
("ambiance/radio", "fallout_lore_contexte", "ambiance_radio", 200, 40, False),
]
SKIP_DIRS = {
"assets_graphiques", "battlemaps", "fonts", "photoshop_psd",
"fonds_de_page", "images_ref", "croquis", "maps", "logos",
"couvertures", ".claude", "archive",
}
SKIP_SLUGS = {"archive__meta_data__compressed"}
MIN_WORDS = 40
# ── State ─────────────────────────────────────────────────────────────────────
def load_state():
try:
return json.loads(STATE_FILE.read_text())
except Exception:
return {"done": [], "failed": []}
def save_state(s):
STATE_FILE.write_text(json.dumps(s, indent=2))
# ── ChromaDB ──────────────────────────────────────────────────────────────────
def _http(method, url, data=None, timeout=30):
body = json.dumps(data).encode() if data is not None else None
req = urllib.request.Request(url, data=body, method=method,
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.loads(r.read())
def get_or_create_collection(name):
try:
r = _http("POST", f"{CHROMA_BASE}/collections",
{"name": name, "metadata": {"hnsw:space": "cosine"}})
return r["id"]
except urllib.error.HTTPError as e:
if e.code == 409:
cols = _http("GET", f"{CHROMA_BASE}/collections")
return next(c["id"] for c in cols if c["name"] == name)
raise
def upsert_batch(col_id, ids, embeddings, documents, metadatas):
_http("POST", f"{CHROMA_BASE}/collections/{col_id}/upsert", {
"ids": ids, "embeddings": embeddings,
"documents": documents, "metadatas": metadatas,
}, timeout=60)
# ── Embeddings ────────────────────────────────────────────────────────────────
def embed(text):
safe = " ".join(text.split()[:280])
r = _http("POST", f"{OLLAMA_URL}/api/embeddings",
{"model": EMBED_MODEL, "prompt": safe}, timeout=60)
return r["embedding"]
# ── PDF extraction ────────────────────────────────────────────────────────────
def extract_text(pdf_path):
"""pdftotext -layout en priorité, fallback pypdf, fallback fitz."""
text = ""
try:
r = subprocess.run(
["pdftotext", "-layout", str(pdf_path), "-"],
capture_output=True, timeout=180,
)
if r.returncode == 0:
text = r.stdout.decode("utf-8", errors="replace")
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
if len(text.strip()) < 200:
try:
from pypdf import PdfReader
reader = PdfReader(str(pdf_path))
text = "\n".join(p.extract_text() or "" for p in reader.pages)
except Exception:
try:
import fitz
doc = fitz.open(str(pdf_path))
text = "\n".join(p.get_text() for p in doc)
doc.close()
except Exception as e:
raise RuntimeError(f"Aucune méthode d'extraction n'a fonctionné : {e}")
return text.strip()
# ── Chunking structurel ───────────────────────────────────────────────────────
def is_section_header(line):
l = line.strip()
if not l or len(l) > 80:
return False
if l.isupper() and len(l.split()) <= 8:
return True
if re.match(r'^(CHAPITRE|CHAPTER|PARTIE|PART|SECTION|\d+[\.\)])\s+\S', l, re.I):
return True
return False
def chunk_structural(text, chunk_words=450, overlap_words=80):
raw_blocks = re.split(r'\n{2,}', text)
blocks = []
for b in raw_blocks:
b = b.strip()
if not b:
continue
if len(b.split()) > 800:
sub = [s.strip() for s in b.split('\n') if s.strip()]
blocks.extend(sub)
else:
blocks.append(b)
chunks = []
current_blocks, current_words = [], 0
for block in blocks:
bwords = len(block.split())
if bwords < MIN_WORDS and not is_section_header(block):
current_blocks.append(block)
current_words += bwords
continue
if current_words >= chunk_words:
chunk = "\n\n".join(current_blocks).strip()
if len(chunk.split()) >= MIN_WORDS:
chunks.append(chunk)
overlap_b, overlap_w = [], 0
for ob in reversed(current_blocks):
ow = len(ob.split())
if overlap_w + ow <= overlap_words:
overlap_b.insert(0, ob)
overlap_w += ow
else:
break
current_blocks = overlap_b + [block]
current_words = overlap_w + bwords
else:
current_blocks.append(block)
current_words += bwords
if current_blocks:
chunk = "\n\n".join(current_blocks).strip()
if len(chunk.split()) >= MIN_WORDS:
chunks.append(chunk)
return chunks
# ── Routing & métadonnées ─────────────────────────────────────────────────────
def route_pdf(rel_path):
rel = rel_path.replace("\\", "/")
for part in rel.split("/")[:-1]:
if part.lower() in SKIP_DIRS:
return None
rel_low = rel.lower()
for prefix, col, cat, cw, ov, canon in ROUTING:
if rel_low.startswith(prefix.lower()):
return col, cat, cw, ov, canon
return None
def detect_langue(filename):
n = filename.lower()
if re.search(r'(_fr[_\-\.])|(_fr$)|(manuel.*officiel)|(bestiaire)|(armes)|'
r'(special.*comp)|(regles_maison)|(frequence)|(pizza)', n):
return "fr"
if re.search(r'(_en[_\-\.])|(_en$)|(quickstart)|(booklet)|(toolkit)|(settlers)|'
r'(enclave)|(rust_devil)|(winter_of_atom)|(cascadia)|(hunted)|'
r'(showdown)|(fully_operational)|(last_boat)', n):
return "en"
return "fr"
def detect_version(filename):
m = re.search(r'v(\d+[\.\d]*)', filename.lower())
return m.group(1) if m else ""
# ── Main ──────────────────────────────────────────────────────────────────────
def main():
print("=== embed_fallout v3 — chunking structurel + routing multi-collections ===\n")
all_pdfs = []
for root, dirs, files in os.walk(PDF_DIR):
dirs[:] = [d for d in dirs if d.lower() not in SKIP_DIRS]
for f in sorted(files):
if f.lower().endswith(".pdf"):
all_pdfs.append(Path(root) / f)
all_pdfs.sort()
to_process, skipped = [], []
for pdf_path in all_pdfs:
rel = str(pdf_path.relative_to(PDF_DIR))
slug = rel.replace(os.sep, "__").replace(" ", "_").lower()[:-4]
r = route_pdf(rel)
if r is None or slug in SKIP_SLUGS:
skipped.append(rel)
continue
col, cat, cw, ov, canon = r
to_process.append({
"path": pdf_path, "rel": rel, "slug": slug,
"col": col, "cw": cw, "ov": ov,
"meta": {
"source": pdf_path.name,
"categorie": cat,
"langue": detect_langue(pdf_path.name),
"canonique": "true" if canon else "false",
"version": detect_version(pdf_path.name),
},
})
print(f"PDFs trouvés : {len(all_pdfs)}")
print(f" A ingérer : {len(to_process)}")
print(f" Ignorés : {len(skipped)}")
for s in skipped:
print(f" [SKIP] {s}")
col_ids = {}
print()
for name in sorted(set(p["col"] for p in to_process)):
col_ids[name] = get_or_create_collection(name)
print(f" Collection '{name}' : {col_ids[name][:8]}...")
state = load_state()
# Reset state pour repartir propre (collections wipées)
state = {"done": [], "failed": []}
save_state(state)
total_chunks = 0
for item in to_process:
slug = item["slug"]
if slug in state["done"]:
print(f"[résumé] {item['rel']}")
continue
print(f"\n[->] {item['rel']}")
print(f" col={item['col']} cat={item['meta']['categorie']}"
f" lang={item['meta']['langue']} canon={item['meta']['canonique']}")
try:
text = extract_text(item["path"])
print(f" {len(text)} chars extraits")
chunks = chunk_structural(text, item["cw"], item["ov"])
print(f" {len(chunks)} chunks (~{item['cw']}w cible, overlap {item['ov']}w)")
ids, embeddings, documents, metadatas = [], [], [], []
for i, chunk in enumerate(chunks):
t0 = time.time()
emb = embed(chunk)
wc = len(chunk.split())
ids.append(f"{slug}__{i:04d}")
embeddings.append(emb)
documents.append(chunk)
metadatas.append({**item["meta"],
"chunk": i, "total_chunks": len(chunks),
"word_count": wc})
print(f" chunk {i+1}/{len(chunks)} ({wc}w) {time.time()-t0:.1f}s")
if len(ids) >= BATCH_SIZE:
upsert_batch(col_ids[item["col"]], ids, embeddings, documents, metadatas)
ids, embeddings, documents, metadatas = [], [], [], []
if ids:
upsert_batch(col_ids[item["col"]], ids, embeddings, documents, metadatas)
total_chunks += len(chunks)
state["done"].append(slug)
state["failed"] = [f for f in state["failed"] if f.get("slug") != slug]
print(f" OK — {len(chunks)} chunks")
except Exception as e:
state["failed"].append({"slug": slug, "error": str(e)})
print(f" ERREUR : {e}")
save_state(state)
print(f"\n{'='*60}")
print(f"TERMINÉ — {len(state['done'])}/{len(to_process)} PDFs")
print(f"Chunks totaux : {total_chunks}")
if state.get("failed"):
print("Échecs :")
for f in state["failed"]:
print(f" - {f['slug']}: {f['error']}")
if __name__ == "__main__":
main()
+1
View File
@@ -0,0 +1 @@
pymupdf
+4 -2
View File
@@ -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}},
+1 -1
View File
@@ -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: