/* =========================================================== Views (set 2): Visor, Terminal, Arena, Ranking, Sources, Settings =========================================================== */ const { useState: useState2, useEffect: useEffect2, useMemo: useMemo2, useRef: useRef2, Fragment } = React; /* ------------------------- RETRY VISOR ------------------------- */ function VisorView({ run, nav }) { const attempts = run.writer.attempts_history; const [selIdx, setSelIdx] = useState2(attempts.length - 1); const [showDiff, setShowDiff] = useState2(false); const sel = attempts[selIdx]; const prev = selIdx > 0 ? attempts[selIdx - 1] : null; return (
VISOR DE REINTENTOS · {run.id}

{run.editor.selected_titulo}

{attempts.length} intentos writer · {run.writer.winner_model}
{/* LEFT git-log */}
Historial · git-log
{attempts.map((a, i) => (
setSelIdx(i)} >
{a.passed ? : }
Attempt {a.attempt_n}{a.passed ? ' ✓' : ''}
{a.model}
{a.passed ? approved : a.reason === 'factcheck' ? factcheck : quality}
{a.duration_seconds.toFixed(1)}s · {a.prompt_tokens}↑/{a.output_tokens}↓ tok
))}
Veredicto del selector
{run.selector.reason}
Modelo ganador
{run.selector.winner}
mistral {run.selector.scores['mistral-nemo:12b'].toFixed(2)} · gemma {run.selector.scores['gemma4:e4b'].toFixed(2)}
{/* RIGHT main */}
{attempts.map((a, i) => (
setSelIdx(i)} > Intento {a.attempt_n} {a.passed && } {!a.passed && }
))}
{selIdx > 0 && ( )}
{/* header strip */}
Modelo
{sel.model}
Duración
{sel.duration_seconds.toFixed(1)} s
Prompt
{sel.prompt_tokens.toLocaleString()} tok
Output
{sel.output_tokens.toLocaleString()} tok
Estado
{sel.passed ? aprobado : sel.reason === 'factcheck' ? rechazado · factcheck : rechazado · quality gate}
{/* rejection card */} {!sel.passed && sel.quality_issues.length > 0 && (
{sel.reason === 'factcheck' ? 'Fact-check gate' : 'Quality gate'}
Motivos del rechazo
{sel.quality_issues.length} problemas
    {sel.quality_issues.map((iss, k) => { const cat = iss.startsWith('Frase prohibida') ? 'frase' : iss.startsWith('Adjetivo') ? 'adjetivo' : iss.startsWith('Párrafo') ? 'densidad' : iss.startsWith('Estructura') ? 'estructura' : 'otro'; return (
  • {cat} "$1"')}} />
  • ); })}
{sel.removed_phrases.length > 0 && (
Frases marcadas para reescribir
{sel.removed_phrases.map((p, k) => ( "{p}" ))}
)}
)} {sel.passed && (
Quality gate aprobado en {sel.duration_seconds.toFixed(1)}s
Todas las verificaciones pasaron: conteo de palabras, párrafos, frases prohibidas, densidad factual. Este intento fue elegido por el selector como el ganador del A/B.
)} {/* article preview / diff */} {showDiff && prev ? (
DIFFIntento {prev.attempt_n} → Intento {sel.attempt_n}
removed added
Intento {prev.attempt_n} (rechazado)
Intento {sel.attempt_n}{sel.passed ? ' (aprobado)' : ' (rechazado)'}
) : (
PREVIEWBorrador renderizado
frase marcada texto removido

{run.editor.selected_titulo}

Borrador · intento {sel.attempt_n} · {sel.model}
)}
); } function DiffPane({ html, mode }) { // Cheap line-diff visualization: strip html tags into paragraphs const paras = html.replace(/

([^<]+)<\/h2>/g, '\n## $1\n').replace(/<\/?p>/g, '\n').split('\n').filter(p => p.trim()).map(p => p.replace(/<[^>]+>/g, '').trim()); return (
{paras.map((p, i) => { // mark first paragraph as "changed" for diff illusion const isMark = i === 1; const isHeader = p.startsWith('##'); const text = isHeader ? p.slice(2).trim() : p; if (isMark && mode === 'left') { return

{text}

; } if (isMark && mode === 'right') { return

{text.replace(/Cabe destacar que/i, '').replace(/importante/g, 'inédito').replace(/No obstante, el contexto sigue siendo complejo\./, '')}

; } if (isHeader) return

{text}

; return

{text}

; })}
); } /* ------------------------- TERMINAL ------------------------- */ function TerminalView({ logs }) { const [filter, setFilter] = useState2('ALL'); const [search, setSearch] = useState2(''); const [follow, setFollow] = useState2(true); const [visible, setVisible] = useState2(50); const scrollRef = useRef2(null); // simulate "live" appending useEffect2(() => { if (!follow) return; const id = setInterval(() => { setVisible(v => Math.min(v + 1, logs.length)); }, 900); return () => clearInterval(id); }, [follow, logs.length]); useEffect2(() => { if (follow && scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; } }, [visible, follow]); const filtered = useMemo2(() => { return logs.slice(0, visible).filter(l => { if (filter !== 'ALL' && l.level !== filter) return false; if (search && !(l.message.toLowerCase().includes(search.toLowerCase()) || l.module.toLowerCase().includes(search.toLowerCase()))) return false; return true; }); }, [logs, visible, filter, search]); return (
Stream

Live terminal

~/logs/newsroom-2026-05-12.log {filtered.length} líneas
CONNECTED · ws://localhost:8000/logs setSearch(e.target.value)} placeholder="grep…" />
{filtered.map(l => (
{l.ts_compact} [{l.level}] {l.module}: {l.message}
))} {follow && (
_
)}
); } /* ------------------------- RANKING ------------------------- */ function RankingView({ runs }) { const latest = runs[0]; const candidates = latest.collector.candidates; const [sortBy, setSortBy] = useState2('score'); const sorted = [...candidates].sort((a, b) => { if (sortBy === 'score') return b.editorial_score - a.editorial_score; if (sortBy === 'source') return a.fuente.localeCompare(b.fuente); if (sortBy === 'freshness') return b.freshness_score - a.freshness_score; return 0; }); const maxScore = Math.max(...candidates.map(c => c.editorial_score)); return (
Cola actual

Ranking de candidatos

{candidates.length} candidatos run {latest.id} {fmtClock(latest.timestamp)}
{sorted.map((c, i) => { const isSelected = c.titulo === latest.editor.selected_titulo; const bd = { source: c.source_score, freshness: c.freshness_score, relevance: c.relevance_score, trends: c.depth_bonus, gemini: Math.max(0, c.editorial_score - c.source_score - c.freshness_score - c.relevance_score - c.depth_bonus), }; return ( ); })}
# setSortBy('source')} style={{cursor:'pointer'}}>Titular candidato Fuente setSortBy('score')} style={{cursor:'pointer', textAlign:'right'}}>Editorial ▼ Desglose
{String(i+1).padStart(2, '0')}
{c.titulo}
{isSelected && seleccionado}
{c.fuente} · w {(window.SOURCES.find(s => s.name === c.fuente)?.weight || 0.85).toFixed(2)} {c.editorial_score.toFixed(3)}
Source weight Freshness Relevance Trends boost Gemini boost
); } /* ------------------------- ARENA ------------------------- */ function ArenaView({ runs }) { const wins = { 'mistral-nemo:12b': 0, 'gemma4:e4b': 0 }; runs.forEach(r => { wins[r.selector.winner] = (wins[r.selector.winner] || 0) + 1; }); const totalDuels = runs.length; const mistralRate = (wins['mistral-nemo:12b'] / totalDuels * 100); const gemmaRate = (wins['gemma4:e4b'] / totalDuels * 100); const leader = mistralRate > gemmaRate ? 'mistral-nemo:12b' : 'gemma4:e4b'; // build cumulative win-rate "over time" (synthetic but coherent) const points = 30; const mistralLine = []; const gemmaLine = []; let mw = 0, gw = 0; for (let i = 0; i < points; i++) { const isMistral = ((i * 7 + 3) % 11) < 7; if (isMistral) mw++; else gw++; const total = i + 1; mistralLine.push((mw / total) * 100); gemmaLine.push((gw / total) * 100); } // by category const cats = window.CATEGORIES; const byCat = cats.map(c => { const a = 0.45 + Math.random() * 0.4; return { cat: c, mistral: a * 100, gemma: (1 - a) * 100 }; }); // recent duels (use runs) const duels = runs.slice(0, 10); // sheet state const [open, setOpen] = useState2(null); return (
A/B

Arena de escritores

{totalDuels} duelos · ganador líder · {leader}
{/* HERO */}
Writer A
mistral-nemo:12b
{mistralRate.toFixed(0)}%
win-rate
duelos {wins['mistral-nemo:12b']} dur. media 11.4s longitud media 458 pal.
vs
Writer B
gemma4:e4b
{gemmaRate.toFixed(0)}%
win-rate
duelos {wins['gemma4:e4b']} dur. media 9.8s longitud media 421 pal.
{/* Win-rate over time + by category */}
TENDENCIAWin-rate acumulado · últimos 30 duelos
CATEGORÍASWin-rate por sección
{byCat.map(b => (
{b.cat} m {b.mistral.toFixed(0)}% · g {b.gemma.toFixed(0)}%
))}
{/* Recent duels */}
HISTORIALDuelos recientes
click → veredicto completo
{duels.map(d => { const mScore = d.selector.scores['mistral-nemo:12b']; const gScore = d.selector.scores['gemma4:e4b']; const gap = Math.abs(mScore - gScore); const loser = d.selector.winner === 'mistral-nemo:12b' ? 'gemma4:e4b' : 'mistral-nemo:12b'; return ( setOpen(d)}> ); })}
Run Titular Ganador Perdedor Razón del selector Δ score
{d.id}
{d.editor.selected_titulo}
{d.selector.winner} {loser}
{d.selector.reason}
+{gap.toFixed(2)}
{open && setOpen(null)} />}
); } function LineChart({ series }) { const w = 600, h = 220, pad = 30; const all = series.flatMap(s => s.data); const min = 0, max = 100; const n = series[0].data.length; const xAt = i => pad + (i / (n - 1)) * (w - pad - 10); const yAt = v => h - pad + 6 - ((v - min) / (max - min)) * (h - pad - 16); return (
{/* gridlines */} {[0,25,50,75,100].map(v => ( {v}% ))} {series.map((s, si) => ( `${xAt(i)},${yAt(v)}`).join(' ')} fill="none" stroke={s.color} strokeWidth="2" /> {s.data.map((v, i) => i % 5 === 0 && )} ))} {/* x axis */} run 1 run {n} {/* legend */} {series.map((s, i) => ( {s.label} ))}
); } function DuelSheet({ run, onClose }) { const attempts = run.writer.attempts_history; // synthesize draft B as a variant of the first attempt's html const draftA = attempts[attempts.length - 1]; const draftB = { model: draftA.model === 'mistral-nemo:12b' ? 'gemma4:e4b' : 'mistral-nemo:12b', html: attempts[0].html, }; return (
e.stopPropagation()}>
Veredicto del A/B · {run.id}
{run.editor.selected_titulo}
{draftA.model}
{run.selector.winner === draftA.model && winner}
{draftB.model}
{run.selector.winner === draftB.model && winner}
Razón del selector
"{run.selector.reason}"
scores · mistral {run.selector.scores['mistral-nemo:12b'].toFixed(2)} gemma {run.selector.scores['gemma4:e4b'].toFixed(2)} Δ {Math.abs(run.selector.scores['mistral-nemo:12b'] - run.selector.scores['gemma4:e4b']).toFixed(2)}
); } /* ------------------------- SOURCES ------------------------- */ function SourcesView({ runs }) { // aggregate per-source const sources = window.SOURCES.map(s => { const delivered = runs.reduce((a, r) => a + r.collector.candidates.filter(c => c.fuente === s.name).length, 0); const selected = runs.filter(r => r.editor.selected_fuente === s.name).length; const published = runs.filter(r => r.editor.selected_fuente === s.name && r.publisher.success).length; return { ...s, delivered, selected, published, winrate: delivered ? (selected / delivered * 100) : 0 }; }).sort((a, b) => b.weight - a.weight); const [open, setOpen] = useState2(null); return (
Performance

Matriz de fuentes

{sources.length} fuentes · últimas {runs.length} ejecuciones
{sources.map(s => ( setOpen(s)}> ))}
Fuente Peso Candidatos Seleccionados Publicados Win-rate
{s.name} 1.2 ? 'gold' : s.weight > 0.9 ? 'info' : ''}>w {s.weight.toFixed(2)} {s.delivered} {s.selected} {s.published}
{s.winrate.toFixed(1)}%
{open && (
setOpen(null)}>
e.stopPropagation()} style={{maxWidth: 720}}>
Fuente
{open.name}
peso · {open.weight.toFixed(2)} · {open.delivered} candidatos · {open.selected} seleccionados
Últimos titulares
{runs.flatMap(r => r.collector.candidates.filter(c => c.fuente === open.name).map(c => ({...c, runId: r.id, ts: r.timestamp}))) .slice(0, 10).map((c, i) => (
{c.titulo}
score {c.editorial_score.toFixed(3)} {c.runId} {fmtClock(c.ts)}
))}
)}
); } /* ------------------------- SETTINGS ------------------------- */ function SettingsView() { const cfg = window.getMockConfig(); const maxW = Math.max(...Object.values(cfg.source_weights)); return (
Config

Settings

Read-only · edita config.py en el backend
MODELSModelo por agente
{Object.entries(cfg.models).map(([agent, model]) => ( ))}
{agent} {model}
PIPELINEParámetros
{Object.entries(cfg.pipeline).map(([k, v]) => ( ))}
{k} {String(v)}
SOURCESPesos por fuente
{Object.entries(cfg.source_weights).map(([name, w]) => (
{name} {w.toFixed(2)}
1.2 ? 'var(--gold)' : w > 0.9 ? 'var(--info)' : 'var(--text3)'}} />
))}
); } Object.assign(window, { VisorView, TerminalView, RankingView, ArenaView, SourcesView, SettingsView, });