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>
|
||||
<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="/lore" class="{{ 'active' if tab=='lore' else '' }}">📜 LORE</a>
|
||||
</div>
|
||||
|
||||
{% if flash_msg %}
|
||||
@@ -395,6 +396,129 @@ if (el) {
|
||||
}
|
||||
</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' %}
|
||||
<!-- ===================================================== -->
|
||||
<!-- ONGLET PARAMÈTRES -->
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user