Spaces:
Running
Running
Download app.js from fivetech/AI-Debate-Arena: direct link, hf CLI and curl.
- Browser
- Download file 80.7 kB
-
https://huggingface.co/spaces/fivetech/AI-Debate-Arena/resolve/main/app.js
- Command line
-
hf download hf://spaces/fivetech/AI-Debate-Arena/app.js
-
curl -L -o app.js https://huggingface.co/spaces/fivetech/AI-Debate-Arena/resolve/main/app.js
80.7 kB
| // AI DEBATE ARENA — V2 (Fases 1-3 + memoria local) | |
| // Motor: wllama (mismo runtime que Multi-Agent-Debate-MiMo-WebGPU) | |
| import { Wllama } from 'https://cdn.jsdelivr.net/npm/@wllama/wllama@3.5.1/esm/index.js'; | |
| /* ================= CONFIG ================= */ | |
| const params = new URLSearchParams(location.search); | |
| const MODELS = { | |
| mimo: { label: 'MiMo 9B', repo: 'bartowski/MiMo-V2.6-Distill-Qwen-9B-GGUF', file: 'MiMo-V2.6-Distill-Qwen-9B-Q4_K_M.gguf' }, | |
| qwen: { label: 'Qwen2.5 0.5B', repo: 'Qwen/Qwen2.5-0.5B-Instruct-GGUF', file: 'qwen2.5-0.5b-instruct-q4_k_m.gguf' }, | |
| }; | |
| const MOCK = params.get('mock') === '1'; // gancho de pruebas: motor simulado sin modelo real | |
| const CONFIG = { | |
| modelUrl: params.get('modelUrl') || '', | |
| repo: params.get('repo') || '', | |
| file: params.get('file') || '', | |
| nCtx: parseInt(params.get('ctx')) || 8192, | |
| maxTokens: parseInt(params.get('maxTokens')) || 320, | |
| judgeTokens: parseInt(params.get('judgeTokens')) || 400, // límite por juez; acotable en pruebas | |
| temperature: parseFloat(params.get('temp')) || 0.7, | |
| }; | |
| const WEIGHTS = [ | |
| ['argumentacion', 'Calidad argumentativa', 25], | |
| ['logica', 'Razonamiento lógico', 25], | |
| ['evidencia', 'Evidencias', 20], | |
| ['refutacion', 'Refutación', 15], | |
| ['adaptabilidad', 'Adaptabilidad', 10], | |
| ['claridad', 'Claridad', 5], | |
| ]; | |
| const JUDGES = [ | |
| { id: 'logic', name: 'Logic Judge', emoji: '🧠', criteria: ['logica', 'claridad'], desc: 'Coherencia y estructura lógica' }, | |
| { id: 'evidence', name: 'Evidence Judge', emoji: '📚', criteria: ['evidencia', 'argumentacion'], desc: 'Calidad de evidencias y afirmaciones' }, | |
| { id: 'strategy', name: 'Strategy Judge', emoji: '♟️', criteria: ['refutacion', 'adaptabilidad'], desc: 'Capacidad de respuesta y adaptación' }, | |
| ]; | |
| const MODES = [ | |
| { id: 'duel', name: '1 vs 1 — Duel', desc: 'Dos agentes se enfrentan en rondas alternas. Ideal para debates intensos.', live: true }, | |
| { id: 'ffa', name: 'Free For All', desc: 'Hasta ocho agentes defienden sus propias posiciones.', live: true }, | |
| { id: 'team', name: 'Team Battle', desc: 'Dos equipos de agentes colaboran y compiten entre sí (2v2, 3v3 o 4v4).', live: true }, | |
| { id: 'tournament', name: 'Survival Tournament', desc: 'Eliminatorias de 4 u 8 agentes: el ganador de cada duelo avanza hasta la gran final.', live: true }, | |
| ]; | |
| const STRATEGY_TEXT = { | |
| attack: 'Estrategia ATTACK: busca debilidades en los argumentos de tus rivales y atácalas con precisión. Señala exactamente qué afirmación es débil y por qué.', | |
| defense: 'Estrategia DEFENSE: protege tu posición. Refuerza tus puntos fuertes y responde a cada objeción que te hayan hecho.', | |
| evidence: 'Estrategia EVIDENCE: prioriza evidencias, datos y razonamientos verificables sobre la retórica.', | |
| socratic: 'Estrategia SOCRATIC: cuestiona mediante preguntas incisivas que expongan las contradicciones de tus rivales.', | |
| counterattack: 'Estrategia COUNTERATTACK: responde directamente a cada objeción recibida con un contraargumento concreto.', | |
| }; | |
| const ACHIEVEMENTS = { | |
| master_debater: { emoji: '🏆', name: 'Master Debater (victoria)' }, | |
| tournament_champion: { emoji: '🥇', name: 'Campeón de torneo' }, | |
| logic_master: { emoji: '🧠', name: 'Logic Master (mejor lógica)' }, | |
| evidence_champion: { emoji: '📚', name: 'Evidence Champion (mejor evidencia)' }, | |
| best_refutation: { emoji: '🛡️', name: 'Best Refutation (mejor refutación)' }, | |
| first_win: { emoji: '🎖️', name: 'Primera victoria' }, | |
| five_wins: { emoji: '🔥', name: '5 victorias' }, | |
| ten_wins: { emoji: '👑', name: '10 victorias' }, | |
| veteran: { emoji: '⚔️', name: 'Veterano (10 batallas)' }, | |
| }; | |
| /* Logros persistentes: SOLO se otorgan desde batallas con evaluación real del modelo | |
| (los llamadores lo garantizan; mock y heurísticas nunca otorgan logros). */ | |
| function applyAwardsAndMilestones(ps, winner, winnerTeam) { | |
| ps.forEach(a => { | |
| const st = stats[a.id]; if (!st) return; | |
| st.awards = st.awards || {}; | |
| if (st.wins >= 1) st.awards.first_win = 1; | |
| if (st.wins >= 5) st.awards.five_wins = 1; | |
| if (st.wins >= 10) st.awards.ten_wins = 1; | |
| if (st.matches >= 10) st.awards.veteran = 1; | |
| }); | |
| const critMap = { logica: 'logic_master', evidencia: 'evidence_champion', refutacion: 'best_refutation' }; | |
| Object.entries(critMap).forEach(([crit, key]) => { | |
| const best = [...ps].sort((a, b) => ((battle.scores?.[b.id]?.[crit]) ?? 0) - ((battle.scores?.[a.id]?.[crit]) ?? 0))[0]; | |
| if (best && stats[best.id]) stats[best.id].awards[key] = (stats[best.id].awards[key] || 0) + 1; | |
| }); | |
| if (battle.mode === 'team') { | |
| if (winnerTeam) ps.filter(a => a.team === winnerTeam).forEach(a => { stats[a.id].awards.master_debater = (stats[a.id].awards.master_debater || 0) + 1; }); | |
| } else if (winner && stats[winner.id]) { | |
| stats[winner.id].awards.master_debater = (stats[winner.id].awards.master_debater || 0) + 1; | |
| } | |
| } | |
| /* Calidad del veredicto: la RUTA fiable (!heuristic && !MOCK) no acredita CALIDAD. | |
| Esquema: nota numérica 0-10 para cada participante y criterio. | |
| Consistencia: el ganador declarado por el veredicto debe estar entre los máximos totales. */ | |
| function evaluationQualityOK(scores, verdict, ps, mode, winner) { | |
| for (const a of ps) { | |
| const sa = scores?.[a.id]; | |
| if (!sa) return false; | |
| for (const [k] of WEIGHTS) { | |
| const v = sa[k]; | |
| if (typeof v !== 'number' || isNaN(v) || v < 0 || v > 10) return false; | |
| } | |
| } | |
| if (mode !== 'team' && verdict?.winner && verdict.winner !== 'Empate' && winner) { | |
| const totals = ps.map(a => totalScore(a.id, scores)); | |
| const top = Math.max(...totals); | |
| const topIds = ps.filter((a, i) => totals[i] === top).map(a => a.id); | |
| if (!topIds.includes(winner.id)) return false; | |
| } | |
| return true; | |
| } | |
| const ADAPTIVE_CYCLE = ['attack', 'defense', 'evidence', 'socratic', 'counterattack']; | |
| const PHASES = [ | |
| { id: 'opening', label: 'Opening' }, | |
| { id: 'crossfire', label: 'Crossfire' }, | |
| { id: 'rebuttal', label: 'Rebuttal' }, | |
| { id: 'closing', label: 'Closing' }, | |
| { id: 'judging', label: 'Judging' }, | |
| { id: 'verdict', label: 'Verdict' }, | |
| ]; | |
| const DEFAULT_AGENTS = [ | |
| { id: 'professor', name: 'The Professor', emoji: '🔬', color: '#3B82F6', specialty: 'Ciencia', style: 'Analítico', strategy: 'evidence', | |
| stats: { logic: 95, creativity: 72, aggression: 35 }, | |
| system: 'Eres The Professor, científico analítico. Razonas con precisión, citas mecanismos y datos, y distingues lo demostrado de lo especulativo.' }, | |
| { id: 'strategist', name: 'The Strategist', emoji: '♟️', color: '#8B5CF6', specialty: 'Estrategia', style: 'Calculador', strategy: 'counterattack', | |
| stats: { logic: 90, creativity: 68, aggression: 55 }, | |
| system: 'Eres The Strategist, un calculador. Piensas dos movimientos por delante, explotas las concesiones del rival y conviertes sus argumentos en los tuyos.' }, | |
| { id: 'skeptic', name: 'The Skeptic', emoji: '🧐', color: '#F59E0B', specialty: 'Pensamiento crítico', style: 'Escéptico', strategy: 'socratic', | |
| stats: { logic: 92, creativity: 60, aggression: 50 }, | |
| system: 'Eres The Skeptic. No aceptas nada sin pruebas: cuestionas supuestos, pides evidencia y señalas falacias. Duro con las ideas, respetuoso con las personas.' }, | |
| { id: 'innovator', name: 'The Innovator', emoji: '💡', color: '#10B981', specialty: 'Tecnología', style: 'Creativo', strategy: 'attack', | |
| stats: { logic: 70, creativity: 96, aggression: 60 }, | |
| system: 'Eres The Innovator, mente creativa tecnológica. Propones escenarios audaces, conexiones inesperadas y visiones de futuro, y defiendes la experimentación.' }, | |
| { id: 'philosopher', name: 'The Philosopher', emoji: '🏛️', color: '#EC4899', specialty: 'Filosofía', style: 'Socrático', strategy: 'defense', | |
| stats: { logic: 88, creativity: 80, aggression: 30 }, | |
| system: 'Eres The Philosopher. Examinas los fundamentos éticos y conceptuales del tema, defines los términos con cuidado y defiendes tu posición con coherencia.' }, | |
| { id: 'economist', name: 'The Economist', emoji: '📊', color: '#14B8A6', specialty: 'Economía', style: 'Pragmático', strategy: 'evidence', | |
| stats: { logic: 85, creativity: 55, aggression: 45 }, | |
| system: 'Eres The Economist, pragmático. Evalúas incentivos, costes y beneficios, y traes el debate a consecuencias medibles y trade-offs reales.' }, | |
| { id: 'challenger', name: 'The Challenger', emoji: '⚔️', color: '#EF4444', specialty: 'Argumentación', style: 'Agresivo', strategy: 'attack', | |
| stats: { logic: 75, creativity: 65, aggression: 95 }, | |
| system: 'Eres The Challenger, combativo y directo. Atacas los argumentos rivales sin rodeos, con energía y contundencia, pero siempre con razones.' }, | |
| { id: 'synthesizer', name: 'The Synthesizer', emoji: '🤝', color: '#6366F1', specialty: 'Síntesis', style: 'Conciliador', strategy: 'adaptive', | |
| stats: { logic: 80, creativity: 78, aggression: 25 }, | |
| system: 'Eres The Synthesizer. Integras lo mejor de cada posición, encuentras síntesis y cambias de táctica según evoluciona el debate.' }, | |
| ]; | |
| const TOPIC_POOL = [ | |
| '¿Debería la IA tener derechos legales?', '¿Es la exploración espacial una buena inversión pública?', | |
| '¿Redes sociales: más beneficio o más daño?', '¿Debería ser obligatoria la semana laboral de 4 días?', | |
| '¿Superará la IA a los médicos en diagnóstico?', '¿Es el arte generado por IA verdadero arte?', | |
| '¿Deberían prohibirse los coches de combustión en 2035?', '¿Es la privacidad un derecho absoluto?', | |
| '¿Nuclear o renovables para la transición energética?', '¿Debería haber un límite de riqueza personal?', | |
| '¿Es la educación online tan válida como la presencial?', '¿Deberían los algoritmos recomendadores ser auditados?', | |
| '¿Es el teletrabajo mejor que la oficina?', '¿Debería existir una renta básica universal?', | |
| '¿Son los videojuegos cultura?', '¿Debería la ingeniería genética humana ser legal?', | |
| ]; | |
| const EMOJIS = ['🔬','♟️','🧐','💡','🏛️','📊','⚔️','🤝','🦊','🐺','🦁','🐉','🤖','👾','🎯','🔥','🌊','⚡','🧭','🎭']; | |
| const COLORS = ['#3B82F6','#8B5CF6','#F59E0B','#10B981','#EC4899','#14B8A6','#EF4444','#6366F1','#F97316','#84CC16','#06B6D4','#A855F7']; | |
| /* ================= STATE MANAGER ================= */ | |
| const LS = { | |
| get(k, d) { try { const v = JSON.parse(localStorage.getItem(k)); return v ?? d; } catch { return d; } }, | |
| set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch (e) { console.warn('persist fail', e); } }, | |
| }; | |
| /* Persistencia durable: IndexedDB guarda el historial completo de batallas (hasta 50); | |
| localStorage queda como caché síncrona de arranque (10) y para agentes/stats/selección. */ | |
| const IDB = { | |
| db: null, | |
| open() { | |
| return new Promise(res => { | |
| let req; | |
| try { req = indexedDB.open('ai-debate-arena', 1); } catch { return res(null); } | |
| req.onupgradeneeded = () => { req.result.createObjectStore('matches', { keyPath: 'id' }); }; | |
| req.onsuccess = () => res(req.result); | |
| req.onerror = () => res(null); | |
| }); | |
| }, | |
| allMatches() { | |
| return new Promise((res, rej) => { | |
| const rq = this.db.transaction('matches', 'readonly').objectStore('matches').getAll(); | |
| rq.onsuccess = () => res(rq.result || []); | |
| rq.onerror = () => rej(rq.error); | |
| }); | |
| }, | |
| saveMatches(list) { | |
| return new Promise(res => { | |
| if (!this.db) return res(); | |
| try { | |
| const tx = this.db.transaction('matches', 'readwrite'); | |
| const st = tx.objectStore('matches'); | |
| st.clear(); | |
| list.forEach(m => st.put(m)); | |
| tx.oncomplete = () => res(); | |
| tx.onerror = () => { console.warn('idb save fail', tx.error); res(); }; | |
| } catch (e) { console.warn('idb save fail', e); res(); } | |
| }); | |
| }, | |
| async init() { | |
| this.db = await this.open(); | |
| if (!this.db) return; | |
| try { | |
| const stored = await this.allMatches(); | |
| if (!stored.length && matches.length) { | |
| await this.saveMatches(matches); // migración única desde localStorage | |
| } else if (stored.length) { | |
| const byId = new Map(); | |
| [...stored, ...matches].forEach(m => byId.set(m.id, m)); | |
| matches = [...byId.values()].sort((a, b) => String(b.date || '').localeCompare(String(a.date || ''))).slice(0, 50); | |
| if (document.querySelector('#view-hall')?.classList.contains('active')) renderHall(); | |
| } | |
| } catch (e) { console.warn('idb init fail', e); } | |
| } | |
| }; | |
| let agents = LS.get('arena_agents', null) || structuredClone(DEFAULT_AGENTS); | |
| let stats = LS.get('arena_stats', {}); // id -> {wins,losses,draws,scoreSum,matches,awards:{}} | |
| let matches = LS.get('arena_matches', []); // últimas 10 batallas (eventos + veredicto) | |
| let selected = LS.get('arena_selected', ['professor', 'challenger']); | |
| let mode = 'duel'; | |
| let wllama = null, modelKey = null, activeModelLabel = null; | |
| function updateModelInUse() { | |
| const el = document.getElementById('model-in-use'); | |
| if (el) el.textContent = 'Modelo en esta sesión: ' + (activeModelLabel || 'sin cargar'); | |
| } | |
| let battle = null; // estado de la batalla activa | |
| let replayTimer = null; | |
| let running = false, stopRequested = false; | |
| /* ================= HELPERS ================= */ | |
| const $ = s => document.querySelector(s); | |
| function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } | |
| function agentById(id) { return agents.find(a => a.id === id); } | |
| function shade(hex, amt) { | |
| const n = parseInt(hex.slice(1), 16); | |
| let r = (n >> 16) + amt, g = ((n >> 8) & 255) + amt, b = (n & 255) + amt; | |
| r = Math.max(0, Math.min(255, r)); g = Math.max(0, Math.min(255, g)); b = Math.max(0, Math.min(255, b)); | |
| return '#' + ((r << 16) | (g << 8) | b).toString(16).padStart(6, '0'); | |
| } | |
| function strategyOf(agent, round) { | |
| if (agent.strategy !== 'adaptive') return agent.strategy; | |
| return ADAPTIVE_CYCLE[(round - 1 + DEFAULT_AGENTS.findIndex(d => d.id === agent.id) + 20) % ADAPTIVE_CYCLE.length]; | |
| } | |
| function md(t) { return (typeof marked !== 'undefined') ? marked.parse(t) : esc(t).replace(/\n/g, '<br>'); } | |
| function persistAll() { | |
| LS.set('arena_agents', agents); LS.set('arena_stats', stats); LS.set('arena_selected', selected); | |
| LS.set('arena_matches', matches.slice(0, 10)); | |
| if (IDB.db) IDB.saveMatches(matches); | |
| } | |
| /* ================= NAV ================= */ | |
| document.querySelectorAll('nav button').forEach(b => b.addEventListener('click', () => showView(b.dataset.view))); | |
| function showView(v) { | |
| if (v !== 'battle' && replayTimer) { clearInterval(replayTimer); replayTimer = null; } | |
| document.querySelectorAll('.view').forEach(s => s.classList.remove('active')); | |
| $('#view-' + v).classList.add('active'); | |
| document.querySelectorAll('nav button').forEach(b => b.classList.toggle('active', b.dataset.view === v)); | |
| if (v === 'hall') renderHall(); | |
| if (v === 'roadmap') renderRoadmap(); | |
| if (v === 'roster') renderRoster(); | |
| if (v === 'lobby') renderLobby(); | |
| } | |
| /* ================= LOBBY ================= */ | |
| function renderModes() { | |
| $('#modes').innerHTML = MODES.map(m => ` | |
| <div class="mode-card ${m.id === mode ? 'sel' : ''} ${m.live ? '' : 'soon'}" data-mode="${m.id}"> | |
| <span class="badge ${m.live ? 'live' : 'dev'}">${m.live ? 'DISPONIBLE' : 'EN DESARROLLO'}</span> | |
| <h3>${m.name}</h3><p>${m.desc}</p> | |
| </div>`).join(''); | |
| document.querySelectorAll('.mode-card').forEach(c => c.addEventListener('click', () => { | |
| const m = MODES.find(x => x.id === c.dataset.mode); | |
| if (!m.live) return; | |
| mode = m.id; | |
| if (mode === 'duel' && selected.length > 2) selected = selected.slice(0, 2); | |
| renderModes(); renderPicked(); renderRoster(); | |
| })); | |
| } | |
| function renderPicked() { | |
| const need = mode === 'duel' ? '2 agentes' : mode === 'team' ? 'equipos parejos: 4, 6 u 8 agentes' : mode === 'tournament' ? '4 u 8 agentes' : '3-8 agentes'; | |
| $('#picked-line').innerHTML = `<span class="chip">Modo: <b>${MODES.find(m => m.id === mode).name}</b></span>` + | |
| selected.map(id => { const a = agentById(id); return a ? `<span class="chip" style="border-color:${a.color}">${a.emoji} <b>${esc(a.name)}</b></span>` : ''; }).join('') + | |
| `<span class="chip" style="color:var(--muted)">necesitas ${need} — selecciona en «Agentes»</span>`; | |
| } | |
| function fillTopicPick() { | |
| const sel = $('#topic-pick'); | |
| sel.querySelectorAll('option:not(:first-child)').forEach(o => o.remove()); | |
| [...TOPIC_POOL].sort(() => Math.random() - .5).slice(0, 10).forEach(t => { | |
| const o = document.createElement('option'); o.value = t; o.textContent = t; sel.appendChild(o); | |
| }); | |
| } | |
| $('#topic-pick').addEventListener('change', e => { if (e.target.value) $('#topic').value = e.target.value; e.target.value = ''; }); | |
| $('#random-match').addEventListener('click', () => { | |
| mode = 'duel'; | |
| const pool = agents.map(a => a.id); | |
| selected = []; | |
| while (selected.length < 2 && pool.length) selected.push(pool.splice(Math.floor(Math.random() * pool.length), 1)[0]); | |
| $('#topic').value = TOPIC_POOL[Math.floor(Math.random() * TOPIC_POOL.length)]; | |
| $('#rounds').value = String(1 + Math.floor(Math.random() * 3)); | |
| renderModes(); renderPicked(); renderRoster(); | |
| }); | |
| function renderLobby() { renderModes(); renderPicked(); fillTopicPick(); } | |
| /* ================= ROSTER ================= */ | |
| function renderRoster() { | |
| const min = mode === 'duel' ? 2 : 3, max = mode === 'duel' ? 2 : 8; | |
| $('#pick-count').textContent = `(${selected.length}/${mode === 'duel' ? 2 : '3-8'})`; | |
| $('#roster').innerHTML = agents.map(a => { | |
| const st = stats[a.id] || { wins: 0, losses: 0, draws: 0, scoreSum: 0, matches: 0 }; | |
| const avg = st.matches ? (st.scoreSum / st.matches).toFixed(1) : '—'; | |
| return ` | |
| <div class="agent-card ${selected.includes(a.id) ? 'sel' : ''}" data-id="${a.id}" style="--ac:${a.color}"> | |
| <div class="tools"> | |
| <button class="icon-btn" data-edit="${a.id}" title="Editar">✏️</button> | |
| </div> | |
| <div class="avatar">${a.emoji}</div> | |
| <h3>${esc(a.name)}</h3> | |
| <div class="spec">${esc(a.specialty)}</div> | |
| <div class="style">${esc(a.style)} · estrategia ${a.strategy.toUpperCase()}</div> | |
| ${realBattleCount(a.id) ? `<div class="style" style="color:var(--accent2)">🧠 memoria: ${realBattleCount(a.id)} batalla(s) real(es)</div>` : ''} | |
| ${[['Lógica', a.stats.logic], ['Creatividad', a.stats.creativity], ['Agresividad', a.stats.aggression]].map(([k, v]) => | |
| `<div class="stat"><span>${k}</span><div class="bar"><div style="width:${v}%"></div></div><b>${v}</b></div>`).join('')} | |
| <div class="record">🏆 ${st.wins}V · ${st.losses}D · ${st.draws}E · media ${avg} · ${st.matches} batallas</div> | |
| </div>`; | |
| }).join(''); | |
| document.querySelectorAll('.agent-card').forEach(c => c.addEventListener('click', e => { | |
| if (e.target.closest('[data-edit]')) return; | |
| const id = c.dataset.id; | |
| if (selected.includes(id)) { | |
| if (selected.length <= min && selected.length <= 2) { /* permite bajar a 0, se valida al empezar */ } | |
| selected = selected.filter(x => x !== id); | |
| } else { | |
| if (selected.length >= max) selected.shift(); | |
| selected.push(id); | |
| } | |
| persistAll(); renderRoster(); renderPicked(); | |
| })); | |
| document.querySelectorAll('[data-edit]').forEach(b => b.addEventListener('click', () => openAgentModal(b.dataset.edit))); | |
| } | |
| /* ================= AGENT MODAL ================= */ | |
| let editingId = null; | |
| function openAgentModal(id) { | |
| editingId = id; | |
| const a = id ? agentById(id) : { name: '', emoji: '🤖', color: COLORS[0], specialty: '', style: '', strategy: 'attack', system: '', stats: { logic: 70, creativity: 70, aggression: 50 } }; | |
| $('#am-title').textContent = id ? 'Editar agente' : 'Nuevo agente'; | |
| $('#am-name').value = a.name; $('#am-spec').value = a.specialty; $('#am-style').value = a.style; | |
| $('#am-strategy').value = a.strategy; $('#am-system').value = a.system; | |
| $('#am-delete').style.display = id ? 'inline-block' : 'none'; | |
| $('#am-emojis').innerHTML = EMOJIS.map(e => `<button data-e="${e}" class="${e === a.emoji ? 'sel' : ''}">${e}</button>`).join(''); | |
| $('#am-colors').innerHTML = COLORS.map(c => `<button data-c="${c}" class="${c === a.color ? 'sel' : ''}" style="background:${c}"></button>`).join(''); | |
| $('#am-emojis').querySelectorAll('button').forEach(b => b.addEventListener('click', () => { $('#am-emojis').querySelectorAll('button').forEach(x => x.classList.remove('sel')); b.classList.add('sel'); })); | |
| $('#am-colors').querySelectorAll('button').forEach(b => b.addEventListener('click', () => { $('#am-colors').querySelectorAll('button').forEach(x => x.classList.remove('sel')); b.classList.add('sel'); })); | |
| $('#agent-modal').classList.add('open'); | |
| } | |
| $('#add-agent').addEventListener('click', () => openAgentModal(null)); | |
| $('#am-cancel').addEventListener('click', () => $('#agent-modal').classList.remove('open')); | |
| $('#am-save').addEventListener('click', () => { | |
| const name = $('#am-name').value.trim(); | |
| if (!name) return; | |
| const emoji = $('#am-emojis .sel')?.dataset.e || '🤖'; | |
| const color = $('#am-colors .sel')?.dataset.c || COLORS[0]; | |
| const data = { | |
| name, emoji, color, | |
| specialty: $('#am-spec').value.trim() || 'Generalista', | |
| style: $('#am-style').value.trim() || 'Libre', | |
| strategy: $('#am-strategy').value, | |
| system: $('#am-system').value.trim() || `Eres ${name}, un debatiente agudo y claro.`, | |
| }; | |
| if (editingId) Object.assign(agentById(editingId), data); | |
| else agents.push({ id: 'custom_' + Date.now(), stats: { logic: 70, creativity: 70, aggression: 50 }, ...data }); | |
| persistAll(); $('#agent-modal').classList.remove('open'); renderRoster(); renderPicked(); | |
| }); | |
| $('#am-delete').addEventListener('click', () => { | |
| if (!editingId || agents.length <= 2) return; | |
| agents = agents.filter(a => a.id !== editingId); | |
| selected = selected.filter(x => x !== editingId); | |
| persistAll(); $('#agent-modal').classList.remove('open'); renderRoster(); renderPicked(); | |
| }); | |
| /* ================= MODEL RUNTIME ================= */ | |
| function setPill(state, text) { | |
| const p = $('#model-pill'); p.className = state; $('#model-pill-text').textContent = text; | |
| } | |
| function setLoad(pct, text) { | |
| $('#load-bar').style.width = Math.max(0, Math.min(100, pct)) + '%'; | |
| if (text !== undefined) $('#load-status').textContent = text; | |
| } | |
| async function ensureModel() { | |
| if (MOCK) { | |
| modelKey = 'mock'; activeModelLabel = 'motor simulado (pruebas, sin modelo real)'; setPill('ready', 'motor simulado (pruebas)'); setLoad(100, 'Motor simulado listo.'); updateModelInUse(); return; } | |
| const key = $('#model-sel').value; | |
| const m = CONFIG.repo ? { label: 'personalizado', repo: CONFIG.repo, file: CONFIG.file } : MODELS[key]; | |
| const url = CONFIG.modelUrl || `https://huggingface.co/${m.repo}/resolve/main/${m.file}`; | |
| // Etiqueta honesta: si hay modelUrl, el preset del selector NO refleja el modelo real | |
| const dispLabel = CONFIG.modelUrl ? 'modelo por URL: ' + (CONFIG.modelUrl.split('/').pop() || CONFIG.modelUrl) : m.label; | |
| if (wllama && modelKey === url) return; | |
| $('#start-btn').disabled = true; | |
| setPill('loading', 'cargando ' + dispLabel + '…'); | |
| if (key === 'mimo' && !CONFIG.modelUrl && !CONFIG.repo) setLoad(0, `⚠️ MiMo 9B: fallo de generación observado en pruebas con wllama 3.5.1/3.8.1 (en validación). Descargando ${m.file} de todos modos (ctx ${CONFIG.nCtx})…`); | |
| else setLoad(0, `Descargando ${m.file} (ctx ${CONFIG.nCtx})…`); | |
| try { | |
| if (wllama) await wllama.exit(); | |
| wllama = new Wllama({ | |
| 'default': 'https://cdn.jsdelivr.net/npm/@wllama/wllama@3.5.1/esm/wasm/wllama.wasm', | |
| }, { parallelDownloads: 5 }); | |
| await wllama.loadModelFromUrl(url, { | |
| n_gpu_layers: 999, n_ctx: CONFIG.nCtx, n_batch: 512, | |
| n_threads: Math.max(1, Math.min(8, navigator.hardwareConcurrency || 4)), | |
| progressCallback: ({ loaded, total }) => { | |
| if (total > 0) { | |
| const pct = Math.round((loaded / total) * 100); | |
| setLoad(pct, `Descargando… ${pct}% (${(loaded / 1048576).toFixed(0)} MB)`); | |
| } | |
| }, | |
| }); | |
| modelKey = url; | |
| activeModelLabel = dispLabel; | |
| setLoad(100, `${dispLabel} listo.`); | |
| setPill('ready', dispLabel + ' listo'); | |
| updateModelInUse(); | |
| } catch (e) { | |
| console.error(e); | |
| setPill('', 'error de carga'); | |
| setLoad(0, 'Error al cargar el modelo: ' + (e?.message || e) + ' — libera espacio del navegador o prueba el modelo ligero.'); | |
| wllama = null; modelKey = null; | |
| throw e; | |
| } finally { | |
| $('#start-btn').disabled = false; | |
| } | |
| } | |
| const RULES = 'Responde SOLO con tu intervención: 2-5 frases directas, en el idioma del tema. No escribas tu nombre ni prefijos como "Nombre:". No uses listas largas. No repitas argumentos ya expuestos.'; | |
| async function generate(system, user, maxTokens, onDelta) { | |
| if (MOCK) { | |
| let text; | |
| if (/Head Judge/.test(system)) { | |
| const m = user.match(/totales: ([^:\n]+):/); | |
| text = JSON.stringify({ winner: m ? m[1].trim() : 'Empate', | |
| justification: 'Veredicto simulado: el ganador presentó argumentos más estructurados, respondió mejor a las objeciones y cerró con una síntesis más sólida.', | |
| decisive: ['Refutación directa en la ronda 1', 'Cierre con síntesis clara'], | |
| mistakes: ['Alguna generalización sin evidencia'], | |
| best: ['Apertura con tesis bien delimitada'] }); | |
| } else if (/Judge|juez/.test(system)) { | |
| const m = user.match(/Participantes: ([^\n]+)/); | |
| const names = m ? m[1].split(',').map(s => s.trim()) : ['Agente']; | |
| const scores = {}; | |
| names.forEach((n, i) => { scores[n] = { logica: 8 - i, claridad: 7, evidencia: 8 - i, argumentacion: 7, refutacion: 8 - i, adaptabilidad: 6 }; }); | |
| text = JSON.stringify(scores); | |
| } else { | |
| const who = (user.match(/Ahora intervienes tú, ([^.]+)\./) || [,''])[1]; | |
| text = `Intervención simulada de ${who}. Defiendo mi posición con un argumento principal y un ejemplo concreto. Respondo a la objeción anterior con un contraargumento breve. Mantengo mi tesis con matices.`; | |
| } | |
| let shown = ''; | |
| for (let i = 0; i < text.length; i += 12) { | |
| if (stopRequested) return shown; | |
| shown = text.slice(0, i + 12); | |
| if (onDelta) onDelta(shown); | |
| await new Promise(r => setTimeout(r, 8)); | |
| } | |
| return text; | |
| } | |
| const stream = await wllama.createChatCompletion({ | |
| messages: [{ role: 'system', content: system }, { role: 'user', content: user }], | |
| max_tokens: maxTokens || CONFIG.maxTokens, temperature: CONFIG.temperature, stream: true, | |
| }); | |
| let out = ''; | |
| for await (const chunk of stream) { | |
| if (stopRequested) break; | |
| const tk = chunk.choices?.[0]?.delta?.content || ''; | |
| if (tk) { out += tk; if (onDelta) onDelta(out); } | |
| } | |
| return out.replace(/^\s*(?:\*\*)?[A-Za-zÀ-ɏ0-9_ ]{1,30}:\s*/, '').trim(); | |
| } | |
| function extractJSON(text) { | |
| if (!text) return null; | |
| const cleaned = String(text).replace(/```(?:json)?/gi, ' '); | |
| const tryParse = s => { | |
| try { return JSON.parse(s); } catch { /* sigue */ } | |
| try { return JSON.parse(s.replace(/,\s*([}\]])/g, '$1')); } catch { /* comas colgantes */ } | |
| return null; | |
| }; | |
| for (let start = cleaned.indexOf('{'); start >= 0; start = cleaned.indexOf('{', start + 1)) { | |
| let depth = 0; | |
| for (let i = start; i < cleaned.length; i++) { | |
| if (cleaned[i] === '{') depth++; | |
| else if (cleaned[i] === '}') { | |
| depth--; | |
| if (depth === 0) { const r = tryParse(cleaned.slice(start, i + 1)); if (r) return r; break; } | |
| } | |
| } | |
| } | |
| return null; | |
| } | |
| /* ================= BATTLE ENGINE ================= */ | |
| function participants() { return selected.map(agentById).filter(Boolean); } | |
| function transcriptView(events, maxChars = 9000) { | |
| const lines = events.map(e => `${e.agent}: ${e.content}`); | |
| let out = lines.join('\n\n'); | |
| while (out.length > maxChars && lines.length > 2) { lines.splice(1, 1); out = lines.join('\n\n'); } | |
| return out; | |
| } | |
| function renderPhaseTrack(nowId) { | |
| const order = ['opening', 'crossfire', 'rebuttal', 'closing', 'judging', 'verdict']; | |
| const nowIdx = order.indexOf(nowId); | |
| $('#phase-track').innerHTML = PHASES.map(p => { | |
| const idx = order.indexOf(p.id); | |
| return `<span class="phase-step ${p.id === nowId ? 'now' : ''} ${idx < nowIdx ? 'done' : ''}">${p.label}</span>`; | |
| }).join(''); | |
| } | |
| function renderFighters(turnId) { | |
| const ps = battle ? battle.participants : participants(); | |
| if (ps.length === 2) { | |
| $('#fighters').innerHTML = ps.map(a => ` | |
| <div class="fighter ${a.id === turnId ? 'turn' : ''}" style="--ac:${a.color}" id="fighter-${a.id}"> | |
| <div class="avatar">${a.emoji}</div><h3>${esc(a.name)}</h3> | |
| <div class="strat">${esc(a.specialty)} · ${a.strategy.toUpperCase()}</div> | |
| </div>`).join('<div class="vs">VS</div>'); | |
| } else { | |
| $('#fighters').innerHTML = `<div style="grid-column:1/-1;display:flex;gap:8px;flex-wrap:wrap;justify-content:center">` + ps.map(a => ` | |
| <div class="fighter ${a.id === turnId ? 'turn' : ''}" style="--ac:${a.color};padding:9px 14px;min-width:110px" id="fighter-${a.id}"> | |
| <div class="avatar" style="font-size:1.4rem">${a.emoji}</div><h3 style="font-size:.8rem">${esc(a.name)}</h3> | |
| <div class="strat">${a.team ? 'EQUIPO ' + a.team + ' · ' : ''}${a.strategy.toUpperCase()}</div> | |
| </div>`).join('') + '</div>'; | |
| } | |
| } | |
| function addEvent(agent, phase, content, round, isUser) { | |
| const ev = { id: 'ev_' + battle.events.length, round, phase, agent: isUser ? 'Tú' : agent.name, agentId: isUser ? 'user' : agent.id, content, ts: new Date().toISOString() }; | |
| battle.events.push(ev); | |
| const log = $('#battle-log'); | |
| const div = document.createElement('div'); | |
| div.className = 'msg' + (isUser ? ' user' : ''); | |
| div.style.setProperty('--ac', isUser ? '#8b5cf6' : agent.color); | |
| div.innerHTML = `<div class="mavatar">${isUser ? '⚡' : agent.emoji}</div> | |
| <div class="mbody"><div class="mhead" style="color:${isUser ? '#8b5cf6' : agent.color}">${isUser ? 'Tú (challenge)' : esc(agent.name) + (agent.team ? ` <span style="opacity:.75">· Eq. ${agent.team}</span>` : '')} | |
| <span class="ph">${phase} · ronda ${round}</span></div> | |
| <div class="mtext">${md(content)}</div></div>`; | |
| log.appendChild(div); log.scrollTop = log.scrollHeight; | |
| return div; | |
| } | |
| function addSystemMsg(text) { | |
| const log = $('#battle-log'); | |
| const div = document.createElement('div'); | |
| div.className = 'msg system'; | |
| div.innerHTML = `<div class="mbody" style="width:100%"><div class="mtext">${esc(text)}</div></div>`; | |
| log.appendChild(div); log.scrollTop = log.scrollHeight; | |
| } | |
| function agentMemory(agentId) { | |
| // memoria persistente: SOLO batallas con motor y evaluación verificados (nunca mock/heurística/inconsistente ni registros legacy sin motor) | |
| const real = matches.filter(m => m.engine === 'wllama' && !m.heuristic && !m.inconsistent && m.participants.includes(agentId)); | |
| if (!real.length) return ''; | |
| const st = stats[agentId]; | |
| const parts = []; | |
| if (st && st.matches) parts.push(`${st.matches} batallas evaluadas (${st.wins} victorias, ${st.losses} derrotas, ${st.draws} empates)`); | |
| const recent = real.slice(0, 3).map(m => { | |
| const res = m.mode === 'tournament' ? (m.winner === agentId ? 'ganaste el torneo' : 'no ganaste el torneo') | |
| : m.winnerTeam ? ((m.teams && m.teams[agentId]) === m.winnerTeam ? 'ganó tu equipo' : 'perdió tu equipo') | |
| : m.winner === agentId ? 'ganaste' : (m.winner ? 'perdiste' : 'empate'); | |
| return `«${m.topic}»: ${res}`; | |
| }); | |
| if (recent.length) parts.push('últimas: ' + recent.join(' · ')); | |
| return parts.length ? `\nTu historial en esta arena (memoria persistente): ${parts.join('. ')}. Aprende de tus batallas anteriores: mantén lo que te dio victorias y corrige lo que te hizo perder.\n` : ''; | |
| } | |
| function realBattleCount(agentId) { | |
| return matches.filter(m => m.engine === 'wllama' && !m.heuristic && !m.inconsistent && m.participants.includes(agentId)).length; | |
| } | |
| async function agentIntervention(agent, phase, round, extraInstr) { | |
| const strat = strategyOf(agent, round); | |
| const system = agent.system + ' ' + (STRATEGY_TEXT[strat] || '') + ' ' + RULES; | |
| const tm = battle.tournament && battle.tournament.currentMatch != null ? battle.tournament.matches[battle.tournament.currentMatch] : null; | |
| const view = transcriptView(tm ? tm.events : battle.events); | |
| const phaseInstr = { | |
| opening: 'Fase OPENING: presenta tu tesis inicial sobre el tema, con tu posición clara.', | |
| crossfire: 'Fase CROSSFIRE: reacciona a la última intervención; pregunta, presiona o responde según tu estrategia.', | |
| rebuttal: 'Fase REBUTTAL: refuta el argumento rival más fuerte en tu contra y refuerza tu posición.', | |
| closing: 'Fase CLOSING: presenta tu conclusión final, tu mejor síntesis para ganar el debate.', | |
| }[phase] || ''; | |
| let teamCtx = ''; | |
| if (battle.mode === 'team' && agent.team) { | |
| const mates = battle.participants.filter(x => x.team === agent.team && x.id !== agent.id).map(x => x.name); | |
| const rivals = battle.participants.filter(x => x.team !== agent.team).map(x => x.name); | |
| teamCtx = `\nEstás en el EQUIPO ${agent.team}${mates.length ? ' junto a ' + mates.join(', ') : ''}. Equipo rival (EQUIPO ${agent.team === 'A' ? 'B' : 'A'}): ${rivals.join(', ')}. Coordina con tu equipo: refuerza y extiende los puntos de tus compañeros, no los contradigas; concentra tus ataques en el equipo rival.`; | |
| } | |
| const memCtx = agentMemory(agent.id); | |
| const rmCtx = battle.rematch ? `\nREVANCHA: ya se disputó una batalla anterior sobre este tema con estos participantes.\n${battle.rematch}\nMantén tu personalidad y estilo, pero AJUSTA tu estrategia: corrige los errores señalados, refuerza lo que funcionó y no repitas literalmente tus intervenciones anteriores.\n` : ''; | |
| const user = `Tema del debate: ${battle.topic}\nRonda ${round} de ${battle.rounds}.\n${phaseInstr}\n${extraInstr || ''}${teamCtx}${rmCtx}${memCtx}\n\n` + | |
| (view ? `Intervenciones hasta ahora:\n${view}\n\n` : 'Aún no hay intervenciones.\n\n') + | |
| `Ahora intervienes tú, ${agent.name}.`; | |
| const div = document.createElement('div'); | |
| div.className = 'msg streaming'; div.style.setProperty('--ac', agent.color); | |
| div.innerHTML = `<div class="mavatar">${agent.emoji}</div> | |
| <div class="mbody"><div class="mhead" style="color:${agent.color}">${esc(agent.name)}${agent.team ? ` <span style="opacity:.75">· Eq. ${agent.team}</span>` : ''} | |
| <span class="ph">${phase} · ronda ${round} · ${strat}</span></div><div class="mtext"></div></div>`; | |
| const log = $('#battle-log'); log.appendChild(div); | |
| const textEl = div.querySelector('.mtext'); | |
| setPill('loading', agent.name + ' escribiendo…'); | |
| const answer = await generate(system, user, CONFIG.maxTokens, partial => { | |
| textEl.textContent = partial; log.scrollTop = log.scrollHeight; | |
| }); | |
| div.classList.remove('streaming'); | |
| if (stopRequested || !answer) { div.remove(); setPill('ready', modelKey ? 'listo' : 'sin modelo'); return null; } | |
| textEl.innerHTML = md(answer); | |
| const ev = { id: 'ev_' + battle.events.length, round, phase, agent: agent.name, agentId: agent.id, content: answer, strategy: strat, ts: new Date().toISOString() }; | |
| if (tm) { ev.match = battle.tournament.currentMatch; tm.events.push(ev); } | |
| battle.events.push(ev); | |
| setPill('ready', 'listo'); | |
| return answer; | |
| } | |
| /* ---- Tournament: eliminatorias con pasos aplanados; la pausa/reanudación por pasos sigue funcionando ---- */ | |
| const TOURN_NAMES = { 4: ['Semifinal 1', 'Semifinal 2', 'Final'], 8: ['Cuartos 1', 'Cuartos 2', 'Cuartos 3', 'Cuartos 4', 'Semifinal 1', 'Semifinal 2', 'Final'] }; | |
| const TOURN_FEEDERS = { 4: [[0, 1]], 8: [[0, 1], [2, 3], [4, 5]] }; | |
| function setupTournament(b) { | |
| const n = b.participants.length; | |
| const names = TOURN_NAMES[n]; | |
| const matches = []; | |
| for (let i = 0; i < n / 2; i++) matches.push({ name: names[i], a: 2 * i, b: 2 * i + 1, winner: null, judged: false, events: [] }); | |
| b.tournament = { size: n, matches, names, currentMatch: 0 }; | |
| // primera ronda completa encolada de entrada; las siguientes rondas se crean al conocerse los ganadores | |
| b.steps = []; | |
| for (let i = 0; i < n / 2; i++) b.steps.push(...tournamentMatchSteps(b, i)); | |
| } | |
| function tournamentMatchSteps(b, mi) { | |
| const m = b.tournament.matches[mi]; | |
| const steps = [{ banner: `🏟️ ${m.name}: ${m.a != null ? b.participants[m.a].name : '?'} vs ${m.b != null ? b.participants[m.b].name : '?'}`, match: mi }]; | |
| const pair = [m.a, m.b]; | |
| pair.forEach(idx => steps.push({ phase: 'opening', round: 1, idx, match: mi })); | |
| for (let r = 1; r <= b.rounds; r++) { | |
| pair.forEach(idx => steps.push({ phase: 'crossfire', round: r, idx, match: mi })); | |
| pair.forEach(idx => steps.push({ phase: 'rebuttal', round: r, idx, match: mi })); | |
| } | |
| pair.forEach(idx => steps.push({ phase: 'closing', round: b.rounds, idx, match: mi })); | |
| steps.push({ judge: mi }); | |
| return steps; | |
| } | |
| function tournamentAdvance(b) { | |
| const t = b.tournament, n = t.size, names = t.names; | |
| const nextIdx = t.matches.length; | |
| if (nextIdx >= names.length) return false; // la final ya existe | |
| const feeders = TOURN_FEEDERS[n][nextIdx - n / 2]; | |
| // solo crear la siguiente eliminatoria cuando sus dos alimentadores ya se jugaron | |
| if (!t.matches[feeders[0]].judged || !t.matches[feeders[1]].judged) return false; | |
| const wA = t.matches[feeders[0]].winner, wB = t.matches[feeders[1]].winner; | |
| const m = { name: names[nextIdx], a: wA, b: wB, winner: null, judged: false, events: [] }; | |
| t.matches.push(m); | |
| if (wA == null || wB == null) { | |
| addSystemMsg(`⚠️ ${m.name} no puede disputarse: falta un ganador fiable de una ronda anterior.`); | |
| m.judged = true; m.skipped = true; | |
| b.steps.push({ judge: nextIdx }); | |
| } else { | |
| b.steps.push(...tournamentMatchSteps(b, nextIdx)); | |
| } | |
| return true; | |
| } | |
| function buildSteps(b) { | |
| const steps = []; | |
| b.participants.forEach((a, i) => steps.push({ phase: 'opening', round: 1, idx: i })); | |
| for (let r = 1; r <= b.rounds; r++) { | |
| b.participants.forEach((a, i) => steps.push({ phase: 'crossfire', round: r, idx: i })); | |
| b.participants.forEach((a, i) => steps.push({ phase: 'rebuttal', round: r, idx: i })); | |
| } | |
| b.participants.forEach((a, i) => steps.push({ phase: 'closing', round: b.rounds, idx: i })); | |
| return steps; | |
| } | |
| const PHASE_BANNER = { | |
| opening: '📣 Fase OPENING — cada agente presenta su tesis', | |
| crossfire: '🔥 Ronda {r}/{R} — CROSSFIRE', | |
| rebuttal: '🛡️ Ronda {r}/{R} — REBUTTAL', | |
| closing: '🏁 Fase CLOSING — conclusiones finales', | |
| }; | |
| const PHASE_TRACK_MAP = { opening: 'opening', crossfire: 'crossfire', rebuttal: 'rebuttal', closing: 'closing' }; | |
| async function runBattle() { | |
| running = true; stopRequested = false; | |
| $('#stop-btn').style.display = 'inline-block'; $('#stop-btn').textContent = '⏸ Pausar'; | |
| try { | |
| if (!battle.steps) battle.steps = buildSteps(battle); | |
| let lastKey = null; | |
| for (let s = battle.resume.step || 0; s < battle.steps.length; s++) { | |
| if (stopRequested) { battle.resume = { step: s }; break; } | |
| const st = battle.steps[s]; | |
| if (st.banner) { addSystemMsg(st.banner); battle.resume = { step: s + 1 }; continue; } | |
| if (st.judge !== undefined) { | |
| await judgeTournamentMatch(st.judge); | |
| if (stopRequested) { battle.resume = { step: s }; break; } | |
| battle.resume = { step: s + 1 }; | |
| continue; | |
| } | |
| const a = battle.participants[st.idx]; | |
| if (!a) { battle.resume = { step: s + 1 }; continue; } | |
| if (battle.tournament && st.match !== undefined) battle.tournament.currentMatch = st.match; | |
| const key = st.phase + ':' + st.round; | |
| if (key !== lastKey) { | |
| renderPhaseTrack(PHASE_TRACK_MAP[st.phase]); | |
| addSystemMsg(PHASE_BANNER[st.phase].replace('{r}', st.round).replace('{R}', battle.rounds)); | |
| lastKey = key; | |
| // Aviso de cambio de estrategia adaptive al entrar en una nueva ronda de crossfire | |
| if (st.phase === 'crossfire' && st.round > 1) { | |
| battle.participants.filter(x => x.strategy === 'adaptive').forEach(x => | |
| addSystemMsg(`🔄 ${x.name} (Adaptive) pasa a estrategia ${strategyOf(x, st.round).toUpperCase()} en la ronda ${st.round}`)); | |
| } | |
| } | |
| renderFighters(a.id); | |
| await agentIntervention(a, st.phase, st.round); | |
| if (stopRequested) { battle.resume = { step: s }; break; } | |
| battle.resume = { step: s + 1 }; | |
| } | |
| renderFighters(null); | |
| if (stopRequested) { | |
| addSystemMsg('⏸ Batalla en pausa. Pulsa START BATTLE para continuar donde se quedó.'); | |
| } else if (battle.mode === 'tournament') { | |
| battle.referee = await runReferee(battle.events, battle.participants); | |
| finalizeTournament(); | |
| } else if (!battle.winner) { | |
| const jr = await runJudging(); | |
| if (jr) { | |
| battle.referee = await runReferee(battle.events, battle.participants); | |
| finalizeBattle(jr.verdict); | |
| } | |
| } | |
| } finally { | |
| running = false; | |
| $('#stop-btn').style.display = 'none'; | |
| } | |
| } | |
| /* ================= REFEREE AI ================= */ | |
| const REFEREE_CATS = [ | |
| ['repeticiones', '🔁 Repeticiones'], | |
| ['evasiones', '💨 Evasiones'], | |
| ['fueraDeTema', '🎯 Fuera de tema'], | |
| ['incoherencias', '🌀 Incoherencias'], | |
| ]; | |
| async function runReferee(events, ps) { | |
| // Referee AI: detecta problemas de conducta argumental. Mismo runtime local: observaciones, no verificación externa ni puntuación. | |
| addSystemMsg('🚩 Referee AI analiza la conducta argumental…'); | |
| const names = ps.map(a => a.name); | |
| const system = 'Eres el Referee AI de un torneo de debate. Detectas SOLO problemas de conducta argumental: repeticiones (el mismo argumento repetido sin añadir nada), evasiones (preguntas u objeciones directas ignoradas), respuestas fuera de tema e incoherencias (contradicciones del mismo participante consigo mismo). Sé estricto y concreto: resume la intervención concreta. No inventes problemas: si no los hay, devuelve listas vacías.'; | |
| const user = `Tema: ${battle.topic}\nParticipantes: ${names.join(', ')}\n\nTranscripción:\n${transcriptView(events)}\n\n` + | |
| 'Responde ÚNICAMENTE con JSON válido, sin markdown: {"repeticiones": [{"agente": "nombre exacto", "detalle": "..."}], "evasiones": [...], "fueraDeTema": [...], "incoherencias": [...]}. Arrays vacíos si no hay observaciones.'; | |
| let parsed = null; | |
| try { | |
| const out = await generate(system, user, CONFIG.judgeTokens); | |
| parsed = extractJSON(out); | |
| if (!parsed) { | |
| const out2 = await generate(system, user + '\n\nIMPORTANTE: responde ÚNICAMENTE con el JSON válido pedido, sin explicaciones, sin markdown y sin texto adicional.', CONFIG.judgeTokens); | |
| parsed = extractJSON(out2); | |
| } | |
| } catch (e) { console.warn('referee', e); } | |
| // validación de esquema: 4 arrays de {agente, detalle}; solo entradas que nombran un participante real | |
| const clean = {}; | |
| let ok = !!parsed && typeof parsed === 'object'; | |
| if (ok) { | |
| for (const [key] of REFEREE_CATS) { | |
| const arr = Array.isArray(parsed[key]) ? parsed[key] : null; | |
| if (!arr) { ok = false; break; } | |
| clean[key] = arr.filter(x => x && typeof x.detalle === 'string' && names.includes(x.agente)) | |
| .map(x => ({ agente: x.agente, detalle: x.detalle.slice(0, 300) })); | |
| } | |
| } | |
| if (!ok) { addSystemMsg('🚩 Referee AI: respuesta no estructurada — observaciones no disponibles'); return { available: false }; } | |
| const total = Object.values(clean).reduce((n, a) => n + a.length, 0); | |
| addSystemMsg(total ? `🚩 Referee AI: ${total} observación(es) de conducta` : '🚩 Referee AI: sin observaciones de conducta'); | |
| return { available: true, ...clean }; | |
| } | |
| function renderReferee() { | |
| const el = $('#referee-card'); | |
| if (!el) return; | |
| const r = battle.referee; | |
| if (!r) { el.innerHTML = ''; return; } | |
| const mockPill = MOCK ? ' <span class="mock-pill">🧪 SIMULADO</span>' : ''; | |
| if (!r.available) { | |
| el.innerHTML = `<h2 class="section">🚩 Referee AI — conducta argumental ${mockPill}</h2><p style="color:var(--muted);font-size:.85rem">Observaciones no disponibles (respuesta no estructurada). No se infiere nada sobre la conducta de los participantes.</p>`; | |
| return; | |
| } | |
| const blocks = REFEREE_CATS.map(([key, label]) => { | |
| const items = r[key] || []; | |
| return `<div class="judge-card"><h4>${label}</h4>${items.length | |
| ? items.map(x => `<span class="jline"><b style="color:var(--text)">${esc(x.agente)}:</b> ${esc(x.detalle)}</span>`).join('') | |
| : '<span class="jline" style="color:var(--muted)">sin observaciones</span>'}</div>`; | |
| }).join(''); | |
| el.innerHTML = `<h2 class="section">🚩 Referee AI — conducta argumental ${mockPill}</h2> | |
| <div class="judge-cards">${blocks}</div> | |
| <p class="note">Observaciones del mismo modelo local (mismo runtime): señalan patrones de conducta argumental; no son verificación externa ni afectan a puntuaciones ni premios.</p>`; | |
| } | |
| /* ================= JUDGES ================= */ | |
| async function runJudging(pl, evs) { | |
| renderPhaseTrack('judging'); | |
| addSystemMsg('⚖️ Los tres jueces evalúan de forma independiente…'); | |
| const ps = pl || battle.participants; | |
| const names = ps.map(a => a.name); | |
| const transcript = transcriptView(evs || battle.events, 12000); | |
| const teamNote = battle.mode === 'team' ? '\nFormato por equipos: EQUIPO A = ' + ps.filter(a => a.team === 'A').map(a => a.name).join(', ') + '; EQUIPO B = ' + ps.filter(a => a.team === 'B').map(a => a.name).join(', ') : ''; | |
| const scores = {}; // agentId -> criterion -> media de jueces | |
| const judgeReports = []; | |
| let heuristicEval = false; // true si alguna puntuación NO viene de JSON estructurado del modelo | |
| ps.forEach(a => scores[a.id] = {}); | |
| for (const j of JUDGES) { | |
| if (stopRequested) return null; | |
| const critList = j.criteria.map(c => WEIGHTS.find(w => w[0] === c)[1]).join('" y "'); | |
| const system = `Eres ${j.name}, juez de un torneo de debate (${j.desc}). Evalúas SOLO los criterios "${critList}". Eres estricto, independiente y no conoces las puntuaciones de otros jueces.`; | |
| const user = `Tema: ${battle.topic}\nParticipantes: ${names.join(', ')}${teamNote}\n\nTranscripción:\n${transcript}\n\n` + | |
| `Puntúa a cada participante de 0 a 10 en cada criterio. Responde SOLO con JSON válido: {"${names[0]}":{"${j.criteria[0]}":N,"${j.criteria[1] || j.criteria[0]}":N},...}`; | |
| let parsed = null; | |
| try { | |
| const out = await generate(system, user, CONFIG.judgeTokens); | |
| parsed = extractJSON(out); | |
| if (!parsed) { | |
| // un reintento con instrucción más estricta; si sigue fallando, queda como "no evaluable" | |
| const out2 = await generate(system, user + '\n\nIMPORTANTE: responde ÚNICAMENTE con el JSON válido pedido, sin explicaciones, sin markdown y sin texto adicional.', CONFIG.judgeTokens); | |
| parsed = extractJSON(out2); | |
| } | |
| } catch (e) { console.warn(j.name, e); } | |
| // fallback heurístico si el JSON no es parseable | |
| ps.forEach(a => { | |
| j.criteria.forEach(c => { | |
| let v = parsed?.[a.name]?.[c]; | |
| if (typeof v !== 'number' || isNaN(v)) { | |
| heuristicEval = true; | |
| const mine = (evs || battle.events).filter(e => e.agentId === a.id); | |
| v = Math.min(10, 4 + mine.length + (mine.reduce((s, e) => s + e.content.length, 0) / 600)); | |
| v = Math.round(v * 10) / 10; | |
| } | |
| v = Math.max(0, Math.min(10, v)); | |
| const cur = scores[a.id][c]; | |
| scores[a.id][c] = cur === undefined ? v : Math.round(((cur + v) / 2) * 10) / 10; | |
| }); | |
| }); | |
| judgeReports.push({ judge: j.name, emoji: j.emoji, parsed, fallback: !parsed }); | |
| addSystemMsg(`${j.emoji} ${j.name} ha entregado su evaluación`); | |
| } | |
| // HEAD JUDGE | |
| let verdict = null; | |
| try { | |
| const totals = ps.map(a => ({ name: a.name, total: totalScore(a.id, scores) })); | |
| const system = 'Eres el Head Judge de un torneo de debate. Consolidas las evaluaciones de tres jueces y produces el veredicto final, con justificación breve y concreta.'; | |
| const user = `Tema: ${battle.topic}\nPuntuaciones ponderadas totales: ${totals.map(t => `${t.name}: ${t.total}`).join(', ')}\n\n` + | |
| `Transcripción (resumen):\n${transcript.slice(0, 6000)}\n\n` + | |
| `Responde SOLO con JSON válido: {"winner":"<nombre exacto del ganador o Empate>","justification":"2-4 frases","decisive":["argumento decisivo 1","2"],"mistakes":["error detectado 1","2"],"best":["mejor intervención 1"]}`; | |
| const out = await generate(system, user, CONFIG.judgeTokens + 50); | |
| verdict = extractJSON(out); | |
| if (!verdict) { | |
| const out2 = await generate(system, user + '\n\nIMPORTANTE: responde ÚNICAMENTE con el JSON válido pedido, sin explicaciones, sin markdown y sin texto adicional.', CONFIG.judgeTokens + 50); | |
| verdict = extractJSON(out2); | |
| } | |
| } catch (e) { console.warn('head judge', e); } | |
| // compat: modos no torneo mantienen el estado en battle | |
| battle.scores = scores; battle.judgeReports = judgeReports; battle.heuristicEval = heuristicEval; | |
| return { scores, judgeReports, verdict, heuristic: heuristicEval }; | |
| } | |
| function teamTotals() { | |
| if (!battle || battle.mode !== 'team') return null; | |
| const avg = t => { const ms = battle.participants.filter(a => a.team === t); return ms.length ? Math.round(ms.reduce((s, a) => s + totalScore(a.id), 0) / ms.length * 100) / 100 : 0; }; | |
| return { A: avg('A'), B: avg('B') }; | |
| } | |
| async function judgeTournamentMatch(mi) { | |
| const m = battle.tournament.matches[mi]; | |
| if (m.judged || m.skipped) return; | |
| const pair = [battle.participants[m.a], battle.participants[m.b]]; | |
| const jr = await runJudging(pair, m.events); | |
| if (!jr) return; // pausa durante el juicio: se reintenta al reanudar | |
| m.judged = true; m.scores = jr.scores; m.judgeReports = jr.judgeReports; m.verdict = jr.verdict; m.heuristic = jr.heuristic; | |
| const tA = totalScore(pair[0].id, jr.scores), tB = totalScore(pair[1].id, jr.scores); | |
| m.totalA = tA; m.totalB = tB; | |
| let w = null; | |
| if (!jr.heuristic) { | |
| if (jr.verdict && jr.verdict.winner && jr.verdict.winner !== 'Empate') w = pair.find(a => a.name.toLowerCase() === String(jr.verdict.winner).toLowerCase()) || null; | |
| if (!w && tA !== tB) w = tA > tB ? pair[0] : pair[1]; | |
| } | |
| m.winner = w ? battle.participants.indexOf(w) : null; | |
| addSystemMsg(w ? `✅ ${m.name}: avanza ${w.name} (${Math.max(tA, tB)} vs ${Math.min(tA, tB)})` : `⚠️ ${m.name}: sin ganador fiable (evaluación no disponible)`); | |
| if (!jr.heuristic && !MOCK) { | |
| pair.forEach(a => { | |
| const st = stats[a.id] = stats[a.id] || { wins: 0, losses: 0, draws: 0, scoreSum: 0, matches: 0, awards: {} }; | |
| st.matches++; st.scoreSum += totalScore(a.id, jr.scores); | |
| if (!w) st.draws++; else if (a.id === w.id) st.wins++; else st.losses++; | |
| }); | |
| persistAll(); | |
| } | |
| tournamentAdvance(battle); | |
| } | |
| function finalizeTournament() { | |
| renderPhaseTrack('verdict'); | |
| const t = battle.tournament; | |
| const finalM = t.matches[t.matches.length - 1]; | |
| const champ = finalM && finalM.winner != null ? battle.participants[finalM.winner] : null; | |
| battle.winner = champ ? champ.id : null; | |
| battle.verdict = finalM ? finalM.verdict : null; | |
| if (champ && !battle.heuristicEval && !MOCK) { | |
| const st = stats[champ.id] = stats[champ.id] || { wins: 0, losses: 0, draws: 0, scoreSum: 0, matches: 0, awards: {} }; | |
| st.awards.tournament_champion = (st.awards.tournament_champion || 0) + 1; | |
| } | |
| battle.heuristicEval = t.matches.some(m => m.heuristic || m.skipped); | |
| battle.finishedAt = new Date().toISOString(); | |
| matches.unshift({ id: battle.id, topic: battle.topic, mode: battle.mode, rounds: battle.rounds, participants: battle.participants.map(a => a.id), | |
| events: battle.events, winner: battle.winner, referee: battle.referee || undefined, heuristic: battle.heuristicEval, simulated: MOCK || undefined, engine: MOCK ? 'simulated' : 'wllama', | |
| tournament: t.matches.map(m => ({ name: m.name, a: m.a, b: m.b, winner: m.winner, totalA: m.totalA, totalB: m.totalB, heuristic: !!m.heuristic, skipped: !!m.skipped })), | |
| date: battle.finishedAt }); | |
| if (matches.length > 50) matches.length = 50; | |
| persistAll(); | |
| renderTournamentResults(champ); | |
| renderReferee(); | |
| showView('results'); | |
| } | |
| function totalScore(agentId, scores) { | |
| const s = (scores || battle.scores || {})[agentId] || {}; | |
| let t = 0; | |
| WEIGHTS.forEach(([k, , w]) => { t += (s[k] ?? 5) * w / 100; }); | |
| return Math.round(t * 100) / 100; | |
| } | |
| function finalizeBattle(verdict) { | |
| renderPhaseTrack('verdict'); | |
| const ps = battle.participants; | |
| const totals = ps.map(a => ({ a, total: totalScore(a.id) })).sort((x, y) => y.total - x.total); | |
| let winner = null, winnerTeam = null; | |
| if (battle.mode === 'team') { | |
| const tt = teamTotals(); | |
| if (verdict && verdict.winner && verdict.winner !== 'Empate') { | |
| const w = String(verdict.winner).toLowerCase(); | |
| winnerTeam = w.includes('equipo a') || w === 'a' ? 'A' : (w.includes('equipo b') || w === 'b' ? 'B' : null); | |
| } | |
| if (!winnerTeam && tt && tt.A !== tt.B) winnerTeam = tt.A > tt.B ? 'A' : 'B'; | |
| if (battle.heuristicEval) winnerTeam = null; // sin ganador declarado cuando la evaluación no es fiable | |
| } else { | |
| if (verdict && verdict.winner && verdict.winner !== 'Empate') { | |
| winner = ps.find(a => a.name.toLowerCase() === String(verdict.winner).toLowerCase()) || null; | |
| } | |
| if (!winner && totals.length >= 2 && totals[0].total > totals[1].total) winner = totals[0].a; | |
| if (battle.heuristicEval) winner = null; // sin ganador declarado cuando la evaluación no es fiable | |
| } | |
| battle.winner = winner ? winner.id : null; | |
| battle.winnerTeam = winnerTeam; | |
| battle.verdict = verdict; | |
| battle.finishedAt = new Date().toISOString(); | |
| // calidad del veredicto: esquema + consistencia; sin ella no cuenta para stats/logros aunque la ruta fuese real | |
| const qualityOK = !battle.heuristicEval && !MOCK && evaluationQualityOK(battle.scores, verdict, ps, battle.mode, winner); | |
| battle.evalInconsistent = !battle.heuristicEval && !MOCK && !qualityOK; | |
| if (battle.evalInconsistent) { winner = null; battle.winner = null; battle.winnerTeam = null; } | |
| // stats: solo cuentan batallas con evaluación real Y consistente del modelo (ni heurísticas ni simuladas ni inconsistentes) | |
| if (qualityOK) { | |
| ps.forEach(a => { | |
| const st = stats[a.id] = stats[a.id] || { wins: 0, losses: 0, draws: 0, scoreSum: 0, matches: 0, awards: {} }; | |
| st.matches++; | |
| st.scoreSum += totalScore(a.id); | |
| if (battle.mode === 'team') { | |
| if (!winnerTeam) st.draws++; | |
| else if (a.team === winnerTeam) st.wins++; | |
| else st.losses++; | |
| } else if (!winner) st.draws++; | |
| else if (a.id === winner.id) st.wins++; | |
| else st.losses++; | |
| }); | |
| applyAwardsAndMilestones(ps, winner, winnerTeam); | |
| } | |
| // guardar batalla (Arena Memory) | |
| matches.unshift({ id: battle.id, topic: battle.topic, mode: battle.mode, rounds: battle.rounds, participants: ps.map(a => a.id), teams: battle.mode === 'team' ? Object.fromEntries(ps.map(a => [a.id, a.team])) : undefined, events: battle.events, scores: battle.scores, winner: battle.winner, winnerTeam, verdict, referee: battle.referee || undefined, heuristic: !!battle.heuristicEval, inconsistent: !!battle.evalInconsistent || undefined, simulated: MOCK || undefined, engine: MOCK ? 'simulated' : 'wllama', date: battle.finishedAt }); | |
| if (matches.length > 50) matches.length = 50; | |
| persistAll(); | |
| renderResults(totals, winner, verdict); | |
| renderReferee(); | |
| showView('results'); | |
| } | |
| /* ================= RESULTS ================= */ | |
| function renderJudgeParsed(parsed) { | |
| // render legible de la evaluación estructurada de un juez; nunca JSON crudo | |
| try { | |
| return Object.entries(parsed).map(([k, v]) => { | |
| if (v && typeof v === 'object' && !Array.isArray(v)) { | |
| const parts = Object.entries(v).map(([ck, cv]) => `${ck}: ${typeof cv === 'object' ? JSON.stringify(cv) : cv}`); | |
| return `<span class="jline"><b style="color:var(--text)">${esc(k)}</b> — ${esc(parts.join(' · '))}</span>`; | |
| } | |
| return `<span class="jline"><b style="color:var(--text)">${esc(k)}:</b> ${esc(typeof v === 'object' && v !== null ? JSON.stringify(v) : String(v))}</span>`; | |
| }).join(''); | |
| } catch { return 'Evaluación estructurada (detalle completo en la exportación JSON).'; } | |
| } | |
| function mockNoteHtml() { | |
| return MOCK ? '<p id="mock-note" style="margin:6px 0 10px"><span class="mock-pill">🧪 SIMULADO — pruebas sin modelo real: notas, veredictos y premios no son una evaluación real</span></p>' : ''; | |
| } | |
| function renderTournamentResults(champ) { | |
| const t = battle.tournament; | |
| const heur = battle.heuristicEval; | |
| $('#verdict-hero').innerHTML = ` | |
| <div class="crown">${champ ? '🏆' : '⚠️'}</div> | |
| <h1 style="font-size:1.8rem;font-weight:900">${champ ? `<span style="color:${champ.color}">${champ.emoji} ${esc(champ.name)}</span> — CAMPEÓN DEL TORNEO` : '⚠️ Torneo sin campeón fiable'}</h1> | |
| <p style="color:var(--muted);margin-top:8px;font-size:.9rem">${esc(battle.topic)}</p> | |
| ${heur ? `<p style="max-width:660px;margin:12px auto 0;font-size:.88rem;line-height:1.6;color:#f59e0b">Alguna eliminatoria no tuvo evaluación estructurada del modelo. Las rondas sin evaluación fiable no cuentan para el Hall of Fame ni otorgan premios.</p>` : ''} | |
| ${MOCK ? '<p style="margin-top:10px"><span class="mock-pill">🧪 Resultado SIMULADO (pruebas, sin modelo real)</span></p>' : ''}`; | |
| document.querySelector('#mech-metrics')?.remove(); | |
| document.querySelector('#mock-note')?.remove(); | |
| if (MOCK) $('#score-table').parentElement.insertAdjacentHTML('beforebegin', mockNoteHtml()); | |
| $('#score-table').innerHTML = ` | |
| <tr><th>Eliminatoria</th><th>Enfrentamiento</th><th>Resultado</th></tr> | |
| ${t.matches.map(m => { | |
| const an = m.a != null ? battle.participants[m.a].name : '?', bn = m.b != null ? battle.participants[m.b].name : '?'; | |
| const res = m.skipped ? '<span style="color:var(--muted)">no disputada</span>' | |
| : m.heuristic ? '<span style="color:#f59e0b">sin ganador fiable</span>' | |
| : m.winner != null ? `${m.totalA} — ${m.totalB} → <b>${esc(battle.participants[m.winner].name)}</b>` : '<span style="color:var(--muted)">empate</span>'; | |
| return `<tr><td>${esc(m.name)}</td><td>${esc(an)} vs ${esc(bn)}</td><td>${res}</td></tr>`; | |
| }).join('')}`; | |
| $('#judge-cards').innerHTML = t.matches.filter(m => m.judgeReports).map(m => | |
| `<div class="judge-card"><h4>⚖️ ${esc(m.name)}</h4><p>${m.heuristic ? 'Evaluación heurística (respuesta no estructurada).' : (m.verdict?.justification ? esc(m.verdict.justification) : 'Veredicto por puntuación ponderada.')}</p></div>`).join(''); | |
| $('#awards').innerHTML = (MOCK ? '<span class="mock-pill">🧪 SIMULADO</span>' : '') + (champ && !heur | |
| ? `<span class="award">🏆 ${esc(champ.name)} — Campeón del Torneo</span>` | |
| : '<span class="award">⚠️ Sin premios: evaluación no disponible en alguna ronda</span>'); | |
| } | |
| function renderResults(totals, winner, verdict) { | |
| const heur = !!battle.heuristicEval; | |
| $('#verdict-hero').innerHTML = ` | |
| <div class="crown">${heur || battle.evalInconsistent ? '⚠️' : '👑'}</div> | |
| <h1 style="font-size:1.8rem;font-weight:900">${heur ? '⚠️ Evaluación no fiable' : battle.evalInconsistent ? '⚠️ Veredicto inconsistente (no superó la validación)' : (battle.mode === 'team' ? (battle.winnerTeam ? `🛡️ EQUIPO ${battle.winnerTeam} gana la batalla` : '🤝 EMPATE ENTRE EQUIPOS') : (winner ? `<span style="color:${winner.color}">${winner.emoji} ${esc(winner.name)}</span> gana la batalla` : '🤝 EMPATE'))}</h1> | |
| ${!heur && battle.mode === 'team' ? `<p style="margin-top:6px;font-size:.95rem">Equipo A <b>${teamTotals().A}</b> — Equipo B <b>${teamTotals().B}</b></p>` : ''} | |
| <p style="color:var(--muted);margin-top:8px;font-size:.9rem">${esc(battle.topic)}</p> | |
| ${heur ? `<p style="max-width:660px;margin:12px auto 0;font-size:.88rem;line-height:1.6;color:#f59e0b">El modelo no produjo evaluaciones estructuradas: no hay puntuaciones fiables para esta batalla. Solo se muestran métricas mecánicas (número y longitud de intervenciones), que NO son una valoración del debate. Esta batalla no cuenta para el Hall of Fame ni otorga premios.</p>` : ''} | |
| ${battle.evalInconsistent ? `<p style="max-width:660px;margin:12px auto 0;font-size:.88rem;line-height:1.6;color:#f59e0b">El veredicto del modelo no superó la validación de esquema/consistencia (ganador declarado ≠ puntuaciones o notas fuera de rango): esta batalla no cuenta para el Hall of Fame ni otorga premios.</p>` : ''} | |
| ${!heur && verdict?.justification ? `<p style="max-width:640px;margin:12px auto 0;font-size:.9rem;line-height:1.6">${esc(verdict.justification)}</p>` : ''} | |
| ${MOCK ? '<p style="margin-top:10px"><span class="mock-pill">🧪 Resultado SIMULADO (pruebas, sin modelo real)</span></p>' : ''}`; | |
| document.querySelector('#mech-metrics')?.remove(); | |
| document.querySelector('#mock-note')?.remove(); | |
| if (MOCK) $('#score-table').parentElement.insertAdjacentHTML('beforebegin', mockNoteHtml()); | |
| if (heur) { | |
| // sin evaluación real no se presentan notas por criterio: solo "no disponible" + métricas mecánicas separadas | |
| $('#score-table').innerHTML = ` | |
| <tr><th>Criterio (peso)</th>${totals.map(t => `<th style="color:${t.a.color}">${t.a.emoji} ${esc(t.a.name)}</th>`).join('')}</tr> | |
| ${WEIGHTS.map(([k, label, w]) => `<tr><td>${label} <span style="color:var(--muted)">(${w}%)</span></td>${totals.map(() => `<td style="color:var(--muted)">no disponible</td>`).join('')}</tr>`).join('')} | |
| <tr style="font-weight:800"><td>TOTAL</td>${totals.map(() => `<td style="color:var(--muted)">—</td>`).join('')}</tr>`; | |
| const mech = totals.map(t => { | |
| const mine = battle.events.filter(e => e.agentId === t.a.id); | |
| return `${t.a.emoji} ${esc(t.a.name)}: ${mine.length} intervenciones · ${mine.reduce((s, e) => s + e.content.length, 0)} caracteres`; | |
| }).join(' · '); | |
| $('#score-table').insertAdjacentHTML('afterend', `<p id="mech-metrics" style="color:var(--muted);font-size:.8rem;margin-top:8px">Métricas mecánicas (NO son una evaluación del debate): ${mech}</p>`); | |
| } else { | |
| $('#score-table').innerHTML = ` | |
| <tr><th>Criterio (peso)</th>${totals.map(t => `<th style="color:${t.a.color}">${t.a.emoji} ${esc(t.a.name)}</th>`).join('')}</tr> | |
| ${WEIGHTS.map(([k, label, w]) => `<tr><td>${label} <span style="color:var(--muted)">(${w}%)</span></td>${totals.map(t => `<td>${battle.scores[t.a.id][k] ?? '—'}</td>`).join('')}</tr>`).join('')} | |
| <tr style="font-weight:800"><td>TOTAL</td>${totals.map(t => `<td style="color:${t.a.color}">${t.total}</td>`).join('')}</tr>`; | |
| } | |
| $('#judge-cards').innerHTML = battle.judgeReports.map(r => ` | |
| <div class="judge-card"><h4>${r.emoji} ${r.judge}</h4><p>${r.parsed ? renderJudgeParsed(r.parsed) : 'Evaluación heurística (respuesta no estructurada).'}</p></div>`).join('') + | |
| `<div class="judge-card"><h4>👑 Head Judge</h4><p>${verdict?.justification ? esc(verdict.justification) : 'Veredicto por puntuación ponderada.'}</p> | |
| ${verdict?.decisive ? `<p style="margin-top:6px"><b>Decisivo:</b> ${verdict.decisive.map(esc).join(' · ')}</p>` : ''} | |
| ${verdict?.mistakes ? `<p style="margin-top:6px"><b>Errores:</b> ${verdict.mistakes.map(esc).join(' · ')}</p>` : ''}</div>`; | |
| // awards por criterio: solo con evaluación real; la heurística no otorga méritos | |
| if (heur || battle.evalInconsistent) { | |
| $('#awards').innerHTML = '<span class="award">⚠️ Sin premios: evaluación no fiable o inconsistente</span>'; | |
| } else { | |
| const awards = [`<span class="award">🏆 ${battle.mode === 'team' ? (battle.winnerTeam ? 'Equipo ' + battle.winnerTeam + ' — Master Debaters' : 'Empate honorable') : (winner ? esc(winner.name) + ' — Master Debater' : 'Empate honorable')}</span>`]; | |
| [['logica', '🧠 Logic Master'], ['evidencia', '📚 Evidence Champion'], ['refutacion', '🛡️ Best Refutation']].forEach(([k, label]) => { | |
| const best = [...battle.participants].sort((a, b) => (battle.scores[b.id][k] ?? 0) - (battle.scores[a.id][k] ?? 0))[0]; | |
| if (best) awards.push(`<span class="award">${label}: ${esc(best.name)}</span>`); | |
| }); | |
| $('#awards').innerHTML = (MOCK ? '<span class="mock-pill">🧪 SIMULADO</span>' : '') + awards.join(''); | |
| } | |
| } | |
| function buildRematchText(prev) { | |
| // Revancha estratégica: solo con veredicto estructurado real; sin evaluación fiable no hay guía que dar | |
| if (!prev || !prev.verdict || prev.heuristicEval) return null; | |
| const parts = []; | |
| const wName = prev.mode === 'team' ? (prev.winnerTeam ? 'ganó el Equipo ' + prev.winnerTeam : 'hubo empate') | |
| : (prev.winner ? 'ganó ' + (prev.participants.find(a => a.id === prev.winner)?.name || prev.winner) : 'hubo empate'); | |
| parts.push('Resultado anterior: ' + wName + '.'); | |
| if (prev.verdict.justification) parts.push('Justificación del Head Judge: ' + prev.verdict.justification); | |
| if (prev.verdict.decisive?.length) parts.push('Puntos decisivos: ' + prev.verdict.decisive.join(' · ')); | |
| if (prev.verdict.mistakes?.length) parts.push('Errores señalados: ' + prev.verdict.mistakes.join(' · ')); | |
| return parts.join('\n'); | |
| } | |
| $('#rematch-btn').addEventListener('click', () => { | |
| const rm = buildRematchText(battle); | |
| startBattle(battle.topic, rm); | |
| }); | |
| $('#results-lobby').addEventListener('click', () => showView('lobby')); | |
| /* ================= EXPORT ================= */ | |
| function battleExportData() { | |
| const heur = !!battle.heuristicEval; | |
| const rel = !heur && !MOCK; // fiable solo con evaluación estructurada del modelo real | |
| const d = { | |
| app: 'AI Debate Arena V2', | |
| exportedAt: new Date().toISOString(), | |
| topic: battle.topic, | |
| mode: battle.mode, | |
| rounds: battle.rounds, | |
| date: battle.finishedAt || null, | |
| engine: MOCK ? 'simulado (pruebas, sin modelo real)' : 'wllama (CPU/WASM, local)', | |
| participants: battle.participants.map(a => ({ id: a.id, name: a.name, emoji: a.emoji, team: a.team || null })), | |
| evaluationReliable: rel, | |
| simulated: MOCK || undefined, | |
| winner: rel ? (battle.winner || null) : null, | |
| winnerTeam: rel ? (battle.winnerTeam || null) : null, | |
| verdict: rel ? (battle.verdict || null) : null, | |
| scores: rel ? (battle.scores || null) : null, | |
| events: battle.events.map(e => ({ round: e.round, phase: e.phase, match: e.match ?? null, agent: e.agent, content: e.content, ts: e.ts })), | |
| referee: battle.referee || null, | |
| disclaimer: 'Los agentes, jueces y referee comparten un único modelo local con contextos independientes: no son modelos distintos ni verificación factual externa.' | |
| }; | |
| if (battle.mode === 'tournament' && battle.tournament) { | |
| d.tournament = battle.tournament.matches.map(m => ({ | |
| name: m.name, | |
| a: m.a != null ? battle.participants[m.a].name : null, | |
| b: m.b != null ? battle.participants[m.b].name : null, | |
| winner: m.winner != null ? battle.participants[m.winner].name : null, | |
| totalA: m.totalA ?? null, | |
| totalB: m.totalB ?? null, | |
| heuristic: !!m.heuristic, | |
| skipped: !!m.skipped | |
| })); | |
| } | |
| return d; | |
| } | |
| function battleMarkdown() { | |
| const d = battleExportData(); | |
| const L = ['# AI Debate Arena — Transcripción', '']; | |
| L.push(`- **Tema:** ${d.topic}`); | |
| L.push(`- **Modo:** ${d.mode} · ${d.rounds} rondas`); | |
| L.push(`- **Fecha:** ${d.date || '—'}`); | |
| L.push(`- **Motor:** ${d.engine}`); | |
| L.push(`- **Participantes:** ${d.participants.map(p => `${p.emoji} ${p.name}${p.team ? ' (Equipo ' + p.team + ')' : ''}`).join(' · ')}`); | |
| L.push('', '## Debate', ''); | |
| d.events.forEach(e => { | |
| L.push(`**[Ronda ${e.round} · ${e.phase}${e.match != null ? ' · partido ' + (e.match + 1) : ''}] ${e.agent}:**`, '', e.content, ''); | |
| }); | |
| if (d.tournament) { | |
| L.push('## Eliminatorias', ''); | |
| d.tournament.forEach(m => { | |
| const res = m.skipped ? 'no disputada' : m.heuristic ? 'sin ganador fiable (evaluación no estructurada)' : m.winner ? `${m.totalA} — ${m.totalB} → ${m.winner}` : 'empate'; | |
| L.push(`- **${m.name}:** ${m.a || '?'} vs ${m.b || '?'} — ${res}`); | |
| }); | |
| L.push(''); | |
| } | |
| L.push('## Evaluación', ''); | |
| if (!d.evaluationReliable) { | |
| L.push('⚠️ Evaluación no fiable (simulada o heurística): no hay puntuaciones ni ganador. Esta batalla no cuenta para el Hall of Fame ni otorga premios.'); | |
| } else { | |
| if (d.scores) { | |
| const ids = Object.keys(d.scores); | |
| L.push('| Criterio | ' + ids.map(id => d.participants.find(p => p.id === id)?.name || id).join(' | ') + ' |'); | |
| L.push('|' + '---|'.repeat(ids.length + 1)); | |
| WEIGHTS.forEach(([k, label, w]) => { | |
| L.push(`| ${label} (${w}%) | ` + ids.map(id => d.scores[id][k] ?? '—').join(' | ') + ' |'); | |
| }); | |
| L.push(''); | |
| } | |
| if (d.verdict?.justification) L.push('**Veredicto:** ' + d.verdict.justification, ''); | |
| const w = d.winnerTeam ? 'Equipo ' + d.winnerTeam : (d.participants.find(p => p.id === d.winner)?.name || null); | |
| L.push('**Resultado:** ' + (w ? w + ' gana' : 'Empate')); | |
| } | |
| if (d.referee) { | |
| L.push('', '## Referee AI — conducta argumental', ''); | |
| if (!d.referee.available) L.push('Observaciones no disponibles (respuesta no estructurada).'); | |
| else REFEREE_CATS.forEach(([key, label]) => { | |
| L.push(`**${label}:**`); | |
| const items = d.referee[key] || []; | |
| if (items.length) items.forEach(x => L.push(`- ${x.agente}: ${x.detalle}`)); | |
| else L.push('- sin observaciones'); | |
| }); | |
| } | |
| L.push('', '---', '_' + d.disclaimer + '_'); | |
| return L.join('\n'); | |
| } | |
| function downloadFile(name, text, type) { | |
| const blob = new Blob([text], { type }); | |
| const a = document.createElement('a'); | |
| a.href = URL.createObjectURL(blob); | |
| a.download = name; | |
| a.click(); | |
| setTimeout(() => URL.revokeObjectURL(a.href), 5000); | |
| } | |
| $('#export-md').addEventListener('click', () => downloadFile(`arena-${battle.id}.md`, battleMarkdown(), 'text/markdown')); | |
| $('#export-json').addEventListener('click', () => downloadFile(`arena-${battle.id}.json`, JSON.stringify(battleExportData(), null, 2), 'application/json')); | |
| /* ================= BATTLE CONTROL ================= */ | |
| function startBattle(topic, rematchText) { | |
| if (replayTimer) { clearInterval(replayTimer); replayTimer = null; } | |
| let ps = participants(); | |
| const min = mode === 'duel' ? 2 : mode === 'team' ? 4 : 3; | |
| if (ps.length < min) { alert(`Selecciona al menos ${min} agentes en la pestaña Agentes.`); showView('roster'); return; } | |
| if (mode === 'team' && ps.length % 2) { alert('Team Battle necesita un número par de agentes (4, 6 u 8): equipos parejos.'); showView('roster'); return; } | |
| if (mode === 'tournament' && ps.length !== 4 && ps.length !== 8) { alert('Survival Tournament necesita exactamente 4 u 8 agentes.'); showView('roster'); return; } | |
| if (!topic) { $('#topic').focus(); return; } | |
| if (mode === 'team') { | |
| // asignación alterna por orden de selección y turnos entrelazados A1,B1,A2,B2… | |
| const teamA = [], teamB = []; | |
| ps.forEach((a, i) => (i % 2 ? teamB : teamA).push({ ...a, team: i % 2 ? 'B' : 'A' })); | |
| ps = []; | |
| for (let i = 0; i < teamA.length; i++) { ps.push(teamA[i], teamB[i]); } | |
| } | |
| battle = { | |
| id: 'match_' + Date.now(), topic, mode, rounds: parseInt($('#rounds').value) || 2, | |
| participants: ps, events: [], scores: {}, judgeReports: [], | |
| resume: { step: 0 }, steps: null, winner: null, winnerTeam: null, verdict: null, | |
| tournament: null, createdAt: new Date().toISOString(), | |
| rematch: rematchText || null, | |
| }; | |
| if (mode === 'tournament') setupTournament(battle); | |
| $('#battle-log').innerHTML = ''; | |
| $('#challenge-input').value = ''; | |
| showView('battle'); | |
| renderPhaseTrack('opening'); | |
| renderFighters(null); | |
| addSystemMsg(`🏟️ ${MODES.find(m => m.id === mode).name} — Tema: «${topic}» — ${battle.rounds} ronda(s)`); | |
| if (battle.rematch) addSystemMsg('🔁 REVANCHA ESTRATÉGICA — los agentes conservan su personalidad y reciben el veredicto de la batalla anterior como guía para ajustar su estrategia (sugerencia al modelo, no cambio verificado).'); | |
| ensureModel().then(() => runBattle()).catch(() => showView('lobby')); | |
| } | |
| $('#start-btn').addEventListener('click', () => { | |
| if (running) return; | |
| if (battle && stopRequested && battle.events.length && !battle.winner) { | |
| // reanudar batalla pausada | |
| stopRequested = false; | |
| showView('battle'); | |
| ensureModel().then(() => runBattle()).catch(() => {}); | |
| return; | |
| } | |
| startBattle($('#topic').value.trim()); | |
| }); | |
| $('#topic').addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); $('#start-btn').click(); } }); | |
| $('#stop-btn').addEventListener('click', () => { stopRequested = true; $('#stop-btn').textContent = 'Pausando…'; }); | |
| $('#exit-battle').addEventListener('click', () => { | |
| if (running) stopRequested = true; | |
| if (replayTimer) { clearInterval(replayTimer); replayTimer = null; } | |
| showView('lobby'); | |
| }); | |
| function userChallenge() { | |
| const text = $('#challenge-input').value.trim(); | |
| if (!text || !battle) return; | |
| $('#challenge-input').value = ''; | |
| let curStep = null; | |
| if (battle.steps) { | |
| for (let k = Math.min(battle.resume.step || 0, battle.steps.length - 1); k >= 0; k--) { | |
| if (battle.steps[k].phase) { curStep = battle.steps[k]; break; } | |
| } | |
| } | |
| addEvent({ name: 'Tú', emoji: '⚡', color: '#8b5cf6' }, 'challenge', text, curStep ? curStep.round : 1, true); | |
| } | |
| $('#challenge-btn').addEventListener('click', userChallenge); | |
| $('#challenge-input').addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); userChallenge(); } }); | |
| /* ================= HALL OF FAME ================= */ | |
| function renderHall() { | |
| const rows = agents.map(a => { | |
| const st = stats[a.id] || { wins: 0, losses: 0, draws: 0, scoreSum: 0, matches: 0 }; | |
| const avg = st.matches ? st.scoreSum / st.matches : 0; | |
| return { a, st, avg, winrate: st.matches ? Math.round(100 * st.wins / st.matches) : 0 }; | |
| }).sort((x, y) => (y.st.wins - x.st.wins) || (y.avg - x.avg)); | |
| $('#hall-table').innerHTML = ` | |
| <tr><th>Agente</th><th>Victorias</th><th>Media</th><th>Winrate</th><th>Batallas</th><th>Logros</th><th>Confianza</th></tr> | |
| ${rows.map(({ a, st, avg, winrate }) => { | |
| const conf = st.matches < 3 ? '<span class="conf low">baja</span>' : st.matches < 10 ? '<span class="conf mid">media</span>' : '<span class="conf high">alta</span>'; | |
| const ach = Object.entries(st.awards || {}).map(([k, n]) => ACHIEVEMENTS[k] ? `<span title="${ACHIEVEMENTS[k].name}" style="cursor:help">${ACHIEVEMENTS[k].emoji}${n > 1 ? '×' + n : ''}</span>` : '').join(' ') || '<span style="color:var(--muted)">—</span>'; | |
| return `<tr><td><span style="color:${a.color}">${a.emoji} ${esc(a.name)}</span></td><td>${st.wins}</td><td>${st.matches ? avg.toFixed(1) : '—'}</td><td>${st.matches ? winrate + '%' : '—'}</td><td>${st.matches}</td><td>${ach}</td><td>${conf}</td></tr>`; | |
| }).join('')}`; | |
| $('#match-list').innerHTML = matches.length ? matches.map(m => { | |
| const w = m.winner ? agentById(m.winner) : null; | |
| return `<div class="match-item" data-match="${m.id}"> | |
| <div class="mtopic">${esc(m.topic)}</div> | |
| <div class="mmeta">${MODES.find(x => x.id === m.mode)?.name || m.mode} · ${m.events.length} intervenciones · ${new Date(m.date).toLocaleString('es')}</div> | |
| <div class="mmeta">${m.simulated ? '🧪 Resultado simulado (pruebas, sin modelo real)' : m.heuristic ? '⚠️ sin evaluación fiable (no cuenta para el Hall)' : m.inconsistent ? '⚠️ veredicto inconsistente (no cuenta para el Hall)' : (m.mode === 'tournament' ? '🏆 Campeón: ' + (w ? esc(w.name) : '—') : m.winnerTeam ? '🛡️ Equipo ' + m.winnerTeam : '👑 ' + (w ? esc(w.name) : 'Empate'))}</div> | |
| <button class="btn ghost small" data-replay="${m.id}">▶ Replay</button> | |
| </div>`; | |
| }).join('') : '<p style="color:var(--muted);font-size:.85rem">Aún no hay batallas guardadas.</p>'; | |
| document.querySelectorAll('[data-replay]').forEach(b => b.addEventListener('click', e => { e.stopPropagation(); startReplay(b.dataset.replay); })); | |
| } | |
| /* ================= REPLAY ================= */ | |
| async function startReplay(matchId) { | |
| const m = matches.find(x => x.id === matchId); | |
| if (!m) return; | |
| const ps = m.participants.map(agentById).filter(Boolean).map(a => m.teams && m.teams[a.id] ? { ...a, team: m.teams[a.id] } : a); | |
| battle = { id: m.id, topic: m.topic, mode: m.mode, rounds: m.rounds, participants: ps, events: [], resume: { step: 0 } }; | |
| showView('battle'); | |
| renderPhaseTrack('verdict'); | |
| renderFighters(null); | |
| $('#battle-log').innerHTML = ''; | |
| addSystemMsg(`▶ REPLAY — ${m.topic} (${new Date(m.date).toLocaleString('es')})${m.simulated ? ' · 🧪 SIMULADO (pruebas, sin modelo real)' : ''}`); | |
| if (replayTimer) clearInterval(replayTimer); | |
| let i = 0, lastMatch = null; | |
| replayTimer = setInterval(() => { | |
| if (i >= m.events.length) { | |
| clearInterval(replayTimer); replayTimer = null; | |
| const w = m.winner ? agentById(m.winner) : null; | |
| addSystemMsg((m.mode === 'tournament' ? (w ? `🏆 Campeón: ${w.name}` : '⚠️ Torneo sin campeón fiable') : m.winnerTeam ? `🛡️ Ganador: Equipo ${m.winnerTeam}` : (w ? `👑 Ganador: ${w.name}` : '🤝 Empate')) + (m.simulated ? ' · 🧪 SIMULADO (pruebas)' : '')); | |
| return; | |
| } | |
| const ev = m.events[i++]; | |
| if (m.mode === 'tournament' && ev.match !== undefined && ev.match !== lastMatch) { | |
| lastMatch = ev.match; | |
| const tm = m.tournament && m.tournament[ev.match]; | |
| if (tm) addSystemMsg(`🏟️ ${tm.name}`); | |
| } | |
| const a = ev.agentId === 'user' ? { name: 'Tú', emoji: '⚡', color: '#8b5cf6' } : ps.find(x => x.id === ev.agentId) || agentById(ev.agentId) || { name: ev.agent, emoji: '🤖', color: '#888' }; | |
| addEvent(a, ev.phase, ev.content, ev.round, ev.agentId === 'user'); | |
| }, 1100); | |
| } | |
| /* ================= ROADMAP ================= */ | |
| function renderRoadmap() { | |
| const phases = [ | |
| { n: 1, name: 'Arena Foundation', tag: 'done', items: ['Estructura modular', 'Integración con el runtime (wllama)', 'Agent Manager', 'Arena State Manager', 'Sistema de turnos y fases', 'Modalidad 1 vs 1'] }, | |
| { n: 2, name: 'Battle Experience', tag: 'done', items: ['Arena Lobby', 'Selección visual de agentes', 'Live Battle con streaming', 'Crossfire y refutaciones', 'Participación humana (Challenge Agent)'] }, | |
| { n: 3, name: 'Intelligence System', tag: 'partial', items: ['Tres jueces independientes + Head Judge', 'Puntuaciones ponderadas', 'Informe de resultados', 'Referee AI: detector de repeticiones, evasiones, fuera de tema e incoherencias (implementado; validado en pruebas simuladas, pendiente prueba real)'] }, | |
| { n: 4, name: 'Tournament Mode', tag: 'partial', items: ['Free For All', 'Team Battle (2v2, 3v3, 4v4 con veredicto por equipos)', 'Survival Tournament (eliminatorias de 4 u 8 con campeón)', 'Rematches con estrategia revisada (implementado; validado en pruebas simuladas, pendiente prueba real)'] }, | |
| { n: 5, name: 'Memory & Replay', tag: 'partial', items: ['Historial de competiciones (IndexedDB + caché localStorage)', 'Replay de batallas', 'Implementado (validado en pruebas simuladas, pendiente prueba real): IndexedDB, exportación JSON/Markdown, memoria persistente de agentes. Pendiente: exportación HTML/PDF'] }, | |
| { n: 6, name: 'Premium Experience', tag: 'todo', items: ['Animaciones y sonidos', 'Efectos de competición', 'Logros (9 implementados, solo se otorgan con evaluación real y consistente; validado en pruebas simuladas, pendiente prueba real)', 'Personalización avanzada', 'Optimización WebGPU', 'Multi-Model Arena'] }, | |
| ]; | |
| const labels = { done: 'DISPONIBLE', partial: 'PARCIAL', todo: 'PENDIENTE' }; | |
| $('#roadmap-list').innerHTML = phases.map(p => ` | |
| <div class="phase ${p.tag}"><h3>Fase ${p.n} — ${p.name} <span class="tag ${p.tag}">${labels[p.tag]}</span></h3> | |
| <ul>${p.items.map(i => `<li>${esc(i)}</li>`).join('')}</ul></div>`).join(''); | |
| } | |
| /* ================= INIT ================= */ | |
| renderLobby(); | |
| renderRoster(); | |
| if (params.get('topic')) $('#topic').value = params.get('topic'); | |
| // gancho de pruebas (solo modo mock): inspección de estado interno | |
| IDB.init(); | |
| if (MOCK) window.__arenaTest = { setBattle: b => { battle = b; }, getBattle: () => battle, agents: () => agents, stats: () => stats, finalizeBattle, applyAwardsAndMilestones, evaluationQualityOK, agentMemory }; | |
| updateModelInUse(); | |