← back to Ventura Corridor
public/duplicates.html
127 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Duplicate headlines · The Corridor</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;1,400;1,500&family=Inter:wght@200;300;400;500&family=JetBrains+Mono:wght@200;300&display=swap');
:root{--noir:#0a0a0c;--noir-rise:#131316;--ink:#f0ece2;--ink-mute:#888475;--metal:#b89968;--metal-glow:#d4b683;--green:#6a9b73;--red:#b66565;--rule:#2a2622}
*{box-sizing:border-box}body{margin:0;background:var(--noir);color:var(--ink);font-family:Inter,sans-serif;font-weight:300}
header{padding:30px 32px 16px;border-bottom:1px solid var(--rule)}
header h1{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:400;font-size:32px;margin:0}
header h1 em{color:var(--metal)}
header .sub{font-size:11px;letter-spacing:.26em;text-transform:uppercase;color:var(--ink-mute);margin-top:4px}
nav{padding:10px 32px;border-bottom:1px solid var(--rule);display:flex;gap:12px;flex-wrap:wrap;font-size:9px;letter-spacing:.26em;text-transform:uppercase}
nav a{color:var(--ink-mute);text-decoration:none;padding:5px 10px;border:1px solid var(--rule)}
nav a:hover, nav a.active{color:var(--metal);border-color:var(--metal)}
main{padding:32px;max-width:1100px;margin:0 auto}
.summary{padding:18px;background:var(--noir-rise);border:1px solid var(--rule);margin-bottom:24px;text-align:center;font-family:'Cormorant Garamond',serif;font-style:italic;font-size:18px;color:var(--ink)}
.summary b{color:var(--metal-glow);font-weight:500}
.group{background:var(--noir-rise);border:1px solid var(--rule);padding:24px;margin-bottom:18px}
.group .head{font-family:'Cormorant Garamond',serif;font-style:italic;font-size:24px;color:var(--metal);margin-bottom:6px;font-weight:500}
.group .count{font-size:9px;letter-spacing:.32em;text-transform:uppercase;color:var(--ink-mute);margin-bottom:14px}
.group ul{list-style:none;padding:0;margin:0}
.group li{display:grid;grid-template-columns:50px 1fr auto auto auto;gap:12px;padding:8px 0;border-bottom:1px dotted var(--rule);font-size:13px;align-items:center}
.group li.oldest{opacity:0.55;font-style:italic}
.group li .id{font-family:'JetBrains Mono',monospace;color:var(--metal);font-size:11px}
.group li .biz{color:var(--ink)}
.group li .meta{font-family:'JetBrains Mono',monospace;font-size:10px;color:var(--ink-mute)}
.group li .actions{display:flex;gap:6px}
.group li .actions a, .group li .actions button{font-size:8px;letter-spacing:.22em;text-transform:uppercase;color:var(--metal);border:1px solid var(--rule);padding:4px 8px;text-decoration:none;background:transparent;cursor:pointer;font-family:Inter,sans-serif}
.group li .actions a:hover, .group li .actions button:hover{border-color:var(--metal)}
.group li .actions button.regen{color:var(--metal-glow);border-color:var(--metal-glow)}
.group li .actions button.regen:disabled{opacity:0.5;cursor:default}
.group .legend{margin-top:10px;padding-top:10px;border-top:1px solid var(--rule);font-size:11px;color:var(--ink-mute);font-style:italic;font-family:'Cormorant Garamond',serif}
.empty{text-align:center;padding:80px;color:var(--green);font-style:italic;font-family:'Cormorant Garamond',serif;font-size:24px}
</style>
</head>
<body>
<header>
<h1>Duplicate <em>headlines</em></h1>
<div class="sub">when qwen3 templates · click regen on the runner-up to introduce variety</div>
</header>
<nav>
<a href="/today.html">today</a>
<a href="/magazine.html">magazine</a>
<a href="/issue">issue</a>
<a href="/scoreboard.html">scoreboard</a>
<a href="/duplicates.html" class="active">dupes</a>
<a href="/health.html">health</a>
</nav>
<main id="root"><div class="summary">Loading…</div></main>
<script>
function escHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }
async function load() {
const d = await fetch('/api/magazine/duplicates').then(r => r.json());
const root = document.getElementById('root');
if (d.groups === 0) {
root.innerHTML = '<div class="empty">✓ Every headline is unique.</div>';
return;
}
let html = `<div class="summary"><b>${d.groups}</b> duplicate group${d.groups===1?'':'s'} affecting <b>${d.total_affected}</b> features</div>`;
// Headline-stem analysis — looser than exact-match dupes
const stems = await fetch('/api/magazine/headline-stems').then(r => r.json()).catch(() => ({ stems: [] }));
const topStems = (stems.stems || []).slice(0, 8);
if (topStems.length) {
html += `<div class="group">
<div class="head" style="color:var(--metal-glow)">Templated phrases</div>
<div class="count">3-word phrases recurring across headlines · ${stems.count} total templates · ${stems.total_headlines} headlines analyzed</div>
<ul style="font-family:'JetBrains Mono',monospace;font-size:13px">${topStems.map(s => `
<li style="grid-template-columns:60px 1fr 1fr">
<span class="id">${s.count}×</span>
<span class="biz" style="font-family:'Cormorant Garamond',serif;font-style:italic;color:var(--ink)">"${escHtml(s.phrase)}"</span>
<span class="meta" style="white-space:normal;line-height:1.4">${s.examples.map(e => escHtml(e.slice(0, 32))).join(' · ')}</span>
</li>`).join('')}</ul>
<div class="legend">If a phrase appears 3+ times, qwen3 has likely fallen into a template — consider regen on the latest few features that use it.</div>
</div>`;
}
for (const g of d.rows) {
const features = g.features;
// Oldest by generated_at; mark it as the "keeper"
const sorted = [...features].sort((a, b) => new Date(a.generated_at) - new Date(b.generated_at));
const keeperId = sorted[0].id;
html += `<div class="group">
<div class="head">"${escHtml(g.headline)}"</div>
<div class="count">${g.dup_count} copies · runner-ups eligible for regen</div>
<ul>${features.map(f => {
const isKeeper = Number(f.id) === Number(keeperId);
const ts = new Date(f.generated_at).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
return `<li class="${isKeeper ? 'oldest' : ''}">
<span class="id">#${f.id}${isKeeper ? ' ★' : ''}</span>
<span class="biz">${escHtml(f.biz_name)}</span>
<span class="meta">${escHtml(f.category_tag || '—')} · ${escHtml(f.status)} · ${f.editorial_len}c</span>
<span class="meta">${escHtml(ts)}</span>
<span class="actions">
<a href="/magazine/${f.id}" target="_blank">view</a>
${isKeeper ? '' : `<button class="regen" onclick="regen(${f.id}, this)">regen</button>`}
</span>
</li>`;
}).join('')}</ul>
<div class="legend">★ = oldest copy (kept). Runner-ups can be re-prompted to introduce variety.</div>
</div>`;
}
root.innerHTML = html;
}
async function regen(id, btn) {
if (!confirm(`Regenerate feature ${id}? This calls qwen3 again on Mac1; takes ~30s.`)) return;
btn.disabled = true;
btn.textContent = '…';
try {
const r = await fetch(`/api/magazine/${id}/regen`, { method: 'POST' });
if (!r.ok) throw new Error(await r.text());
btn.textContent = '✓';
setTimeout(load, 800);
} catch (e) {
btn.disabled = false;
btn.textContent = 'regen';
alert('regen failed: ' + e.message);
}
}
load();
</script>
</body>
</html>