Compare commits

..

15 Commits

Author SHA1 Message Date
Corback bd8416dceb gitignore: untrack ROADMAP.md 2026-06-17 16:32:26 +02:00
Corback e66af92f6a gitignore: add deploy.sh, ROADMAP.md, cleanup doublons 2026-06-17 16:31:44 +02:00
Corback 674adefe39 gitignore: add CLAUDE.md, PDF/, INFRA_PRIVATE.md 2026-06-17 16:23:00 +02:00
Corback ea8e24e238 refacto: dashboard Flask Blueprints + templates Jinja2, engine run/lore_enricher mis a jour 2026-06-17 14:17:18 +00:00
Corback ae6356ded4 Add embed_fallout v3 — structural chunking + multi-collection routing 2026-06-17 13:14:21 +00:00
Corback 2a6ad68f73 roadmap: add Phase 9 multi-interface (admin/player/bug), mark fallout-visu unification done 2026-06-16 11:00:40 +00:00
Corback 20df05ff72 fix: add SSH tunnel startup script, docker-compose uses start.sh 2026-06-16 10:59:30 +00:00
Corback 3330931f30 unify: dashboard now lives in repo, docker-compose moved to root, credentials in .env 2026-06-16 10:54:02 +00:00
Corback 5957a059b6 sanitize: remove sensitive IPs/credentials from README, add INFRA_PRIVATE.md to .gitignore 2026-06-16 10:48:20 +00:00
Corback efe66026ad docs: README complet + ROADMAP phases 1-9, points critiques, décisions archi 2026-06-16 10:37:51 +00:00
Corback cf57c84dd1 fix: crash test timeout 180s, indent fix, Ollama via host.docker.internal + iptables 2026-06-16 09:48:38 +00:00
Corback b4847f03bb feat: PipBoy UI redesign, onglet OUTILS (stress test + lancement commandes), crash_results.json shared path 2026-06-16 09:10:11 +00:00
Corback bbfd2dc45f feat: PipBoy rename, /lore tab fix, run.py status check loop, crash test script 2026-06-16 08:53:02 +00:00
Corback a16d671db4 feat: lore enricher pipeline + /lore dashboard tab + fix chroma_collection name 2026-06-16 08:40:45 +00:00
Corback 99bd77e18b fix: params page cfg=None + hot-reload config chaque jour
dashboard/app.py:
  load_sim_config() retourne None (pas {}) quand le fichier est absent
  — {} est falsy en Python, masquait le formulaire même quand la config
  était chargeable mais vide. None est explicitement distinguable.
  load_all_modes() charge toujours depuis sim_001.json (référence 5 modes)
  — sim_002 n'a qu'un seul mode dans son bloc 'modes', le tableau comparatif
  était vide pour S2.
  {% if cfg is not none %} dans le template au lieu de {% if cfg %}
  Message d'avertissement sous le bouton save : prise en compte au prochain jour
  Message d'erreur précis si fichier introuvable (path + hint mount Docker)
  /params/save: vérification if cfg is None

run.py:
  Hot-reload config à chaque tick==0 (frontière de jour) :
    cfg.load(config_path, session_id=SESSION_ID)
    effective_mode mis à jour depuis la config rechargée
    tick_sleep recalculé (TICK_SLEEP_SEC env var prime toujours)
  → Changer mode/drain/encounter via dashboard prend effet le jour suivant
    sans redémarrer le process
2026-06-16 08:25:59 +00:00
32 changed files with 4336 additions and 830 deletions
+8
View File
@@ -0,0 +1,8 @@
DB_HOST=127.0.0.1
DB_PORT=15432
DB_NAME=fallout
DB_USER=fallout
DB_PASS=
OLLAMA_URL=http://host.docker.internal:11434
MODEL_MJ=qwen2.5:14b
MODEL_PNJ=qwen2.5:7b
+8
View File
@@ -6,3 +6,11 @@ __pycache__/
*.sqlite *.sqlite
.DS_Store .DS_Store
Thumbs.db Thumbs.db
INFRA_PRIVATE.md
CLAUDE.md
ROADMAP.md
PDF/
deploy.sh
*.bak
*.bak_*
docker-compose.yml.bak_pre_refacto
+231
View File
@@ -0,0 +1,231 @@
# Fallout: Venice of Wasteland — Moteur de simulation JDR
Simulation autonome d'un univers **Fallout 2D20** situé en Louisiane post-nucléaire.
Le moteur simule des PNJ, des factions, des rencontres et une économie — sans MJ humain permanent.
Un LLM joue le rôle de MJ narrateur, un second LLM anime les PNJ.
**Dashboard** → [fallout.coyoteos.ovh](https://fallout.coyoteos.ovh) (PipBoy)
**Gitea** → [git.coyoteos.ovh](https://git.coyoteos.ovh) — repo `Corback/fallout-venice`
> ⚠️ Les informations de connexion (IPs, credentials, clés SSH) sont dans `INFRA_PRIVATE.md` (local uniquement, non commité).
---
## Architecture générale
```
┌─────────────────────────────────────────────────────────┐
│ AMPÈRE (Oracle ARM) │
│ │
│ ┌──────────────┐ ┌───────────────┐ ┌────────────┐ │
│ │ run.py S1 │ │ run.py S2 │ │ PipBoy │ │
│ │ (sim engine)│ │ (sim engine) │ │ Flask │ │
│ └──────┬───────┘ └───────┬───────┘ └─────┬──────┘ │
│ │ │ │ │
│ ┌──────▼───────────────────▼────────────────▼──────┐ │
│ │ Ollama (localhost:11434) │ │
│ │ qwen2.5:14b (MJ) + qwen2.5:7b (PNJ) │ │
│ └───────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ ChromaDB :8800 — collection fallout_lore │ │
│ │ 1403 chunks : règles 2D20, lore canon, ambiance │ │
│ └────────────────────────────────────────────────────┘ │
└────────────────────────┬────────────────────────────────┘
│ SSH tunnel :15432
┌────────────────────────▼────────────────────────────────┐
│ VIGILE (OVH) │
│ PostgreSQL — base "fallout" │
└─────────────────────────────────────────────────────────┘
```
---
## Stack technique
| Composant | Technologie | Détail |
|-----------|------------|--------|
| Moteur sim | Python 3.11 | `src/engine/` |
| Dashboard | Flask + Jinja2 | Docker `fallout-visu` |
| LLM MJ | qwen2.5:14b via Ollama | ~30s/réponse warm |
| LLM PNJ | qwen2.5:7b via Ollama | ~5s/réponse warm |
| Base de données | PostgreSQL 15 (Vigile) | SSH tunnel depuis Ampère |
| RAG | ChromaDB v1.0.0 (API v2) | collection `fallout_lore` |
| Proxy | nginx-proxy-manager | SSL Let's Encrypt auto |
| Gitea | git.coyoteos.ovh:2222 | repo Corback/fallout-venice |
---
## Structure du projet
```
fallout-venice/
├── src/
│ ├── engine/
│ │ ├── run.py # Lanceur principal (hot-reload config, status check loop)
│ │ ├── tick.py # Orchestration d'un tick
│ │ ├── encounter.py # Rencontres (safe zones, raids, commerce PNJ)
│ │ ├── combat_engine.py # Résolution combats 2D20
│ │ ├── sim_config.py # Chargement + deep-merge config JSON
│ │ ├── init_pnj.py # Initialisation PNJ en DB
│ │ ├── lore_enricher.py # Enrichissement lore via LLM + RAG Chroma
│ │ └── db.py # Accès PostgreSQL
│ ├── config/
│ │ ├── sim_001.json # Config session 1 (référence des 5 modes)
│ │ ├── sim_002.json # Config session 2
│ │ └── crash_results.json # Résultats derniers tests LLM (lu par PipBoy)
│ └── data/
│ └── encounter_tables.json # Tables rencontres, zones sûres, pools entités
├── dashboard/
│ └── app.py # Source PipBoy — copier dans /home/ubuntu/fallout-visu/
├── tools/
│ ├── llm_crash_test.py # Stress test LLM (modes: simultane/decale/solo)
│ └── capacity_test.py # Test capacité N joueurs simultanés
├── README.md
└── ROADMAP.md
```
> ⚠️ Le dashboard actif est `/home/ubuntu/fallout-visu/app.py` (monté dans Docker).
> `dashboard/app.py` est la source de vérité — toujours synchroniser les deux après modification.
---
## Système de PNJ — 4 tiers
| Tier | Label | Comportement | LLM actuel |
|------|-------|-------------|-----------|
| 0 | BOSS | Immortel, chef de faction | Non (prévu 14b) |
| 1 | ACTIFS SIM | Simulés chaque tick, mémoire persistante | Non (prévu 7b) |
| 2 | PNJ+ | Réagissent aux événements importants | Non (prévu 7b) |
| 3 | PASSAGE | Décor narratif | Jamais |
Actuellement tous les PNJ sont animés par le **moteur Python seul**.
Le LLM PNJ sera déclenché uniquement sur **interaction joueur directe** (Phase 5).
---
## Zones sûres
`independance` | `nola_vieux_carre` | `baton_rouge` | `laplace`
Règles : pas de rencontres lambda/groupe. Seuls les raids de faction (faible probabilité) et le commerce PNJ-à-PNJ sont possibles.
---
## Modes de simulation
Configurables depuis le PipBoy — prise en compte au **prochain jour** (hot-reload).
| Mode | Rencontres | Drain | Économie | Usage |
|------|-----------|-------|---------|-------|
| `pacifiste` | ×0.4 | ×0.7 | normal | Test/debug |
| `politique` | ×0.6 | ×1.0 | normal | Intrigues factions |
| `guerre_commerciale` | ×1.2 | ×1.5 | ×1.5 caps | Blocus, routes coupées |
| `guerre` | ×2.0 | ×1.8 | pénurie | Front de guerre actif |
| `survie_extreme` | ×1.8 | ×2.5 | ×0.3 | Stress test pur |
---
## ChromaDB — collection `fallout_lore`
**1403 chunks** — source de vérité en lecture seule.
| Catégorie | Chunks | Contenu |
|-----------|--------|---------|
| `regles_core` | ~934 | Règles 2D20 Fallout |
| `regles_supplement` | 279 | Suppléments et extensions |
| `lore_inspiration` | 86 | Inspiration univers Fallout |
| `ambiance` | 43 | Descriptions atmosphériques |
| `aventure` | 29 | Scénarios de référence |
| `lore_canon` | 32 | **Bible Venice of Wasteland v1.1 & v2.0** |
`fallout_lore_enriched` → reçoit les propositions lore **acceptées** depuis le PipBoy.
---
## Performances LLM mesurées (Ampère ARM)
| Joueurs simultanés | Tick complet | Actions/heure | Viable tick 10min |
|-------------------|-------------|---------------|-----------------|
| 1 | 35s | 103 | ✓ |
| 5 | 57s | 62 | ✓ |
| 10 | 1m17 | 46 | ✓ |
| 50 | 3m32 | 16 | ✓ (8 timeouts) |
**Plafond recommandé V1 : 10-15 joueurs actifs simultanés.**
---
## Lancement des simulations
```bash
# SSH sur Ampère (voir INFRA_PRIVATE.md pour la clé et l'IP)
cd /home/ubuntu/fallout-venice/src/engine
# Session 1
nohup env SESSION_ID=1 TICK_SLEEP_SEC=60 PYTHONUNBUFFERED=1 \
python3 -u run.py > ~/fallout_sim_s1.log 2>&1 &
# Session 2
nohup env SESSION_ID=2 TICK_SLEEP_SEC=60 PYTHONUNBUFFERED=1 \
python3 -u run.py > ~/fallout_sim_s2.log 2>&1 &
tail -f ~/fallout_sim_s1.log
```
**Reset propre d'une session :**
1. PipBoy → Paramètres → **STOP** (attendre max 5 ticks)
2. PipBoy → Paramètres → **Reset Jour/Tick**
3. Relancer via terminal
---
## Enrichissement lore
```bash
cd /home/ubuntu/fallout-venice/src/engine
python3 lore_enricher.py --faction grand_krewe # une faction
python3 lore_enricher.py --faction all # toutes
python3 lore_enricher.py --list-factions # liste
```
Valider les propositions → PipBoy onglet **ENRICHISSEMENT**
---
## Variables d'environnement clés
| Variable | Défaut | Description |
|----------|--------|-------------|
| `SESSION_ID` | 1 | Session simulée |
| `TICK_SLEEP_SEC` | 60 | Durée d'un tick (secondes) |
| `PYTHONUNBUFFERED` | — | `1` pour logs temps réel |
| `OLLAMA_URL` | `http://localhost:11434` | Endpoint Ollama |
| `MODEL_MJ` | `qwen2.5:14b` | Modèle MJ narrateur |
| `MODEL_PNJ` | `qwen2.5:7b` | Modèle animation PNJ |
| `DB_HOST` / `DB_PORT` | voir INFRA_PRIVATE.md | PostgreSQL via tunnel SSH |
---
## ⚠️ Points critiques — à ne jamais oublier
**ChromaDB**
- API v2 uniquement — `/api/v1/` retourne `{"error":"Unimplemented"}`
- La collection `fallout_vst` **n'existe pas** — c'est `fallout_lore`
- 299 chunks avaient le champ `type` au lieu de `category` — corrigé en juin 2026
**Dashboard**
- Le fichier actif est `/home/ubuntu/fallout-visu/app.py` (monté Docker), pas `dashboard/app.py`
- Toujours copier les deux après modification
**Simulation**
- `run.py` vérifie le statut DB toutes les **5 ticks** — STOP depuis PipBoy n'est pas instantané
- Hot-reload config JSON au **début de chaque jour** (tick 0), pas immédiatement
**Réseau Docker**
- Le container PipBoy accède à Ollama via `host.docker.internal:11434`
- Une règle iptables autorise le subnet Docker → port 11434. Voir INFRA_PRIVATE.md si disparaît après reboot.
**Infos de connexion**
- Voir `INFRA_PRIVATE.md` (local uniquement, dans `.gitignore`)
View File
+13 -763
View File
@@ -1,773 +1,23 @@
""" """
VAULTCOM — Fallout: Venice of Wasteland PipBoy — Fallout: Venice of Wasteland
Dashboard simulation. Schéma DB actuel (migration_003). Dashboard unifié : Simulation | Outils | Lore enrichment | Paramètres
Onglets: Simulation (par session) | Paramètres (config + restart)
""" """
from flask import Flask
import os, json, subprocess from .routes import sim, tools, lore, sim_proposals, params
import psycopg2
import psycopg2.extras
from flask import Flask, render_template_string, jsonify, request, redirect, url_for
app = Flask(__name__)
DB_HOST = os.getenv("DB_HOST", "localhost") def create_app() -> Flask:
DB_PORT = int(os.getenv("DB_PORT", 5432)) app = Flask(__name__, template_folder="templates")
DB_NAME = os.getenv("DB_NAME", "fallout")
DB_USER = os.getenv("DB_USER", "fallout")
DB_PASS = os.getenv("DB_PASS", "VeniceOfWasteland2026!")
SIM_CONFIGS_DIR = os.getenv("SIM_CONFIGS_DIR", "/app/src/config") app.register_blueprint(sim.bp)
app.register_blueprint(tools.bp)
app.register_blueprint(lore.bp)
app.register_blueprint(sim_proposals.bp)
app.register_blueprint(params.bp)
def get_conn(): return app
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 load_sim_config(session_id: int) -> dict:
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 {}
def save_sim_config(session_id: int, cfg: dict):
path = os.path.join(SIM_CONFIGS_DIR, f"sim_{session_id:03d}.json")
with open(path, "w") as f:
json.dump(cfg, f, indent=2, ensure_ascii=False)
def get_all_sessions():
return query("SELECT * FROM sessions ORDER BY id")
# ---------------------------------------------------------------------------
# HTML Template
# ---------------------------------------------------------------------------
TEMPLATE = r"""<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>VAULTCOM — Fallout: Venice of Wasteland</title>
<style>
:root {
--pip-green: #00ff41; --pip-amber: #ffb347; --pip-red: #ff4444;
--pip-blue: #44aaff; --pip-dim: #003300; --bg: #0a0a0a;
--panel: #0d1a0d; --border: #1a3d1a;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: var(--bg); color: var(--pip-green);
font-family: 'Courier New', monospace; font-size: 13px; padding: 8px; }
h1 { font-size: 18px; letter-spacing: 4px; text-align: center; padding: 8px 0;
border-bottom: 1px solid var(--pip-green); margin-bottom: 8px; }
/* ---- NAV TABS ---- */
.nav { display: flex; gap: 4px; margin-bottom: 10px; border-bottom: 1px solid var(--border); padding-bottom: 4px; }
.nav a { padding: 4px 14px; text-decoration: none; color: #555;
border: 1px solid #222; font-size: 12px; letter-spacing: 1px; }
.nav a.active { color: var(--pip-amber); border-color: var(--pip-amber); background: #1a1000; }
.nav a:hover:not(.active) { color: var(--pip-green); border-color: var(--border); }
.nav-sep { flex-grow: 1; }
.nav .session-btn { border-color: var(--border); }
.nav .session-btn.active { color: var(--pip-green); border-color: var(--pip-green); background: #001a00; }
/* ---- LAYOUT ---- */
.header-bar { display: flex; justify-content: space-between; align-items: center;
padding: 4px 8px; background: var(--pip-dim); border: 1px solid var(--border);
margin-bottom: 8px; font-size: 11px; color: var(--pip-amber); flex-wrap: wrap; gap: 4px; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.grid3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 10px; margin-top: 10px; }
.panel { background: var(--panel); border: 1px solid var(--border); padding: 8px; }
.panel h2 { font-size: 12px; letter-spacing: 2px; color: var(--pip-amber);
border-bottom: 1px solid var(--border); padding-bottom: 4px; margin-bottom: 6px; }
table { width: 100%; border-collapse: collapse; }
th { color: var(--pip-amber); font-size: 11px; padding: 3px 4px; text-align: left;
border-bottom: 1px solid var(--border); }
td { padding: 2px 4px; font-size: 11px; border-bottom: 1px dashed #0f2a0f; vertical-align: top; }
tr:hover td { background: #0f200f; }
.tier0 { color: #ff6600; } .tier1 { color: var(--pip-green); }
.tier2 { color: var(--pip-blue); } .tier3 { color: #aaaaaa; }
.dead { color: #444; text-decoration: line-through; }
.hp-bar { display: inline-block; width: 60px; height: 8px; background: #111; border: 1px solid #333; vertical-align: middle; }
.hp-fill { height: 100%; background: var(--pip-green); }
.hp-low { background: var(--pip-amber); }
.hp-crit { background: var(--pip-red); }
.badge { display: inline-block; padding: 1px 5px; font-size: 10px; border-radius: 2px; margin: 1px; }
.badge-union { background: #1a3a5c; color: #66aaff; }
.badge-cda { background: #3a1a1a; color: #ff6666; }
.badge-ecumeurs { background: #2a1a3a; color: #cc88ff; }
.badge-syndicat { background: #3a3a1a; color: #ffee66; }
.badge-default { background: #1a1a1a; color: #888; }
.event-combat { color: var(--pip-red); }
.event-encounter { color: var(--pip-amber); }
.event-survival { color: #88cc88; }
.event-death { color: #ff0000; font-weight: bold; }
.relation-hostile { color: var(--pip-red); }
.relation-neutral { color: #888; }
.relation-allie { color: var(--pip-green); }
.scrollable { max-height: 280px; overflow-y: auto; }
.world-zone { display: flex; justify-content: space-between; padding: 3px 0;
border-bottom: 1px dashed #0f2a0f; font-size: 11px; }
.zone-name { color: var(--pip-amber); min-width: 160px; }
.level-bar { display: inline-block; width: 40px; height: 6px; background: #111;
border: 1px solid #333; vertical-align: middle; }
.level-fill { height: 100%; background: var(--pip-green); }
.stat-block { display: inline-flex; gap: 4px; }
.stat-item { font-size: 10px; color: #888; }
.stat-item span { color: var(--pip-green); }
.footer { text-align: center; font-size: 10px; color: #333; padding: 10px 0; }
.refresh-note { font-size: 10px; color: #555; }
/* ---- PARAMS TAB ---- */
.cfg-block { margin-bottom: 12px; }
.cfg-block h3 { font-size: 11px; color: var(--pip-amber); letter-spacing: 1px;
border-bottom: 1px solid var(--border); padding-bottom: 3px; margin-bottom: 6px; }
.cfg-row { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
.cfg-row label { min-width: 220px; font-size: 11px; color: #aaa; }
.cfg-row input[type=number], .cfg-row input[type=text], .cfg-row select {
background: #001a00; color: var(--pip-green); border: 1px solid var(--border);
padding: 2px 6px; font-family: 'Courier New', monospace; font-size: 11px; width: 120px; }
.cfg-row input[type=checkbox] { accent-color: var(--pip-green); }
.btn { padding: 5px 16px; border: 1px solid var(--pip-amber); background: #1a1000;
color: var(--pip-amber); font-family: 'Courier New', monospace; font-size: 12px;
cursor: pointer; letter-spacing: 1px; margin-top: 4px; }
.btn:hover { background: #2a2000; }
.btn-red { border-color: var(--pip-red); color: var(--pip-red); background: #1a0000; }
.btn-red:hover { background: #2a0000; }
.btn-green { border-color: var(--pip-green); color: var(--pip-green); background: #001a00; }
.btn-green:hover { background: #002a00; }
.msg-ok { color: var(--pip-green); font-size: 11px; }
.msg-err { color: var(--pip-red); font-size: 11px; }
.mode-badge { display: inline-block; padding: 2px 8px; border: 1px solid var(--pip-amber);
color: var(--pip-amber); font-size: 11px; letter-spacing: 1px; }
</style>
</head>
<body>
<h1>⚡ VAULTCOM — VENICE OF WASTELAND ⚡</h1>
<!-- ===== NAVIGATION ===== -->
<div class="nav">
{% for s in all_sessions %}
<a href="/?sid={{ s.id }}" class="session-btn {{ 'active' if s.id == current_sid else '' }}">
S{{ s.id }} — {{ s.name[:22] }}
<span style="font-size:9px;color:{{ '#00ff41' if s.status=='active' else '#ff4444' }}">
{{ s.status[:3].upper() }}
</span>
</a>
{% endfor %}
<span class="nav-sep"></span>
<a href="/?sid={{ current_sid }}" class="{{ 'active' if tab=='sim' else '' }}">📡 SIMULATION</a>
<a href="/params?sid={{ current_sid }}" class="{{ 'active' if tab=='params' else '' }}">⚙ PARAMÈTRES</a>
</div>
{% if flash_msg %}
<div style="padding:6px 10px; margin-bottom:8px; border:1px solid {{ '#00ff41' if flash_ok else '#ff4444' }}; color:{{ '#00ff41' if flash_ok else '#ff4444' }}; font-size:11px;">
{{ flash_msg }}
</div>
{% endif %}
{% if tab == 'sim' %}
<!-- ===================================================== -->
<!-- ONGLET SIMULATION -->
<!-- ===================================================== -->
{% if session %}
<div class="header-bar">
<span>SESSION #{{ session.id }} — {{ session.name }}</span>
<span>JOUR {{ session.current_day }} / TICK {{ session.current_tick }}h</span>
<span class="mode-badge">{{ session.mode | upper }}</span>
<span>STATUS: <strong>{{ session.status | upper }}</strong></span>
<span>SEED: {{ session.seed_global }}</span>
<span class="refresh-note">🔄 <a href="?sid={{ session.id }}" style="color:#555;text-decoration:none">Actualiser</a>
<span id="countdown"></span></span>
</div>
{% endif %}
<div class="grid">
<!-- PNJ -->
<div class="panel">
<h2>👥 PNJ ACTIFS ({{ 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 %}
<tr class="{{ 'dead' if not c.is_alive else 'tier' ~ (c.tier or 1) }}">
<td>
{% if c.tier == 0 %}<span class="tier0">★BOSS</span>
{% elif c.tier == 2 %}<span class="tier2">◈ T2</span>
{% elif c.tier == 3 %}<span class="tier3">· T3</span>
{% else %}<span class="tier1">● T1</span>{% endif %}
</td>
<td>{{ c.name }}</td>
<td>
{% if c.is_alive %}
{% set pct = (c.hp / c.max_hp * 100) | int if c.max_hp else 0 %}
<div class="hp-bar"><div class="hp-fill {{ 'hp-crit' if pct < 25 else 'hp-low' if pct < 50 else '' }}" style="width:{{ pct }}%"></div></div>
<small> {{ c.hp }}/{{ c.max_hp }}</small>
{% else %}†{% endif %}
</td>
<td>
{% set f = c.faction_slug or 'sans' %}
<span class="badge badge-{{ 'union' if 'union' in f else 'cda' if 'cda' in f else 'ecumeurs' if 'ecumeur' in f else 'default' }}">
{{ f[:12] }}</span>
</td>
<td><small>{{ (c.location_slug or '')[:14] }}</small></td>
<td>{{ c.caps }}¢</td>
</tr>
{% endfor %}
</table>
</div>
</div>
<!-- Événements -->
<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 class="tier3">{{ ev.day }}/{{ ev.tick }}</td>
<td class="event-{{ ev.event_type }}"><small>{{ ev.event_type }}</small></td>
<td><small>{{ ev.description[:80] }}{% if ev.description|length > 80 %}…{% endif %}</small></td>
</tr>
{% endfor %}
{% if not events %}<tr><td colspan="3" style="color:#444;text-align:center">Aucun événement</td></tr>{% endif %}
</table>
</div>
</div>
</div>
<div class="grid3">
<!-- World state -->
<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="stat-block">
<span class="stat-item">🍖<div class="level-bar"><div class="level-fill" style="width:{{ ws.food_level or 0 }}%"></div></div></span>
<span class="stat-item">💧<div class="level-bar"><div class="level-fill" style="width:{{ ws.water_level or 0 }}%"></div></div></span>
<span class="stat-item">🛡<div class="level-bar"><div class="level-fill" style="width:{{ ws.security_level or 0 }}%"></div></div></span>
<span class="stat-item">👤<span>{{ ws.pop_count or 0 }}</span></span>
</span>
</div>
{% endfor %}
{% if not world_state %}<p style="color:#444;font-size:11px">Pas de données monde</p>{% endif %}
</div>
</div>
<!-- Relations factions -->
<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 %}
<tr>
<td><small>{{ r.faction_a[:12] }}</small></td>
<td><small>{{ r.faction_b[:12] }}</small></td>
<td class="{{ 'relation-hostile' if r.relation_score < -20 else 'relation-allie' if r.relation_score > 20 else 'relation-neutral' }}">
{{ r.relation_score }}</td>
<td><small class="{{ 'relation-hostile' if r.relation_score < -20 else 'relation-allie' if r.relation_score > 20 else 'relation-neutral' }}">
{{ r.relation_label or ('HOSTILE' if r.relation_score < -20 else 'ALLIÉ' if r.relation_score > 20 else 'NEUTRE') }}
</small></td>
</tr>
{% endfor %}
{% if not faction_relations %}<tr><td colspan="4" style="color:#444">Pas de données</td></tr>{% endif %}
</table>
</div>
</div>
<!-- Stats -->
<div class="panel">
<h2>📊 STATS SIMULATION</h2>
<table>
<tr><th>MÉTRIQUE</th><th>VALEUR</th></tr>
{% for k, v in stats.items() %}
<tr><td>{{ k }}</td><td class="tier1">{{ v }}</td></tr>
{% endfor %}
</table>
<br>
<h2 style="margin-top:6px">🎭 PNJ PAR TIER</h2>
<table>
<tr><th>TIER</th><th>LABEL</th><th>VIVANTS</th><th>MORTS</th></tr>
{% for row in tier_stats %}
<tr class="tier{{ row.tier }}">
<td>{{ row.tier }}</td><td>{{ row.label }}</td>
<td>{{ row.alive }}</td><td class="tier3">{{ row.dead }}</td>
</tr>
{% endfor %}
</table>
<br>
<h2 style="margin-top:6px">🏙 PNJ PAR ZONE (TOP 5)</h2>
<table>
<tr><th>ZONE</th><th>PNJ</th></tr>
{% for row in zone_stats %}
<tr><td><small>{{ row.location_slug }}</small></td><td>{{ row.cnt }}</td></tr>
{% endfor %}
</table>
</div>
</div>
<!-- Encounters -->
<div class="panel" style="margin-top:10px">
<h2>⚡ RENCONTRES RÉCENTES</h2>
<div class="scrollable" style="max-height:160px">
<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 class="tier3">{{ ev.day }}/{{ ev.tick }}</td>
<td class="event-encounter"><small>{{ ev.event_type }}</small></td>
<td><small>{{ ev.actor_name or '' }}</small></td>
<td><small>{{ (ev.location_slug or '')[:14] }}</small></td>
<td><small>{{ ev.description[:90] }}{% if ev.description|length > 90 %}…{% endif %}</small></td>
</tr>
{% endfor %}
{% if not encounters %}<tr><td colspan="5" style="color:#444;text-align:center">Aucune rencontre</td></tr>{% endif %}
</table>
</div>
</div>
<!-- Boss inventory -->
{% if boss_inventory %}
<div class="panel" style="margin-top:10px">
<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">{{ row.char_name }}</td>
<td>{{ row.item_name }}</td>
<td class="tier3"><small>{{ row.item_type }}</small></td>
<td>{{ row.quantity }}</td>
<td>{{ '' if row.is_equipped else '' }}</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
<script>
// Countdown auto-refresh 30s
var t = 30;
var el = document.getElementById('countdown');
if (el) {
setInterval(function() {
t--;
el.textContent = ' (' + t + 's)';
if (t <= 0) { location.reload(); }
}, 1000);
}
</script>
{% elif tab == 'params' %}
<!-- ===================================================== -->
<!-- ONGLET PARAMÈTRES -->
<!-- ===================================================== -->
{% if session %}
<div class="header-bar">
<span>SESSION #{{ session.id }} — {{ session.name }}</span>
<span class="mode-badge">{{ session.mode | upper }}</span>
<span>STATUS: <strong style="color:{{ '#00ff41' if session.status=='active' else '#ff4444' }}">{{ session.status | upper }}</strong></span>
<span>JOUR {{ session.current_day }} / TICK {{ session.current_tick }}h</span>
</div>
{% endif %}
<div class="grid">
<!-- Config JSON éditable -->
<div class="panel">
<h2>⚙ CONFIG SIM_{{ '%03d' % current_sid }}.JSON</h2>
<form method="POST" action="/params/save?sid={{ current_sid }}">
{% if cfg %}
<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>
{% else %}
<p style="color:#ff4444;font-size:11px">Fichier sim_{{ '%03d' % current_sid }}.json introuvable dans {{ sim_configs_dir }}</p>
{% endif %}
</form>
</div>
<!-- Actions session -->
<div class="panel">
<h2>🔧 GESTION SESSION #{{ current_sid }}</h2>
<div class="cfg-block">
<h3>STATUT DB</h3>
<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>
&nbsp;
<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>
&nbsp;
<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>
<p style="margin-top:6px;font-size:10px;color:#555">
Note: ne tue pas le process Python. Mettre "paused" ou "stopped" fait sortir run.py au prochain tick.
</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' if m_name == cfg.get('mode','') else '' }}">
{{ m_name }}{% if m_name == cfg.get('mode','') %} ◀{% endif %}</td>
<td class="tier3"><small>×{{ m_vals.get('survival',{}).get('drain_speed','') }}</small></td>
<td class="tier3"><small>×{{ m_vals.get('encounter',{}).get('global_rate_multiplier','') }}</small></td>
<td class="tier3"><small>×{{ m_vals.get('economy',{}).get('caps_global_multiplier','') }}</small></td>
</tr>
{% endfor %}
</table>
</div>
<div class="cfg-block">
<h3>COMMANDES AMPÈRE</h3>
<p style="font-size:10px;color:#666;margin-bottom:6px">Copier-coller dans votre terminal SSH :</p>
<div style="background:#001000;border:1px solid var(--border);padding:6px;font-size:10px;color:#88aa88;">
<p style="color:#555"># Lancer/relancer S{{ current_sid }} :</p>
<p>cd /home/ubuntu/fallout-venice/src/engine</p>
<p>nohup env SESSION_ID={{ current_sid }} SIM_CONFIG=../config/sim_{{ '%03d' % current_sid }}.json TICK_SLEEP_SEC=60 PYTHONUNBUFFERED=1 python3 -u run.py > ~/fallout_sim_s{{ current_sid }}.log 2>&1 &</p>
<br>
<p style="color:#555"># Voir les logs :</p>
<p>tail -f ~/fallout_sim_s{{ current_sid }}.log</p>
</div>
</div>
</div>
</div><!-- /grid -->
{% endif %}
<div class="footer">VAULT-TEC VAULTCOM v4.0 | S{{ current_sid }} | J={{ session.current_day if session else '?' }} T={{ session.current_tick if session else '?' }}h</div>
</body>
</html>"""
# ---------------------------------------------------------------------------
# Helpers data
# ---------------------------------------------------------------------------
def get_sim_data(sid: int) -> dict:
session = query_one("SELECT * FROM sessions WHERE id = %s", (sid,))
if not session:
session = query_one("SELECT * FROM sessions ORDER BY id DESC LIMIT 1")
characters = query("""
SELECT c.*, COALESCE(c.tier, 1) as tier
FROM characters c
WHERE c.session_id = %s
ORDER BY COALESCE(c.tier,1), c.is_alive DESC, c.name
""", (sid,))
alive_count = sum(1 for c in characters if c["is_alive"])
events = query("""
SELECT e.*, c.name as actor_name FROM events e
LEFT JOIN characters c ON c.id = e.actor_id
WHERE e.session_id = %s
AND e.event_type NOT IN ('encounter_lambda','encounter_group','encounter_actif','encounter_boss','cycle_tick')
ORDER BY e.day DESC, e.tick DESC, e.id DESC LIMIT 50
""", (sid,))
encounters = query("""
SELECT e.*, c.name as actor_name FROM events e
LEFT JOIN characters c ON c.id = e.actor_id
WHERE e.session_id = %s AND e.event_type LIKE 'encounter%%'
ORDER BY e.day DESC, e.tick DESC, e.id DESC LIMIT 30
""", (sid,))
world_state = query("""
SELECT DISTINCT ON (location_slug) *
FROM world_state WHERE session_id = %s
ORDER BY location_slug, day DESC
""", (sid,))
faction_relations = query("""
SELECT * FROM faction_relations WHERE session_id = %s
ORDER BY relation_score ASC
""", (sid,))
tier_labels = {0: "BOSS", 1: "ACTIFS SIM", 2: "PNJ+", 3: "PASSAGE"}
tier_raw = query("""
SELECT COALESCE(tier,1) as tier,
COUNT(*) FILTER (WHERE is_alive) as alive,
COUNT(*) FILTER (WHERE NOT is_alive) as dead
FROM characters WHERE session_id = %s
GROUP BY COALESCE(tier,1) ORDER BY COALESCE(tier,1)
""", (sid,))
tier_stats = [{"tier": r["tier"], "label": tier_labels.get(r["tier"],"?"),
"alive": r["alive"], "dead": r["dead"]} for r in tier_raw]
zone_stats = query("""
SELECT location_slug, COUNT(*) as cnt FROM characters
WHERE session_id = %s AND is_alive = TRUE
GROUP BY location_slug ORDER BY cnt DESC LIMIT 5
""", (sid,))
ev_total = query_one("SELECT COUNT(*) as n FROM events WHERE session_id=%s", (sid,))
cb_total = query_one("SELECT COUNT(*) as n FROM events WHERE session_id=%s AND event_type='combat'", (sid,))
dt_total = query_one("SELECT COUNT(*) as n FROM events WHERE session_id=%s AND event_type='death'", (sid,))
enc_total = query_one("SELECT COUNT(*) as n FROM events WHERE session_id=%s AND event_type LIKE 'encounter%%'", (sid,))
stats = {
"Événements total": ev_total["n"] if ev_total else 0,
"Combats": cb_total["n"] if cb_total else 0,
"Morts": dt_total["n"] if dt_total else 0,
"Rencontres": enc_total["n"] if enc_total else 0,
"PNJ vivants": alive_count,
"PNJ morts": len(characters) - alive_count,
}
boss_inventory = query("""
SELECT c.name as char_name, i.item_name, i.item_type, i.quantity, i.is_equipped
FROM inventory i
JOIN characters c ON c.id = i.character_id
WHERE c.session_id = %s AND COALESCE(c.tier,1) = 0
ORDER BY c.name, i.is_equipped DESC
""", (sid,))
return dict(
session=session,
characters=characters,
alive_count=alive_count,
total_count=len(characters),
events=events,
encounters=encounters,
world_state=world_state,
faction_relations=faction_relations,
tier_stats=tier_stats,
zone_stats=zone_stats,
stats=stats,
boss_inventory=boss_inventory,
)
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@app.route("/")
def index():
all_sessions = get_all_sessions()
sid = int(request.args.get("sid", all_sessions[-1]["id"] if all_sessions else 1))
data = get_sim_data(sid)
return render_template_string(TEMPLATE,
tab="sim",
current_sid=sid,
all_sessions=all_sessions,
flash_msg=None,
flash_ok=True,
modes={},
cfg={},
sim_configs_dir=SIM_CONFIGS_DIR,
**data,
)
@app.route("/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 = cfg.get("modes", {})
flash_msg = request.args.get("msg")
flash_ok = request.args.get("ok", "1") == "1"
return render_template_string(TEMPLATE,
tab="params",
current_sid=sid,
all_sessions=all_sessions,
session=session,
cfg=cfg,
modes=modes,
sim_configs_dir=SIM_CONFIGS_DIR,
flash_msg=flash_msg,
flash_ok=flash_ok,
# unused in params tab but required by template
characters=[], alive_count=0, total_count=0,
events=[], encounters=[], world_state=[],
faction_relations=[], tier_stats=[], zone_stats=[],
stats={}, boss_inventory=[],
)
@app.route("/params/save", methods=["POST"])
def params_save():
sid = int(request.args.get("sid", 1))
cfg = load_sim_config(sid)
if not cfg:
return redirect(url_for("params", sid=sid, msg="Fichier config introuvable", ok=0))
f = request.form
cfg["name"] = f.get("name", cfg.get("name", ""))
cfg["mode"] = f.get("mode", cfg.get("mode", "pacifiste"))
cfg.setdefault("tick", {})["tick_sleep_sec"] = int(f.get("tick_sleep_sec", 60))
cfg.setdefault("survival", {}).update({
"drain_speed": float(f.get("survival_drain_speed", 1.0)),
"fatigue_hp_per_2pts": int(f.get("survival_fatigue_hp", 2)),
"enabled": "survival_enabled" in f,
})
cfg.setdefault("encounter", {}).update({
"global_rate_multiplier": float(f.get("encounter_rate", 1.0)),
"enabled": "encounter_enabled" in f,
})
cfg.setdefault("economy", {})["caps_global_multiplier"] = float(f.get("caps_multiplier", 1.0))
try:
save_sim_config(sid, cfg)
execute("UPDATE sessions SET mode=%s WHERE id=%s", (cfg["mode"], sid))
msg = f"Config sim_{sid:03d}.json sauvegardée. Mode DB mis à jour → {cfg['mode']}."
ok = 1
except Exception as e:
msg = f"Erreur: {e}"
ok = 0
return redirect(url_for("params", sid=sid, msg=msg, ok=ok))
@app.route("/params/setstatus", methods=["POST"])
def params_setstatus():
sid = int(request.args.get("sid", 1))
status = request.form.get("status", "paused")
try:
execute("UPDATE sessions SET status=%s WHERE id=%s", (status, sid))
msg = f"Session {sid} → status '{status}'. Le process run.py sortira au prochain tick si arrêté."
ok = 1
except Exception as e:
msg = f"Erreur: {e}"
ok = 0
return redirect(url_for("params", sid=sid, msg=msg, ok=ok))
@app.route("/params/resetday", methods=["POST"])
def params_resetday():
sid = int(request.args.get("sid", 1))
day = int(request.form.get("day", 1))
tick = int(request.form.get("tick", 0))
try:
execute("UPDATE sessions SET current_day=%s, current_tick=%s WHERE id=%s", (day, tick, sid))
msg = f"Session {sid} remise à J{day} T{tick}h."
ok = 1
except Exception as e:
msg = f"Erreur: {e}"
ok = 0
return redirect(url_for("params", sid=sid, msg=msg, ok=ok))
@app.route("/api/state")
def api_state():
all_sessions = get_all_sessions()
sid = int(request.args.get("sid", all_sessions[-1]["id"] if all_sessions else 1))
session = query_one("SELECT * FROM sessions WHERE id=%s", (sid,))
alive = query("""
SELECT id, name, hp, max_hp, tier, faction_slug, location_slug
FROM characters WHERE session_id=%s AND is_alive=TRUE
""", (sid,))
return jsonify({"session": session, "alive_pnj": alive})
if __name__ == "__main__": if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=False) create_app().run(host="0.0.0.0", port=5000, debug=False)
+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})
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
set -e
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 -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 %}
+34
View File
@@ -0,0 +1,34 @@
services:
fallout-visu:
image: python:3.11-alpine
container_name: fallout-visu
restart: unless-stopped
working_dir: /app
volumes:
- /opt/coyote/fallout/src/dashboard:/app/dashboard:ro
- /home/ubuntu/.ssh/vigile_backup:/ssh/vigile.key:ro
- /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:
- 'host.docker.internal:172.20.0.1'
environment:
- PYTHONUNBUFFERED=1
- LETSENCRYPT_HOST=fallout.coyoteos.ovh
- VIRTUAL_HOST=fallout.coyoteos.ovh
- DB_HOST=${DB_HOST}
- DB_PORT=${DB_PORT}
- DB_NAME=${DB_NAME}
- DB_USER=${DB_USER}
- DB_PASS=${DB_PASS}
- SIM_CONFIGS_DIR=/app/src/config
- OLLAMA_URL=${OLLAMA_URL}
- MODEL_MJ=${MODEL_MJ}
- MODEL_PNJ=${MODEL_PNJ}
- CHROMA_URL=http://chromadb:8000
networks:
proxy-nw:
external: true
+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
+54
View File
@@ -0,0 +1,54 @@
{
"test_type": "capacity",
"mode": "capacity_test",
"timestamp": "2026-06-16T10:19:26.345074",
"file_date": "16/06/2026 10:19:26",
"model_mj": "qwen2.5:14b",
"model_pnj": "qwen2.5:7b",
"stats": [
{
"n_players": 1,
"pnj_avg_wall_s": 4.8,
"pnj_avg_p50_s": 4.8,
"pnj_avg_p95_s": 4.8,
"mj_avg_elapsed_s": 29.9,
"tick_total_avg_s": 34.6,
"actions_per_hour": 103,
"errors": 0,
"viable_realtime_1h": true
},
{
"n_players": 5,
"pnj_avg_wall_s": 27.5,
"pnj_avg_p50_s": 17.2,
"pnj_avg_p95_s": 27.5,
"mj_avg_elapsed_s": 29.8,
"tick_total_avg_s": 57.2,
"actions_per_hour": 62,
"errors": 0,
"viable_realtime_1h": true
},
{
"n_players": 10,
"pnj_avg_wall_s": 47.4,
"pnj_avg_p50_s": 26.4,
"pnj_avg_p95_s": 47.4,
"mj_avg_elapsed_s": 29.9,
"tick_total_avg_s": 77.3,
"actions_per_hour": 46,
"errors": 0,
"viable_realtime_1h": true
},
{
"n_players": 50,
"pnj_avg_wall_s": 180.2,
"pnj_avg_p50_s": 91.6,
"pnj_avg_p95_s": 172.2,
"mj_avg_elapsed_s": 31.9,
"tick_total_avg_s": 212.1,
"actions_per_hour": 16,
"errors": 8,
"viable_realtime_1h": true
}
]
}
+130 -48
View File
@@ -1,29 +1,24 @@
{ {
"_comment": "Config session 1 — Venice of Wasteland. Sections 'modes.<mode>' font un deep-merge sur les valeurs de base.", "_comment": "Config session 1 — Venice of Wasteland. Sections 'modes.<mode>' font un deep-merge sur les valeurs de base.",
"session": { "session": {
"session_id": 1, "session_id": 1,
"name": "Session 001 — Louisiane post-apo", "name": "Session 001 — Louisiane post-apo",
"seed_global": 42 "seed_global": 42
}, },
"mode": "pacifiste", "mode": "pacifiste",
"tick": { "tick": {
"tick_sleep_sec": 60, "tick_sleep_sec": 30,
"ticks_per_day": 24, "ticks_per_day": 24,
"day_start_tick": 6, "day_start_tick": 6,
"night_start_tick": 20, "night_start_tick": 20,
"llm_tick_interval": 6 "llm_tick_interval": 6
}, },
"survival": { "survival": {
"_comment": "drain_speed : diviseur sur HUNGER/THIRST/SLEEP_TICKS (2.0 = 2x plus vite, 0.5 = 2x plus lent)", "_comment": "drain_speed : diviseur sur HUNGER/THIRST/SLEEP_TICKS (2.0 = 2x plus vite, 0.5 = 2x plus lent)",
"drain_speed": 1.0, "drain_speed": 1.0,
"fatigue_hp_per_2pts": 2, "fatigue_hp_per_2pts": 2,
"enabled": true "enabled": true
}, },
"economy": { "economy": {
"_comment": "Multiplicateur global caps (appliqué à tous les caps_range des rôles)", "_comment": "Multiplicateur global caps (appliqué à tous les caps_range des rôles)",
"caps_global_multiplier": 1.0, "caps_global_multiplier": 1.0,
@@ -31,14 +26,12 @@
"loot_caps_max": 25, "loot_caps_max": 25,
"trade_price_variance": 0.2 "trade_price_variance": 0.2
}, },
"encounter": { "encounter": {
"_comment": "global_rate_multiplier : appliqué sur chance_per_tick de chaque zone", "_comment": "global_rate_multiplier : appliqué sur chance_per_tick de chaque zone",
"global_rate_multiplier": 1.0, "global_rate_multiplier": 1.0,
"combat_lethality": 1.0, "combat_lethality": 1.0,
"enabled": true "enabled": true
}, },
"world": { "world": {
"_comment": "Populations réalistes pour un wasteland post-apo. 500 habitants c'est une grande cité en zone sûre.", "_comment": "Populations réalistes pour un wasteland post-apo. 500 habitants c'est une grande cité en zone sûre.",
"pop_count_by_zone": { "pop_count_by_zone": {
@@ -57,77 +50,166 @@
"food_drain_per_pop": 0.003, "food_drain_per_pop": 0.003,
"security_day_regen": 0.2, "security_day_regen": 0.2,
"security_night_drain": 0.3, "security_night_drain": 0.3,
"conflict_security_range": [-0.5, 0.2] "conflict_security_range": [
-0.5,
0.2
]
}, },
"faction_relations": { "faction_relations": {
"initial_volatility": 0.05, "initial_volatility": 0.05,
"drift_per_llm_tick": 1 "drift_per_llm_tick": 1
}, },
"llm": { "llm": {
"enabled": false, "enabled": false,
"model_mj": "qwen2.5:14b", "model_mj": "qwen2.5:14b",
"model_pnj": "qwen2.5:7b", "model_pnj": "qwen2.5:7b",
"ollama_url": "http://localhost:11434", "ollama_url": "http://localhost:11434",
"chroma_url": "http://localhost:8000", "chroma_url": "http://localhost:8000",
"chroma_collection": "fallout_vst" "chroma_collection": "fallout_lore"
}, },
"modes": { "modes": {
"pacifiste": { "pacifiste": {
"_comment": "Monde presque sûr, idéal pour tester la sim sans mort en cascade", "_comment": "Monde presque sûr, idéal pour tester la sim sans mort en cascade",
"encounter": { "global_rate_multiplier": 0.4, "combat_lethality": 0.5 }, "encounter": {
"survival": { "drain_speed": 0.7 }, "global_rate_multiplier": 0.4,
"world": { "security_day_regen": 0.4, "security_night_drain": 0.1, "conflict_security_range": [-0.1, 0.3] }, "combat_lethality": 0.5
"faction_relations": { "initial_volatility": 0.02, "drift_per_llm_tick": 1 } },
"survival": {
"drain_speed": 0.7
},
"world": {
"security_day_regen": 0.4,
"security_night_drain": 0.1,
"conflict_security_range": [
-0.1,
0.3
]
},
"faction_relations": {
"initial_volatility": 0.02,
"drift_per_llm_tick": 1
}
}, },
"politique": { "politique": {
"_comment": "Intrigues factions, peu de violence directe, mais relations volatiles", "_comment": "Intrigues factions, peu de violence directe, mais relations volatiles",
"encounter": { "global_rate_multiplier": 0.6, "combat_lethality": 0.7 }, "encounter": {
"survival": { "drain_speed": 1.0 }, "global_rate_multiplier": 0.6,
"world": { "security_day_regen": 0.2, "security_night_drain": 0.3, "conflict_security_range": [-0.8, 0.1] }, "combat_lethality": 0.7
"faction_relations": { "initial_volatility": 0.3, "drift_per_llm_tick": 5 } },
"survival": {
"drain_speed": 1.0
},
"world": {
"security_day_regen": 0.2,
"security_night_drain": 0.3,
"conflict_security_range": [
-0.8,
0.1
]
},
"faction_relations": {
"initial_volatility": 0.3,
"drift_per_llm_tick": 5
}
}, },
"guerre_commerciale": { "guerre_commerciale": {
"_comment": "Blocus, routes coupées, prix instables. Survie difficile mais pas de front de guerre.", "_comment": "Blocus, routes coupées, prix instables. Survie difficile mais pas de front de guerre.",
"encounter": { "global_rate_multiplier": 1.2, "combat_lethality": 0.9 }, "encounter": {
"survival": { "drain_speed": 1.5 }, "global_rate_multiplier": 1.2,
"economy": { "caps_global_multiplier": 1.5, "trade_price_variance": 0.5 }, "combat_lethality": 0.9
"world": {
"pop_count_by_zone": { "nola_cbd": 15, "pearl_river": 6, "donaldsonville": 8 },
"security_day_regen": 0.1, "security_night_drain": 0.5, "conflict_security_range": [-1.5, 0.0]
}, },
"faction_relations": { "initial_volatility": 0.4, "drift_per_llm_tick": 8 } "survival": {
"drain_speed": 1.5
},
"economy": {
"caps_global_multiplier": 1.5,
"trade_price_variance": 0.5
}, },
"guerre": {
"_comment": "Front de guerre actif. Rencontres fréquentes, ressources rares, morts quotidiennes.",
"encounter": { "global_rate_multiplier": 2.0, "combat_lethality": 1.5 },
"survival": { "drain_speed": 1.8, "fatigue_hp_per_2pts": 3 },
"economy": { "caps_global_multiplier": 0.5, "loot_caps_max": 40, "trade_price_variance": 0.8 },
"world": { "world": {
"pop_count_by_zone": { "pop_count_by_zone": {
"independance": 80, "nola_vieux_carre": 50, "baton_rouge": 40, "nola_cbd": 15,
"laplace": 30, "nola_fleuve": 20, "la_paroisse": 15, "pearl_river": 6,
"nola_cbd": 8, "oak_plantation": 10, "donaldsonville": 6, "pearl_river": 4 "donaldsonville": 8
}, },
"security_day_regen": 0.05, "security_night_drain": 0.8, "conflict_security_range": [-2.0, -0.3] "security_day_regen": 0.1,
"security_night_drain": 0.5,
"conflict_security_range": [
-1.5,
0.0
]
}, },
"faction_relations": { "initial_volatility": 0.8, "drift_per_llm_tick": 15 } "faction_relations": {
"initial_volatility": 0.4,
"drift_per_llm_tick": 8
}
},
"guerre": {
"_comment": "Front de guerre actif. Rencontres fréquentes, ressources rares, morts quotidiennes.",
"encounter": {
"global_rate_multiplier": 2.0,
"combat_lethality": 1.5
},
"survival": {
"drain_speed": 1.8,
"fatigue_hp_per_2pts": 3
},
"economy": {
"caps_global_multiplier": 0.5,
"loot_caps_max": 40,
"trade_price_variance": 0.8
},
"world": {
"pop_count_by_zone": {
"independance": 80,
"nola_vieux_carre": 50,
"baton_rouge": 40,
"laplace": 30,
"nola_fleuve": 20,
"la_paroisse": 15,
"nola_cbd": 8,
"oak_plantation": 10,
"donaldsonville": 6,
"pearl_river": 4
},
"security_day_regen": 0.05,
"security_night_drain": 0.8,
"conflict_security_range": [
-2.0,
-0.3
]
},
"faction_relations": {
"initial_volatility": 0.8,
"drift_per_llm_tick": 15
}
}, },
"survie_extreme": { "survie_extreme": {
"_comment": "Pas de mode narration. Purement sim de survie hostile. Test de résistance.", "_comment": "Pas de mode narration. Purement sim de survie hostile. Test de résistance.",
"encounter": { "global_rate_multiplier": 1.8, "combat_lethality": 2.0 }, "encounter": {
"survival": { "drain_speed": 2.5, "fatigue_hp_per_2pts": 4 }, "global_rate_multiplier": 1.8,
"economy": { "caps_global_multiplier": 0.3, "loot_caps_max": 15 }, "combat_lethality": 2.0
"world": {
"security_day_regen": 0.05, "security_night_drain": 1.0, "conflict_security_range": [-3.0, -0.5]
}, },
"faction_relations": { "initial_volatility": 0.5, "drift_per_llm_tick": 10 } "survival": {
"drain_speed": 2.5,
"fatigue_hp_per_2pts": 4
},
"economy": {
"caps_global_multiplier": 0.3,
"loot_caps_max": 15
},
"world": {
"security_day_regen": 0.05,
"security_night_drain": 1.0,
"conflict_security_range": [
-3.0,
-0.5
]
},
"faction_relations": {
"initial_volatility": 0.5,
"drift_per_llm_tick": 10
} }
} }
},
"name": ""
} }
+1 -1
View File
@@ -67,7 +67,7 @@
"model_pnj": "qwen2.5:7b", "model_pnj": "qwen2.5:7b",
"ollama_url": "http://localhost:11434", "ollama_url": "http://localhost:11434",
"chroma_url": "http://localhost:8000", "chroma_url": "http://localhost:8000",
"chroma_collection": "fallout_vst" "chroma_collection": "fallout_lore"
}, },
"modes": { "modes": {
+379
View File
@@ -0,0 +1,379 @@
"""
Enrichissement de lore par LLM — Fallout: Venice of Wasteland
Usage:
python lore_enricher.py --faction grand_krewe
python lore_enricher.py --faction all
python lore_enricher.py --list-factions
Pipeline:
1. Récupère le lore actuel de la faction depuis Chroma (fallout_lore / lore_canon)
2. Récupère le contexte RAG pertinent (règles 2D20, lore inspiration)
3. Envoie au LLM 14b avec prompt structuré
4. Parse la réponse JSON → INSERT dans lore_proposals (status=pending)
5. L'utilisateur valide via le dashboard /lore
"""
import os, sys, json, re, argparse
import psycopg2, psycopg2.extras
import urllib.request, urllib.error
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
CHROMA_URL = os.getenv("CHROMA_URL", "http://localhost:8800")
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434")
LLM_MODEL = os.getenv("LLM_MODEL", "qwen2.5:14b")
CHROMA_COL = os.getenv("CHROMA_COL", "fallout_lore")
CHROMA_COL_OUT = os.getenv("CHROMA_COL_OUT", "fallout_lore_enriched")
DB_HOST = os.getenv("DB_HOST", "127.0.0.1")
DB_PORT = int(os.getenv("DB_PORT", 15432))
DB_NAME = os.getenv("DB_NAME", "fallout")
DB_USER = os.getenv("DB_USER", "fallout")
DB_PASS = os.getenv("DB_PASS", "VeniceOfWasteland2026!")
CHROMA_BASE = f"{CHROMA_URL}/api/v2/tenants/default_tenant/databases/default_database"
FACTIONS = [
"union", "cda", "ecumeurs", "grand_krewe", "dynaste_oak",
"regie", "syndicat_capitole", "consortium",
]
FACTION_LABELS = {
"union": "L'Union (milice populaire bayou)",
"cda": "La CdA — Confraternité de l'Acier (chapitre local)",
"ecumeurs": "Les Écumeurs (pirates fluviaux)",
"grand_krewe": "Le Grand Krewe (goules mystiques de La Paroisse)",
"dynaste_oak": "La Dynaste d'Oak (planteurs esclavagistes)",
"regie": "La Régie (bureaucratie survivaliste NOLA)",
"syndicat_capitole": "Le Syndicat du Capitole (négociants Baton Rouge)",
"consortium": "Le Consortium (marchands neutres)",
}
# ---------------------------------------------------------------------------
# Helpers HTTP
# ---------------------------------------------------------------------------
def http_post(url: str, body: dict) -> dict:
data = json.dumps(body).encode()
req = urllib.request.Request(url, data=data, method="POST",
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=120) as r:
return json.loads(r.read())
def http_get(url: str) -> dict:
with urllib.request.urlopen(url, timeout=30) as r:
return json.loads(r.read())
# ---------------------------------------------------------------------------
# Chroma
# ---------------------------------------------------------------------------
def _get_collection_id(name: str) -> str | None:
cols = http_get(f"{CHROMA_BASE}/collections")
for c in cols:
if c["name"] == name:
return c["id"]
return None
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,
"include": ["documents", "metadatas", "distances"],
}
if where:
body["where"] = where
try:
res = http_post(f"{CHROMA_BASE}/collections/{collection_id}/query", body)
docs = res["documents"][0]
metas = res["metadatas"][0]
dists = res["distances"][0]
return [{"text": d, "meta": m, "distance": s}
for d, m, s in zip(docs, metas, dists)]
except Exception as e:
print(f" [chroma] Erreur query: {e}")
return []
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}},
"limit": 200,
"include": ["documents", "metadatas"],
}
try:
res = http_post(f"{CHROMA_BASE}/collections/{collection_id}/get", body)
return [{"text": d, "meta": m}
for d, m in zip(res["documents"], res["metadatas"])]
except Exception as e:
print(f" [chroma] Erreur get_by_category: {e}")
return []
def chroma_upsert(collection_id: str, documents: list[dict]):
"""Insère ou met à jour des documents dans une collection."""
if not documents:
return
body = {
"ids": [d["id"] for d in documents],
"documents": [d["text"] for d in documents],
"metadatas": [d["meta"] for d in documents],
}
http_post(f"{CHROMA_BASE}/collections/{collection_id}/upsert", body)
def ensure_collection(name: str) -> str:
"""Crée la collection si elle n'existe pas, retourne son ID."""
col_id = _get_collection_id(name)
if col_id:
return col_id
body = {"name": name, "metadata": {"hnsw:space": "cosine"}}
res = http_post(f"{CHROMA_BASE}/collections", body)
return res["id"]
# ---------------------------------------------------------------------------
# PostgreSQL
# ---------------------------------------------------------------------------
def get_db():
return psycopg2.connect(host=DB_HOST, port=DB_PORT, dbname=DB_NAME,
user=DB_USER, password=DB_PASS)
def insert_proposals(faction_slug: str, proposals: list[dict],
source_chunks: list[dict]) -> int:
conn = get_db()
inserted = 0
try:
with conn.cursor() as cur:
for p in proposals:
cur.execute("""
INSERT INTO lore_proposals
(faction_slug, change_type, field_path,
original_text, proposed_text, rationale, source_chunks)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""", (
faction_slug,
p.get("change_type", "description"),
p.get("field_path"),
p.get("original_text"),
p["proposed_text"],
p.get("rationale"),
json.dumps([c["meta"].get("source","?") for c in source_chunks[:5]]),
))
inserted += 1
conn.commit()
finally:
conn.close()
return inserted
# ---------------------------------------------------------------------------
# LLM
# ---------------------------------------------------------------------------
SYSTEM_PROMPT = """Tu es un auteur expert en JDR post-apocalyptique. Tu travailles sur "Fallout: Venice of Wasteland", un univers Fallout 2D20 situé en Louisiane post-nucléaire.
Ton rôle : enrichir le lore d'une faction en proposant des ajouts SPÉCIFIQUES, cohérents avec les règles 2D20 et l'atmosphère Fallout.
Règles pour tes propositions :
- Rester fidèle à l'esprit Fallout (dark humour, critique sociale, espoir fragile)
- S'inspirer du cadre louisianais (vaudou, bayous, Mardi Gras, Créoles, Second Line)
- Être mécaniquement utilisable en JDR (perks, traits, objectifs jouables)
- Proposer du NOUVEAU contenu, pas reformuler l'existant
- Chaque proposition doit être autonome et validable individuellement
Format de réponse OBLIGATOIRE — JSON uniquement, sans texte autour :
{
"proposals": [
{
"change_type": "description|trait|objectif|relation|pnj_notable|rituel|territoire|rumeur",
"field_path": "chemin.du.champ.modifié",
"original_text": "texte existant si remplacement, null si ajout",
"proposed_text": "le nouveau contenu proposé",
"rationale": "pourquoi ce changement enrichit le lore (1 phrase)"
}
]
}"""
def build_user_prompt(faction_slug: str, faction_label: str,
lore_chunks: list[dict], rule_chunks: list[dict]) -> str:
lore_text = "\n\n---\n\n".join(c["text"] for c in lore_chunks[:6])
rule_text = "\n\n---\n\n".join(c["text"] for c in rule_chunks[:4])
return f"""## FACTION À ENRICHIR : {faction_label}
### LORE ACTUEL (source de vérité — ne pas contredire) :
{lore_text if lore_text else "(aucun chunk lore_canon trouvé pour cette faction)"}
### CONTEXTE RÈGLES 2D20 PERTINENT :
{rule_text if rule_text else "(aucune règle trouvée)"}
---
Génère entre 5 et 10 propositions d'enrichissement pour la faction **{faction_slug}**.
Varie les change_type : au moins 2 "trait", 1 "objectif", 1 "pnj_notable", 1 "rituel" ou "rumeur".
Réponds UNIQUEMENT avec le JSON demandé."""
def call_llm(prompt: str) -> str:
body = {
"model": LLM_MODEL,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
"stream": False,
"options": {"temperature": 0.7, "num_predict": 3000},
}
try:
res = http_post(f"{OLLAMA_URL}/api/chat", body)
return res["message"]["content"]
except Exception as e:
print(f" [llm] Erreur: {e}")
return ""
def parse_llm_json(raw: str) -> list[dict]:
"""Extrait le JSON de la réponse LLM (qui peut avoir du texte autour)."""
raw = raw.strip()
# Chercher le premier bloc JSON
match = re.search(r'\{[\s\S]*\}', raw)
if not match:
print(" [parse] Aucun JSON trouvé dans la réponse")
return []
try:
data = json.loads(match.group())
proposals = data.get("proposals", [])
# Valider la structure minimale
valid = []
for p in proposals:
if "proposed_text" in p:
valid.append(p)
return valid
except json.JSONDecodeError as e:
print(f" [parse] JSON invalide: {e}")
# Tentative de nettoyage
try:
cleaned = re.sub(r',\s*}', '}', match.group())
cleaned = re.sub(r',\s*]', ']', cleaned)
data = json.loads(cleaned)
return data.get("proposals", [])
except Exception:
return []
# ---------------------------------------------------------------------------
# Pipeline principal
# ---------------------------------------------------------------------------
def enrich_faction(faction_slug: str) -> int:
print(f"\n{'='*60}")
print(f" ENRICHISSEMENT : {FACTION_LABELS.get(faction_slug, faction_slug)}")
print(f"{'='*60}")
col_id = _get_collection_id(CHROMA_COL)
if not col_id:
print(f" [ERR] Collection '{CHROMA_COL}' introuvable dans Chroma")
return 0
# 1. Lore existant de la faction (lore_canon)
print(f" → Recherche lore_canon pour '{faction_slug}'...")
lore_chunks = chroma_query(col_id, f"faction {faction_slug} Venice of Wasteland Louisiane",
n=8, where={"category": {"$eq": "lore_canon"}})
print(f" {len(lore_chunks)} chunks lore_canon trouvés")
# 2. Contexte règles pertinent
print(f" → Recherche règles 2D20 pertinentes...")
rule_chunks = chroma_query(col_id,
f"faction organisation traits perks objectifs conflits Fallout 2D20",
n=6, where={"category": {"$eq": "regles_core"}})
print(f" {len(rule_chunks)} chunks règles trouvés")
# 3. Contexte inspiration
inspi_chunks = chroma_query(col_id,
f"{faction_slug} culture rituel organisation wasteland",
n=4, where={"category": {"$eq": "lore_inspiration"}})
print(f" {len(inspi_chunks)} chunks inspiration trouvés")
all_context = lore_chunks + rule_chunks + inspi_chunks
# 4. Build prompt
faction_label = FACTION_LABELS.get(faction_slug, faction_slug)
prompt = build_user_prompt(faction_slug, faction_label, lore_chunks,
rule_chunks + inspi_chunks)
# 5. Appel LLM
print(f" → Appel {LLM_MODEL}...")
raw_response = call_llm(prompt)
if not raw_response:
print(" [ERR] Réponse LLM vide")
return 0
# 6. Parse
proposals = parse_llm_json(raw_response)
print(f"{len(proposals)} propositions parsées")
if not proposals:
print(" [WARN] Réponse brute LLM:")
print(raw_response[:500])
return 0
# 7. Afficher un résumé
for i, p in enumerate(proposals):
print(f" [{i+1}] {p.get('change_type','?'):15s} | {p.get('field_path','?')}")
print(f" {p['proposed_text'][:80]}...")
# 8. Insérer en DB
inserted = insert_proposals(faction_slug, proposals, all_context)
print(f"\n{inserted} propositions insérées (status=pending)")
# 9. Upsert dans Chroma enriched (textes acceptés = ceux insérés maintenant,
# la validation se fait via le dashboard)
out_col_id = ensure_collection(CHROMA_COL_OUT)
print(f" → Collection Chroma enriched: {CHROMA_COL_OUT} ({out_col_id[:8]}...)")
return inserted
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(description="Enrichissement lore Fallout Venice")
parser.add_argument("--faction", default=None,
help="Slug faction (ex: grand_krewe) ou 'all'")
parser.add_argument("--list-factions", action="store_true")
args = parser.parse_args()
if args.list_factions:
print("Factions disponibles:")
for slug, label in FACTION_LABELS.items():
print(f" {slug:20s}{label}")
return
if not args.faction:
parser.print_help()
return
targets = FACTIONS if args.faction == "all" else [args.faction]
total = 0
for faction in targets:
if faction not in FACTIONS:
print(f"[WARN] Faction inconnue: {faction}. Disponibles: {', '.join(FACTIONS)}")
continue
n = enrich_faction(faction)
total += n
print(f"\n{'='*60}")
print(f" TOTAL : {total} propositions créées (status=pending)")
print(f" → Aller sur fallout.coyoteos.ovh/lore pour valider")
print(f"{'='*60}")
if __name__ == "__main__":
main()
+22 -1
View File
@@ -67,7 +67,10 @@ def run(config_path: str | None = None):
# Initialiser le world_state si vide # Initialiser le world_state si vide
_init_world_state(SESSION_ID, day) _init_world_state(SESSION_ID, day)
STATUS_CHECK_INTERVAL = 5 # vérifier le statut DB toutes les N ticks
try: try:
tick_count = 0
while True: while True:
tick_engine.process_tick(SESSION_ID, day, current_tick, effective_mode) tick_engine.process_tick(SESSION_ID, day, current_tick, effective_mode)
db.update_session_tick(SESSION_ID, day, current_tick) db.update_session_tick(SESSION_ID, day, current_tick)
@@ -75,10 +78,28 @@ def run(config_path: str | None = None):
if tick_sleep > 0: if tick_sleep > 0:
time.sleep(tick_sleep) time.sleep(tick_sleep)
# Vérification statut + reset DB (permet stop/pause/reset depuis le dashboard)
tick_count += 1
if tick_count % STATUS_CHECK_INTERVAL == 0:
live = db.get_session(SESSION_ID)
if live and live["status"] != "active":
print(f"\n[DASH] Statut changé → '{live['status']}' — arrêt propre.")
print(f" Dernier état : Jour {day}, Tick {current_tick}h")
return
# Détecter reset jour/tick : si la DB est en arrière de >1 jour, c'est un reset intentionnel
if live and live["current_day"] < day - 1:
print(f"\n[DASH] Reset détecté (DB: J{live['current_day']} vs sim: J{day}) — relecture position.")
day = live["current_day"]
current_tick = live["current_tick"]
day, current_tick = tick_engine.next_tick(day, current_tick) day, current_tick = tick_engine.next_tick(day, current_tick)
# Réinit world_state au début de chaque nouveau jour # Début d'un nouveau jour : hot-reload config depuis le JSON
if current_tick == 0: if current_tick == 0:
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", "tick_sleep_sec", tick_sleep)))
_init_world_state(SESSION_ID, day) _init_world_state(SESSION_ID, day)
except KeyboardInterrupt: except KeyboardInterrupt:
+343
View File
@@ -0,0 +1,343 @@
"""
Capacity Test — Fallout Venice of Wasteland
Série de tests pour mesurer la capacité de charge LLM.
Simule N joueurs simultanés et mesure :
- Temps de réponse par joueur (P50, P95, max)
- Débit total (tokens/s global)
- Temps d'attente queue (joueur 1 vs joueur N)
- Viabilité pour le mode temps-réel (1h/tick)
Usage :
python3 capacity_test.py
python3 capacity_test.py --quick (skip 50-player test)
python3 capacity_test.py --output /path/to/results.json
"""
import os, sys, time, json, argparse, threading, statistics
import urllib.request
from datetime import datetime
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434")
MODEL_MJ = os.getenv("MODEL_MJ", "qwen2.5:14b")
MODEL_PNJ = os.getenv("MODEL_PNJ", "qwen2.5:7b")
OUTPUT = os.getenv("CRASH_RESULTS_PATH",
"/home/ubuntu/fallout-venice/src/config/crash_results.json")
# ---------------------------------------------------------------------------
# Prompts variés pour éviter le cache
# ---------------------------------------------------------------------------
PNJ_PROMPTS = [
"Tu es un garde de La Régie. Réponds en 1 phrase à : 'Laisse-moi passer.'",
"Tu es un marchand créole de Pearl River. Réponds en 1 phrase à : 'T'as du carburant ?'",
"Tu es une goule du Grand Krewe. Réponds en 1 phrase à : 'Que veut le Baron ?'",
"Tu es un Écumeur. Réponds en 1 phrase à : 'On veut juste traverser le fleuve.'",
"Tu es Sœur Eulalie de L'Union. Réponds en 1 phrase à : 'J'ai été mordu.'",
"Tu es un technicien de la CdA. Réponds en 1 phrase à : 'Répare mon Pip-Boy.'",
"Tu es un scavenger neutre. Réponds en 1 phrase à : 'Combien pour cette armure ?'",
"Tu es un informateur du Syndicat. Réponds en 1 phrase à : 'Qui contrôle la Route 61 ?'",
"Tu es un fermier de Oak Plantation. Réponds en 1 phrase à : 'Vous travaillez pour qui ?'",
"Tu es un passeur de Laplace. Réponds en 1 phrase à : 'Combien pour traverser ?'",
]
MJ_PROMPTS = [
"En 2 phrases, décris la réaction de la foule quand les joueurs entrent dans le marché de NOLA.",
"En 2 phrases, décris l'ambiance de Pearl River à l'aube après une nuit de combat.",
"En 2 phrases, que se passe-t-il quand le Grand Krewe envoie un émissaire aux joueurs ?",
"En 2 phrases, décris les conséquences d'un vol de convoi de la Régie.",
"En 2 phrases, comment réagit L'Union quand elle apprend qu'un de ses membres a trahi ?",
]
# ---------------------------------------------------------------------------
# HTTP
# ---------------------------------------------------------------------------
def http_post(url: str, body: dict, timeout: int = 180) -> dict:
data = json.dumps(body).encode()
req = urllib.request.Request(url, data=data, method="POST",
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.loads(r.read())
def call_llm(model: str, prompt: str, player_id: int = 0) -> dict:
t0 = time.time()
try:
res = http_post(f"{OLLAMA_URL}/api/generate", {
"model": model,
"prompt": prompt,
"stream": False,
"options": {"num_predict": 80, "temperature": 0.7, "seed": player_id},
})
elapsed = time.time() - t0
tokens = res.get("eval_count", 0)
return {
"player_id": player_id,
"model": model,
"tokens": tokens,
"elapsed": round(elapsed, 2),
"tok_s": round(tokens / elapsed, 2) if elapsed > 0 else 0,
"queued_s": round(res.get("load_duration", 0) / 1e9, 2),
"error": None,
"enqueued_at": t0,
}
except Exception as e:
return {
"player_id": player_id, "model": model,
"tokens": 0, "elapsed": round(time.time() - t0, 2),
"tok_s": 0, "queued_s": 0,
"error": str(e)[:80], "enqueued_at": t0,
}
# ---------------------------------------------------------------------------
# Scénarios
# ---------------------------------------------------------------------------
def scenario_warmup() -> dict:
"""1 appel MJ + 1 appel PNJ pour chauffer les modèles."""
print(" [WARMUP] Chargement des modèles en RAM...")
r_mj = call_llm(MODEL_MJ, MJ_PROMPTS[0], 0)
r_pnj = call_llm(MODEL_PNJ, PNJ_PROMPTS[0], 0)
ok = "" if not r_mj["error"] and not r_pnj["error"] else ""
print(f" {ok} MJ: {r_mj['elapsed']}s ({r_mj['tok_s']} t/s) "
f"PNJ: {r_pnj['elapsed']}s ({r_pnj['tok_s']} t/s)")
return {"mj_warmup": r_mj, "pnj_warmup": r_pnj}
def scenario_n_players(n: int, rounds: int = 3) -> dict:
"""
Simule N joueurs envoyant une action simultanément.
Chaque joueur → 1 appel PNJ 7b (en parallèle via threads).
Après tous les PNJ → 1 appel MJ 14b (narrateur du tick).
"""
print(f"\n [{n} JOUEURS] {rounds} round(s) — {n} PNJ en parallèle + 1 MJ après")
all_rounds = []
for r in range(rounds):
round_results = {"pnj": [], "mj": None}
t_round_start = time.time()
# Phase 1 : N joueurs en parallèle
lock = threading.Lock()
threads = []
def player_action(pid: int):
prompt = PNJ_PROMPTS[pid % len(PNJ_PROMPTS)]
res = call_llm(MODEL_PNJ, prompt, pid)
with lock:
round_results["pnj"].append(res)
for i in range(n):
t = threading.Thread(target=player_action, args=(i,))
threads.append(t)
t_first = time.time()
for t in threads:
t.start()
for t in threads:
t.join()
t_all_pnj_done = time.time()
pnj_total_wall = round(t_all_pnj_done - t_first, 2)
# Phase 2 : MJ synthétise
mj_prompt = (
f"Tick {r+1}, {n} joueurs ont agi simultanément dans NOLA. "
+ MJ_PROMPTS[r % len(MJ_PROMPTS)]
)
mj_res = call_llm(MODEL_MJ, mj_prompt, -1)
round_results["mj"] = mj_res
t_round_total = round(time.time() - t_round_start, 2)
# Stats PNJ de ce round
pnj_ok = [x for x in round_results["pnj"] if not x["error"]]
pnj_elapsed = sorted([x["elapsed"] for x in pnj_ok])
p50 = statistics.median(pnj_elapsed) if pnj_elapsed else 0
p95 = pnj_elapsed[int(len(pnj_elapsed) * 0.95)] if len(pnj_elapsed) > 1 else (pnj_elapsed[0] if pnj_elapsed else 0)
status = "" if not mj_res["error"] and len(pnj_ok) == n else f"{n - len(pnj_ok)} erreurs"
print(f" R{r+1}: PNJ wall={pnj_total_wall}s P50={p50}s P95={p95}s | "
f"MJ={mj_res['elapsed']}s | total={t_round_total}s | {status}")
all_rounds.append({
"round": r + 1,
"pnj": round_results["pnj"],
"mj": mj_res,
"pnj_wall_time": pnj_total_wall,
"round_total_time": t_round_total,
"pnj_p50": p50,
"pnj_p95": p95,
})
return {"n_players": n, "rounds": all_rounds}
def compute_stats(scenario_result: dict) -> dict:
n = scenario_result["n_players"]
rounds = scenario_result["rounds"]
all_pnj_elapsed = [x["elapsed"] for r in rounds for x in r["pnj"] if not x.get("error")]
all_pnj_wall = [r["pnj_wall_time"] for r in rounds]
all_mj_elapsed = [r["mj"]["elapsed"] for r in rounds if not r["mj"].get("error")]
all_total = [r["round_total_time"] for r in rounds]
all_p50 = [r["pnj_p50"] for r in rounds]
all_p95 = [r["pnj_p95"] for r in rounds]
errors = sum(1 for r in rounds for x in r["pnj"] if x.get("error"))
def avg(lst): return round(sum(lst) / len(lst), 2) if lst else 0
tick_duration_s = avg(all_total)
viable_realtime = tick_duration_s < 3600
viable_5min = tick_duration_s < 300
return {
"n_players": n,
"pnj_avg_elapsed_s": avg(all_pnj_elapsed),
"pnj_avg_wall_s": avg(all_pnj_wall),
"pnj_avg_p50_s": avg(all_p50),
"pnj_avg_p95_s": avg(all_p95),
"mj_avg_elapsed_s": avg(all_mj_elapsed),
"tick_total_avg_s": tick_duration_s,
"tick_total_min_s": min(all_total) if all_total else 0,
"tick_total_max_s": max(all_total) if all_total else 0,
"errors": errors,
"viable_realtime_1h": viable_realtime,
"viable_5min_tick": viable_5min,
"actions_per_hour": int(3600 / tick_duration_s) if tick_duration_s > 0 else 0,
}
# ---------------------------------------------------------------------------
# Rapport
# ---------------------------------------------------------------------------
SEP = "=" * 70
def print_report(warmup: dict, scenarios: list[dict], stats_list: list[dict]):
print(f"\n{SEP}")
print(f" RAPPORT CAPACITÉ — FALLOUT VENICE — {datetime.now().strftime('%d/%m/%Y %H:%M')}")
print(SEP)
w_mj = warmup.get("mj_warmup", {})
w_pnj = warmup.get("pnj_warmup", {})
print(f"\n BASELINE (warm)")
print(f" MJ 14b : {w_mj.get('elapsed','?')}s | {w_mj.get('tok_s','?')} tok/s")
print(f" PNJ 7b : {w_pnj.get('elapsed','?')}s | {w_pnj.get('tok_s','?')} tok/s")
print(f"\n {'N':>4} {'PNJ wall':>10} {'P50':>7} {'P95':>7} {'MJ':>7} {'TICK total':>11} {'Actions/h':>10} {'1h viable':>10} ERR")
print(f" {'-'*4} {'-'*10} {'-'*7} {'-'*7} {'-'*7} {'-'*11} {'-'*10} {'-'*10} {'-'*4}")
for s in stats_list:
v1h = "" if s["viable_realtime_1h"] else ""
err = str(s["errors"]) if s["errors"] else "0"
print(f" {s['n_players']:>4} "
f"{s['pnj_avg_wall_s']:>9.1f}s "
f"{s['pnj_avg_p50_s']:>6.1f}s "
f"{s['pnj_avg_p95_s']:>6.1f}s "
f"{s['mj_avg_elapsed_s']:>6.1f}s "
f"{s['tick_total_avg_s']:>10.1f}s "
f"{s['actions_per_hour']:>10} "
f"{v1h:>10} "
f"{err:>4}")
print(f"\n LÉGENDE")
print(f" PNJ wall : durée réelle pour que TOUS les PNJ répondent (parallèle)")
print(f" P50 / P95 : médiane / 95e percentile du temps d'attente par joueur")
print(f" TICK total : PNJ wall + MJ (= durée d'un tick complet)")
print(f" Actions/h : combien de ticks complets tiennent en 1h réelle")
print(f" 1h viable : tick < 1h (compatible mode temps-réel)")
print(f"\n RECOMMANDATIONS")
for s in stats_list:
n = s["n_players"]
t = s["tick_total_avg_s"]
a = s["actions_per_hour"]
if a >= 6:
msg = f"{n} joueurs → 1 action toutes les {round(t/60,1)} min — jouable"
elif a >= 1:
msg = f"{n} joueurs → 1 action toutes les {round(t/60,1)} min — lent mais viable en temps réel"
else:
msg = f"{n} joueurs → tick de {round(t/60,1)} min — dépasse 1h, non viable en temps réel"
print(f" {msg}")
print(f"\n{SEP}")
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(description="Capacity test LLM Fallout Venice")
parser.add_argument("--quick", action="store_true", help="Skip 50-player test")
parser.add_argument("--output", default=OUTPUT)
args = parser.parse_args()
print(f"\n{SEP}")
print(f" CAPACITY TEST — FALLOUT VENICE OF WASTELAND")
print(f" Ollama : {OLLAMA_URL}")
print(f" MJ : {MODEL_MJ} | PNJ : {MODEL_PNJ}")
print(f" Début : {datetime.now().strftime('%H:%M:%S')}")
print(SEP)
# Vérifier Ollama
try:
with urllib.request.urlopen(f"{OLLAMA_URL}/api/tags", timeout=5) as r:
models = [m["name"] for m in json.loads(r.read()).get("models", [])]
print(f"\n Modèles dispos : {', '.join(models)}")
except Exception as e:
print(f" [ERR] Ollama inaccessible : {e}")
sys.exit(1)
# 1. Warmup (toujours)
print(f"\n{SEP}")
print(" PHASE 0 — WARMUP (chargement modèles en RAM)")
print(SEP)
warmup = scenario_warmup()
# 2. Série de tests
configs = [
{"n": 1, "rounds": 3}, # baseline solo
{"n": 5, "rounds": 3}, # petit groupe
{"n": 10, "rounds": 2}, # groupe moyen
]
if not args.quick:
configs.append({"n": 50, "rounds": 1}) # stress
scenarios = []
stats_list = []
for cfg in configs:
n, rounds = cfg["n"], cfg["rounds"]
print(f"\n{SEP}")
print(f" PHASE — {n} JOUEUR{'S' if n > 1 else ''} SIMULTANÉ{'S' if n > 1 else ''} ({rounds} round{'s' if rounds > 1 else ''})")
print(SEP)
result = scenario_n_players(n, rounds)
s = compute_stats(result)
scenarios.append(result)
stats_list.append(s)
# 3. Rapport terminal
print_report(warmup, scenarios, stats_list)
# 4. Export JSON
output = {
"test_type": "capacity",
"mode": "capacity_test",
"timestamp": datetime.now().isoformat(),
"ollama_url": OLLAMA_URL,
"model_mj": MODEL_MJ,
"model_pnj": MODEL_PNJ,
"warmup": warmup,
"scenarios": scenarios,
"stats": stats_list,
"file_date": datetime.now().strftime("%d/%m/%Y %H:%M:%S"),
}
try:
with open(args.output, "w") as f:
json.dump(output, f, indent=2, ensure_ascii=False)
print(f"\n Résultats écrits → {args.output}")
except Exception as e:
print(f" [WARN] Impossible d'écrire le fichier : {e}")
if __name__ == "__main__":
main()
+326
View File
@@ -0,0 +1,326 @@
"""
LLM Crash Test — Fallout: Venice of Wasteland
Test de performance des deux LLM (MJ 14b + PNJ 7b) en conditions réelles.
Modes:
--mode simultane : les deux LLM tournent en parallèle (threads)
--mode decale : push progressif PNJ → MJ (pipeline producteur/consommateur)
--mode solo-mj : seulement le 14b
--mode solo-pnj : seulement le 7b
Usage:
python llm_crash_test.py --mode simultane --rounds 5
python llm_crash_test.py --mode decale --rounds 10 --pnj-count 3
"""
import os, sys, time, json, argparse, threading, queue
import urllib.request
from datetime import datetime
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434")
MODEL_MJ = os.getenv("MODEL_MJ", "qwen2.5:14b")
MODEL_PNJ = os.getenv("MODEL_PNJ", "qwen2.5:7b")
# ---------------------------------------------------------------------------
# Prompts de test réalistes (contexte Fallout Venice)
# ---------------------------------------------------------------------------
PNJ_PROMPTS = [
"Tu es Remy Tureaud, marchand créole de Pearl River. Réponds en 2 phrases à : 'T'as des piles ?'",
"Tu es Sœur Eulalie, soigneuse de L'Union. Réponds en 2 phrases à : 'J'ai une blessure par balle.'",
"Tu es Jacques-Henri, garde de la Régie. Réponds en 2 phrases à : 'Laisse-moi passer le checkpoint.'",
"Tu es Mama Voodoo, goule du Grand Krewe. Réponds en 2 phrases à : 'C'est quoi ce tatouage ?'",
"Tu es un Écumeur anonyme. Réponds en 2 phrases à : 'On veut juste passer.'",
]
MJ_PROMPTS = [
"Résume en 3 phrases la situation politique entre L'Union et la CdA à New Orleans post-apo.",
"Décris en 3 phrases une rencontre de nuit dans les bayous de Pearl River.",
"En 3 phrases, quelles sont les conséquences d'un blocus sur la route de Baton Rouge ?",
"Décris en 3 phrases l'ambiance d'un marché noir dans le Vieux Carré de NOLA.",
"En 3 phrases, comment réagit le Grand Krewe si des étrangers fouillent leurs ruines ?",
]
# ---------------------------------------------------------------------------
# LLM call
# ---------------------------------------------------------------------------
def call_llm(model: str, prompt: str, timeout: int = 180) -> dict:
"""Appel Ollama, retourne {tokens, elapsed, error, text}."""
t0 = time.time()
body = json.dumps({
"model": model,
"prompt": prompt,
"stream": False,
"options": {"num_predict": 150, "temperature": 0.7},
}).encode()
req = urllib.request.Request(
f"{OLLAMA_URL}/api/generate", data=body, method="POST",
headers={"Content-Type": "application/json"}
)
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
data = json.loads(r.read())
elapsed = time.time() - t0
tokens = data.get("eval_count", 0)
return {
"model": model,
"tokens": tokens,
"elapsed": round(elapsed, 2),
"tok_s": round(tokens / elapsed, 1) if elapsed > 0 else 0,
"error": None,
"text": data.get("response", "")[:120],
}
except Exception as e:
return {
"model": model,
"tokens": 0,
"elapsed": round(time.time() - t0, 2),
"tok_s": 0,
"error": str(e)[:80],
"text": "",
}
# ---------------------------------------------------------------------------
# Modes
# ---------------------------------------------------------------------------
def run_solo(model: str, prompts: list[str], rounds: int) -> list[dict]:
results = []
for i in range(rounds):
prompt = prompts[i % len(prompts)]
print(f" [{model}] Round {i+1}/{rounds}...", end="", flush=True)
r = call_llm(model, prompt)
print(f" {r['tok_s']} tok/s | {r['elapsed']}s | {'ERR: '+r['error'] if r['error'] else 'OK'}")
results.append(r)
return results
def run_simultane(rounds: int, pnj_count: int = 2) -> dict:
"""Les deux LLM tournent en parallèle via threads."""
print(f"\n[SIMULTANE] {MODEL_MJ} + {MODEL_PNJ} x{pnj_count} PNJ — {rounds} rounds\n")
all_results = {"mj": [], "pnj": []}
lock = threading.Lock()
def mj_worker(round_idx: int):
prompt = MJ_PROMPTS[round_idx % len(MJ_PROMPTS)]
r = call_llm(MODEL_MJ, prompt)
with lock:
all_results["mj"].append(r)
status = f"ERR: {r['error']}" if r['error'] else f"{r['tok_s']} tok/s"
print(f" [MJ 14b] R{round_idx+1}{status} ({r['elapsed']}s)")
def pnj_worker(round_idx: int, pnj_idx: int):
prompt = PNJ_PROMPTS[(round_idx * pnj_count + pnj_idx) % len(PNJ_PROMPTS)]
r = call_llm(MODEL_PNJ, prompt)
with lock:
all_results["pnj"].append(r)
status = f"ERR: {r['error']}" if r['error'] else f"{r['tok_s']} tok/s"
print(f" [PNJ 7b] R{round_idx+1} PNJ{pnj_idx+1}{status} ({r['elapsed']}s)")
t_start = time.time()
for i in range(rounds):
threads = []
t = threading.Thread(target=mj_worker, args=(i,))
threads.append(t)
for j in range(pnj_count):
t = threading.Thread(target=pnj_worker, args=(i, j))
threads.append(t)
for t in threads:
t.start()
for t in threads:
t.join()
print()
total = time.time() - t_start
return {**all_results, "total_elapsed": round(total, 1)}
def run_decale(rounds: int, pnj_count: int = 3) -> dict:
"""Pipeline producteur/consommateur : les PNJ accumulent des infos → poussé vers le MJ."""
print(f"\n[DÉCALÉ] {pnj_count} PNJ 7b → buffer → MJ 14b — {rounds} rounds\n")
pnj_queue: queue.Queue = queue.Queue()
all_results = {"mj": [], "pnj": [], "latency_pnj_to_mj": []}
stop_flag = threading.Event()
def pnj_producer():
idx = 0
while not stop_flag.is_set():
prompt = PNJ_PROMPTS[idx % len(PNJ_PROMPTS)]
r = call_llm(MODEL_PNJ, prompt)
r["produced_at"] = time.time()
pnj_queue.put(r)
with threading.Lock():
status = f"ERR: {r['error']}" if r['error'] else f"{r['tok_s']} tok/s"
print(f" [PNJ 7b ] prod #{idx+1}{status} ({r['elapsed']}s) | queue={pnj_queue.qsize()}")
idx += 1
def mj_consumer():
consumed = 0
while consumed < rounds:
# Attendre qu'il y ait au moins 1 item dans la queue (ou timeout)
try:
pnj_result = pnj_queue.get(timeout=90)
except queue.Empty:
print(" [MJ 14b] Timeout attente PNJ")
break
latency = time.time() - pnj_result["produced_at"]
# Construire le prompt MJ à partir du résultat PNJ
pnj_text = pnj_result.get("text", "(vide)")
mj_prompt = (
f"Un PNJ vient de parler : \"{pnj_text}\"\n"
f"En tant que MJ, en 2 phrases, quelle est la conséquence narrative ?"
)
r = call_llm(MODEL_MJ, mj_prompt)
r["latency_from_pnj"] = round(latency, 2)
all_results["mj"].append(r)
all_results["latency_pnj_to_mj"].append(round(latency + r["elapsed"], 2))
status = f"ERR: {r['error']}" if r['error'] else f"{r['tok_s']} tok/s"
print(f" [MJ 14b ] cons #{consumed+1}{status} ({r['elapsed']}s) | pipeline={round(latency+r['elapsed'],1)}s")
consumed += 1
pnj_queue.task_done()
print()
# Lancer N threads PNJ + 1 thread MJ
pnj_threads = [threading.Thread(target=pnj_producer, daemon=True) for _ in range(pnj_count)]
mj_thread = threading.Thread(target=mj_consumer)
t_start = time.time()
for t in pnj_threads:
t.start()
mj_thread.start()
mj_thread.join()
stop_flag.set()
# Récupérer tous les résultats PNJ dans la queue restante
while not pnj_queue.empty():
all_results["pnj"].append(pnj_queue.get())
total = time.time() - t_start
return {**all_results, "total_elapsed": round(total, 1)}
# ---------------------------------------------------------------------------
# Rapport
# ---------------------------------------------------------------------------
def print_report(results: dict, mode: str):
def stats(lst: list[dict]) -> dict:
if not lst:
return {}
ok = [r for r in lst if not r.get("error")]
err = len(lst) - len(ok)
if not ok:
return {"calls": len(lst), "errors": err}
tok_s = [r["tok_s"] for r in ok]
elaps = [r["elapsed"] for r in ok]
return {
"calls": len(lst),
"errors": err,
"avg_tok_s": round(sum(tok_s) / len(tok_s), 1),
"min_tok_s": min(tok_s),
"max_tok_s": max(tok_s),
"avg_elapsed": round(sum(elaps) / len(elaps), 1),
"total_tokens": sum(r["tokens"] for r in ok),
}
sep = "=" * 60
print(f"\n{sep}")
print(f" RAPPORT — MODE {mode.upper()}")
print(f" {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(sep)
if "mj" in results and results["mj"]:
s = stats(results["mj"])
print(f"\n MJ 14b ({MODEL_MJ})")
for k, v in s.items():
print(f" {k:20s} : {v}")
if "pnj" in results and results["pnj"]:
s = stats(results["pnj"])
print(f"\n PNJ 7b ({MODEL_PNJ})")
for k, v in s.items():
print(f" {k:20s} : {v}")
if "latency_pnj_to_mj" in results and results["latency_pnj_to_mj"]:
lat = results["latency_pnj_to_mj"]
print(f"\n Pipeline PNJ→MJ (latence totale)")
print(f" avg_pipeline_s : {round(sum(lat)/len(lat), 1)}")
print(f" min_pipeline_s : {min(lat)}")
print(f" max_pipeline_s : {max(lat)}")
print(f"\n Durée totale : {results.get('total_elapsed', '?')}s")
# Score de viabilité
mj_ok = not any(r.get("error") for r in results.get("mj", []))
pnj_ok = not any(r.get("error") for r in results.get("pnj", []))
mj_speed = stats(results.get("mj", [])).get("avg_tok_s", 0)
pnj_speed = stats(results.get("pnj", [])).get("avg_tok_s", 0)
print(f"\n DIAGNOSTIC")
print(f" MJ stable : {'' if mj_ok else '✗ ERREURS'}")
print(f" PNJ stable : {'' if pnj_ok else '✗ ERREURS'}")
if mj_speed:
viable = "✓ VIABLE" if mj_speed > 8 else "⚠ LENT (< 8 tok/s)" if mj_speed > 3 else "✗ TROP LENT"
print(f" MJ débit : {mj_speed} tok/s → {viable}")
if pnj_speed:
viable = "✓ VIABLE" if pnj_speed > 15 else "⚠ LENT" if pnj_speed > 5 else "✗ TROP LENT"
print(f" PNJ débit : {pnj_speed} tok/s → {viable}")
print(sep)
# Export JSON
report_path = os.getenv("CRASH_RESULTS_PATH", "/home/ubuntu/fallout-venice/src/config/crash_results.json")
try:
with open(report_path, "w") as f:
json.dump({"mode": mode, "results": results,
"stats": {"mj": stats(results.get("mj",[])),
"pnj": stats(results.get("pnj",[]))}}, f, indent=2)
print(f"\n Rapport JSON : {report_path}")
except Exception:
pass
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(description="LLM Crash Test — Fallout Venice")
parser.add_argument("--mode", choices=["simultane","decale","solo-mj","solo-pnj"],
default="simultane")
parser.add_argument("--rounds", type=int, default=5, help="Nombre de rounds MJ")
parser.add_argument("--pnj-count", type=int, default=2, help="PNJ parallèles (modes simultane/decale)")
args = parser.parse_args()
print(f"=== LLM CRASH TEST — {args.mode.upper()} ===")
print(f"Ollama : {OLLAMA_URL}")
print(f"MJ : {MODEL_MJ}")
print(f"PNJ : {MODEL_PNJ}")
print(f"Rounds : {args.rounds} | PNJ parallèles : {args.pnj_count}")
# Vérifier que Ollama répond
try:
with urllib.request.urlopen(f"{OLLAMA_URL}/api/tags", timeout=5) as r:
models = [m["name"] for m in json.loads(r.read()).get("models", [])]
print(f"Modèles disponibles : {', '.join(models[:5])}")
except Exception as e:
print(f"[ERR] Ollama inaccessible : {e}")
sys.exit(1)
t_global = time.time()
if args.mode == "simultane":
results = run_simultane(args.rounds, args.pnj_count)
elif args.mode == "decale":
results = run_decale(args.rounds, args.pnj_count)
elif args.mode == "solo-mj":
r = run_solo(MODEL_MJ, MJ_PROMPTS, args.rounds)
results = {"mj": r, "total_elapsed": round(time.time() - t_global, 1)}
elif args.mode == "solo-pnj":
r = run_solo(MODEL_PNJ, PNJ_PROMPTS, args.rounds)
results = {"pnj": r, "total_elapsed": round(time.time() - t_global, 1)}
print_report(results, args.mode)
if __name__ == "__main__":
main()