feat: lore enricher pipeline + /lore dashboard tab + fix chroma_collection name

This commit is contained in:
Corback
2026-06-16 08:40:45 +00:00
parent 99bd77e18b
commit a16d671db4
4 changed files with 784 additions and 66 deletions
+147 -65
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.",
"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": ""
}
+1 -1
View File
@@ -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": {
+377
View File
@@ -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()