feat: lore enricher pipeline + /lore dashboard tab + fix chroma_collection name
This commit is contained in:
@@ -190,6 +190,7 @@ TEMPLATE = r"""<!DOCTYPE html>
|
|||||||
<span class="nav-sep"></span>
|
<span class="nav-sep"></span>
|
||||||
<a href="/?sid={{ current_sid }}" class="{{ 'active' if tab=='sim' else '' }}">📡 SIMULATION</a>
|
<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>
|
<a href="/params?sid={{ current_sid }}" class="{{ 'active' if tab=='params' else '' }}">⚙ PARAMÈTRES</a>
|
||||||
|
<a href="/lore" class="{{ 'active' if tab=='lore' else '' }}">📜 LORE</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if flash_msg %}
|
{% if flash_msg %}
|
||||||
@@ -395,6 +396,129 @@ if (el) {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{% elif tab == 'lore' %}
|
||||||
|
<!-- ===================================================== -->
|
||||||
|
<!-- ONGLET LORE -->
|
||||||
|
<!-- ===================================================== -->
|
||||||
|
<style>
|
||||||
|
.lore-card { background: var(--panel); border: 1px solid var(--border); padding: 10px; margin-bottom: 8px; }
|
||||||
|
.lore-card h3 { font-size: 11px; color: var(--pip-amber); letter-spacing: 1px; margin-bottom: 6px; }
|
||||||
|
.lore-diff { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 8px; }
|
||||||
|
.diff-old { background: #1a0000; border: 1px solid #3a0000; padding: 6px; font-size: 10px; color: #ff8888; white-space: pre-wrap; }
|
||||||
|
.diff-new { background: #001a00; border: 1px solid #003a00; padding: 6px; font-size: 10px; color: #88ff88; white-space: pre-wrap; }
|
||||||
|
.diff-label { font-size: 10px; color: #666; margin-bottom: 2px; }
|
||||||
|
.lore-meta { font-size: 10px; color: #666; margin-bottom: 6px; display: flex; gap: 12px; flex-wrap: wrap; }
|
||||||
|
.lore-actions { display: flex; gap: 6px; align-items: flex-start; flex-wrap: wrap; }
|
||||||
|
.lore-actions textarea { background: #001500; color: var(--pip-green); border: 1px solid var(--border);
|
||||||
|
font-family: 'Courier New', monospace; font-size: 10px; padding: 4px; width: 100%; min-height: 60px; resize: vertical; }
|
||||||
|
.badge-pending { background: #2a2200; color: var(--pip-amber); }
|
||||||
|
.badge-accepted { background: #002a00; color: var(--pip-green); }
|
||||||
|
.badge-rejected { background: #2a0000; color: var(--pip-red); }
|
||||||
|
.badge-modified { background: #001a2a; color: var(--pip-blue); }
|
||||||
|
.lore-filters { display: flex; gap: 8px; margin-bottom: 10px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.lore-filters label { font-size: 11px; color: #aaa; }
|
||||||
|
.lore-filters select { background: #001a00; color: var(--pip-green); border: 1px solid var(--border);
|
||||||
|
font-family: 'Courier New', monospace; font-size: 11px; padding: 2px 6px; }
|
||||||
|
.lore-stat-bar { display: flex; gap: 12px; margin-bottom: 10px; font-size: 11px; flex-wrap: wrap; }
|
||||||
|
.lore-stat { color: #666; } .lore-stat span { color: var(--pip-amber); }
|
||||||
|
.change-type-badge { display: inline-block; padding: 1px 6px; font-size: 10px;
|
||||||
|
border: 1px solid #333; color: #888; margin-right: 4px; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="header-bar">
|
||||||
|
<span>📜 ENRICHISSEMENT LORE — VENICE OF WASTELAND</span>
|
||||||
|
<span class="lore-stat-bar">
|
||||||
|
<span class="lore-stat">En attente : <span>{{ lore_stats.pending }}</span></span>
|
||||||
|
<span class="lore-stat">Acceptées : <span style="color:var(--pip-green)">{{ lore_stats.accepted }}</span></span>
|
||||||
|
<span class="lore-stat">Rejetées : <span style="color:#ff4444">{{ lore_stats.rejected }}</span></span>
|
||||||
|
<span class="lore-stat">Modifiées : <span style="color:var(--pip-blue)">{{ lore_stats.modified }}</span></span>
|
||||||
|
</span>
|
||||||
|
</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:10px;color:#555">{{ proposals|length }} proposition(s) affichée(s)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not proposals %}
|
||||||
|
<div class="panel" style="color:#555;text-align:center;padding:20px">
|
||||||
|
Aucune proposition lore.<br>
|
||||||
|
<small style="color:#333">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 badge-{{ p.status }}">{{ p.status | upper }}</span>
|
||||||
|
<span class="change-type-badge">{{ p.change_type }}</span>
|
||||||
|
<span style="color:var(--pip-amber)">{{ p.faction_slug }}</span>
|
||||||
|
{% if p.field_path %}<span>{{ p.field_path }}</span>{% endif %}
|
||||||
|
<span style="color:#444">{{ 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:4px">
|
||||||
|
<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é (sera utilisé à la place du proposé)...">{{ p.proposed_text }}</textarea>
|
||||||
|
<button type="submit" class="btn" style="margin-top:4px">✎ MODIFIER & ACCEPTER</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% elif p.status == 'modified' and p.modified_text %}
|
||||||
|
<div style="margin-top:6px">
|
||||||
|
<div class="diff-label">TEXTE MODIFIÉ ACCEPTÉ</div>
|
||||||
|
<div class="diff-new" style="border-color:var(--pip-blue)">{{ p.modified_text }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
{% elif tab == 'params' %}
|
{% elif tab == 'params' %}
|
||||||
<!-- ===================================================== -->
|
<!-- ===================================================== -->
|
||||||
<!-- ONGLET PARAMÈTRES -->
|
<!-- ONGLET PARAMÈTRES -->
|
||||||
@@ -775,6 +899,141 @@ def params_resetday():
|
|||||||
ok = 0
|
ok = 0
|
||||||
return redirect(url_for("params", sid=sid, msg=msg, ok=ok))
|
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")
|
@app.route("/api/state")
|
||||||
def api_state():
|
def api_state():
|
||||||
all_sessions = get_all_sessions()
|
all_sessions = get_all_sessions()
|
||||||
|
|||||||
+130
-48
@@ -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": ""
|
||||||
}
|
}
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user