diff --git a/dashboard/app.py b/dashboard/app.py
index 10e3bcf..f8f24e0 100644
--- a/dashboard/app.py
+++ b/dashboard/app.py
@@ -190,6 +190,7 @@ TEMPLATE = r"""
📡 SIMULATION
⚙ PARAMÈTRES
+ 📜 LORE
{% if flash_msg %}
@@ -395,6 +396,129 @@ if (el) {
}
+{% elif tab == 'lore' %}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ proposals|length }} proposition(s) affichée(s)
+
+
+{% if not proposals %}
+
+ Aucune proposition lore.
+ Lancer : python3 lore_enricher.py --faction grand_krewe sur Ampère
+
+{% endif %}
+
+{% for p in proposals %}
+
+
+ #{{ p.id }}
+ {{ p.status | upper }}
+ {{ p.change_type }}
+ {{ p.faction_slug }}
+ {% if p.field_path %}{{ p.field_path }}{% endif %}
+ {{ p.created_at.strftime('%d/%m %H:%M') if p.created_at else '' }}
+
+
+
{{ p.rationale or '(pas de justification)' }}
+
+
+
+
ORIGINAL
+
{{ p.original_text or '(ajout — pas de texte original)' }}
+
+
+
PROPOSÉ
+
{{ p.proposed_text }}
+
+
+
+ {% if p.status == 'pending' %}
+
+
+
+
+
+ {% elif p.status == 'modified' and p.modified_text %}
+
+
TEXTE MODIFIÉ ACCEPTÉ
+
{{ p.modified_text }}
+
+ {% endif %}
+
+{% endfor %}
+
{% elif tab == 'params' %}
@@ -775,6 +899,141 @@ def params_resetday():
ok = 0
return redirect(url_for("params", sid=sid, msg=msg, ok=ok))
+CHROMA_URL = os.getenv("CHROMA_URL", "http://localhost:8800")
+CHROMA_COL_OUT = os.getenv("CHROMA_COL_OUT", "fallout_lore_enriched")
+CHROMA_BASE = f"{CHROMA_URL}/api/v2/tenants/default_tenant/databases/default_database"
+
+def _chroma_post(path: str, body: dict):
+ import urllib.request
+ data = __import__("json").dumps(body).encode()
+ req = urllib.request.Request(f"{CHROMA_BASE}{path}", data=data, method="POST",
+ headers={"Content-Type": "application/json"})
+ try:
+ with urllib.request.urlopen(req, timeout=10) as r:
+ return __import__("json").loads(r.read())
+ except Exception:
+ return {}
+
+def _chroma_get_enriched_col_id():
+ import urllib.request
+ try:
+ with urllib.request.urlopen(f"{CHROMA_BASE}/collections", timeout=5) as r:
+ cols = __import__("json").loads(r.read())
+ for c in cols:
+ if c["name"] == CHROMA_COL_OUT:
+ return c["id"]
+ except Exception:
+ pass
+ # Créer la collection si absente
+ res = _chroma_post("/collections", {"name": CHROMA_COL_OUT, "metadata": {"hnsw:space": "cosine"}})
+ return res.get("id")
+
+def _upsert_to_chroma_enriched(proposal: dict):
+ col_id = _chroma_get_enriched_col_id()
+ if not col_id:
+ return
+ text = proposal.get("modified_text") or proposal.get("proposed_text", "")
+ doc_id = f"lore_proposal_{proposal['id']}"
+ _chroma_post(f"/collections/{col_id}/upsert", {
+ "ids": [doc_id],
+ "documents": [text],
+ "metadatas": [{
+ "faction_slug": proposal.get("faction_slug", ""),
+ "change_type": proposal.get("change_type", ""),
+ "field_path": proposal.get("field_path") or "",
+ "category": "lore_enriched",
+ "source": f"lore_proposal_{proposal['id']}",
+ }],
+ })
+
+def get_lore_proposals(faction: str = "", status: str = "pending") -> list[dict]:
+ sql = "SELECT * FROM lore_proposals WHERE 1=1"
+ params = []
+ if faction:
+ sql += " AND faction_slug = %s"
+ params.append(faction)
+ if status:
+ sql += " AND status = %s"
+ params.append(status)
+ sql += " ORDER BY id DESC LIMIT 100"
+ return query(sql, params or None)
+
+def get_lore_stats() -> dict:
+ rows = query("SELECT status, COUNT(*) as n FROM lore_proposals GROUP BY status")
+ m = {r["status"]: r["n"] for r in rows}
+ return {"pending": m.get("pending",0), "accepted": m.get("accepted",0),
+ "rejected": m.get("rejected",0), "modified": m.get("modified",0)}
+
+def get_all_factions() -> list[str]:
+ rows = query("SELECT DISTINCT faction_slug FROM lore_proposals ORDER BY faction_slug")
+ return [r["faction_slug"] for r in rows]
+
+@app.route("/lore")
+def lore():
+ all_sessions = get_all_sessions()
+ sid = int(request.args.get("sid", all_sessions[-1]["id"] if all_sessions else 1))
+ session = query_one("SELECT * FROM sessions WHERE id = %s", (sid,))
+ filt_faction = request.args.get("faction", "")
+ filt_status = request.args.get("status", "pending")
+ proposals = get_lore_proposals(filt_faction, filt_status)
+ flash_msg = request.args.get("msg")
+ flash_ok = request.args.get("ok", "1") == "1"
+ return render_template_string(TEMPLATE,
+ tab="lore",
+ current_sid=sid,
+ all_sessions=all_sessions,
+ session=session,
+ proposals=proposals,
+ lore_stats=get_lore_stats(),
+ all_factions=get_all_factions(),
+ filt_faction=filt_faction,
+ filt_status=filt_status,
+ flash_msg=flash_msg,
+ flash_ok=flash_ok,
+ cfg=None, modes={}, sim_configs_dir=SIM_CONFIGS_DIR,
+ characters=[], alive_count=0, total_count=0,
+ events=[], encounters=[], world_state=[],
+ faction_relations=[], tier_stats=[], zone_stats=[],
+ stats={}, boss_inventory=[],
+ )
+
+@app.route("/lore/action", methods=["POST"])
+def lore_action():
+ prop_id = int(request.form.get("id", 0))
+ action = request.form.get("action", "")
+ modified_text = request.form.get("modified_text", "").strip()
+ filt_faction = request.form.get("filt_faction", "")
+ filt_status = request.form.get("filt_status", "pending")
+
+ proposal = query_one("SELECT * FROM lore_proposals WHERE id = %s", (prop_id,))
+ if not proposal:
+ return redirect(url_for("lore", msg="Proposition introuvable", ok=0,
+ faction=filt_faction, status=filt_status))
+ try:
+ if action == "accept":
+ execute("UPDATE lore_proposals SET status='accepted', reviewed_at=NOW() WHERE id=%s", (prop_id,))
+ _upsert_to_chroma_enriched({**proposal, "modified_text": None})
+ msg = f"Proposition #{prop_id} acceptée et indexée dans {CHROMA_COL_OUT}."
+ elif action == "reject":
+ execute("UPDATE lore_proposals SET status='rejected', reviewed_at=NOW() WHERE id=%s", (prop_id,))
+ msg = f"Proposition #{prop_id} rejetée."
+ elif action == "modify":
+ if not modified_text:
+ modified_text = proposal["proposed_text"]
+ execute("""UPDATE lore_proposals SET status='modified', modified_text=%s, reviewed_at=NOW()
+ WHERE id=%s""", (modified_text, prop_id))
+ _upsert_to_chroma_enriched({**proposal, "modified_text": modified_text})
+ msg = f"Proposition #{prop_id} modifiée et indexée dans {CHROMA_COL_OUT}."
+ else:
+ msg = "Action inconnue."
+ ok = 1
+ except Exception as e:
+ msg = f"Erreur: {e}"
+ ok = 0
+
+ return redirect(url_for("lore", msg=msg, ok=ok,
+ faction=filt_faction, status=filt_status))
+
@app.route("/api/state")
def api_state():
all_sessions = get_all_sessions()
diff --git a/src/config/sim_001.json b/src/config/sim_001.json
index caf6036..9c33769 100644
--- a/src/config/sim_001.json
+++ b/src/config/sim_001.json
@@ -1,29 +1,24 @@
{
"_comment": "Config session 1 — Venice of Wasteland. Sections 'modes.' font un deep-merge sur les valeurs de base.",
-
"session": {
"session_id": 1,
"name": "Session 001 — Louisiane post-apo",
"seed_global": 42
},
-
"mode": "pacifiste",
-
"tick": {
- "tick_sleep_sec": 60,
+ "tick_sleep_sec": 30,
"ticks_per_day": 24,
"day_start_tick": 6,
"night_start_tick": 20,
"llm_tick_interval": 6
},
-
"survival": {
"_comment": "drain_speed : diviseur sur HUNGER/THIRST/SLEEP_TICKS (2.0 = 2x plus vite, 0.5 = 2x plus lent)",
"drain_speed": 1.0,
"fatigue_hp_per_2pts": 2,
"enabled": true
},
-
"economy": {
"_comment": "Multiplicateur global caps (appliqué à tous les caps_range des rôles)",
"caps_global_multiplier": 1.0,
@@ -31,103 +26,190 @@
"loot_caps_max": 25,
"trade_price_variance": 0.2
},
-
"encounter": {
"_comment": "global_rate_multiplier : appliqué sur chance_per_tick de chaque zone",
"global_rate_multiplier": 1.0,
"combat_lethality": 1.0,
"enabled": true
},
-
"world": {
"_comment": "Populations réalistes pour un wasteland post-apo. 500 habitants c'est une grande cité en zone sûre.",
"pop_count_by_zone": {
- "independance": 120,
+ "independance": 120,
"nola_vieux_carre": 90,
- "baton_rouge": 70,
- "laplace": 60,
- "nola_fleuve": 40,
- "la_paroisse": 35,
- "nola_cbd": 25,
- "oak_plantation": 20,
- "donaldsonville": 15,
- "pearl_river": 10
+ "baton_rouge": 70,
+ "laplace": 60,
+ "nola_fleuve": 40,
+ "la_paroisse": 35,
+ "nola_cbd": 25,
+ "oak_plantation": 20,
+ "donaldsonville": 15,
+ "pearl_river": 10
},
"water_drain_per_pop": 0.004,
- "food_drain_per_pop": 0.003,
- "security_day_regen": 0.2,
+ "food_drain_per_pop": 0.003,
+ "security_day_regen": 0.2,
"security_night_drain": 0.3,
- "conflict_security_range": [-0.5, 0.2]
+ "conflict_security_range": [
+ -0.5,
+ 0.2
+ ]
},
-
"faction_relations": {
"initial_volatility": 0.05,
"drift_per_llm_tick": 1
},
-
"llm": {
"enabled": false,
- "model_mj": "qwen2.5:14b",
+ "model_mj": "qwen2.5:14b",
"model_pnj": "qwen2.5:7b",
- "ollama_url": "http://localhost:11434",
- "chroma_url": "http://localhost:8000",
- "chroma_collection": "fallout_vst"
+ "ollama_url": "http://localhost:11434",
+ "chroma_url": "http://localhost:8000",
+ "chroma_collection": "fallout_lore"
},
-
"modes": {
"pacifiste": {
"_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 },
- "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 }
+ "encounter": {
+ "global_rate_multiplier": 0.4,
+ "combat_lethality": 0.5
+ },
+ "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": {
"_comment": "Intrigues factions, peu de violence directe, mais relations volatiles",
- "encounter": { "global_rate_multiplier": 0.6, "combat_lethality": 0.7 },
- "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 }
+ "encounter": {
+ "global_rate_multiplier": 0.6,
+ "combat_lethality": 0.7
+ },
+ "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": {
"_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 },
- "survival": { "drain_speed": 1.5 },
- "economy": { "caps_global_multiplier": 1.5, "trade_price_variance": 0.5 },
- "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]
+ "encounter": {
+ "global_rate_multiplier": 1.2,
+ "combat_lethality": 0.9
},
- "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
+ },
+ "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
+ }
},
-
"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]
+ "encounter": {
+ "global_rate_multiplier": 2.0,
+ "combat_lethality": 1.5
},
- "faction_relations": { "initial_volatility": 0.8, "drift_per_llm_tick": 15 }
+ "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": {
"_comment": "Pas de mode narration. Purement sim de survie hostile. Test de résistance.",
- "encounter": { "global_rate_multiplier": 1.8, "combat_lethality": 2.0 },
- "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]
+ "encounter": {
+ "global_rate_multiplier": 1.8,
+ "combat_lethality": 2.0
},
- "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": ""
+}
\ No newline at end of file
diff --git a/src/config/sim_002.json b/src/config/sim_002.json
index f3b16a3..5043dc1 100644
--- a/src/config/sim_002.json
+++ b/src/config/sim_002.json
@@ -67,7 +67,7 @@
"model_pnj": "qwen2.5:7b",
"ollama_url": "http://localhost:11434",
"chroma_url": "http://localhost:8000",
- "chroma_collection": "fallout_vst"
+ "chroma_collection": "fallout_lore"
},
"modes": {
diff --git a/src/engine/lore_enricher.py b/src/engine/lore_enricher.py
new file mode 100644
index 0000000..03cc606
--- /dev/null
+++ b/src/engine/lore_enricher.py
@@ -0,0 +1,377 @@
+"""
+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: str, query_text: str, n: int = 8,
+ where: dict | None = None) -> list[dict]:
+ """Recherche sémantique dans une collection Chroma."""
+ 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: str, 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()