- Replace all CDN script tags (marked, plotly) with self-hosted static files - Add DOMPurify for sanitizing markdown-rendered HTML - Add escapeHtml() helper to base.html for all innerHTML operations - Sanitize dynamic data in innerHTML across 13 templates - Add security headers (X-Content-Type-Options, X-Frame-Options, Referrer-Policy) - Add SSRF protection to proposal intake URL fetcher (block private/loopback IPs) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
225 lines
10 KiB
HTML
225 lines
10 KiB
HTML
{% extends "base.html" %}
|
||
{% set active_page = "ratings" %}
|
||
|
||
{% block title %}Ratings — IETF Draft Analyzer{% endblock %}
|
||
|
||
{% block extra_head %}<script src="/static/js/plotly.min.js"></script>{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="mb-6">
|
||
<h1 class="text-2xl font-bold text-white">Rating Analytics</h1>
|
||
<p class="text-slate-400 text-sm mt-1">Distribution and analysis of AI-generated ratings across five dimensions. Each draft is rated 1–5 on novelty, maturity, overlap, momentum, and relevance by Claude AI, then combined into a weighted composite score.</p>
|
||
</div>
|
||
|
||
<!-- Score Distribution -->
|
||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5 mb-6">
|
||
<h2 class="text-sm font-semibold text-slate-300 mb-1">Composite Score Distribution</h2>
|
||
<p class="text-xs text-slate-500 mb-3">The composite score is a weighted average of all five dimensions (each 20%). Scores range from 1.0 (low) to 5.0 (high). Most drafts cluster in the 2.0–3.5 range.</p>
|
||
<div id="scoreHist" style="height: 300px;"></div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||
<!-- Dimension Box Plots -->
|
||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||
<h2 class="text-sm font-semibold text-slate-300 mb-1">Score Distributions by Dimension</h2>
|
||
<p class="text-xs text-slate-500 mb-3"><b>Novelty</b>: originality of ideas. <b>Maturity</b>: completeness and specification detail. <b>Overlap</b>: redundancy with other drafts (high = more unique). <b>Momentum</b>: adoption likelihood and community traction. <b>Relevance</b>: importance to AI agent infrastructure.</p>
|
||
<div id="dimDist" style="height: 350px;"></div>
|
||
</div>
|
||
<!-- Category Radar -->
|
||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||
<h2 class="text-sm font-semibold text-slate-300 mb-3">Category Rating Profiles</h2>
|
||
<div id="radar" style="height: 350px;"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Scatter: novelty vs maturity -->
|
||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5 mb-6">
|
||
<h2 class="text-sm font-semibold text-slate-300 mb-1">Novelty vs Maturity (bubble = relevance)</h2>
|
||
<p class="text-xs text-slate-500 mb-3">Each dot is a rated draft. Drafts in the top-right corner are both novel and mature — prime candidates for standardization. Bubble size reflects relevance. Click a point to view the draft.</p>
|
||
<div id="scatter" style="height: 450px;"></div>
|
||
</div>
|
||
|
||
<!-- Top 20 Leaderboard -->
|
||
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
|
||
<div class="p-4 border-b border-slate-800">
|
||
<h2 class="text-sm font-semibold text-slate-300">Top 20 Drafts by Composite Score</h2>
|
||
<p class="text-xs text-slate-500 mt-1">Highest-rated drafts across all dimensions. Green (4+) = strong, amber (3) = moderate, grey (<3) = needs improvement.</p>
|
||
</div>
|
||
<div class="overflow-x-auto">
|
||
<table class="w-full text-sm">
|
||
<thead>
|
||
<tr class="border-b border-slate-800 text-left text-xs text-slate-500">
|
||
<th class="px-4 py-3 font-medium">#</th>
|
||
<th class="px-4 py-3 font-medium">Draft</th>
|
||
<th class="px-4 py-3 font-medium text-center">Score</th>
|
||
<th class="px-4 py-3 font-medium text-center">Novelty</th>
|
||
<th class="px-4 py-3 font-medium text-center">Maturity</th>
|
||
<th class="px-4 py-3 font-medium text-center">Relevance</th>
|
||
<th class="px-4 py-3 font-medium text-center">Momentum</th>
|
||
<th class="px-4 py-3 font-medium text-center">Overlap</th>
|
||
<th class="px-4 py-3 font-medium">Category</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="leaderboard" class="divide-y divide-slate-800/50">
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block extra_scripts %}
|
||
<script>
|
||
const PLOTLY_LAYOUT = {
|
||
paper_bgcolor: 'rgba(0,0,0,0)', plot_bgcolor: 'rgba(0,0,0,0)',
|
||
font: { color: '#94a3b8', family: 'Inter, system-ui, sans-serif', size: 12 },
|
||
margin: { t: 20, r: 20, b: 40, l: 50 },
|
||
xaxis: { gridcolor: '#1e293b', zerolinecolor: '#334155' },
|
||
yaxis: { gridcolor: '#1e293b', zerolinecolor: '#334155' },
|
||
};
|
||
const CFG = { responsive: true, displayModeBar: false };
|
||
|
||
const dist = {{ dist | tojson }};
|
||
const radar = {{ radar | tojson }};
|
||
|
||
// Score Histogram
|
||
Plotly.newPlot('scoreHist', [{
|
||
x: dist.scores,
|
||
type: 'histogram',
|
||
nbinsx: 25,
|
||
marker: { color: '#3b82f6', line: { color: '#1e40af', width: 1 } },
|
||
hovertemplate: 'Score: %{x:.1f}<br>Count: %{y}<extra></extra>',
|
||
}], {
|
||
...PLOTLY_LAYOUT,
|
||
xaxis: { ...PLOTLY_LAYOUT.xaxis, title: 'Composite Score' },
|
||
yaxis: { ...PLOTLY_LAYOUT.yaxis, title: 'Count' },
|
||
}, CFG);
|
||
|
||
// Box plots for each dimension
|
||
const dims = ['novelty', 'maturity', 'overlap', 'momentum', 'relevance'];
|
||
const dimLabelsBox = ['Novelty', 'Maturity', 'Overlap', 'Momentum', 'Relevance'];
|
||
const colors = ['#3b82f6', '#22c55e', '#ef4444', '#f59e0b', '#a855f7'];
|
||
const boxTraces = dims.map((d, i) => ({
|
||
y: dist[d], name: dimLabelsBox[i],
|
||
type: 'box', marker: { color: colors[i] }, boxmean: true,
|
||
}));
|
||
Plotly.newPlot('dimDist', boxTraces, {
|
||
...PLOTLY_LAYOUT,
|
||
showlegend: false,
|
||
yaxis: { ...PLOTLY_LAYOUT.yaxis, range: [0.5, 5.5], dtick: 1 },
|
||
}, CFG);
|
||
|
||
// Radar
|
||
const radarDims = ['novelty', 'maturity', 'relevance', 'momentum', 'low_overlap'];
|
||
const radarLabels = ['Novelty', 'Maturity', 'Relevance', 'Momentum', 'Low Overlap'];
|
||
const radarTraces = Object.entries(radar).map(([cat, vals]) => ({
|
||
type: 'scatterpolar',
|
||
r: radarDims.map(d => vals[d]).concat([vals[radarDims[0]]]),
|
||
theta: radarLabels.concat([radarLabels[0]]),
|
||
fill: 'toself', name: `${cat} (${vals.count})`, opacity: 0.4,
|
||
}));
|
||
Plotly.newPlot('radar', radarTraces, {
|
||
...PLOTLY_LAYOUT,
|
||
polar: {
|
||
bgcolor: 'rgba(0,0,0,0)',
|
||
radialaxis: { visible: true, range: [0, 5], gridcolor: '#1e293b', color: '#64748b' },
|
||
angularaxis: { gridcolor: '#1e293b', color: '#94a3b8' },
|
||
},
|
||
legend: { font: { size: 10, color: '#94a3b8' } },
|
||
margin: { t: 30, r: 60, b: 30, l: 60 },
|
||
}, CFG);
|
||
|
||
// Scatter: novelty vs maturity
|
||
const catGroups = {};
|
||
dist.names.forEach((name, i) => {
|
||
const cat = dist.categories[i];
|
||
if (!catGroups[cat]) catGroups[cat] = { x: [], y: [], size: [], text: [] };
|
||
catGroups[cat].x.push(dist.novelty[i] + (Math.random() - 0.5) * 0.3);
|
||
catGroups[cat].y.push(dist.maturity[i] + (Math.random() - 0.5) * 0.3);
|
||
catGroups[cat].size.push(Math.max(dist.relevance[i] * 4, 6));
|
||
catGroups[cat].text.push(name);
|
||
});
|
||
const scatterTraces = Object.entries(catGroups).map(([cat, d]) => ({
|
||
x: d.x, y: d.y, text: d.text, name: cat,
|
||
mode: 'markers', type: 'scatter',
|
||
marker: { size: d.size, opacity: 0.7 },
|
||
hovertemplate: '<b>%{text}</b><br>Novelty: %{x:.1f}<br>Maturity: %{y:.1f}<extra>' + cat + '</extra>',
|
||
}));
|
||
Plotly.newPlot('scatter', scatterTraces, {
|
||
...PLOTLY_LAYOUT,
|
||
xaxis: { ...PLOTLY_LAYOUT.xaxis, title: 'Novelty', range: [0.5, 5.5], dtick: 1 },
|
||
yaxis: { ...PLOTLY_LAYOUT.yaxis, title: 'Maturity', range: [0.5, 5.5], dtick: 1 },
|
||
legend: { font: { size: 10, color: '#94a3b8' } },
|
||
hovermode: 'closest',
|
||
}, CFG);
|
||
|
||
// Click scatter points to navigate to draft detail
|
||
document.getElementById('scatter').on('plotly_click', function(data) {
|
||
const pt = data.points[0];
|
||
if (pt.text) {
|
||
window.location.href = '/drafts/' + pt.text;
|
||
}
|
||
});
|
||
|
||
// Top 20 Leaderboard
|
||
(function buildLeaderboard() {
|
||
// Combine arrays into objects and sort by score descending
|
||
const drafts = dist.names.map((name, i) => ({
|
||
name,
|
||
score: dist.scores[i],
|
||
novelty: dist.novelty[i],
|
||
maturity: dist.maturity[i],
|
||
relevance: dist.relevance[i],
|
||
momentum: dist.momentum[i],
|
||
overlap: dist.overlap[i],
|
||
category: dist.categories[i],
|
||
source: (dist.sources || [])[i] || 'ietf',
|
||
}));
|
||
drafts.sort((a, b) => b.score - a.score);
|
||
|
||
const tbody = document.getElementById('leaderboard');
|
||
const top20 = drafts.slice(0, 20);
|
||
|
||
function scoreClass(score) {
|
||
if (score >= 3.5) return 'score-high';
|
||
if (score >= 2.5) return 'score-mid';
|
||
return 'score-low';
|
||
}
|
||
|
||
function dimBadge(val, inverted = false) {
|
||
let cls;
|
||
if (inverted) {
|
||
cls = val <= 2 ? 'text-green-400' : val <= 3 ? 'text-yellow-400' : 'text-red-400';
|
||
} else {
|
||
cls = val >= 4 ? 'text-green-400' : val >= 3 ? 'text-yellow-400' : 'text-slate-500';
|
||
}
|
||
return `<span class="${cls}">${val}</span>`;
|
||
}
|
||
|
||
top20.forEach((d, i) => {
|
||
const shortName = d.name.replace('draft-', '').substring(0, 40);
|
||
const sourceBadge = d.source !== 'ietf' ? ` <span style="display:inline-block;padding:1px 5px;border-radius:4px;font-size:0.55rem;font-weight:600;background:rgba(168,85,247,0.15);color:#c084fc;border:1px solid rgba(168,85,247,0.3);vertical-align:middle">${escapeHtml(d.source.toUpperCase())}</span>` : '';
|
||
const row = document.createElement('tr');
|
||
row.className = 'hover:bg-slate-800/50 transition';
|
||
row.innerHTML = `
|
||
<td class="px-4 py-3 text-slate-500 font-mono text-xs">${i + 1}</td>
|
||
<td class="px-4 py-3">
|
||
<a href="/drafts/${encodeURIComponent(d.name)}" class="text-blue-400 hover:text-blue-300 transition text-xs font-mono">${escapeHtml(shortName)}</a>${sourceBadge}
|
||
</td>
|
||
<td class="px-4 py-3 text-center">
|
||
<span class="score-badge ${scoreClass(d.score)}">${d.score.toFixed(2)}</span>
|
||
</td>
|
||
<td class="px-4 py-3 text-center">${dimBadge(d.novelty)}</td>
|
||
<td class="px-4 py-3 text-center">${dimBadge(d.maturity)}</td>
|
||
<td class="px-4 py-3 text-center">${dimBadge(d.relevance)}</td>
|
||
<td class="px-4 py-3 text-center">${dimBadge(d.momentum)}</td>
|
||
<td class="px-4 py-3 text-center">${dimBadge(d.overlap, true)}</td>
|
||
<td class="px-4 py-3">
|
||
<span class="px-2 py-0.5 rounded text-[10px] bg-slate-800 text-slate-400">${escapeHtml(d.category)}</span>
|
||
</td>
|
||
`;
|
||
tbody.appendChild(row);
|
||
});
|
||
})();
|
||
</script>
|
||
{% endblock %}
|