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' %} + + + + + +
+ 📜 ENRICHISSEMENT LORE — VENICE OF WASTELAND + + En attente : {{ lore_stats.pending }} + Acceptées : {{ lore_stats.accepted }} + Rejetées : {{ lore_stats.rejected }} + Modifiées : {{ lore_stats.modified }} + +
+ +
+ + + + + {{ proposals|length }} proposition(s) affichée(s) +
+ +{% if not proposals %} +
+ Aucune proposition lore.
+ Lancer : python3 lore_enricher.py --faction grand_krewe sur Ampère +
+{% endif %} + +{% for p in proposals %} +
+
+ #{{ p.id }} + {{ p.status | upper }} + {{ p.change_type }} + {{ p.faction_slug }} + {% if p.field_path %}{{ p.field_path }}{% endif %} + {{ p.created_at.strftime('%d/%m %H:%M') if p.created_at else '' }} +
+ +

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

+ +
+
+
ORIGINAL
+
{{ p.original_text or '(ajout — pas de texte original)' }}
+
+
+
PROPOSÉ
+
{{ p.proposed_text }}
+
+
+ + {% if p.status == 'pending' %} +
+
+ + + + + +
+
+ + + + + +
+
+ + + + + + +
+
+ {% elif p.status == 'modified' and p.modified_text %} +
+
TEXTE MODIFIÉ ACCEPTÉ
+
{{ p.modified_text }}
+
+ {% endif %} +
+{% endfor %} + {% elif tab == 'params' %} @@ -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()