Critical fixes:
- Fix rating clamp range 1-10 → 1-5 (actual scale)
- Add `ietf ideas convergence` command (SequenceMatcher at 0.75 threshold)
- Fix "628 cross-org ideas" → 130 (verified from current DB) across 8 files
Security fixes:
- Sanitize FTS5 query input (strip special chars + boolean operators)
- Add rate limiting (10 req/min/IP) on Claude-calling endpoints
- Change <path:name> → <string:name> on draft routes
Codebase fixes:
- Add Database context manager (__enter__/__exit__)
- Wire false_positive filtering into queries (exclude by default in web UI)
- Fix Post 3 arithmetic ("~300" → "~409" distinct proposals)
Content & licensing:
- Add MIT LICENSE file
- Add IPR/FRAND notes (BCP 79, RFC 8179) to Posts 03 and 07
- Qualify "4:1 safety ratio" with monthly variation in 6 remaining files
- Add "Data as of March 2026" freeze-date headers to all 10 blog posts
- Hedge causal language in Post 04
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
218 lines
11 KiB
HTML
218 lines
11 KiB
HTML
{% extends "base.html" %}
|
|
{% set active_page = "overview" %}
|
|
|
|
{% block title %}Overview — IETF Draft Analyzer{% endblock %}
|
|
|
|
{% block extra_head %}<script src="/static/js/plotly.min.js"></script>{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="mb-8 flex flex-wrap items-start justify-between gap-4">
|
|
<div>
|
|
<h1 class="text-2xl font-bold text-white">Dashboard Overview</h1>
|
|
<p class="text-slate-400 text-sm mt-1">IETF AI/Agent Internet-Drafts at a glance. Drafts are fetched from the IETF Datatracker, then analyzed by Claude AI across five dimensions (novelty, maturity, overlap, momentum, relevance) to produce a composite score from 1.0 to 5.0.</p>
|
|
</div>
|
|
<a href="/export/obsidian"
|
|
class="inline-flex items-center gap-2 px-4 py-2 bg-purple-600/80 hover:bg-purple-500 text-white text-sm font-medium rounded-lg transition-colors flex-shrink-0"
|
|
title="Download all research data as an Obsidian vault with interlinked notes, Mermaid charts, and YAML frontmatter">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
|
Download for Obsidian
|
|
</a>
|
|
</div>
|
|
|
|
<!-- Stat cards -->
|
|
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 mb-8">
|
|
<a href="/drafts" class="stat-card rounded-xl border border-slate-800 p-5 relative overflow-hidden hover:border-blue-500/40 transition group">
|
|
<div class="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-blue-500 to-blue-400"></div>
|
|
<div class="text-3xl font-bold text-blue-400">{{ stats.total_drafts }}</div>
|
|
<div class="text-xs text-slate-400 mt-1 uppercase tracking-wider group-hover:text-blue-400/70 transition">Total Drafts →</div>
|
|
</a>
|
|
<a href="/ratings" class="stat-card rounded-xl border border-slate-800 p-5 relative overflow-hidden hover:border-emerald-500/40 transition group">
|
|
<div class="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-emerald-500 to-emerald-400"></div>
|
|
<div class="text-3xl font-bold text-emerald-400">{{ stats.rated_count }}</div>
|
|
<div class="text-xs text-slate-400 mt-1 uppercase tracking-wider group-hover:text-emerald-400/70 transition">Rated Drafts →</div>
|
|
</a>
|
|
<a href="/authors" class="stat-card rounded-xl border border-slate-800 p-5 relative overflow-hidden hover:border-purple-500/40 transition group">
|
|
<div class="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-purple-500 to-purple-400"></div>
|
|
<div class="text-3xl font-bold text-purple-400">{{ stats.author_count }}</div>
|
|
<div class="text-xs text-slate-400 mt-1 uppercase tracking-wider group-hover:text-purple-400/70 transition">Authors →</div>
|
|
</a>
|
|
<a href="/ideas" class="stat-card rounded-xl border border-slate-800 p-5 relative overflow-hidden hover:border-amber-500/40 transition group">
|
|
<div class="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-amber-500 to-amber-400"></div>
|
|
<div class="text-3xl font-bold text-amber-400">{{ stats.idea_count }}</div>
|
|
<div class="text-xs text-slate-400 mt-1 uppercase tracking-wider group-hover:text-amber-400/70 transition">Ideas →</div>
|
|
</a>
|
|
<a href="/gaps" class="stat-card rounded-xl border border-slate-800 p-5 relative overflow-hidden hover:border-red-500/40 transition group">
|
|
<div class="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-red-500 to-red-400"></div>
|
|
<div class="text-3xl font-bold text-red-400">{{ stats.gap_count }}</div>
|
|
<div class="text-xs text-slate-400 mt-1 uppercase tracking-wider group-hover:text-red-400/70 transition">Gaps Found →</div>
|
|
</a>
|
|
</div>
|
|
|
|
<!-- Charts row 1: Score distribution + Category donut -->
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
|
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
|
<h2 class="text-sm font-semibold text-slate-300 mb-1">Composite Score Distribution</h2>
|
|
<p class="text-xs text-slate-500 mb-3">Weighted average of five AI-rated dimensions (novelty 20%, maturity 20%, uniqueness 20%, momentum 20%, relevance 20%). Higher scores indicate drafts that are novel, mature, unique, gaining traction, and highly relevant to AI agent infrastructure.</p>
|
|
<div id="scoreHist" style="height: 300px;"></div>
|
|
</div>
|
|
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
|
<h2 class="text-sm font-semibold text-slate-300 mb-1">Drafts by Category</h2>
|
|
<p class="text-xs text-slate-500 mb-3">Categories are assigned by Claude during analysis. A draft can belong to multiple categories (e.g., both "A2A protocols" and "AI safety/alignment").</p>
|
|
<div id="categoryPie" style="height: 300px;"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Timeline (full width) -->
|
|
<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-3">Submissions Over Time</h2>
|
|
<div id="timeline" 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: 420px;"></div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_scripts %}
|
|
<script>
|
|
// Shared Plotly config
|
|
const PLOTLY_LAYOUT = {
|
|
paper_bgcolor: 'transparent',
|
|
plot_bgcolor: 'transparent',
|
|
font: { color: '#94a3b8', family: 'Inter, system-ui, sans-serif', size: 12 },
|
|
margin: { t: 30, r: 20, b: 40, l: 40 },
|
|
xaxis: { gridcolor: '#1e293b', zerolinecolor: '#334155' },
|
|
yaxis: { gridcolor: '#1e293b', zerolinecolor: '#334155' },
|
|
};
|
|
const PLOTLY_CONFIG = { responsive: true, displayModeBar: false };
|
|
|
|
const PALETTE = [
|
|
'#3b82f6', '#ef4444', '#22c55e', '#a855f7', '#f59e0b',
|
|
'#06b6d4', '#ec4899', '#84cc16', '#f97316', '#8b5cf6',
|
|
'#14b8a6', '#e11d48', '#64748b', '#eab308', '#6366f1',
|
|
];
|
|
|
|
// --- Score histogram ---
|
|
const scores = {{ scores | tojson }};
|
|
if (scores.length > 0) {
|
|
Plotly.newPlot('scoreHist', [{
|
|
x: scores,
|
|
type: 'histogram',
|
|
nbinsx: 20,
|
|
marker: {
|
|
color: 'rgba(59, 130, 246, 0.7)',
|
|
line: { color: '#3b82f6', width: 1 },
|
|
},
|
|
hovertemplate: 'Score: %{x}<br>Count: %{y}<extra></extra>',
|
|
}], {
|
|
...PLOTLY_LAYOUT,
|
|
xaxis: { ...PLOTLY_LAYOUT.xaxis, title: { text: 'Composite Score', font: { size: 11 } } },
|
|
yaxis: { ...PLOTLY_LAYOUT.yaxis, title: { text: 'Count', font: { size: 11 } } },
|
|
}, PLOTLY_CONFIG);
|
|
} else {
|
|
document.getElementById('scoreHist').innerHTML = '<p class="text-slate-500 text-sm text-center mt-20">No score data available</p>';
|
|
}
|
|
|
|
// --- Category donut ---
|
|
const categories = {{ categories | tojson }};
|
|
const catNames = Object.keys(categories);
|
|
const catVals = Object.values(categories);
|
|
if (catNames.length > 0) {
|
|
Plotly.newPlot('categoryPie', [{
|
|
labels: catNames,
|
|
values: catVals,
|
|
type: 'pie',
|
|
hole: 0.45,
|
|
textinfo: 'label+percent',
|
|
textposition: 'outside',
|
|
textfont: { size: 10, color: '#94a3b8' },
|
|
hovertemplate: '%{label}<br>%{value} drafts (%{percent})<extra></extra>',
|
|
marker: { colors: PALETTE },
|
|
pull: catVals.map((_, i) => i === 0 ? 0.03 : 0),
|
|
}], {
|
|
...PLOTLY_LAYOUT,
|
|
showlegend: false,
|
|
margin: { t: 10, r: 10, b: 10, l: 10 },
|
|
}, PLOTLY_CONFIG);
|
|
// Click category to filter drafts
|
|
document.getElementById('categoryPie').on('plotly_click', function(data) {
|
|
const cat = data.points[0].label;
|
|
if (cat) window.location.href = '/drafts?cat=' + encodeURIComponent(cat);
|
|
});
|
|
} else {
|
|
document.getElementById('categoryPie').innerHTML = '<p class="text-slate-500 text-sm text-center mt-20">No category data available</p>';
|
|
}
|
|
|
|
// --- Timeline (stacked area) ---
|
|
const timeline = {{ timeline | tojson }};
|
|
if (timeline.months && timeline.months.length > 0) {
|
|
const timeTraces = timeline.categories.map((cat, i) => ({
|
|
x: timeline.months,
|
|
y: timeline.series[cat],
|
|
name: cat,
|
|
type: 'scatter',
|
|
mode: 'lines',
|
|
stackgroup: 'one',
|
|
line: { width: 0.5, color: PALETTE[i % PALETTE.length] },
|
|
fillcolor: PALETTE[i % PALETTE.length] + '80',
|
|
hovertemplate: '%{x}<br>' + cat + ': %{y}<extra></extra>',
|
|
}));
|
|
Plotly.newPlot('timeline', timeTraces, {
|
|
...PLOTLY_LAYOUT,
|
|
xaxis: { ...PLOTLY_LAYOUT.xaxis, title: { text: 'Month', font: { size: 11 } } },
|
|
yaxis: { ...PLOTLY_LAYOUT.yaxis, title: { text: 'Drafts', font: { size: 11 } } },
|
|
legend: { font: { size: 10, color: '#94a3b8' }, orientation: 'h', y: -0.2, x: 0.5, xanchor: 'center' },
|
|
hovermode: 'x unified',
|
|
}, PLOTLY_CONFIG);
|
|
} else {
|
|
document.getElementById('timeline').innerHTML = '<p class="text-slate-500 text-sm text-center mt-20">No timeline data available</p>';
|
|
}
|
|
|
|
// --- Category radar ---
|
|
const radar = {{ radar | tojson }};
|
|
const dims = ['novelty', 'maturity', 'relevance', 'momentum', 'low_overlap'];
|
|
const dimLabels = ['Novelty', 'Maturity', 'Relevance', 'Momentum', 'Low Overlap'];
|
|
const radarCats = Object.keys(radar);
|
|
if (radarCats.length > 0) {
|
|
const radarTraces = radarCats.map((cat, i) => {
|
|
const vals = radar[cat];
|
|
return {
|
|
type: 'scatterpolar',
|
|
r: dims.map(d => vals[d]).concat([vals[dims[0]]]),
|
|
theta: dimLabels.concat([dimLabels[0]]),
|
|
fill: 'toself',
|
|
fillcolor: PALETTE[i % PALETTE.length] + '20',
|
|
line: { color: PALETTE[i % PALETTE.length], width: 2 },
|
|
name: cat + ' (' + vals.count + ')',
|
|
opacity: 0.85,
|
|
hovertemplate: cat + '<br>%{theta}: %{r:.1f}<extra></extra>',
|
|
};
|
|
});
|
|
Plotly.newPlot('radar', radarTraces, {
|
|
...PLOTLY_LAYOUT,
|
|
polar: {
|
|
bgcolor: 'transparent',
|
|
radialaxis: {
|
|
visible: true,
|
|
range: [0, 5],
|
|
gridcolor: '#1e293b',
|
|
color: '#64748b',
|
|
tickfont: { size: 10 },
|
|
},
|
|
angularaxis: {
|
|
gridcolor: '#1e293b',
|
|
color: '#94a3b8',
|
|
tickfont: { size: 11 },
|
|
},
|
|
},
|
|
legend: { font: { size: 10, color: '#94a3b8' }, x: 1.05, y: 0.5 },
|
|
margin: { t: 30, r: 120, b: 30, l: 60 },
|
|
}, PLOTLY_CONFIG);
|
|
} else {
|
|
document.getElementById('radar').innerHTML = '<p class="text-slate-500 text-sm text-center mt-20">No radar data available</p>';
|
|
}
|
|
</script>
|
|
{% endblock %}
|