Idea quality pipeline, web UI features, academic paper
- Tighten idea extraction prompts (1-4 ideas, no sub-features) reducing 1,907 ideas to 468 across 434 drafts (78% reduction) - Add embedding-based dedup (ietf dedup-ideas) for same-draft similarity - Add novelty scoring (ietf ideas score) and filtering (ietf ideas filter) using Claude to rate ideas 1-5, removing 49 generic building blocks - Final count: 419 high-quality ideas (avg 1.1/draft) - Web UI: gap explorer with live draft generation and pre-generated demos - Web UI: D3.js author collaboration network (498 nodes, 1142 edges, 68 clusters, org filtering, interactive zoom/pan) - Academic paper: 15-page LaTeX workshop paper analyzing the 434-draft AI agent standards landscape - Save improvement ideas backlog to data/reports/improvement-ideas.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
65
src/webui/templates/about.html
Normal file
65
src/webui/templates/about.html
Normal file
@@ -0,0 +1,65 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "about" %}
|
||||
|
||||
{% block title %}About — IETF Draft Analyzer{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-3xl">
|
||||
<h1 class="text-2xl font-bold text-white mb-6">About IETF Draft Analyzer</h1>
|
||||
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-3">What is this?</h2>
|
||||
<p class="text-sm text-slate-400 leading-relaxed mb-4">
|
||||
A tool for tracking, categorizing, rating, and mapping IETF Internet-Drafts
|
||||
focused on AI and agent-related topics. It uses Claude for analysis and rating,
|
||||
Ollama for embeddings, and SQLite for storage.
|
||||
</p>
|
||||
<p class="text-sm text-slate-400 leading-relaxed">
|
||||
The dashboard provides interactive visualizations of the draft landscape,
|
||||
including category breakdowns, rating distributions, author networks,
|
||||
extracted ideas, and gap analysis.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-3">Current Data</h2>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<div class="text-slate-500">Total Drafts</div>
|
||||
<div class="text-xl font-bold text-blue-400">{{ stats.total_drafts }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-slate-500">Rated Drafts</div>
|
||||
<div class="text-xl font-bold text-green-400">{{ stats.rated_count }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-slate-500">Authors Tracked</div>
|
||||
<div class="text-xl font-bold text-purple-400">{{ stats.author_count }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-slate-500">Ideas Extracted</div>
|
||||
<div class="text-xl font-bold text-amber-400">{{ stats.idea_count }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-slate-500">Gaps Identified</div>
|
||||
<div class="text-xl font-bold text-red-400">{{ stats.gap_count }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-slate-500">API Tokens Used</div>
|
||||
<div class="text-xl font-bold text-slate-300">{{ "{:,}".format(stats.input_tokens + stats.output_tokens) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-3">Tech Stack</h2>
|
||||
<ul class="text-sm text-slate-400 space-y-2">
|
||||
<li><span class="text-slate-200 font-medium">Analysis:</span> Claude (Sonnet for analysis, Haiku for bulk)</li>
|
||||
<li><span class="text-slate-200 font-medium">Embeddings:</span> Ollama (nomic-embed-text)</li>
|
||||
<li><span class="text-slate-200 font-medium">Storage:</span> SQLite with FTS5 full-text search</li>
|
||||
<li><span class="text-slate-200 font-medium">Dashboard:</span> Flask, Tailwind CSS, Plotly.js</li>
|
||||
<li><span class="text-slate-200 font-medium">Data source:</span> IETF Datatracker API</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
598
src/webui/templates/authors.html
Normal file
598
src/webui/templates/authors.html
Normal file
@@ -0,0 +1,598 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "authors" %}
|
||||
|
||||
{% block title %}Author Network — IETF Draft Analyzer{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<script src="https://d3js.org/d3.v7.min.js"></script>
|
||||
<style>
|
||||
#networkSvg {
|
||||
width: 100%;
|
||||
height: 600px;
|
||||
cursor: grab;
|
||||
}
|
||||
#networkSvg:active { cursor: grabbing; }
|
||||
#networkSvg .node { cursor: pointer; }
|
||||
#networkSvg .node circle { stroke: rgba(255,255,255,0.15); stroke-width: 1.5px; transition: r 0.2s; }
|
||||
#networkSvg .node:hover circle { stroke: #60a5fa; stroke-width: 2.5px; }
|
||||
#networkSvg .node text { pointer-events: none; }
|
||||
#networkSvg .link { stroke-opacity: 0.25; }
|
||||
#networkSvg .link:hover { stroke-opacity: 0.7; }
|
||||
.tooltip-card {
|
||||
position: absolute; pointer-events: none; z-index: 50;
|
||||
background: #1e293b; border: 1px solid #334155; border-radius: 8px;
|
||||
padding: 10px 14px; font-size: 12px; color: #e2e8f0;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
||||
max-width: 280px; opacity: 0; transition: opacity 0.15s;
|
||||
}
|
||||
.tooltip-card.visible { opacity: 1; }
|
||||
.legend-swatch { width: 12px; height: 12px; border-radius: 3px; display: inline-block; }
|
||||
.cluster-card { transition: all 0.2s; }
|
||||
.cluster-card:hover { border-color: #3b82f6 !important; }
|
||||
.filter-btn { transition: all 0.15s; }
|
||||
.filter-btn:hover { background: rgba(59, 130, 246, 0.2); }
|
||||
.filter-btn.active { background: rgba(59, 130, 246, 0.3); border-color: #3b82f6; color: #60a5fa; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">Author Network</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">Interactive collaboration graph of {{ network.nodes | length }} authors across {{ orgs | length }} organizations</p>
|
||||
</div>
|
||||
|
||||
<!-- Summary stats -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
|
||||
<div class="stat-card rounded-xl border border-slate-800 p-4 relative overflow-hidden">
|
||||
<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-xs text-slate-500 uppercase tracking-wider">Authors Shown</div>
|
||||
<div class="text-2xl font-bold text-white mt-1">{{ network.nodes | length }}</div>
|
||||
</div>
|
||||
<div class="stat-card rounded-xl border border-slate-800 p-4 relative overflow-hidden">
|
||||
<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-xs text-slate-500 uppercase tracking-wider">Organizations</div>
|
||||
<div class="text-2xl font-bold text-white mt-1">{{ orgs | length }}</div>
|
||||
</div>
|
||||
<div class="stat-card rounded-xl border border-slate-800 p-4 relative overflow-hidden">
|
||||
<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-xs text-slate-500 uppercase tracking-wider">Co-Author Links</div>
|
||||
<div class="text-2xl font-bold text-white mt-1">{{ network.edges | length }}</div>
|
||||
</div>
|
||||
<div class="stat-card rounded-xl border border-slate-800 p-4 relative overflow-hidden">
|
||||
<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-xs text-slate-500 uppercase tracking-wider">Clusters</div>
|
||||
<div class="text-2xl font-bold text-white mt-1">{{ network.clusters | length }}</div>
|
||||
</div>
|
||||
<div class="stat-card rounded-xl border border-slate-800 p-4 relative overflow-hidden">
|
||||
<div class="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-rose-500 to-rose-400"></div>
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wider">Multi-Draft</div>
|
||||
<div class="text-2xl font-bold text-white mt-1">{{ authors | selectattr('draft_count', 'gt', 1) | list | length }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- D3 Force-directed Network Graph -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5 mb-6 relative">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 mb-3">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-slate-300">Co-Authorship Network</h2>
|
||||
<p class="text-xs text-slate-500 mt-0.5">Node size = draft count. Color = organization. Edge thickness = shared drafts. Drag nodes to rearrange. Scroll to zoom.</p>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<button id="resetZoom" class="text-xs px-3 py-1.5 rounded-lg border border-slate-700 text-slate-400 hover:text-white hover:border-slate-500 transition">Reset View</button>
|
||||
<select id="highlightOrg" class="text-xs px-3 py-1.5 rounded-lg border border-slate-700 bg-slate-800 text-slate-300 focus:outline-none focus:border-blue-500">
|
||||
<option value="">All Organizations</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<svg id="networkSvg"></svg>
|
||||
<div id="tooltip" class="tooltip-card"></div>
|
||||
</div>
|
||||
<!-- Legend -->
|
||||
<div id="legend" class="flex flex-wrap gap-3 mt-3 pt-3 border-t border-slate-800"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<!-- Organization bar chart -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-300 mb-1">Organizations by Draft Count</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">Color intensity = number of authors from that org.</p>
|
||||
<div id="orgChart" style="height: 500px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Cross-org collaboration -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-300 mb-1">Cross-Organization Collaboration</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">Organizations co-authoring drafts together.</p>
|
||||
<div id="crossOrgChart" style="height: 500px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Collaboration Clusters -->
|
||||
{% if network.clusters %}
|
||||
<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">Collaboration Clusters</h2>
|
||||
<p class="text-xs text-slate-500 mb-4">Connected groups of authors who co-author drafts. Click a cluster to highlight it in the graph.</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" id="clusterGrid">
|
||||
{% for c in network.clusters[:12] %}
|
||||
<div class="cluster-card bg-slate-800/50 rounded-lg border border-slate-700/50 p-4 cursor-pointer" data-cluster-id="{{ c.id }}" onclick="highlightCluster({{ c.id }})">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm font-semibold text-white">Cluster #{{ c.id + 1 }}</span>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-blue-500/20 text-blue-400">{{ c.size }} authors</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1 mb-2">
|
||||
{% for org, count in c.org_mix.items() %}
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-slate-700 text-slate-300">{{ org }} ({{ count }})</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="text-xs text-slate-500 truncate" title="{{ c.members | join(', ') }}">
|
||||
{{ c.members[:5] | join(', ') }}{% if c.members | length > 5 %} +{{ c.members | length - 5 }} more{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Top Authors Table and Org Stats side by side -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
||||
<!-- Top authors table -->
|
||||
<div class="lg:col-span-2 bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
|
||||
<div class="p-4 border-b border-slate-800 flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold text-slate-300">Top Authors</h2>
|
||||
<span class="text-xs text-slate-500">Showing top {{ authors | length }}</span>
|
||||
</div>
|
||||
<div class="overflow-x-auto max-h-[600px] overflow-y-auto">
|
||||
<table class="w-full text-sm" id="authorsTable">
|
||||
<thead class="sticky top-0 z-10">
|
||||
<tr class="border-b border-slate-800 bg-slate-900">
|
||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-slate-400 cursor-pointer hover:text-slate-200" data-sort="index">#</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-slate-400 cursor-pointer hover:text-slate-200" data-sort="name">Author</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-slate-400 cursor-pointer hover:text-slate-200" data-sort="org">Organization</th>
|
||||
<th class="px-4 py-2.5 text-right text-xs font-medium text-slate-400 cursor-pointer hover:text-slate-200" data-sort="drafts">Drafts</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800/50" id="authorsBody">
|
||||
{% for a in authors %}
|
||||
<tr class="hover:bg-slate-800/50 transition author-row" data-name="{{ a.name }}" data-org="{{ a.affiliation }}" data-count="{{ a.draft_count }}">
|
||||
<td class="px-4 py-2.5 text-slate-500 text-xs">{{ loop.index }}</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<a href="/drafts?q={{ a.name | urlencode }}" class="text-blue-400 hover:text-blue-300 transition">{{ a.name }}</a>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-slate-500 text-xs truncate max-w-[200px]" title="{{ a.affiliation }}">{{ a.affiliation }}</td>
|
||||
<td class="px-4 py-2.5 text-right">
|
||||
<span class="px-2 py-0.5 rounded-full text-xs font-medium
|
||||
{% if a.draft_count >= 5 %}bg-green-500/20 text-green-400
|
||||
{% elif a.draft_count >= 3 %}bg-blue-500/20 text-blue-400
|
||||
{% else %}bg-slate-700/50 text-slate-400{% endif %}">
|
||||
{{ a.draft_count }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Organization stats cards -->
|
||||
<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">Organization Stats</h2>
|
||||
</div>
|
||||
<div class="max-h-[600px] overflow-y-auto divide-y divide-slate-800/50">
|
||||
{% for o in orgs %}
|
||||
<div class="px-4 py-3 hover:bg-slate-800/30 transition cursor-pointer org-card" data-org="{{ o.org }}" onclick="filterByOrg('{{ o.org | e }}')">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-sm text-slate-200 font-medium truncate max-w-[180px]" title="{{ o.org }}">{{ o.org }}</span>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-blue-500/20 text-blue-400">{{ o.draft_count }} drafts</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs text-slate-500">
|
||||
<span>{{ o.author_count }} author{{ 's' if o.author_count != 1 }}</span>
|
||||
<span class="text-slate-700">|</span>
|
||||
<span>{{ (o.draft_count / o.author_count) | round(1) }} drafts/author</span>
|
||||
</div>
|
||||
<div class="mt-1.5 w-full bg-slate-800 rounded-full h-1.5">
|
||||
<div class="bg-blue-500/60 h-1.5 rounded-full" style="width: {{ (o.draft_count / orgs[0].draft_count * 100) | round }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</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: 20, r: 20, b: 40, l: 50 },
|
||||
xaxis: { gridcolor: '#1e293b', zerolinecolor: '#334155' },
|
||||
yaxis: { gridcolor: '#1e293b', zerolinecolor: '#334155' },
|
||||
};
|
||||
const CFG = { responsive: true, displayModeBar: false };
|
||||
|
||||
const PALETTE = [
|
||||
'#3b82f6', '#ef4444', '#22c55e', '#a855f7', '#f59e0b',
|
||||
'#06b6d4', '#ec4899', '#84cc16', '#f97316', '#8b5cf6',
|
||||
'#14b8a6', '#e11d48', '#64748b', '#eab308', '#6366f1',
|
||||
'#fb923c', '#34d399', '#c084fc', '#38bdf8', '#fbbf24',
|
||||
];
|
||||
|
||||
// --- Data from server ---
|
||||
const network = {{ network | tojson }};
|
||||
|
||||
// ===========================================================
|
||||
// D3.js Force-Directed Co-Authorship Network
|
||||
// ===========================================================
|
||||
(function() {
|
||||
if (network.nodes.length === 0) {
|
||||
document.getElementById('networkSvg').outerHTML =
|
||||
'<p class="text-slate-500 text-sm text-center py-20">No co-authorship data available</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const svg = d3.select('#networkSvg');
|
||||
const container = svg.node().parentElement;
|
||||
const width = container.clientWidth;
|
||||
const height = 600;
|
||||
svg.attr('viewBox', [0, 0, width, height]);
|
||||
|
||||
// Build org color map (top orgs by frequency)
|
||||
const orgCounts = {};
|
||||
network.nodes.forEach(n => {
|
||||
if (n.org) orgCounts[n.org] = (orgCounts[n.org] || 0) + 1;
|
||||
});
|
||||
const orgsSorted = Object.entries(orgCounts).sort((a,b) => b[1] - a[1]);
|
||||
const orgColor = {};
|
||||
orgsSorted.forEach(([org], i) => {
|
||||
orgColor[org] = i < PALETTE.length ? PALETTE[i] : '#475569';
|
||||
});
|
||||
|
||||
// Populate org dropdown
|
||||
const orgSelect = document.getElementById('highlightOrg');
|
||||
orgsSorted.slice(0, 30).forEach(([org, cnt]) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = org;
|
||||
opt.textContent = `${org} (${cnt})`;
|
||||
orgSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
// Populate legend
|
||||
const legendEl = document.getElementById('legend');
|
||||
orgsSorted.slice(0, 12).forEach(([org]) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'flex items-center gap-1.5 text-xs text-slate-400';
|
||||
item.innerHTML = `<span class="legend-swatch" style="background:${orgColor[org]}"></span>${org}`;
|
||||
legendEl.appendChild(item);
|
||||
});
|
||||
|
||||
// Build cluster lookup
|
||||
const clusterOf = {};
|
||||
(network.clusters || []).forEach(c => {
|
||||
c.members.forEach(m => { clusterOf[m] = c.id; });
|
||||
});
|
||||
|
||||
// Prepare simulation data (deep copy to avoid mutating)
|
||||
const nodes = network.nodes.map(n => ({...n}));
|
||||
const links = network.edges.map(e => ({
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
weight: e.weight,
|
||||
}));
|
||||
|
||||
// Size scale
|
||||
const maxDrafts = d3.max(nodes, n => n.draft_count) || 1;
|
||||
const rScale = d3.scaleSqrt().domain([1, maxDrafts]).range([4, 22]);
|
||||
|
||||
// Force simulation
|
||||
const simulation = d3.forceSimulation(nodes)
|
||||
.force('link', d3.forceLink(links)
|
||||
.id(d => d.id)
|
||||
.distance(d => 80 / Math.sqrt(d.weight))
|
||||
.strength(d => 0.3 * d.weight)
|
||||
)
|
||||
.force('charge', d3.forceManyBody().strength(-120).distanceMax(300))
|
||||
.force('center', d3.forceCenter(width / 2, height / 2))
|
||||
.force('collision', d3.forceCollide().radius(d => rScale(d.draft_count) + 3))
|
||||
.force('x', d3.forceX(width / 2).strength(0.03))
|
||||
.force('y', d3.forceY(height / 2).strength(0.03));
|
||||
|
||||
// Zoom behavior
|
||||
const g = svg.append('g');
|
||||
const zoom = d3.zoom()
|
||||
.scaleExtent([0.2, 5])
|
||||
.on('zoom', (event) => g.attr('transform', event.transform));
|
||||
svg.call(zoom);
|
||||
|
||||
document.getElementById('resetZoom').addEventListener('click', () => {
|
||||
svg.transition().duration(500).call(zoom.transform, d3.zoomIdentity);
|
||||
});
|
||||
|
||||
// Draw edges
|
||||
const linkGroup = g.append('g').attr('class', 'links');
|
||||
const link = linkGroup.selectAll('line')
|
||||
.data(links)
|
||||
.join('line')
|
||||
.attr('class', 'link')
|
||||
.attr('stroke', '#475569')
|
||||
.attr('stroke-width', d => Math.max(1, d.weight * 1.5));
|
||||
|
||||
// Draw nodes
|
||||
const nodeGroup = g.append('g').attr('class', 'nodes');
|
||||
const node = nodeGroup.selectAll('g')
|
||||
.data(nodes)
|
||||
.join('g')
|
||||
.attr('class', 'node')
|
||||
.call(d3.drag()
|
||||
.on('start', dragStarted)
|
||||
.on('drag', dragged)
|
||||
.on('end', dragEnded)
|
||||
);
|
||||
|
||||
node.append('circle')
|
||||
.attr('r', d => rScale(d.draft_count))
|
||||
.attr('fill', d => orgColor[d.org] || '#475569')
|
||||
.attr('opacity', 0.85);
|
||||
|
||||
// Labels for nodes with 3+ drafts
|
||||
node.filter(d => d.draft_count >= 3)
|
||||
.append('text')
|
||||
.text(d => {
|
||||
const parts = d.name.split(' ');
|
||||
return parts[parts.length - 1];
|
||||
})
|
||||
.attr('dy', d => -(rScale(d.draft_count) + 4))
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('fill', '#94a3b8')
|
||||
.attr('font-size', '9px')
|
||||
.attr('font-family', 'Inter, system-ui, sans-serif');
|
||||
|
||||
// Tooltip
|
||||
const tooltip = document.getElementById('tooltip');
|
||||
|
||||
node.on('mouseover', function(event, d) {
|
||||
const draftList = (d.drafts || []).slice(0, 5).map(dn => {
|
||||
const short = dn.replace(/^draft-/, '');
|
||||
return `<div class="truncate text-slate-400">${short}</div>`;
|
||||
}).join('');
|
||||
const moreCount = (d.drafts || []).length > 5 ? `<div class="text-slate-500 mt-1">+${d.drafts.length - 5} more</div>` : '';
|
||||
|
||||
tooltip.innerHTML = `
|
||||
<div class="font-semibold text-white mb-1">${d.name}</div>
|
||||
<div class="text-slate-400 text-xs mb-2">${d.org || 'Unknown org'}</div>
|
||||
<div class="flex gap-4 text-xs mb-2">
|
||||
<span><span class="text-blue-400 font-medium">${d.draft_count}</span> drafts</span>
|
||||
<span>avg <span class="text-emerald-400 font-medium">${d.avg_score}</span></span>
|
||||
</div>
|
||||
<div class="text-xs">${draftList}${moreCount}</div>
|
||||
`;
|
||||
tooltip.classList.add('visible');
|
||||
|
||||
// Highlight connected
|
||||
const connected = new Set();
|
||||
links.forEach(l => {
|
||||
const sid = typeof l.source === 'object' ? l.source.id : l.source;
|
||||
const tid = typeof l.target === 'object' ? l.target.id : l.target;
|
||||
if (sid === d.id) connected.add(tid);
|
||||
if (tid === d.id) connected.add(sid);
|
||||
});
|
||||
connected.add(d.id);
|
||||
|
||||
node.select('circle')
|
||||
.attr('opacity', n => connected.has(n.id) ? 1 : 0.15);
|
||||
node.selectAll('text')
|
||||
.attr('opacity', n => connected.has(n.id) ? 1 : 0.15);
|
||||
link
|
||||
.attr('stroke-opacity', l => {
|
||||
const sid = typeof l.source === 'object' ? l.source.id : l.source;
|
||||
const tid = typeof l.target === 'object' ? l.target.id : l.target;
|
||||
return (sid === d.id || tid === d.id) ? 0.7 : 0.03;
|
||||
});
|
||||
})
|
||||
.on('mousemove', function(event) {
|
||||
const rect = container.getBoundingClientRect();
|
||||
tooltip.style.left = (event.clientX - rect.left + 15) + 'px';
|
||||
tooltip.style.top = (event.clientY - rect.top - 10) + 'px';
|
||||
})
|
||||
.on('mouseout', function() {
|
||||
tooltip.classList.remove('visible');
|
||||
node.select('circle').attr('opacity', 0.85);
|
||||
node.selectAll('text').attr('opacity', 1);
|
||||
link.attr('stroke-opacity', 0.25);
|
||||
})
|
||||
.on('click', function(event, d) {
|
||||
// Navigate to drafts search for this author
|
||||
window.open(`/drafts?q=${encodeURIComponent(d.name)}`, '_blank');
|
||||
});
|
||||
|
||||
// Tick handler
|
||||
simulation.on('tick', () => {
|
||||
link
|
||||
.attr('x1', d => d.source.x)
|
||||
.attr('y1', d => d.source.y)
|
||||
.attr('x2', d => d.target.x)
|
||||
.attr('y2', d => d.target.y);
|
||||
node.attr('transform', d => `translate(${d.x},${d.y})`);
|
||||
});
|
||||
|
||||
// Drag handlers
|
||||
function dragStarted(event, d) {
|
||||
if (!event.active) simulation.alphaTarget(0.3).restart();
|
||||
d.fx = d.x; d.fy = d.y;
|
||||
}
|
||||
function dragged(event, d) {
|
||||
d.fx = event.x; d.fy = event.y;
|
||||
}
|
||||
function dragEnded(event, d) {
|
||||
if (!event.active) simulation.alphaTarget(0);
|
||||
d.fx = null; d.fy = null;
|
||||
}
|
||||
|
||||
// Org filter dropdown
|
||||
orgSelect.addEventListener('change', function() {
|
||||
const org = this.value;
|
||||
if (!org) {
|
||||
node.select('circle').attr('opacity', 0.85);
|
||||
node.selectAll('text').attr('opacity', 1);
|
||||
link.attr('stroke-opacity', 0.25);
|
||||
return;
|
||||
}
|
||||
const inOrg = new Set(nodes.filter(n => n.org === org).map(n => n.id));
|
||||
node.select('circle')
|
||||
.attr('opacity', n => inOrg.has(n.id) ? 1 : 0.08);
|
||||
node.selectAll('text')
|
||||
.attr('opacity', n => inOrg.has(n.id) ? 1 : 0.08);
|
||||
link.attr('stroke-opacity', l => {
|
||||
const sid = typeof l.source === 'object' ? l.source.id : l.source;
|
||||
const tid = typeof l.target === 'object' ? l.target.id : l.target;
|
||||
return (inOrg.has(sid) && inOrg.has(tid)) ? 0.6 : 0.02;
|
||||
});
|
||||
});
|
||||
|
||||
// Expose cluster highlighting globally
|
||||
window.highlightCluster = function(clusterId) {
|
||||
const cluster = (network.clusters || []).find(c => c.id === clusterId);
|
||||
if (!cluster) return;
|
||||
const members = new Set(cluster.members);
|
||||
|
||||
// Reset org dropdown
|
||||
orgSelect.value = '';
|
||||
|
||||
node.select('circle')
|
||||
.transition().duration(300)
|
||||
.attr('opacity', n => members.has(n.id) ? 1 : 0.08);
|
||||
node.selectAll('text')
|
||||
.transition().duration(300)
|
||||
.attr('opacity', n => members.has(n.id) ? 1 : 0.08);
|
||||
link.transition().duration(300)
|
||||
.attr('stroke-opacity', l => {
|
||||
const sid = typeof l.source === 'object' ? l.source.id : l.source;
|
||||
const tid = typeof l.target === 'object' ? l.target.id : l.target;
|
||||
return (members.has(sid) && members.has(tid)) ? 0.7 : 0.02;
|
||||
});
|
||||
|
||||
// Highlight cluster card
|
||||
document.querySelectorAll('.cluster-card').forEach(c => {
|
||||
c.classList.toggle('border-blue-500', c.dataset.clusterId == clusterId);
|
||||
});
|
||||
|
||||
// Zoom to fit cluster members
|
||||
const clusterNodes = nodes.filter(n => members.has(n.id));
|
||||
if (clusterNodes.length > 0) {
|
||||
const xs = clusterNodes.map(n => n.x);
|
||||
const ys = clusterNodes.map(n => n.y);
|
||||
const x0 = Math.min(...xs) - 50, x1 = Math.max(...xs) + 50;
|
||||
const y0 = Math.min(...ys) - 50, y1 = Math.max(...ys) + 50;
|
||||
const scale = Math.min(width / (x1 - x0), height / (y1 - y0), 3);
|
||||
const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
|
||||
svg.transition().duration(500).call(
|
||||
zoom.transform,
|
||||
d3.zoomIdentity.translate(width/2, height/2).scale(scale).translate(-cx, -cy)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Filter by org (called from org stats cards)
|
||||
window.filterByOrg = function(org) {
|
||||
orgSelect.value = org;
|
||||
orgSelect.dispatchEvent(new Event('change'));
|
||||
};
|
||||
})();
|
||||
|
||||
// ===========================================================
|
||||
// Organization Bar Chart (Plotly)
|
||||
// ===========================================================
|
||||
const orgsData = {{ orgs_data | tojson }};
|
||||
const orgNames = orgsData.map(o => o.org).reverse();
|
||||
const orgDrafts = orgsData.map(o => o.draft_count).reverse();
|
||||
const orgAuthors = orgsData.map(o => o.author_count).reverse();
|
||||
|
||||
Plotly.newPlot('orgChart', [{
|
||||
y: orgNames, x: orgDrafts,
|
||||
type: 'bar', orientation: 'h',
|
||||
marker: {
|
||||
color: orgAuthors,
|
||||
colorscale: [[0, '#1e3a5f'], [0.5, '#3b82f6'], [1, '#60a5fa']],
|
||||
showscale: true,
|
||||
colorbar: {
|
||||
title: { text: 'Authors', font: { color: '#94a3b8', size: 10 } },
|
||||
tickfont: { color: '#94a3b8', size: 10 },
|
||||
thickness: 12, len: 0.5,
|
||||
},
|
||||
},
|
||||
text: orgDrafts.map((d, i) => `${d} drafts, ${orgAuthors[i]} authors`),
|
||||
textposition: 'none',
|
||||
hovertemplate: '<b>%{y}</b><br>%{text}<extra></extra>',
|
||||
}], {
|
||||
...PLOTLY_LAYOUT,
|
||||
margin: { t: 10, r: 80, b: 40, l: 180 },
|
||||
xaxis: { ...PLOTLY_LAYOUT.xaxis, title: { text: 'Draft Count', font: { size: 11 } } },
|
||||
}, CFG);
|
||||
|
||||
// ===========================================================
|
||||
// Cross-Org Collaboration Chart (Plotly)
|
||||
// ===========================================================
|
||||
const crossOrg = {{ cross_org | tojson }};
|
||||
if (crossOrg.length > 0) {
|
||||
const coLabels = crossOrg.map(c => `${c.org_a} + ${c.org_b}`).reverse();
|
||||
const coValues = crossOrg.map(c => c.shared_drafts).reverse();
|
||||
|
||||
Plotly.newPlot('crossOrgChart', [{
|
||||
y: coLabels, x: coValues,
|
||||
type: 'bar', orientation: 'h',
|
||||
marker: {
|
||||
color: coValues.map((v, i) => {
|
||||
const pct = i / Math.max(coValues.length - 1, 1);
|
||||
return `hsl(${160 + pct * 60}, 65%, 50%)`;
|
||||
}),
|
||||
},
|
||||
hovertemplate: '<b>%{y}</b><br>%{x} shared draft(s)<extra></extra>',
|
||||
}], {
|
||||
...PLOTLY_LAYOUT,
|
||||
margin: { t: 10, r: 40, b: 40, l: 240 },
|
||||
xaxis: { ...PLOTLY_LAYOUT.xaxis, title: { text: 'Shared Drafts', font: { size: 11 } }, dtick: 1 },
|
||||
yaxis: { ...PLOTLY_LAYOUT.yaxis, automargin: true, tickfont: { size: 10 } },
|
||||
}, CFG);
|
||||
} else {
|
||||
document.getElementById('crossOrgChart').innerHTML =
|
||||
'<p class="text-slate-500 text-sm text-center mt-20">No cross-org data available</p>';
|
||||
}
|
||||
|
||||
// ===========================================================
|
||||
// Sortable Authors Table
|
||||
// ===========================================================
|
||||
(function() {
|
||||
const table = document.getElementById('authorsTable');
|
||||
const tbody = document.getElementById('authorsBody');
|
||||
let sortCol = null, sortAsc = true;
|
||||
|
||||
table.querySelectorAll('th[data-sort]').forEach(th => {
|
||||
th.addEventListener('click', () => {
|
||||
const col = th.dataset.sort;
|
||||
if (sortCol === col) { sortAsc = !sortAsc; } else { sortCol = col; sortAsc = true; }
|
||||
|
||||
table.querySelectorAll('th[data-sort]').forEach(h =>
|
||||
h.textContent = h.textContent.replace(/ [▲▼]/, ''));
|
||||
th.textContent += sortAsc ? ' ▲' : ' ▼';
|
||||
|
||||
const rows = Array.from(tbody.querySelectorAll('tr'));
|
||||
rows.sort((a, b) => {
|
||||
let va, vb;
|
||||
if (col === 'name') { va = a.dataset.name.toLowerCase(); vb = b.dataset.name.toLowerCase(); }
|
||||
else if (col === 'org') { va = a.dataset.org.toLowerCase(); vb = b.dataset.org.toLowerCase(); }
|
||||
else if (col === 'drafts') { va = parseInt(a.dataset.count); vb = parseInt(b.dataset.count); }
|
||||
else { va = parseInt(a.cells[0].textContent); vb = parseInt(b.cells[0].textContent); }
|
||||
|
||||
if (typeof va === 'number') return sortAsc ? va - vb : vb - va;
|
||||
return sortAsc ? va.localeCompare(vb) : vb.localeCompare(va);
|
||||
});
|
||||
rows.forEach(r => tbody.appendChild(r));
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
165
src/webui/templates/base.html
Normal file
165
src/webui/templates/base.html
Normal file
@@ -0,0 +1,165 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}IETF Draft Analyzer{% endblock %}</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://cdn.plot.ly/plotly-2.35.0.min.js"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'monospace'],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body { font-family: 'Inter', system-ui, sans-serif; }
|
||||
.sidebar-link {
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.sidebar-link:hover, .sidebar-link.active {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #60a5fa;
|
||||
}
|
||||
.sidebar-link.active {
|
||||
border-right: 3px solid #3b82f6;
|
||||
}
|
||||
.stat-card {
|
||||
background: linear-gradient(135deg, rgba(30, 41, 59, 0.8), rgba(30, 41, 59, 0.4));
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.score-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 9999px;
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.score-high { background: rgba(34, 197, 94, 0.2); color: #4ade80; }
|
||||
.score-mid { background: rgba(234, 179, 8, 0.2); color: #facc15; }
|
||||
.score-low { background: rgba(239, 68, 68, 0.2); color: #f87171; }
|
||||
.dim-bar {
|
||||
display: inline-block;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: #3b82f6;
|
||||
vertical-align: middle;
|
||||
}
|
||||
/* Plotly dark overrides */
|
||||
.js-plotly-plot .plotly .modebar { right: 8px !important; }
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: #1e293b; }
|
||||
::-webkit-scrollbar-thumb { background: #475569; border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #64748b; }
|
||||
/* Mobile sidebar */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar { transform: translateX(-100%); }
|
||||
.sidebar.open { transform: translateX(0); }
|
||||
}
|
||||
</style>
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body class="bg-slate-950 text-slate-100 min-h-screen">
|
||||
<!-- Mobile menu button -->
|
||||
<button id="menuBtn" class="md:hidden fixed top-4 left-4 z-50 p-2 bg-slate-800 rounded-lg border border-slate-700">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside id="sidebar" class="sidebar fixed top-0 left-0 h-full w-60 bg-slate-900 border-r border-slate-800 z-40 flex flex-col transition-transform md:translate-x-0">
|
||||
<div class="p-5 border-b border-slate-800">
|
||||
<h1 class="text-lg font-bold text-white tracking-tight">IETF Draft Analyzer</h1>
|
||||
<p class="text-xs text-slate-500 mt-1">AI/Agent Standards Tracker</p>
|
||||
</div>
|
||||
<nav class="flex-1 py-4 overflow-y-auto">
|
||||
<a href="/" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'overview' }}">
|
||||
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/></svg>
|
||||
Overview
|
||||
</a>
|
||||
<a href="/drafts" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'drafts' }}">
|
||||
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 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>
|
||||
Draft Explorer
|
||||
</a>
|
||||
<a href="/ratings" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'ratings' }}">
|
||||
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"/></svg>
|
||||
Ratings
|
||||
</a>
|
||||
<a href="/ideas" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'ideas' }}">
|
||||
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>
|
||||
Ideas
|
||||
</a>
|
||||
<a href="/idea-clusters" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'idea_clusters' }}">
|
||||
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2z"/><circle cx="19" cy="19" r="3" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
||||
Idea Clusters
|
||||
</a>
|
||||
<a href="/gaps" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'gaps' }}">
|
||||
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"/></svg>
|
||||
Gap Explorer
|
||||
</a>
|
||||
<a href="/timeline" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'timeline' }}">
|
||||
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
Timeline
|
||||
</a>
|
||||
<a href="/landscape" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'landscape' }}">
|
||||
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"/></svg>
|
||||
Landscape
|
||||
</a>
|
||||
<a href="/similarity" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'similarity' }}">
|
||||
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/><circle cx="5" cy="6" r="1.5" fill="currentColor" stroke="none"/><circle cx="19" cy="18" r="1.5" fill="currentColor" stroke="none"/><circle cx="18" cy="6" r="1.5" fill="currentColor" stroke="none"/></svg>
|
||||
Similarity
|
||||
</a>
|
||||
<a href="/authors" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'authors' }}">
|
||||
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
|
||||
Authors
|
||||
</a>
|
||||
<a href="/monitor" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'monitor' }}">
|
||||
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5.636 18.364a9 9 0 010-12.728m12.728 0a9 9 0 010 12.728m-9.9-2.829a5 5 0 010-7.07m7.072 0a5 5 0 010 7.07M13 12a1 1 0 11-2 0 1 1 0 012 0z"/></svg>
|
||||
Monitor
|
||||
</a>
|
||||
<div class="border-t border-slate-800 mt-4 pt-4">
|
||||
<a href="/about" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'about' }}">
|
||||
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
About
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="p-4 border-t border-slate-800 text-xs text-slate-600">
|
||||
IETF Draft Analyzer v0.3
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="md:ml-60 min-h-screen">
|
||||
<div class="p-6 md:p-8 max-w-7xl mx-auto">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// Mobile sidebar toggle
|
||||
const menuBtn = document.getElementById('menuBtn');
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
menuBtn?.addEventListener('click', () => sidebar.classList.toggle('open'));
|
||||
// Close on click outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (window.innerWidth < 768 && sidebar.classList.contains('open') &&
|
||||
!sidebar.contains(e.target) && !menuBtn.contains(e.target)) {
|
||||
sidebar.classList.remove('open');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
298
src/webui/templates/draft_detail.html
Normal file
298
src/webui/templates/draft_detail.html
Normal file
@@ -0,0 +1,298 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "drafts" %}
|
||||
|
||||
{% block title %}{{ draft.name }} — IETF Draft Analyzer{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
.detail-card {
|
||||
background: linear-gradient(135deg, rgba(30, 41, 59, 0.8), rgba(30, 41, 59, 0.4));
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.score-ring {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
}
|
||||
.score-ring::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
padding: 4px;
|
||||
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
mask-composite: exclude;
|
||||
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
}
|
||||
.score-ring-high::before { background: linear-gradient(135deg, #22c55e, #4ade80); }
|
||||
.score-ring-mid::before { background: linear-gradient(135deg, #eab308, #facc15); }
|
||||
.score-ring-low::before { background: linear-gradient(135deg, #ef4444, #f87171); }
|
||||
.dim-progress {
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(51, 65, 85, 0.5);
|
||||
overflow: hidden;
|
||||
}
|
||||
.dim-progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.6s ease;
|
||||
}
|
||||
.dim-high { background: linear-gradient(90deg, #22c55e, #4ade80); }
|
||||
.dim-mid { background: linear-gradient(90deg, #eab308, #facc15); }
|
||||
.dim-low { background: linear-gradient(90deg, #ef4444, #f87171); }
|
||||
.idea-type-protocol { background: rgba(59, 130, 246, 0.15); color: #60a5fa; border-color: rgba(59, 130, 246, 0.3); }
|
||||
.idea-type-mechanism { background: rgba(168, 85, 247, 0.15); color: #c084fc; border-color: rgba(168, 85, 247, 0.3); }
|
||||
.idea-type-framework { background: rgba(34, 197, 94, 0.15); color: #4ade80; border-color: rgba(34, 197, 94, 0.3); }
|
||||
.idea-type-architecture { background: rgba(234, 179, 8, 0.15); color: #facc15; border-color: rgba(234, 179, 8, 0.3); }
|
||||
.idea-type-default { background: rgba(100, 116, 139, 0.15); color: #94a3b8; border-color: rgba(100, 116, 139, 0.3); }
|
||||
.ref-rfc { background: rgba(34, 197, 94, 0.15); color: #4ade80; }
|
||||
.ref-draft { background: rgba(59, 130, 246, 0.15); color: #60a5fa; }
|
||||
.ref-other { background: rgba(234, 179, 8, 0.15); color: #facc15; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Breadcrumb + Header -->
|
||||
<div class="mb-6">
|
||||
<a href="/drafts" class="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-300 transition group">
|
||||
<svg class="w-4 h-4 group-hover:-translate-x-0.5 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
Back to Explorer
|
||||
</a>
|
||||
<h1 class="text-xl font-bold text-white mt-3 leading-snug">{{ draft.title }}</h1>
|
||||
<div class="flex flex-wrap items-center gap-3 mt-2">
|
||||
<span class="text-sm text-slate-400 font-mono">{{ draft.name }}</span>
|
||||
{% if draft.rev %}
|
||||
<span class="text-xs px-2 py-0.5 rounded bg-slate-800 text-slate-500 border border-slate-700">rev {{ draft.rev }}</span>
|
||||
{% endif %}
|
||||
<span class="text-xs text-slate-600">{{ draft.date }}</span>
|
||||
{% if draft.rating %}
|
||||
<span class="score-badge {% if draft.rating.score >= 3.5 %}score-high{% elif draft.rating.score >= 2.5 %}score-mid{% else %}score-low{% endif %}">
|
||||
{{ draft.rating.score }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Left column: Main content -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<!-- Abstract -->
|
||||
<div class="detail-card rounded-xl border border-slate-800 p-6">
|
||||
<h2 class="text-sm font-semibold text-slate-300 mb-3 flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7"/></svg>
|
||||
Abstract
|
||||
</h2>
|
||||
<p class="text-sm text-slate-400 leading-relaxed">{{ draft.abstract or "No abstract available." }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Rating Analysis -->
|
||||
{% if draft.rating %}
|
||||
<div class="detail-card rounded-xl border border-slate-800 p-6">
|
||||
<h2 class="text-sm font-semibold text-slate-300 mb-3 flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
|
||||
AI Rating Analysis
|
||||
</h2>
|
||||
{% if draft.rating.summary %}
|
||||
<p class="text-sm text-slate-400 mb-5 leading-relaxed">{{ draft.rating.summary }}</p>
|
||||
{% endif %}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{% for dim, label, icon in [
|
||||
("novelty", "Novelty", "M13 10V3L4 14h7v7l9-11h-7z"),
|
||||
("maturity", "Maturity", "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"),
|
||||
("overlap", "Overlap", "M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"),
|
||||
("momentum", "Momentum", "M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"),
|
||||
("relevance", "Relevance", "M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z")
|
||||
] %}
|
||||
{% set val = draft.rating[dim] %}
|
||||
<div class="bg-slate-800/30 rounded-lg p-4 border border-slate-800/50">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-3.5 h-3.5 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="{{ icon }}"/></svg>
|
||||
<span class="text-xs font-semibold text-slate-300 uppercase tracking-wide">{{ label }}</span>
|
||||
</div>
|
||||
<span class="text-lg font-bold {% if val >= 4 %}text-green-400{% elif val >= 3 %}text-amber-400{% else %}text-red-400{% endif %}">{{ val }}<span class="text-xs text-slate-600 font-normal">/5</span></span>
|
||||
</div>
|
||||
<div class="dim-progress mb-2">
|
||||
<div class="dim-progress-fill {% if val >= 4 %}dim-high{% elif val >= 3 %}dim-mid{% else %}dim-low{% endif %}" style="width: {{ val * 20 }}%"></div>
|
||||
</div>
|
||||
{% if draft.rating[dim + '_note'] %}
|
||||
<p class="text-xs text-slate-500 leading-relaxed">{{ draft.rating[dim + '_note'] }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Ideas -->
|
||||
{% if draft.ideas %}
|
||||
<div class="detail-card rounded-xl border border-slate-800 p-6">
|
||||
<h2 class="text-sm font-semibold text-slate-300 mb-4 flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>
|
||||
Extracted Ideas <span class="text-slate-600 font-normal">({{ draft.ideas|length }})</span>
|
||||
</h2>
|
||||
<div class="space-y-3">
|
||||
{% for idea in draft.ideas %}
|
||||
<div class="bg-slate-800/30 rounded-lg p-4 border border-slate-800/50">
|
||||
<div class="flex items-start gap-2 mb-1">
|
||||
<span class="text-sm font-medium text-slate-200 leading-snug">{{ idea.title }}</span>
|
||||
{% if idea.type %}
|
||||
{% set type_lower = idea.type|lower %}
|
||||
<span class="flex-shrink-0 px-2 py-0.5 rounded-full text-[10px] font-medium border
|
||||
{% if type_lower == 'protocol' %}idea-type-protocol
|
||||
{% elif type_lower == 'mechanism' %}idea-type-mechanism
|
||||
{% elif type_lower == 'framework' %}idea-type-framework
|
||||
{% elif type_lower == 'architecture' %}idea-type-architecture
|
||||
{% else %}idea-type-default{% endif %}">
|
||||
{{ idea.type }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if idea.description %}
|
||||
<p class="text-xs text-slate-500 leading-relaxed mt-1">{{ idea.description }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Right column: Sidebar -->
|
||||
<div class="space-y-6">
|
||||
<!-- Score Card -->
|
||||
{% if draft.rating %}
|
||||
<div class="detail-card rounded-xl border border-slate-800 p-6 text-center">
|
||||
<div class="score-ring {% if draft.rating.score >= 3.5 %}score-ring-high{% elif draft.rating.score >= 2.5 %}score-ring-mid{% else %}score-ring-low{% endif %}">
|
||||
<div>
|
||||
<div class="text-3xl font-bold {% if draft.rating.score >= 3.5 %}text-green-400{% elif draft.rating.score >= 2.5 %}text-amber-400{% else %}text-red-400{% endif %}">
|
||||
{{ draft.rating.score }}
|
||||
</div>
|
||||
<div class="text-[10px] text-slate-500 uppercase tracking-wider">Score</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mini dimension summary -->
|
||||
<div class="mt-4 grid grid-cols-5 gap-1 text-center">
|
||||
{% for dim, abbr in [("novelty","N"), ("maturity","M"), ("overlap","O"), ("momentum","Mo"), ("relevance","R")] %}
|
||||
{% set v = draft.rating[dim] %}
|
||||
<div>
|
||||
<div class="text-xs font-bold {% if v >= 4 %}text-green-400{% elif v >= 3 %}text-amber-400{% else %}text-red-400{% endif %}">{{ v }}</div>
|
||||
<div class="text-[9px] text-slate-600 uppercase">{{ abbr }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Metadata -->
|
||||
<div class="detail-card rounded-xl border border-slate-800 p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-300 mb-3 flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
Metadata
|
||||
</h2>
|
||||
<dl class="space-y-2.5 text-sm">
|
||||
<div class="flex justify-between"><dt class="text-slate-500 text-xs">Date</dt><dd class="text-slate-300">{{ draft.date }}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-slate-500 text-xs">Revision</dt><dd class="text-slate-300">{{ draft.rev or 'N/A' }}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-slate-500 text-xs">Pages</dt><dd class="text-slate-300">{{ draft.pages or 'N/A' }}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-slate-500 text-xs">Words</dt><dd class="text-slate-300">{{ '{:,}'.format(draft.words) if draft.words else 'N/A' }}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-slate-500 text-xs">Working Group</dt><dd class="text-slate-300">{{ draft.group }}</dd></div>
|
||||
</dl>
|
||||
<div class="mt-4 space-y-2">
|
||||
<a href="{{ draft.url }}" target="_blank" rel="noopener"
|
||||
class="flex items-center justify-center gap-2 px-3 py-2 bg-blue-600 text-white rounded-lg text-xs font-medium hover:bg-blue-500 transition">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
|
||||
View on Datatracker
|
||||
</a>
|
||||
{% if draft.text_url %}
|
||||
<a href="{{ draft.text_url }}" target="_blank" rel="noopener"
|
||||
class="flex items-center justify-center gap-2 px-3 py-2 border border-slate-700 text-slate-300 rounded-lg text-xs font-medium hover:border-slate-500 hover:text-white transition">
|
||||
<svg class="w-3.5 h-3.5" 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>
|
||||
Read Full Text
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Authors -->
|
||||
{% if draft.authors %}
|
||||
<div class="detail-card rounded-xl border border-slate-800 p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-300 mb-3 flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||
Authors <span class="text-slate-600 font-normal">({{ draft.authors|length }})</span>
|
||||
</h2>
|
||||
<ul class="space-y-2.5">
|
||||
{% for a in draft.authors %}
|
||||
<li class="flex items-start gap-2">
|
||||
<div class="w-7 h-7 rounded-full bg-slate-800 border border-slate-700 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<span class="text-xs font-semibold text-slate-400">{{ a.name[0]|upper if a.name else '?' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<a href="/drafts?q={{ a.name | urlencode }}" class="text-sm text-blue-400 hover:text-blue-300 transition">{{ a.name }}</a>
|
||||
{% if a.affiliation %}
|
||||
<div class="text-xs text-slate-500">{{ a.affiliation }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Categories -->
|
||||
{% if draft.rating and draft.rating.categories %}
|
||||
<div class="detail-card rounded-xl border border-slate-800 p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-300 mb-3 flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/></svg>
|
||||
Categories
|
||||
</h2>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{% for cat in draft.rating.categories %}
|
||||
<a href="/drafts?cat={{ cat }}"
|
||||
class="px-2.5 py-1 rounded-full text-xs bg-slate-800/60 text-slate-400 border border-slate-700 hover:border-blue-500 hover:text-blue-400 transition">
|
||||
{{ cat }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- References -->
|
||||
{% if draft.refs %}
|
||||
<div class="detail-card rounded-xl border border-slate-800 p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-300 mb-3 flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/></svg>
|
||||
References <span class="text-slate-600 font-normal">({{ draft.refs|length }})</span>
|
||||
</h2>
|
||||
<div class="flex flex-wrap gap-1.5 max-h-48 overflow-y-auto">
|
||||
{% for ref in draft.refs %}
|
||||
{% if ref.type == 'rfc' %}
|
||||
<a href="https://www.rfc-editor.org/rfc/{{ ref.id }}" target="_blank" rel="noopener"
|
||||
class="px-2 py-0.5 rounded text-[10px] font-medium ref-rfc hover:opacity-80 transition">
|
||||
RFC {{ ref.id.replace('rfc', '') }}
|
||||
</a>
|
||||
{% elif ref.type == 'draft' %}
|
||||
<a href="/drafts/{{ ref.id }}"
|
||||
class="px-2 py-0.5 rounded text-[10px] font-medium ref-draft hover:opacity-80 transition">
|
||||
{{ ref.id }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="px-2 py-0.5 rounded text-[10px] font-medium ref-other">
|
||||
{{ ref.type|upper }} {{ ref.id }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
369
src/webui/templates/drafts.html
Normal file
369
src/webui/templates/drafts.html
Normal file
@@ -0,0 +1,369 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "drafts" %}
|
||||
|
||||
{% block title %}Draft Explorer — IETF Draft Analyzer{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
.filter-bar {
|
||||
background: linear-gradient(135deg, rgba(30, 41, 59, 0.8), rgba(30, 41, 59, 0.4));
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.draft-row {
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.draft-row:hover {
|
||||
background: rgba(59, 130, 246, 0.05);
|
||||
}
|
||||
.dim-bar-bg {
|
||||
display: inline-block;
|
||||
width: 40px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(51, 65, 85, 0.6);
|
||||
vertical-align: middle;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dim-bar-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.dim-fill-high { background: #4ade80; }
|
||||
.dim-fill-mid { background: #facc15; }
|
||||
.dim-fill-low { background: #f87171; }
|
||||
.cat-pill {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
background: rgba(51, 65, 85, 0.5);
|
||||
color: #94a3b8;
|
||||
border: 1px solid rgba(71, 85, 105, 0.4);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cat-pill-active {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
color: #60a5fa;
|
||||
border-color: rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
.range-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: #334155;
|
||||
outline: none;
|
||||
}
|
||||
.range-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #3b82f6;
|
||||
cursor: pointer;
|
||||
border: 2px solid #1e293b;
|
||||
}
|
||||
.range-slider::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #3b82f6;
|
||||
cursor: pointer;
|
||||
border: 2px solid #1e293b;
|
||||
}
|
||||
.page-btn {
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.page-btn-active {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
.page-btn-inactive {
|
||||
background: rgba(30, 41, 59, 0.6);
|
||||
border: 1px solid #334155;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.page-btn-inactive:hover {
|
||||
border-color: #475569;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">Draft Explorer</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">Browse, search, and filter {{ result.total }} rated Internet-Drafts on AI and agent topics.</p>
|
||||
</div>
|
||||
|
||||
<!-- Filter Bar -->
|
||||
<div class="filter-bar rounded-xl border border-slate-800 p-5 mb-6">
|
||||
<form method="get" action="/drafts" id="filterForm">
|
||||
<!-- Row 1: Search + Sort + Submit -->
|
||||
<div class="flex flex-wrap gap-3 items-end">
|
||||
<!-- Search -->
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label class="block text-xs font-medium text-slate-500 mb-1.5">Search</label>
|
||||
<input type="text" name="q" value="{{ search }}" placeholder="Search by name, title, or summary..."
|
||||
class="w-full bg-slate-800/60 border border-slate-700 rounded-lg px-4 py-2 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition">
|
||||
</div>
|
||||
<!-- Category dropdown -->
|
||||
<div class="min-w-[180px]">
|
||||
<label class="block text-xs font-medium text-slate-500 mb-1.5">Category</label>
|
||||
<select name="cat"
|
||||
class="w-full bg-slate-800/60 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-blue-500 transition appearance-none"
|
||||
style="background-image: url('data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 fill=%22none%22 viewBox=%220 0 20 20%22><path stroke=%22%236b7280%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22 stroke-width=%221.5%22 d=%22M6 8l4 4 4-4%22/></svg>'); background-position: right 0.5rem center; background-repeat: no-repeat; background-size: 1.2em 1.2em; padding-right: 2rem;">
|
||||
<option value="">All categories</option>
|
||||
{% for cat, count in categories.items() %}
|
||||
<option value="{{ cat }}" {% if current_cat == cat %}selected{% endif %}>{{ cat }} ({{ count }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<!-- Sort -->
|
||||
<div class="min-w-[150px]">
|
||||
<label class="block text-xs font-medium text-slate-500 mb-1.5">Sort by</label>
|
||||
<select name="sort"
|
||||
class="w-full bg-slate-800/60 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-blue-500 transition appearance-none"
|
||||
style="background-image: url('data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 fill=%22none%22 viewBox=%220 0 20 20%22><path stroke=%22%236b7280%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22 stroke-width=%221.5%22 d=%22M6 8l4 4 4-4%22/></svg>'); background-position: right 0.5rem center; background-repeat: no-repeat; background-size: 1.2em 1.2em; padding-right: 2rem;">
|
||||
<option value="score" {% if sort == 'score' %}selected{% endif %}>Score</option>
|
||||
<option value="date" {% if sort == 'date' %}selected{% endif %}>Date</option>
|
||||
<option value="novelty" {% if sort == 'novelty' %}selected{% endif %}>Novelty</option>
|
||||
<option value="maturity" {% if sort == 'maturity' %}selected{% endif %}>Maturity</option>
|
||||
<option value="relevance" {% if sort == 'relevance' %}selected{% endif %}>Relevance</option>
|
||||
<option value="momentum" {% if sort == 'momentum' %}selected{% endif %}>Momentum</option>
|
||||
<option value="overlap" {% if sort == 'overlap' %}selected{% endif %}>Overlap</option>
|
||||
<option value="name" {% if sort == 'name' %}selected{% endif %}>Name</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Sort direction -->
|
||||
<div class="min-w-[110px]">
|
||||
<label class="block text-xs font-medium text-slate-500 mb-1.5">Direction</label>
|
||||
<select name="dir"
|
||||
class="w-full bg-slate-800/60 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-blue-500 transition appearance-none"
|
||||
style="background-image: url('data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 fill=%22none%22 viewBox=%220 0 20 20%22><path stroke=%22%236b7280%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22 stroke-width=%221.5%22 d=%22M6 8l4 4 4-4%22/></svg>'); background-position: right 0.5rem center; background-repeat: no-repeat; background-size: 1.2em 1.2em; padding-right: 2rem;">
|
||||
<option value="desc" {% if sort_dir == 'desc' %}selected{% endif %}>Descending</option>
|
||||
<option value="asc" {% if sort_dir == 'asc' %}selected{% endif %}>Ascending</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Min Score slider -->
|
||||
<div class="mt-4 flex flex-wrap items-center gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="text-xs font-medium text-slate-500 whitespace-nowrap">Min Score:</label>
|
||||
<input type="range" name="min_score" id="scoreSlider" value="{{ min_score }}" step="0.5" min="0" max="5"
|
||||
class="range-slider w-40" oninput="document.getElementById('scoreVal').textContent = this.value">
|
||||
<span id="scoreVal" class="text-sm font-mono font-semibold text-blue-400 w-8">{{ min_score }}</span>
|
||||
</div>
|
||||
<div class="flex gap-2 ml-auto">
|
||||
<button type="submit" class="px-5 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-500 transition-colors">
|
||||
Apply Filters
|
||||
</button>
|
||||
<a href="/drafts" class="px-4 py-2 border border-slate-700 text-slate-400 rounded-lg text-sm hover:border-slate-500 hover:text-slate-300 transition-colors">
|
||||
Reset
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 3: Category pills (quick filter) -->
|
||||
{% if categories %}
|
||||
<div class="mt-4 pt-3 border-t border-slate-800/50">
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<a href="/drafts?q={{ search }}&min_score={{ min_score }}&sort={{ sort }}&dir={{ sort_dir }}"
|
||||
class="cat-pill {% if not current_cat %}cat-pill-active{% endif %}">All</a>
|
||||
{% for cat, count in categories.items() %}
|
||||
<a href="/drafts?cat={{ cat }}&q={{ search }}&min_score={{ min_score }}&sort={{ sort }}&dir={{ sort_dir }}"
|
||||
class="cat-pill {% if current_cat == cat %}cat-pill-active{% endif %}">
|
||||
{{ cat }} <span class="opacity-50">{{ count }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Results count -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<p class="text-sm text-slate-500">
|
||||
Showing <span class="text-slate-300 font-medium">{{ result.drafts|length }}</span> of
|
||||
<span class="text-slate-300 font-medium">{{ result.total }}</span> drafts
|
||||
{% if search %} matching "<span class="text-blue-400">{{ search }}</span>"{% endif %}
|
||||
{% if current_cat %} in <span class="text-blue-400">{{ current_cat }}</span>{% endif %}
|
||||
{% if min_score > 0 %} with score >= <span class="text-blue-400">{{ min_score }}</span>{% endif %}
|
||||
</p>
|
||||
{% if result.pages > 1 %}
|
||||
<p class="text-xs text-slate-600">Page {{ result.page }} of {{ result.pages }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Draft Table -->
|
||||
<div class="bg-slate-900/60 rounded-xl border border-slate-800 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-slate-800 bg-slate-900/80">
|
||||
{% macro sort_header(field, label, extra_class="", title="") %}
|
||||
{% set is_active = sort == field %}
|
||||
{% set next_dir = 'asc' if (is_active and sort_dir == 'desc') else 'desc' %}
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wide {{ extra_class }} {{ 'text-blue-400' if is_active else 'text-slate-500' }}">
|
||||
<a href="/drafts?q={{ search }}&cat={{ current_cat }}&min_score={{ min_score }}&sort={{ field }}&dir={{ next_dir }}"
|
||||
class="hover:text-blue-400 transition inline-flex items-center gap-1"
|
||||
{% if title %}title="{{ title }}"{% endif %}>
|
||||
{{ label }}
|
||||
{% if is_active %}
|
||||
<svg class="w-3 h-3 {{ 'rotate-180' if sort_dir == 'asc' else '' }}" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
{% endif %}
|
||||
</a>
|
||||
</th>
|
||||
{% endmacro %}
|
||||
{{ sort_header("score", "Score", "w-20") }}
|
||||
{{ sort_header("name", "Draft") }}
|
||||
{{ sort_header("date", "Date", "w-24 hidden md:table-cell") }}
|
||||
{{ sort_header("novelty", "Nov", "w-20 hidden lg:table-cell", "Novelty") }}
|
||||
{{ sort_header("maturity", "Mat", "w-20 hidden lg:table-cell", "Maturity") }}
|
||||
{{ sort_header("relevance", "Rel", "w-20 hidden lg:table-cell", "Relevance") }}
|
||||
{{ sort_header("momentum", "Mom", "w-20 hidden xl:table-cell", "Momentum") }}
|
||||
{{ sort_header("overlap", "Ovl", "w-20 hidden xl:table-cell", "Overlap") }}
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wide hidden md:table-cell">Categories</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800/30">
|
||||
{% for d in result.drafts %}
|
||||
<tr class="draft-row">
|
||||
<!-- Score badge -->
|
||||
<td class="px-4 py-3">
|
||||
<span class="score-badge {% if d.score >= 3.5 %}score-high{% elif d.score >= 2.5 %}score-mid{% else %}score-low{% endif %}">
|
||||
{{ d.score }}
|
||||
</span>
|
||||
</td>
|
||||
<!-- Draft name + title -->
|
||||
<td class="px-4 py-3">
|
||||
<a href="/drafts/{{ d.name }}" class="text-blue-400 hover:text-blue-300 font-medium text-sm transition">
|
||||
{{ d.title }}
|
||||
</a>
|
||||
<div class="text-xs text-slate-600 mt-0.5 font-mono">{{ d.name }}</div>
|
||||
{% if d.summary %}
|
||||
<div class="text-xs text-slate-500 mt-1 line-clamp-1 max-w-lg">{{ d.summary }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<!-- Date -->
|
||||
<td class="px-4 py-3 text-xs text-slate-500 hidden md:table-cell whitespace-nowrap">{{ d.date }}</td>
|
||||
<!-- Dimension bars -->
|
||||
{% macro dim_cell(value) %}
|
||||
<td class="px-4 py-3 hidden lg:table-cell">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="dim-bar-bg">
|
||||
<span class="dim-bar-fill {% if value >= 4 %}dim-fill-high{% elif value >= 3 %}dim-fill-mid{% else %}dim-fill-low{% endif %}"
|
||||
style="width: {{ (value / 5 * 100)|int }}%"></span>
|
||||
</span>
|
||||
<span class="text-xs text-slate-500 font-mono w-4 text-right">{{ value }}</span>
|
||||
</div>
|
||||
</td>
|
||||
{% endmacro %}
|
||||
{{ dim_cell(d.novelty) }}
|
||||
{{ dim_cell(d.maturity) }}
|
||||
{{ dim_cell(d.relevance) }}
|
||||
<td class="px-4 py-3 hidden xl:table-cell">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="dim-bar-bg">
|
||||
<span class="dim-bar-fill {% if d.momentum >= 4 %}dim-fill-high{% elif d.momentum >= 3 %}dim-fill-mid{% else %}dim-fill-low{% endif %}"
|
||||
style="width: {{ (d.momentum / 5 * 100)|int }}%"></span>
|
||||
</span>
|
||||
<span class="text-xs text-slate-500 font-mono w-4 text-right">{{ d.momentum }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 hidden xl:table-cell">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="dim-bar-bg">
|
||||
<span class="dim-bar-fill {% if d.overlap >= 4 %}dim-fill-high{% elif d.overlap >= 3 %}dim-fill-mid{% else %}dim-fill-low{% endif %}"
|
||||
style="width: {{ (d.overlap / 5 * 100)|int }}%"></span>
|
||||
</span>
|
||||
<span class="text-xs text-slate-500 font-mono w-4 text-right">{{ d.overlap }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<!-- Categories -->
|
||||
<td class="px-4 py-3 hidden md:table-cell">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{% for cat in d.categories[:3] %}
|
||||
<span class="cat-pill">{{ cat }}</span>
|
||||
{% endfor %}
|
||||
{% if d.categories|length > 3 %}
|
||||
<span class="cat-pill opacity-50">+{{ d.categories|length - 3 }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not result.drafts %}
|
||||
<tr>
|
||||
<td colspan="9" class="px-4 py-12 text-center text-slate-500">
|
||||
<svg class="w-12 h-12 mx-auto mb-3 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
<p class="text-sm">No drafts match your filters.</p>
|
||||
<a href="/drafts" class="text-blue-400 text-sm hover:text-blue-300 mt-1 inline-block">Clear all filters</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if result.pages > 1 %}
|
||||
<nav class="flex items-center justify-center gap-1.5 mt-6">
|
||||
{% if result.page > 1 %}
|
||||
<a href="/drafts?page={{ result.page - 1 }}&q={{ search }}&cat={{ current_cat }}&min_score={{ min_score }}&sort={{ sort }}&dir={{ sort_dir }}"
|
||||
class="page-btn page-btn-inactive">
|
||||
<svg class="w-4 h-4 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
|
||||
Prev
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% set start_page = [1, result.page - 2]|max %}
|
||||
{% set end_page = [result.pages, result.page + 2]|min %}
|
||||
|
||||
{% if start_page > 1 %}
|
||||
<a href="/drafts?page=1&q={{ search }}&cat={{ current_cat }}&min_score={{ min_score }}&sort={{ sort }}&dir={{ sort_dir }}"
|
||||
class="page-btn page-btn-inactive">1</a>
|
||||
{% if start_page > 2 %}<span class="text-slate-600 px-1">...</span>{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% for p in range(start_page, end_page + 1) %}
|
||||
{% if p == result.page %}
|
||||
<span class="page-btn page-btn-active">{{ p }}</span>
|
||||
{% else %}
|
||||
<a href="/drafts?page={{ p }}&q={{ search }}&cat={{ current_cat }}&min_score={{ min_score }}&sort={{ sort }}&dir={{ sort_dir }}"
|
||||
class="page-btn page-btn-inactive">{{ p }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if end_page < result.pages %}
|
||||
{% if end_page < result.pages - 1 %}<span class="text-slate-600 px-1">...</span>{% endif %}
|
||||
<a href="/drafts?page={{ result.pages }}&q={{ search }}&cat={{ current_cat }}&min_score={{ min_score }}&sort={{ sort }}&dir={{ sort_dir }}"
|
||||
class="page-btn page-btn-inactive">{{ result.pages }}</a>
|
||||
{% endif %}
|
||||
|
||||
{% if result.page < result.pages %}
|
||||
<a href="/drafts?page={{ result.page + 1 }}&q={{ search }}&cat={{ current_cat }}&min_score={{ min_score }}&sort={{ sort }}&dir={{ sort_dir }}"
|
||||
class="page-btn page-btn-inactive">
|
||||
Next
|
||||
<svg class="w-4 h-4 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||
</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
118
src/webui/templates/gap_demo.html
Normal file
118
src/webui/templates/gap_demo.html
Normal file
@@ -0,0 +1,118 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "gaps" %}
|
||||
|
||||
{% block title %}Draft Demo — Gap Explorer{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
.draft-viewer {
|
||||
max-height: 75vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.draft-viewer::-webkit-scrollbar { width: 6px; }
|
||||
.draft-viewer::-webkit-scrollbar-track { background: #0f172a; }
|
||||
.draft-viewer::-webkit-scrollbar-thumb { background: #334155; border-radius: 3px; }
|
||||
.draft-tab {
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.draft-tab:hover { background: rgba(59, 130, 246, 0.1); }
|
||||
.draft-tab.active {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
border-color: #3b82f6;
|
||||
color: #60a5fa;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="mb-6 text-sm">
|
||||
<a href="/gaps" class="text-blue-400 hover:text-blue-300 transition">Gap Explorer</a>
|
||||
<span class="text-slate-600 mx-2">/</span>
|
||||
<span class="text-slate-400">Demo Drafts</span>
|
||||
</nav>
|
||||
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">Generated Draft Demo</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">
|
||||
Pre-generated Internet-Drafts addressing identified gaps.
|
||||
These were generated by the gap-to-draft pipeline using Claude to write each section.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% if not generated_drafts %}
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-8 text-center">
|
||||
<svg class="w-12 h-12 text-slate-700 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 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>
|
||||
<p class="text-slate-500">No generated drafts found yet.</p>
|
||||
<p class="text-slate-600 text-sm mt-1">Use the gap detail page to generate one, or run <code class="text-blue-400">ietf draft-gen</code> from the CLI.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
<!-- Draft selector sidebar -->
|
||||
<div class="lg:col-span-1">
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-slate-800">
|
||||
<h3 class="text-sm font-semibold text-slate-300">{{ generated_drafts | length }} Generated Draft{{ 's' if generated_drafts | length != 1 }}</h3>
|
||||
</div>
|
||||
<div class="divide-y divide-slate-800/50">
|
||||
{% for gd in generated_drafts %}
|
||||
<a href="/gaps/demo?file={{ gd.filename | urlencode }}"
|
||||
class="draft-tab block px-4 py-3 border-l-2
|
||||
{% if (selected and gd.filename == selected) or (not selected and loop.first) %}active border-blue-500
|
||||
{% else %}border-transparent{% endif %}">
|
||||
<div class="text-xs font-medium text-slate-300 truncate">{{ gd.title }}</div>
|
||||
<div class="text-[10px] text-slate-600 mt-0.5 font-mono">{{ gd.stem }}</div>
|
||||
<div class="text-[10px] text-slate-600 mt-0.5">{{ (gd.size / 1024) | round(1) }} KB</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Draft viewer -->
|
||||
<div class="lg:col-span-3">
|
||||
{% if draft_text %}
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-slate-800">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-white">{{ draft_info.title if draft_info else 'Draft' }}</h3>
|
||||
<span class="text-[10px] text-slate-600 font-mono">{{ draft_info.filename if draft_info }}</span>
|
||||
</div>
|
||||
<button onclick="downloadCurrentDraft()" class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-medium rounded-lg transition">
|
||||
<svg class="w-3.5 h-3.5" 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 .txt
|
||||
</button>
|
||||
</div>
|
||||
<div class="draft-viewer p-4">
|
||||
<pre class="text-xs text-slate-300 font-mono leading-relaxed whitespace-pre-wrap">{{ draft_text }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-8 text-center">
|
||||
<p class="text-slate-500">Select a draft from the list to view it.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
function downloadCurrentDraft() {
|
||||
const text = {{ draft_text | tojson if draft_text else '""' }};
|
||||
const filename = {{ (draft_info.filename if draft_info else 'draft.txt') | tojson }};
|
||||
if (!text) return;
|
||||
const blob = new Blob([text], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
211
src/webui/templates/gap_detail.html
Normal file
211
src/webui/templates/gap_detail.html
Normal file
@@ -0,0 +1,211 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "gaps" %}
|
||||
|
||||
{% block title %}{{ gap.topic }} — Gap Explorer{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
.draft-output {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.draft-output::-webkit-scrollbar { width: 6px; }
|
||||
.draft-output::-webkit-scrollbar-track { background: #0f172a; }
|
||||
.draft-output::-webkit-scrollbar-thumb { background: #334155; border-radius: 3px; }
|
||||
.generating-spinner {
|
||||
display: inline-block;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 2px solid rgba(255,255,255,0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="mb-6 text-sm">
|
||||
<a href="/gaps" class="text-blue-400 hover:text-blue-300 transition">Gap Explorer</a>
|
||||
<span class="text-slate-600 mx-2">/</span>
|
||||
<span class="text-slate-400">{{ gap.topic }}</span>
|
||||
</nav>
|
||||
|
||||
<!-- Gap header -->
|
||||
<div class="bg-slate-900 rounded-xl border
|
||||
{% if gap.severity == 'critical' %}border-red-500/40
|
||||
{% elif gap.severity == 'high' %}border-orange-500/30
|
||||
{% elif gap.severity == 'medium' %}border-yellow-500/20
|
||||
{% else %}border-slate-800{% endif %}
|
||||
p-6 mb-6">
|
||||
|
||||
<div class="flex items-start justify-between gap-4 mb-4">
|
||||
<h1 class="text-2xl font-bold text-white">{{ gap.topic }}</h1>
|
||||
<span class="px-3 py-1 rounded-full text-xs font-bold whitespace-nowrap
|
||||
{% if gap.severity == 'critical' %}bg-red-500/20 text-red-400 ring-1 ring-red-500/30
|
||||
{% elif gap.severity == 'high' %}bg-orange-500/20 text-orange-400 ring-1 ring-orange-500/30
|
||||
{% elif gap.severity == 'medium' %}bg-yellow-500/20 text-yellow-400 ring-1 ring-yellow-500/30
|
||||
{% else %}bg-green-500/20 text-green-400 ring-1 ring-green-500/30{% endif %}">
|
||||
{{ gap.severity | upper }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if gap.category %}
|
||||
<span class="inline-block px-2.5 py-0.5 rounded text-xs bg-slate-800 text-slate-400 mb-4 font-medium">{{ gap.category }}</span>
|
||||
{% endif %}
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">Description</h3>
|
||||
<p class="text-sm text-slate-300 leading-relaxed">{{ gap.description }}</p>
|
||||
</div>
|
||||
|
||||
{% if gap.evidence %}
|
||||
<div class="bg-slate-800/50 rounded-lg p-4">
|
||||
<h3 class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">Evidence</h3>
|
||||
<p class="text-sm text-slate-400 leading-relaxed">{{ gap.evidence }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Links -->
|
||||
<div class="mt-4 pt-4 border-t border-slate-800/50 flex flex-wrap gap-3">
|
||||
<a href="/drafts?q={{ gap.topic | urlencode }}" class="inline-flex items-center gap-1.5 text-xs text-blue-400/70 hover:text-blue-400 transition">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
||||
Search related drafts
|
||||
</a>
|
||||
{% if gap.category %}
|
||||
<span class="text-slate-700">|</span>
|
||||
<a href="/drafts?cat={{ gap.category | urlencode }}" class="inline-flex items-center gap-1.5 text-xs text-blue-400/70 hover:text-blue-400 transition">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 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>
|
||||
Browse {{ gap.category }} drafts
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Draft Generation Section -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">Generate Internet-Draft</h2>
|
||||
<p class="text-xs text-slate-500 mt-1">Use AI to generate a full Internet-Draft addressing this gap</p>
|
||||
</div>
|
||||
<button id="generateBtn" onclick="generateDraft({{ gap.id }})"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 disabled:bg-slate-700 disabled:text-slate-500 text-white text-sm font-medium rounded-lg transition">
|
||||
<svg id="genIcon" 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="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
||||
<span id="genText">Generate Draft</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Status area -->
|
||||
<div id="genStatus" class="hidden mb-4 p-3 rounded-lg bg-blue-500/10 border border-blue-500/20">
|
||||
<div class="flex items-center gap-2 text-sm text-blue-400">
|
||||
<span class="generating-spinner"></span>
|
||||
<span id="statusText">Generating draft... This may take 1-2 minutes.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error area -->
|
||||
<div id="genError" class="hidden mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20">
|
||||
<p class="text-sm text-red-400" id="errorText"></p>
|
||||
</div>
|
||||
|
||||
<!-- Generated draft output -->
|
||||
<div id="draftOutput" class="hidden">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-sm font-semibold text-slate-300">Generated Draft</h3>
|
||||
<button onclick="downloadDraft()" class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-medium rounded-lg transition">
|
||||
<svg class="w-3.5 h-3.5" 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 .txt
|
||||
</button>
|
||||
</div>
|
||||
<div class="draft-output bg-slate-950 rounded-lg border border-slate-800 p-4">
|
||||
<pre id="draftText" class="text-xs text-slate-300 font-mono leading-relaxed whitespace-pre-wrap"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hint to demo -->
|
||||
<div id="demoHint" class="mt-4 text-xs text-slate-600">
|
||||
Want to see what generated drafts look like without waiting?
|
||||
<a href="/gaps/demo" class="text-blue-500 hover:text-blue-400 transition">View the demo page</a>
|
||||
with {{ generated_drafts | length }} pre-generated examples.
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
let generatedText = '';
|
||||
let generatedFilename = '';
|
||||
|
||||
function generateDraft(gapId) {
|
||||
const btn = document.getElementById('generateBtn');
|
||||
const genIcon = document.getElementById('genIcon');
|
||||
const genText = document.getElementById('genText');
|
||||
const status = document.getElementById('genStatus');
|
||||
const error = document.getElementById('genError');
|
||||
const output = document.getElementById('draftOutput');
|
||||
const hint = document.getElementById('demoHint');
|
||||
|
||||
// Disable button, show spinner
|
||||
btn.disabled = true;
|
||||
genIcon.innerHTML = '';
|
||||
genIcon.classList.add('generating-spinner');
|
||||
genText.textContent = 'Generating...';
|
||||
status.classList.remove('hidden');
|
||||
error.classList.add('hidden');
|
||||
output.classList.add('hidden');
|
||||
hint.classList.add('hidden');
|
||||
|
||||
fetch(`/gaps/${gapId}/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
status.classList.add('hidden');
|
||||
if (data.error) {
|
||||
error.classList.remove('hidden');
|
||||
document.getElementById('errorText').textContent = data.error;
|
||||
btn.disabled = false;
|
||||
genIcon.classList.remove('generating-spinner');
|
||||
genIcon.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>';
|
||||
genText.textContent = 'Retry';
|
||||
} else {
|
||||
generatedText = data.text;
|
||||
generatedFilename = data.filename || 'generated-draft.txt';
|
||||
document.getElementById('draftText').textContent = data.text;
|
||||
output.classList.remove('hidden');
|
||||
genIcon.classList.remove('generating-spinner');
|
||||
genIcon.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>';
|
||||
genText.textContent = 'Done';
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
status.classList.add('hidden');
|
||||
error.classList.remove('hidden');
|
||||
document.getElementById('errorText').textContent = 'Network error: ' + err.message;
|
||||
btn.disabled = false;
|
||||
genIcon.classList.remove('generating-spinner');
|
||||
genIcon.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>';
|
||||
genText.textContent = 'Retry';
|
||||
});
|
||||
}
|
||||
|
||||
function downloadDraft() {
|
||||
if (!generatedText) return;
|
||||
const blob = new Blob([generatedText], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = generatedFilename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
89
src/webui/templates/gaps.html
Normal file
89
src/webui/templates/gaps.html
Normal file
@@ -0,0 +1,89 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "gaps" %}
|
||||
|
||||
{% block title %}Gap Explorer — IETF Draft Analyzer{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">Gap Explorer</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">{{ gaps | length }} identified gaps in AI/agent standards coverage — click any gap to explore details or generate a draft</p>
|
||||
</div>
|
||||
|
||||
<!-- Action bar -->
|
||||
<div class="mb-6 flex flex-wrap gap-3">
|
||||
<a href="/gaps/demo" class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium rounded-lg transition">
|
||||
<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="M9 12h6m-6 4h6m2 5H7a2 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>
|
||||
View Demo Draft
|
||||
</a>
|
||||
{% if generated_drafts %}
|
||||
<span class="inline-flex items-center px-3 py-2 bg-slate-800 text-slate-400 text-sm rounded-lg">
|
||||
{{ generated_drafts | length }} draft{{ 's' if generated_drafts | length != 1 }} already generated
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Severity overview -->
|
||||
{% set ns = namespace(critical=0, high=0, medium=0, low=0) %}
|
||||
{% for gap in gaps %}
|
||||
{% if gap.severity == 'critical' %}{% set ns.critical = ns.critical + 1 %}
|
||||
{% elif gap.severity == 'high' %}{% set ns.high = ns.high + 1 %}
|
||||
{% elif gap.severity == 'medium' %}{% set ns.medium = ns.medium + 1 %}
|
||||
{% else %}{% set ns.low = ns.low + 1 %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
|
||||
<div class="stat-card rounded-xl border border-slate-800 p-4">
|
||||
<div class="text-3xl font-bold text-slate-200">{{ gaps | length }}</div>
|
||||
<div class="text-xs text-slate-400 mt-1">Total Gaps</div>
|
||||
</div>
|
||||
<div class="stat-card rounded-xl border border-red-500/30 p-4">
|
||||
<div class="text-3xl font-bold text-red-400">{{ ns.critical }}</div>
|
||||
<div class="text-xs text-red-400/70 mt-1">Critical</div>
|
||||
</div>
|
||||
<div class="stat-card rounded-xl border border-orange-500/30 p-4">
|
||||
<div class="text-3xl font-bold text-orange-400">{{ ns.high }}</div>
|
||||
<div class="text-xs text-orange-400/70 mt-1">High</div>
|
||||
</div>
|
||||
<div class="stat-card rounded-xl border border-yellow-500/30 p-4">
|
||||
<div class="text-3xl font-bold text-yellow-400">{{ ns.medium }}</div>
|
||||
<div class="text-xs text-yellow-400/70 mt-1">Medium</div>
|
||||
</div>
|
||||
<div class="stat-card rounded-xl border border-green-500/30 p-4">
|
||||
<div class="text-3xl font-bold text-green-400">{{ ns.low }}</div>
|
||||
<div class="text-xs text-green-400/70 mt-1">Low</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gap cards sorted by severity -->
|
||||
<div class="space-y-4">
|
||||
{% for gap in gaps | sort(attribute='severity') %}
|
||||
<a href="/gaps/{{ gap.id }}" class="block bg-slate-900 rounded-xl border
|
||||
{% if gap.severity == 'critical' %}border-red-500/40 hover:border-red-500/60
|
||||
{% elif gap.severity == 'high' %}border-orange-500/30 hover:border-orange-500/50
|
||||
{% elif gap.severity == 'medium' %}border-yellow-500/20 hover:border-yellow-500/40
|
||||
{% else %}border-slate-800 hover:border-slate-700{% endif %}
|
||||
p-5 transition group">
|
||||
<div class="flex items-start justify-between gap-3 mb-3">
|
||||
<h2 class="text-base font-semibold text-white group-hover:text-blue-400 transition">{{ gap.topic }}</h2>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<span class="px-2.5 py-0.5 rounded-full text-xs font-semibold whitespace-nowrap
|
||||
{% if gap.severity == 'critical' %}bg-red-500/20 text-red-400 ring-1 ring-red-500/30
|
||||
{% elif gap.severity == 'high' %}bg-orange-500/20 text-orange-400 ring-1 ring-orange-500/30
|
||||
{% elif gap.severity == 'medium' %}bg-yellow-500/20 text-yellow-400 ring-1 ring-yellow-500/30
|
||||
{% else %}bg-green-500/20 text-green-400 ring-1 ring-green-500/30{% endif %}">
|
||||
{{ gap.severity | upper }}
|
||||
</span>
|
||||
<svg class="w-4 h-4 text-slate-600 group-hover:text-blue-400 transition" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if gap.category %}
|
||||
<span class="inline-block px-2 py-0.5 rounded text-[10px] bg-slate-800 text-slate-400 mb-3 font-medium">{{ gap.category }}</span>
|
||||
{% endif %}
|
||||
|
||||
<p class="text-sm text-slate-400 leading-relaxed">{{ gap.description }}</p>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
200
src/webui/templates/idea_clusters.html
Normal file
200
src/webui/templates/idea_clusters.html
Normal file
@@ -0,0 +1,200 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "idea_clusters" %}
|
||||
|
||||
{% block title %}Idea Clusters — IETF Draft Analyzer{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">Idea Clusters</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">Extracted ideas grouped by semantic similarity using embedding-based clustering</p>
|
||||
</div>
|
||||
|
||||
<div id="emptyState" class="hidden">
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-12 text-center">
|
||||
<svg class="w-16 h-16 mx-auto text-slate-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2z"/>
|
||||
</svg>
|
||||
<h2 class="text-lg font-semibold text-slate-300 mb-2">No idea embeddings found</h2>
|
||||
<p class="text-slate-500">Run <code class="bg-slate-800 px-2 py-1 rounded text-sm font-mono text-blue-400">ietf embed-ideas</code> to generate embeddings first.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="clusterContent" class="hidden">
|
||||
<!-- Stat cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div class="stat-card rounded-xl border border-slate-800 p-5">
|
||||
<p class="text-xs text-slate-500 uppercase tracking-wide">Total Ideas Embedded</p>
|
||||
<p class="text-2xl font-bold text-white mt-1" id="statTotal">0</p>
|
||||
</div>
|
||||
<div class="stat-card rounded-xl border border-slate-800 p-5">
|
||||
<p class="text-xs text-slate-500 uppercase tracking-wide">Clusters Found</p>
|
||||
<p class="text-2xl font-bold text-white mt-1" id="statClusters">0</p>
|
||||
</div>
|
||||
<div class="stat-card rounded-xl border border-slate-800 p-5">
|
||||
<p class="text-xs text-slate-500 uppercase tracking-wide">Avg Cluster Size</p>
|
||||
<p class="text-2xl font-bold text-white mt-1" id="statAvgSize">0</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- t-SNE Scatter -->
|
||||
<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">Idea Embedding Space (t-SNE)</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">Each dot is an extracted idea, colored by cluster. Hover for details, click to view the source draft.</p>
|
||||
<div id="scatterPlot" style="height: 560px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Treemap -->
|
||||
<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">Cluster Sizes</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">Treemap showing relative sizes of each idea cluster.</p>
|
||||
<div id="treemapPlot" style="height: 450px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Cluster cards grid -->
|
||||
<h2 class="text-lg font-semibold text-white mb-4">Cluster Details</h2>
|
||||
<div id="clusterGrid" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 mb-6">
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
const PLOTLY_LAYOUT = {
|
||||
paper_bgcolor: 'transparent', plot_bgcolor: 'rgba(15,23,42,0.5)',
|
||||
font: { color: '#94a3b8', family: 'Inter, system-ui, sans-serif', size: 12 },
|
||||
margin: { t: 20, r: 20, b: 50, l: 50 },
|
||||
xaxis: { gridcolor: '#1e293b', zerolinecolor: '#334155' },
|
||||
yaxis: { gridcolor: '#1e293b', zerolinecolor: '#334155' },
|
||||
};
|
||||
const CFG = { responsive: true, displayModeBar: false };
|
||||
|
||||
const PALETTE = [
|
||||
'#3b82f6', '#ef4444', '#22c55e', '#a855f7', '#f59e0b',
|
||||
'#06b6d4', '#ec4899', '#84cc16', '#f97316', '#8b5cf6',
|
||||
'#14b8a6', '#e11d48', '#64748b', '#eab308', '#6366f1',
|
||||
];
|
||||
|
||||
const data = {{ clusters | tojson }};
|
||||
|
||||
if (data.empty) {
|
||||
document.getElementById('emptyState').classList.remove('hidden');
|
||||
} else {
|
||||
document.getElementById('clusterContent').classList.remove('hidden');
|
||||
|
||||
// Stats
|
||||
const stats = data.stats;
|
||||
document.getElementById('statTotal').textContent = stats.total.toLocaleString();
|
||||
document.getElementById('statClusters').textContent = stats.num_clusters.toLocaleString();
|
||||
document.getElementById('statAvgSize').textContent = stats.num_clusters > 0
|
||||
? (stats.clustered / stats.num_clusters).toFixed(1) : '0';
|
||||
|
||||
// --- t-SNE Scatter ---
|
||||
if (data.scatter.length > 0) {
|
||||
// Group by cluster_id
|
||||
const groups = {};
|
||||
data.scatter.forEach(pt => {
|
||||
if (!groups[pt.cluster_id]) groups[pt.cluster_id] = { x: [], y: [], text: [], names: [] };
|
||||
groups[pt.cluster_id].x.push(pt.x);
|
||||
groups[pt.cluster_id].y.push(pt.y);
|
||||
groups[pt.cluster_id].text.push(pt.title);
|
||||
groups[pt.cluster_id].names.push(pt.draft_name);
|
||||
});
|
||||
|
||||
// Map cluster_id to cluster theme
|
||||
const clusterThemes = {};
|
||||
data.clusters.forEach((c, i) => {
|
||||
// Find the original cluster_id by matching scatter points
|
||||
});
|
||||
|
||||
const clusterIds = Object.keys(groups).sort((a, b) => (groups[b].x.length - groups[a].x.length));
|
||||
const traces = clusterIds.map((cid, i) => {
|
||||
const g = groups[cid];
|
||||
const theme = data.clusters[i] ? data.clusters[i].theme : `Cluster ${cid}`;
|
||||
return {
|
||||
x: g.x, y: g.y, text: g.text, name: theme,
|
||||
customdata: g.names,
|
||||
mode: 'markers', type: 'scatter',
|
||||
marker: {
|
||||
size: 6,
|
||||
color: PALETTE[i % PALETTE.length],
|
||||
opacity: 0.8,
|
||||
line: { width: 0.5, color: 'rgba(255,255,255,0.15)' },
|
||||
},
|
||||
hovertemplate: '<b>%{text}</b><extra>%{customdata}</extra>',
|
||||
};
|
||||
});
|
||||
|
||||
Plotly.newPlot('scatterPlot', traces, {
|
||||
...PLOTLY_LAYOUT,
|
||||
xaxis: { visible: false, showgrid: false, zeroline: false },
|
||||
yaxis: { visible: false, showgrid: false, zeroline: false },
|
||||
legend: { font: { size: 10, color: '#94a3b8' }, bgcolor: 'transparent' },
|
||||
hovermode: 'closest',
|
||||
margin: { t: 10, r: 20, b: 10, l: 20 },
|
||||
}, CFG);
|
||||
|
||||
document.getElementById('scatterPlot').on('plotly_click', function(ev) {
|
||||
const pt = ev.points[0];
|
||||
if (pt.customdata) {
|
||||
window.location.href = '/drafts/' + pt.customdata;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Treemap ---
|
||||
if (data.clusters.length > 0) {
|
||||
const labels = data.clusters.map(c => c.theme);
|
||||
const values = data.clusters.map(c => c.size);
|
||||
const colors = data.clusters.map((_, i) => PALETTE[i % PALETTE.length]);
|
||||
|
||||
Plotly.newPlot('treemapPlot', [{
|
||||
type: 'treemap',
|
||||
labels: labels,
|
||||
parents: labels.map(() => ''),
|
||||
values: values,
|
||||
textinfo: 'label+value',
|
||||
marker: { colors: colors },
|
||||
hovertemplate: '<b>%{label}</b><br>%{value} ideas<extra></extra>',
|
||||
}], {
|
||||
...PLOTLY_LAYOUT,
|
||||
margin: { t: 10, r: 10, b: 10, l: 10 },
|
||||
}, CFG);
|
||||
}
|
||||
|
||||
// --- Cluster Cards ---
|
||||
const grid = document.getElementById('clusterGrid');
|
||||
data.clusters.forEach((cluster, i) => {
|
||||
const color = PALETTE[i % PALETTE.length];
|
||||
const topIdeas = cluster.ideas.slice(0, 3);
|
||||
const ideaListHtml = topIdeas.map(idea =>
|
||||
`<li class="text-xs text-slate-400 truncate" title="${idea.title}">${idea.title}</li>`
|
||||
).join('');
|
||||
const extraCount = cluster.size - topIdeas.length;
|
||||
const extraHtml = extraCount > 0
|
||||
? `<li class="text-xs text-slate-600">+${extraCount} more</li>` : '';
|
||||
|
||||
const draftBadges = cluster.drafts.slice(0, 4).map(d =>
|
||||
`<a href="/drafts/${d}" class="inline-block bg-slate-800 text-slate-400 text-xs px-2 py-0.5 rounded hover:text-blue-400 truncate max-w-[140px]" title="${d}">${d.replace('draft-', '').substring(0, 20)}</a>`
|
||||
).join(' ');
|
||||
const extraDrafts = cluster.drafts.length > 4
|
||||
? `<span class="text-xs text-slate-600">+${cluster.drafts.length - 4}</span>` : '';
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'bg-slate-900 rounded-xl border border-slate-800 p-5';
|
||||
card.innerHTML = `
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-3 h-3 rounded-full" style="background: ${color}"></div>
|
||||
<h3 class="text-sm font-semibold text-white">${cluster.theme}</h3>
|
||||
<span class="ml-auto text-xs text-slate-500">${cluster.size} ideas</span>
|
||||
</div>
|
||||
<ul class="space-y-1 mb-3">${ideaListHtml}${extraHtml}</ul>
|
||||
<div class="border-t border-slate-800 pt-3">
|
||||
<p class="text-xs text-slate-500 mb-1">${cluster.drafts.length} source draft${cluster.drafts.length !== 1 ? 's' : ''}</p>
|
||||
<div class="flex flex-wrap gap-1">${draftBadges}${extraDrafts}</div>
|
||||
</div>
|
||||
`;
|
||||
grid.appendChild(card);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
124
src/webui/templates/ideas.html
Normal file
124
src/webui/templates/ideas.html
Normal file
@@ -0,0 +1,124 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "ideas" %}
|
||||
|
||||
{% block title %}Ideas — IETF Draft Analyzer{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">Extracted Ideas</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">{{ data.total }} technical ideas extracted from rated drafts</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats header -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="stat-card rounded-xl border border-slate-800 p-4">
|
||||
<div class="text-3xl font-bold text-blue-400">{{ data.total }}</div>
|
||||
<div class="text-xs text-slate-400 mt-1">Total Ideas</div>
|
||||
</div>
|
||||
<div class="stat-card rounded-xl border border-slate-800 p-4">
|
||||
<div class="text-3xl font-bold text-purple-400">{{ data.by_type | length }}</div>
|
||||
<div class="text-xs text-slate-400 mt-1">Idea Types</div>
|
||||
</div>
|
||||
{% set top_type = data.by_type.keys() | list %}
|
||||
{% if top_type %}
|
||||
<div class="stat-card rounded-xl border border-slate-800 p-4">
|
||||
<div class="text-lg font-bold text-green-400 truncate">{{ top_type[0] }}</div>
|
||||
<div class="text-xs text-slate-400 mt-1">Most Common Type</div>
|
||||
</div>
|
||||
<div class="stat-card rounded-xl border border-slate-800 p-4">
|
||||
<div class="text-3xl font-bold text-amber-400">{{ data.by_type[top_type[0]] }}</div>
|
||||
<div class="text-xs text-slate-400 mt-1">{{ top_type[0] }} Count</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Chart -->
|
||||
<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">Ideas by Type</h2>
|
||||
<div id="ideasChart" style="height: 350px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Ideas list -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
|
||||
<div class="p-4 border-b border-slate-800 flex flex-col sm:flex-row gap-3">
|
||||
<input type="text" id="ideaSearch" placeholder="Search ideas by title, description, or draft name..."
|
||||
class="flex-1 bg-slate-800 border border-slate-700 rounded-lg px-4 py-2 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:border-blue-500">
|
||||
<select id="typeFilter"
|
||||
class="bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-blue-500">
|
||||
<option value="">All Types</option>
|
||||
{% for t in data.by_type %}
|
||||
<option value="{{ t }}">{{ t }} ({{ data.by_type[t] }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="px-4 py-2 border-b border-slate-800/50 text-xs text-slate-500">
|
||||
<span id="visibleCount">{{ data.ideas | length }}</span> ideas shown
|
||||
</div>
|
||||
<div class="divide-y divide-slate-800/50 max-h-[600px] overflow-y-auto" id="ideaList">
|
||||
{% for idea in data.ideas %}
|
||||
<div class="idea-item px-4 py-3 hover:bg-slate-800/50 transition"
|
||||
data-search="{{ idea.title|lower }} {{ idea.description|lower }} {{ idea.draft_name|lower }}"
|
||||
data-type="{{ idea.type|default('other', true)|lower }}">
|
||||
<div class="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span class="text-sm font-medium text-slate-200">{{ idea.title }}</span>
|
||||
{% if idea.type %}
|
||||
<span class="px-2 py-0.5 rounded text-[10px] font-medium bg-blue-500/20 text-blue-400 whitespace-nowrap">{{ idea.type }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="text-xs text-slate-500 leading-relaxed">{{ idea.description }}</p>
|
||||
<a href="/drafts/{{ idea.draft_name }}" class="text-[10px] text-slate-600 hover:text-blue-400 transition mt-1 inline-block font-mono">{{ idea.draft_name }}</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</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: 10, r: 20, b: 40, l: 140 },
|
||||
xaxis: { gridcolor: '#1e293b', title: 'Count' },
|
||||
yaxis: { gridcolor: '#1e293b' },
|
||||
};
|
||||
|
||||
const byType = {{ data.by_type | tojson }};
|
||||
const types = Object.keys(byType).reverse();
|
||||
const counts = types.map(t => byType[t]);
|
||||
|
||||
// Color gradient from blue to purple
|
||||
const barColors = types.map((_, i) => {
|
||||
const ratio = i / Math.max(types.length - 1, 1);
|
||||
const r = Math.round(59 + ratio * (168 - 59));
|
||||
const g = Math.round(130 + ratio * (85 - 130));
|
||||
const b = Math.round(246 + ratio * (247 - 246));
|
||||
return `rgb(${r},${g},${b})`;
|
||||
});
|
||||
|
||||
Plotly.newPlot('ideasChart', [{
|
||||
y: types, x: counts,
|
||||
type: 'bar', orientation: 'h',
|
||||
marker: { color: barColors },
|
||||
hovertemplate: '<b>%{y}</b>: %{x} ideas<extra></extra>',
|
||||
}], PLOTLY_LAYOUT, { responsive: true, displayModeBar: false });
|
||||
|
||||
// Search and filter
|
||||
function filterIdeas() {
|
||||
const q = document.getElementById('ideaSearch').value.toLowerCase().trim();
|
||||
const typeFilter = document.getElementById('typeFilter').value.toLowerCase();
|
||||
let visible = 0;
|
||||
document.querySelectorAll('.idea-item').forEach(el => {
|
||||
const matchesSearch = !q || el.dataset.search.includes(q);
|
||||
const matchesType = !typeFilter || el.dataset.type === typeFilter;
|
||||
const show = matchesSearch && matchesType;
|
||||
el.style.display = show ? '' : 'none';
|
||||
if (show) visible++;
|
||||
});
|
||||
document.getElementById('visibleCount').textContent = visible;
|
||||
}
|
||||
|
||||
document.getElementById('ideaSearch').addEventListener('input', filterIdeas);
|
||||
document.getElementById('typeFilter').addEventListener('change', filterIdeas);
|
||||
</script>
|
||||
{% endblock %}
|
||||
232
src/webui/templates/landscape.html
Normal file
232
src/webui/templates/landscape.html
Normal file
@@ -0,0 +1,232 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "landscape" %}
|
||||
|
||||
{% block title %}Landscape — IETF Draft Analyzer{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">Draft Landscape</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">Multi-dimensional visualization of the AI/agent draft space</p>
|
||||
</div>
|
||||
|
||||
<!-- Embedding-based t-SNE map -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5 mb-6" id="tsneSection">
|
||||
<h2 class="text-sm font-semibold text-slate-300 mb-1">Embedding Landscape (t-SNE)</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">768-dim embeddings projected to 2D. Color = category, size = composite score. Click for draft detail.</p>
|
||||
<div id="tsneMap" style="height: 560px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Main 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</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">Bubble size = composite score, color = category. Hover for details.</p>
|
||||
<div id="mainScatter" style="height: 560px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<!-- Novelty vs Overlap quadrant -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-300 mb-1">Innovation-Uniqueness Quadrant</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">Novelty vs Overlap — find the novel and unique drafts.</p>
|
||||
<div id="quadrantChart" style="height: 450px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Score distributions -->
|
||||
<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</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">Violin plots for each rating dimension.</p>
|
||||
<div id="violinChart" style="height: 450px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category 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">Category Distribution</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">Number of rated drafts per primary category.</p>
|
||||
<div id="categoryBar" style="height: 400px;"></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
const PLOTLY_LAYOUT = {
|
||||
paper_bgcolor: 'transparent', plot_bgcolor: 'rgba(15,23,42,0.5)',
|
||||
font: { color: '#94a3b8', family: 'Inter, system-ui, sans-serif', size: 12 },
|
||||
margin: { t: 20, r: 20, b: 50, l: 50 },
|
||||
xaxis: { gridcolor: '#1e293b', zerolinecolor: '#334155' },
|
||||
yaxis: { gridcolor: '#1e293b', zerolinecolor: '#334155' },
|
||||
};
|
||||
const CFG = { responsive: true, displayModeBar: false };
|
||||
|
||||
const PALETTE = [
|
||||
'#3b82f6', '#ef4444', '#22c55e', '#a855f7', '#f59e0b',
|
||||
'#06b6d4', '#ec4899', '#84cc16', '#f97316', '#8b5cf6',
|
||||
'#14b8a6', '#e11d48', '#64748b', '#eab308', '#6366f1',
|
||||
];
|
||||
|
||||
const dist = {{ dist | tojson }};
|
||||
const tsneData = {{ tsne_data | tojson }};
|
||||
|
||||
// --- 0. t-SNE Embedding Map ---
|
||||
if (tsneData.length > 0) {
|
||||
const tsneCatGroups = {};
|
||||
tsneData.forEach(d => {
|
||||
if (!tsneCatGroups[d.category]) tsneCatGroups[d.category] = { x: [], y: [], size: [], text: [], names: [] };
|
||||
tsneCatGroups[d.category].x.push(d.x);
|
||||
tsneCatGroups[d.category].y.push(d.y);
|
||||
tsneCatGroups[d.category].size.push(Math.max(d.score * 4, 6));
|
||||
tsneCatGroups[d.category].text.push(d.title);
|
||||
tsneCatGroups[d.category].names.push(d.name);
|
||||
});
|
||||
|
||||
const catList = Object.keys(tsneCatGroups).sort((a, b) =>
|
||||
tsneCatGroups[b].x.length - tsneCatGroups[a].x.length
|
||||
);
|
||||
|
||||
const tsneTraces = catList.map((cat, i) => {
|
||||
const g = tsneCatGroups[cat];
|
||||
return {
|
||||
x: g.x, y: g.y, text: g.text, name: cat,
|
||||
customdata: g.names,
|
||||
mode: 'markers', type: 'scatter',
|
||||
marker: {
|
||||
size: g.size,
|
||||
color: PALETTE[i % PALETTE.length],
|
||||
opacity: 0.8,
|
||||
line: { width: 0.5, color: 'rgba(255,255,255,0.15)' },
|
||||
},
|
||||
hovertemplate: '<b>%{text}</b><extra>' + cat + '</extra>',
|
||||
};
|
||||
});
|
||||
|
||||
const tsnePlot = Plotly.newPlot('tsneMap', tsneTraces, {
|
||||
...PLOTLY_LAYOUT,
|
||||
xaxis: { visible: false, showgrid: false, zeroline: false },
|
||||
yaxis: { visible: false, showgrid: false, zeroline: false },
|
||||
legend: { font: { size: 10, color: '#94a3b8' }, bgcolor: 'transparent' },
|
||||
hovermode: 'closest',
|
||||
margin: { t: 10, r: 20, b: 10, l: 20 },
|
||||
}, CFG);
|
||||
|
||||
// Click to navigate to draft detail
|
||||
document.getElementById('tsneMap').on('plotly_click', function(data) {
|
||||
const pt = data.points[0];
|
||||
if (pt.customdata) {
|
||||
window.location.href = '/drafts/' + pt.customdata;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
document.getElementById('tsneSection').style.display = 'none';
|
||||
}
|
||||
|
||||
// --- Group by category for rating-based charts ---
|
||||
const catGroups = {};
|
||||
dist.names.forEach((name, i) => {
|
||||
const cat = dist.categories[i];
|
||||
if (!catGroups[cat]) catGroups[cat] = { x: [], y: [], nov: [], ovl: [], size: [], text: [], scores: [] };
|
||||
catGroups[cat].x.push(dist.novelty[i] + (Math.random() - 0.5) * 0.25);
|
||||
catGroups[cat].y.push(dist.maturity[i] + (Math.random() - 0.5) * 0.25);
|
||||
catGroups[cat].nov.push(dist.novelty[i]);
|
||||
catGroups[cat].ovl.push(dist.overlap[i]);
|
||||
catGroups[cat].size.push(Math.max(dist.scores[i] * 4, 5));
|
||||
catGroups[cat].text.push(name);
|
||||
catGroups[cat].scores.push(dist.scores[i]);
|
||||
});
|
||||
|
||||
// --- 1. Main Scatter: Novelty vs Maturity ---
|
||||
const mainTraces = Object.entries(catGroups).map(([cat, d]) => ({
|
||||
x: d.x, y: d.y, text: d.text, name: cat,
|
||||
customdata: d.scores.map((s, i) => [s, d.nov[i], d.ovl[i]]),
|
||||
mode: 'markers', type: 'scatter',
|
||||
marker: { size: d.size, opacity: 0.75, line: { width: 0.5, color: 'rgba(255,255,255,0.15)' } },
|
||||
hovertemplate: '<b>%{text}</b><br>Novelty: %{customdata[1]}<br>Maturity: %{y:.0f}<br>Score: %{customdata[0]:.2f}<br>Overlap: %{customdata[2]}<extra>' + cat + '</extra>',
|
||||
}));
|
||||
Plotly.newPlot('mainScatter', mainTraces, {
|
||||
...PLOTLY_LAYOUT,
|
||||
xaxis: { ...PLOTLY_LAYOUT.xaxis, title: 'Novelty', range: [0.3, 5.7], dtick: 1 },
|
||||
yaxis: { ...PLOTLY_LAYOUT.yaxis, title: 'Maturity', range: [0.3, 5.7], dtick: 1 },
|
||||
legend: { font: { size: 10, color: '#94a3b8' }, bgcolor: 'transparent' },
|
||||
}, CFG);
|
||||
|
||||
// Click to navigate to draft detail
|
||||
document.getElementById('mainScatter').on('plotly_click', function(data) {
|
||||
const pt = data.points[0];
|
||||
if (pt.text) {
|
||||
window.location.href = '/drafts/' + pt.text;
|
||||
}
|
||||
});
|
||||
|
||||
// --- 2. Novelty vs Overlap Quadrant ---
|
||||
const quadTraces = Object.entries(catGroups).map(([cat, d]) => ({
|
||||
x: d.nov.map(v => v + (Math.random() - 0.5) * 0.25),
|
||||
y: d.ovl.map(v => v + (Math.random() - 0.5) * 0.25),
|
||||
text: d.text, name: cat,
|
||||
customdata: d.scores,
|
||||
mode: 'markers', type: 'scatter',
|
||||
marker: { size: 7, opacity: 0.7 },
|
||||
hovertemplate: '<b>%{text}</b><br>Novelty: %{x:.0f}<br>Overlap: %{y:.0f}<br>Score: %{customdata:.2f}<extra>' + cat + '</extra>',
|
||||
showlegend: false,
|
||||
}));
|
||||
Plotly.newPlot('quadrantChart', quadTraces, {
|
||||
...PLOTLY_LAYOUT,
|
||||
xaxis: { ...PLOTLY_LAYOUT.xaxis, title: 'Novelty', range: [0.3, 5.7], dtick: 1 },
|
||||
yaxis: { ...PLOTLY_LAYOUT.yaxis, title: 'Overlap', range: [0.3, 5.7], dtick: 1 },
|
||||
shapes: [
|
||||
{ type: 'line', x0: 3, x1: 3, y0: 0, y1: 6, line: { color: '#334155', width: 1, dash: 'dash' } },
|
||||
{ type: 'line', x0: 0, x1: 6, y0: 3, y1: 3, line: { color: '#334155', width: 1, dash: 'dash' } },
|
||||
],
|
||||
annotations: [
|
||||
{ x: 4.5, y: 1.2, text: 'Novel & Unique', showarrow: false, font: { size: 11, color: '#4ade80' } },
|
||||
{ x: 4.5, y: 5.0, text: 'Novel & Overlapping', showarrow: false, font: { size: 11, color: '#facc15' } },
|
||||
{ x: 1.5, y: 1.2, text: 'Mature & Unique', showarrow: false, font: { size: 11, color: '#60a5fa' } },
|
||||
{ x: 1.5, y: 5.0, text: 'Crowded', showarrow: false, font: { size: 11, color: '#f87171' } },
|
||||
],
|
||||
}, CFG);
|
||||
|
||||
// --- 3. Violin / Box Plots ---
|
||||
const dims = ['novelty', 'maturity', 'overlap', 'momentum', 'relevance'];
|
||||
const dimColors = ['#3b82f6', '#22c55e', '#ef4444', '#f59e0b', '#a855f7'];
|
||||
const violinTraces = dims.map((d, i) => ({
|
||||
y: dist[d],
|
||||
name: d.charAt(0).toUpperCase() + d.slice(1),
|
||||
type: 'violin',
|
||||
box: { visible: true },
|
||||
meanline: { visible: true },
|
||||
line: { color: dimColors[i] },
|
||||
fillcolor: dimColors[i] + '30',
|
||||
opacity: 0.85,
|
||||
}));
|
||||
Plotly.newPlot('violinChart', violinTraces, {
|
||||
...PLOTLY_LAYOUT,
|
||||
showlegend: false,
|
||||
yaxis: { ...PLOTLY_LAYOUT.yaxis, range: [0.3, 5.7], dtick: 1, title: 'Score' },
|
||||
}, CFG);
|
||||
|
||||
// --- 4. Category Distribution ---
|
||||
const catCounts = {};
|
||||
dist.categories.forEach(c => { catCounts[c] = (catCounts[c] || 0) + 1; });
|
||||
const sorted = Object.entries(catCounts).sort((a, b) => b[1] - a[1]);
|
||||
const catNames = sorted.map(s => s[0]).reverse();
|
||||
const catValues = sorted.map(s => s[1]).reverse();
|
||||
|
||||
Plotly.newPlot('categoryBar', [{
|
||||
y: catNames, x: catValues,
|
||||
type: 'bar', orientation: 'h',
|
||||
marker: {
|
||||
color: catValues.map((_, i) => {
|
||||
const pct = i / Math.max(catValues.length - 1, 1);
|
||||
return `hsl(${210 + pct * 120}, 70%, 55%)`;
|
||||
}),
|
||||
},
|
||||
text: catValues.map(v => v.toString()),
|
||||
textposition: 'outside',
|
||||
textfont: { color: '#94a3b8', size: 11 },
|
||||
hovertemplate: '<b>%{y}</b><br>%{x} drafts<extra></extra>',
|
||||
}], {
|
||||
...PLOTLY_LAYOUT,
|
||||
margin: { t: 10, r: 60, b: 40, l: 220 },
|
||||
xaxis: { ...PLOTLY_LAYOUT.xaxis, title: 'Number of Drafts' },
|
||||
yaxis: { ...PLOTLY_LAYOUT.yaxis, automargin: true },
|
||||
}, CFG);
|
||||
</script>
|
||||
{% endblock %}
|
||||
191
src/webui/templates/monitor.html
Normal file
191
src/webui/templates/monitor.html
Normal file
@@ -0,0 +1,191 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "monitor" %}
|
||||
|
||||
{% block title %}Monitor — IETF Draft Analyzer{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">Live Monitor</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">Track automated monitoring runs and pipeline status</p>
|
||||
</div>
|
||||
|
||||
<div id="monitor-app"></div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
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',
|
||||
];
|
||||
|
||||
const data = {{ status | tojson }};
|
||||
const app = document.getElementById('monitor-app');
|
||||
|
||||
// Status banner
|
||||
const lastRun = data.last_run;
|
||||
let bannerColor, bannerBorder, bannerText;
|
||||
if (!lastRun) {
|
||||
bannerColor = 'text-slate-400';
|
||||
bannerBorder = 'border-slate-700';
|
||||
bannerText = 'No monitoring runs recorded yet. Run <code class="text-slate-300">ietf monitor run</code> to start.';
|
||||
} else if (lastRun.status === 'completed') {
|
||||
bannerColor = 'text-green-400';
|
||||
bannerBorder = 'border-green-500/30';
|
||||
bannerText = 'Last run completed successfully';
|
||||
} else if (lastRun.status === 'failed') {
|
||||
bannerColor = 'text-red-400';
|
||||
bannerBorder = 'border-red-500/30';
|
||||
bannerText = 'Last run failed: ' + (lastRun.error_message || 'unknown error');
|
||||
} else {
|
||||
bannerColor = 'text-yellow-400';
|
||||
bannerBorder = 'border-yellow-500/30';
|
||||
bannerText = 'A monitoring run is currently in progress...';
|
||||
}
|
||||
|
||||
let html = `
|
||||
<div class="stat-card rounded-xl border ${bannerBorder} p-4 mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-3 h-3 rounded-full ${lastRun && lastRun.status === 'completed' ? 'bg-green-500' : lastRun && lastRun.status === 'failed' ? 'bg-red-500' : lastRun && lastRun.status === 'running' ? 'bg-yellow-500 animate-pulse' : 'bg-slate-600'}"></div>
|
||||
<span class="${bannerColor} font-medium">${bannerText}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Stat cards row
|
||||
const lastTime = lastRun ? (lastRun.started_at || '').replace('T', ' ').slice(0, 19) : '-';
|
||||
const lastDuration = lastRun && lastRun.duration_seconds ? lastRun.duration_seconds.toFixed(1) + 's' : '-';
|
||||
const lastNew = lastRun ? lastRun.new_drafts_found : 0;
|
||||
const totalRuns = data.total_runs;
|
||||
|
||||
html += `
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="stat-card rounded-xl border border-slate-800 p-4">
|
||||
<div class="text-2xl font-bold text-slate-200">${totalRuns}</div>
|
||||
<div class="text-xs text-slate-400 mt-1">Total Runs</div>
|
||||
</div>
|
||||
<div class="stat-card rounded-xl border border-slate-800 p-4">
|
||||
<div class="text-sm font-mono font-bold text-slate-200 truncate">${lastTime}</div>
|
||||
<div class="text-xs text-slate-400 mt-1">Last Run</div>
|
||||
</div>
|
||||
<div class="stat-card rounded-xl border border-slate-800 p-4">
|
||||
<div class="text-2xl font-bold text-slate-200">${lastDuration}</div>
|
||||
<div class="text-xs text-slate-400 mt-1">Duration</div>
|
||||
</div>
|
||||
<div class="stat-card rounded-xl border border-slate-800 p-4">
|
||||
<div class="text-2xl font-bold text-blue-400">${lastNew}</div>
|
||||
<div class="text-xs text-slate-400 mt-1">New Drafts (Last Run)</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Unprocessed counts
|
||||
const up = data.unprocessed;
|
||||
function warnColor(n) { return n > 0 ? 'text-yellow-400 border-yellow-500/30' : 'text-green-400 border-green-500/30'; }
|
||||
|
||||
html += `
|
||||
<h2 class="text-lg font-semibold text-white mb-3">Unprocessed Drafts</h2>
|
||||
<div class="grid grid-cols-3 gap-4 mb-8">
|
||||
<div class="stat-card rounded-xl border ${up.unrated > 0 ? 'border-yellow-500/30' : 'border-green-500/30'} p-4">
|
||||
<div class="text-2xl font-bold ${up.unrated > 0 ? 'text-yellow-400' : 'text-green-400'}">${up.unrated}</div>
|
||||
<div class="text-xs text-slate-400 mt-1">Unrated</div>
|
||||
</div>
|
||||
<div class="stat-card rounded-xl border ${up.unembedded > 0 ? 'border-yellow-500/30' : 'border-green-500/30'} p-4">
|
||||
<div class="text-2xl font-bold ${up.unembedded > 0 ? 'text-yellow-400' : 'text-green-400'}">${up.unembedded}</div>
|
||||
<div class="text-xs text-slate-400 mt-1">Un-embedded</div>
|
||||
</div>
|
||||
<div class="stat-card rounded-xl border ${up.no_ideas > 0 ? 'border-yellow-500/30' : 'border-green-500/30'} p-4">
|
||||
<div class="text-2xl font-bold ${up.no_ideas > 0 ? 'text-yellow-400' : 'text-green-400'}">${up.no_ideas}</div>
|
||||
<div class="text-xs text-slate-400 mt-1">No Ideas</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// New drafts over time chart
|
||||
const runs = data.runs.slice().reverse(); // chronological order
|
||||
if (runs.length > 1) {
|
||||
html += `
|
||||
<h2 class="text-lg font-semibold text-white mb-3">New Drafts Found Over Time</h2>
|
||||
<div id="monitor-chart" class="bg-slate-900/50 rounded-xl border border-slate-800 p-4 mb-8" style="height:300px"></div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Run history table
|
||||
if (data.runs.length > 0) {
|
||||
html += `
|
||||
<h2 class="text-lg font-semibold text-white mb-3">Run History</h2>
|
||||
<div class="bg-slate-900/50 rounded-xl border border-slate-800 overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-slate-800 text-slate-400 text-xs uppercase">
|
||||
<th class="px-4 py-3 text-left">#</th>
|
||||
<th class="px-4 py-3 text-left">Started</th>
|
||||
<th class="px-4 py-3 text-right">Duration</th>
|
||||
<th class="px-4 py-3 text-center">Status</th>
|
||||
<th class="px-4 py-3 text-right">New Drafts</th>
|
||||
<th class="px-4 py-3 text-right">Analyzed</th>
|
||||
<th class="px-4 py-3 text-right">Embedded</th>
|
||||
<th class="px-4 py-3 text-right">Ideas</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>`;
|
||||
|
||||
for (const r of data.runs) {
|
||||
const statusBadge = r.status === 'completed'
|
||||
? '<span class="px-2 py-0.5 rounded-full text-xs font-semibold bg-green-500/20 text-green-400">completed</span>'
|
||||
: r.status === 'failed'
|
||||
? '<span class="px-2 py-0.5 rounded-full text-xs font-semibold bg-red-500/20 text-red-400">failed</span>'
|
||||
: '<span class="px-2 py-0.5 rounded-full text-xs font-semibold bg-yellow-500/20 text-yellow-400">running</span>';
|
||||
const started = (r.started_at || '').replace('T', ' ').slice(0, 19);
|
||||
const dur = r.duration_seconds ? r.duration_seconds.toFixed(1) + 's' : '-';
|
||||
html += `
|
||||
<tr class="border-b border-slate-800/50 hover:bg-slate-800/30">
|
||||
<td class="px-4 py-2.5 text-slate-500">${r.id}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-slate-300">${started}</td>
|
||||
<td class="px-4 py-2.5 text-right text-slate-400">${dur}</td>
|
||||
<td class="px-4 py-2.5 text-center">${statusBadge}</td>
|
||||
<td class="px-4 py-2.5 text-right text-slate-300">${r.new_drafts_found}</td>
|
||||
<td class="px-4 py-2.5 text-right text-slate-300">${r.drafts_analyzed}</td>
|
||||
<td class="px-4 py-2.5 text-right text-slate-300">${r.drafts_embedded}</td>
|
||||
<td class="px-4 py-2.5 text-right text-slate-300">${r.ideas_extracted}</td>
|
||||
</tr>`;
|
||||
}
|
||||
html += `
|
||||
</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
app.innerHTML = html;
|
||||
|
||||
// Render chart
|
||||
if (runs.length > 1) {
|
||||
const x = runs.map(r => (r.started_at || '').slice(0, 19));
|
||||
const y = runs.map(r => r.new_drafts_found || 0);
|
||||
Plotly.newPlot('monitor-chart', [{
|
||||
x: x,
|
||||
y: y,
|
||||
type: 'scatter',
|
||||
mode: 'lines+markers',
|
||||
fill: 'tozeroy',
|
||||
line: { color: PALETTE[0], width: 2 },
|
||||
marker: { color: PALETTE[0], size: 6 },
|
||||
fillcolor: PALETTE[0] + '30',
|
||||
}], {
|
||||
...PLOTLY_LAYOUT,
|
||||
xaxis: { ...PLOTLY_LAYOUT.xaxis, title: { text: 'Run Date', font: { size: 11 } } },
|
||||
yaxis: { ...PLOTLY_LAYOUT.yaxis, title: { text: 'New Drafts', font: { size: 11 } } },
|
||||
}, PLOTLY_CONFIG);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
205
src/webui/templates/overview.html
Normal file
205
src/webui/templates/overview.html
Normal file
@@ -0,0 +1,205 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "overview" %}
|
||||
|
||||
{% block title %}Overview — IETF Draft Analyzer{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<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</p>
|
||||
</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-3">Composite Score Distribution</h2>
|
||||
<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-3">Drafts by Category</h2>
|
||||
<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 %}
|
||||
211
src/webui/templates/ratings.html
Normal file
211
src/webui/templates/ratings.html
Normal file
@@ -0,0 +1,211 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "ratings" %}
|
||||
|
||||
{% block title %}Ratings — IETF Draft Analyzer{% 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</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-3">Composite Score Distribution</h2>
|
||||
<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-3">Score Distributions by Dimension</h2>
|
||||
<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-3">Novelty vs Maturity (bubble = relevance)</h2>
|
||||
<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>
|
||||
</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],
|
||||
}));
|
||||
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) {
|
||||
const 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 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/${d.name}" class="text-blue-400 hover:text-blue-300 transition text-xs font-mono">${shortName}</a>
|
||||
</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)}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="px-2 py-0.5 rounded text-[10px] bg-slate-800 text-slate-400">${d.category}</span>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
249
src/webui/templates/similarity.html
Normal file
249
src/webui/templates/similarity.html
Normal file
@@ -0,0 +1,249 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "similarity" %}
|
||||
|
||||
{% block title %}Similarity — IETF Draft Analyzer{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">Draft Similarity Graph</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">Force-directed graph of draft-to-draft semantic similarity based on embeddings</p>
|
||||
</div>
|
||||
|
||||
<!-- Summary stats -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-4 mb-6">
|
||||
<div class="stat-card rounded-xl border border-slate-800 p-4 relative overflow-hidden">
|
||||
<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-xs text-slate-500 uppercase tracking-wider">Connected Drafts</div>
|
||||
<div class="text-2xl font-bold text-white mt-1" id="statNodes">0</div>
|
||||
</div>
|
||||
<div class="stat-card rounded-xl border border-slate-800 p-4 relative overflow-hidden">
|
||||
<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-xs text-slate-500 uppercase tracking-wider">Similarity Links</div>
|
||||
<div class="text-2xl font-bold text-white mt-1" id="statEdges">0</div>
|
||||
</div>
|
||||
<div class="stat-card rounded-xl border border-slate-800 p-4 relative overflow-hidden">
|
||||
<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-xs text-slate-500 uppercase tracking-wider">Avg Similarity</div>
|
||||
<div class="text-2xl font-bold text-white mt-1" id="statAvgSim">0</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Threshold slider -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-4 mb-6">
|
||||
<div class="flex items-center gap-4 flex-wrap">
|
||||
<label class="text-sm text-slate-300 font-medium">Similarity Threshold:</label>
|
||||
<input type="range" id="thresholdSlider" min="0.50" max="0.99" step="0.01" value="0.75"
|
||||
class="w-48 h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-blue-500">
|
||||
<span class="text-sm font-mono text-blue-400" id="thresholdLabel">0.75</span>
|
||||
<span class="text-xs text-slate-500 ml-2">(<span id="visibleEdges">0</span> edges visible)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Force-directed graph -->
|
||||
<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">Similarity Network</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">Node size = composite score, color = category. Edge opacity = similarity strength. Click a node to view draft detail.</p>
|
||||
<div id="simGraph" style="height: 640px;"></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
const PLOTLY_LAYOUT = {
|
||||
paper_bgcolor: 'transparent',
|
||||
plot_bgcolor: 'transparent',
|
||||
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 PALETTE = [
|
||||
'#3b82f6', '#ef4444', '#22c55e', '#a855f7', '#f59e0b',
|
||||
'#06b6d4', '#ec4899', '#84cc16', '#f97316', '#8b5cf6',
|
||||
'#14b8a6', '#e11d48', '#64748b', '#eab308', '#6366f1',
|
||||
];
|
||||
|
||||
const fullNetwork = {{ network | tojson }};
|
||||
|
||||
// Assign color per category
|
||||
const catSet = [...new Set(fullNetwork.nodes.map(n => n.category))];
|
||||
const catColor = {};
|
||||
catSet.forEach((c, i) => { catColor[c] = PALETTE[i % PALETTE.length]; });
|
||||
|
||||
// Update stat cards
|
||||
document.getElementById('statNodes').textContent = fullNetwork.stats.node_count;
|
||||
document.getElementById('statEdges').textContent = fullNetwork.stats.edge_count;
|
||||
document.getElementById('statAvgSim').textContent = fullNetwork.stats.avg_similarity.toFixed(3);
|
||||
|
||||
function renderGraph(threshold) {
|
||||
const edges = fullNetwork.edges.filter(e => e.similarity >= threshold);
|
||||
|
||||
// Only show nodes that are connected at current threshold
|
||||
const connectedNames = new Set();
|
||||
edges.forEach(e => { connectedNames.add(e.source); connectedNames.add(e.target); });
|
||||
const nodes = fullNetwork.nodes.filter(n => connectedNames.has(n.name));
|
||||
|
||||
document.getElementById('visibleEdges').textContent = edges.length;
|
||||
|
||||
if (nodes.length === 0) {
|
||||
document.getElementById('simGraph').innerHTML = '<p class="text-slate-500 text-sm text-center mt-20">No connections at this threshold. Try lowering it.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Build index
|
||||
const N = nodes.length;
|
||||
const nodeIndex = {};
|
||||
const pos = [];
|
||||
nodes.forEach((n, i) => {
|
||||
nodeIndex[n.name] = i;
|
||||
pos.push({
|
||||
x: Math.cos(i * 2 * Math.PI / N) * 3 + (Math.random() - 0.5),
|
||||
y: Math.sin(i * 2 * Math.PI / N) * 3 + (Math.random() - 0.5)
|
||||
});
|
||||
});
|
||||
|
||||
// Force-directed spring layout
|
||||
const k = Math.sqrt(80.0 / Math.max(N, 1));
|
||||
for (let iter = 0; iter < 150; iter++) {
|
||||
const disp = pos.map(() => ({ x: 0, y: 0 }));
|
||||
const temp = 3.0 * (1 - iter / 150);
|
||||
|
||||
// Repulsion between all pairs
|
||||
for (let i = 0; i < N; i++) {
|
||||
for (let j = i + 1; j < N; j++) {
|
||||
let dx = pos[i].x - pos[j].x;
|
||||
let dy = pos[i].y - pos[j].y;
|
||||
let dist = Math.sqrt(dx * dx + dy * dy) || 0.01;
|
||||
let force = k * k / dist;
|
||||
disp[i].x += (dx / dist) * force;
|
||||
disp[i].y += (dy / dist) * force;
|
||||
disp[j].x -= (dx / dist) * force;
|
||||
disp[j].y -= (dy / dist) * force;
|
||||
}
|
||||
}
|
||||
|
||||
// Attraction along edges
|
||||
for (const e of edges) {
|
||||
const si = nodeIndex[e.source];
|
||||
const ti = nodeIndex[e.target];
|
||||
if (si === undefined || ti === undefined) continue;
|
||||
let dx = pos[si].x - pos[ti].x;
|
||||
let dy = pos[si].y - pos[ti].y;
|
||||
let dist = Math.sqrt(dx * dx + dy * dy) || 0.01;
|
||||
let force = dist * dist / k * e.similarity;
|
||||
disp[si].x -= (dx / dist) * force;
|
||||
disp[si].y -= (dy / dist) * force;
|
||||
disp[ti].x += (dx / dist) * force;
|
||||
disp[ti].y += (dy / dist) * force;
|
||||
}
|
||||
|
||||
// Apply with temperature
|
||||
for (let i = 0; i < N; i++) {
|
||||
let len = Math.sqrt(disp[i].x * disp[i].x + disp[i].y * disp[i].y) || 0.01;
|
||||
pos[i].x += (disp[i].x / len) * Math.min(len, temp);
|
||||
pos[i].y += (disp[i].y / len) * Math.min(len, temp);
|
||||
}
|
||||
}
|
||||
|
||||
// Count connections per node for hover
|
||||
const connCount = {};
|
||||
edges.forEach(e => {
|
||||
connCount[e.source] = (connCount[e.source] || 0) + 1;
|
||||
connCount[e.target] = (connCount[e.target] || 0) + 1;
|
||||
});
|
||||
|
||||
// Build edge traces — group by opacity bands for performance
|
||||
const edgeX = [];
|
||||
const edgeY = [];
|
||||
for (const e of edges) {
|
||||
const si = nodeIndex[e.source];
|
||||
const ti = nodeIndex[e.target];
|
||||
if (si === undefined || ti === undefined) continue;
|
||||
edgeX.push(pos[si].x, pos[ti].x, null);
|
||||
edgeY.push(pos[si].y, pos[ti].y, null);
|
||||
}
|
||||
|
||||
// Compute per-segment opacity based on similarity
|
||||
// Plotly lines don't support per-segment opacity easily, so we use a base color
|
||||
const minSim = Math.min(...edges.map(e => e.similarity));
|
||||
const maxSim = Math.max(...edges.map(e => e.similarity));
|
||||
const avgOpacity = edges.length > 0 ? 0.15 + 0.35 * ((maxSim + minSim) / 2 - threshold) / Math.max(1 - threshold, 0.01) : 0.2;
|
||||
|
||||
const edgeTrace = {
|
||||
x: edgeX, y: edgeY,
|
||||
mode: 'lines',
|
||||
type: 'scatter',
|
||||
line: { color: `rgba(100, 116, 139, ${Math.min(avgOpacity, 0.4).toFixed(2)})`, width: 0.8 },
|
||||
hoverinfo: 'skip',
|
||||
showlegend: false,
|
||||
};
|
||||
|
||||
// Build node trace grouped by category for legend
|
||||
const catGroups = {};
|
||||
nodes.forEach((n, i) => {
|
||||
if (!catGroups[n.category]) catGroups[n.category] = { x: [], y: [], size: [], text: [], names: [] };
|
||||
catGroups[n.category].x.push(pos[i].x);
|
||||
catGroups[n.category].y.push(pos[i].y);
|
||||
catGroups[n.category].size.push(Math.max(n.score * 4, 6));
|
||||
catGroups[n.category].text.push(
|
||||
`<b>${n.title}</b><br>Category: ${n.category}<br>Score: ${n.score}<br>Connections: ${connCount[n.name] || 0}`
|
||||
);
|
||||
catGroups[n.category].names.push(n.name);
|
||||
});
|
||||
|
||||
const catList = Object.keys(catGroups).sort((a, b) =>
|
||||
catGroups[b].x.length - catGroups[a].x.length
|
||||
);
|
||||
|
||||
const nodeTraces = catList.map((cat, i) => {
|
||||
const g = catGroups[cat];
|
||||
return {
|
||||
x: g.x, y: g.y,
|
||||
customdata: g.names,
|
||||
mode: 'markers',
|
||||
type: 'scatter',
|
||||
name: cat,
|
||||
marker: {
|
||||
size: g.size,
|
||||
color: catColor[cat] || '#64748b',
|
||||
opacity: 0.85,
|
||||
line: { color: 'rgba(255,255,255,0.15)', width: 1 },
|
||||
},
|
||||
hovertext: g.text,
|
||||
hoverinfo: 'text',
|
||||
};
|
||||
});
|
||||
|
||||
Plotly.newPlot('simGraph', [edgeTrace, ...nodeTraces], {
|
||||
...PLOTLY_LAYOUT,
|
||||
xaxis: { visible: false, showgrid: false, zeroline: false },
|
||||
yaxis: { visible: false, showgrid: false, zeroline: false },
|
||||
legend: { font: { size: 10, color: '#94a3b8' }, bgcolor: 'transparent', x: 1.02, y: 0.5 },
|
||||
margin: { t: 10, r: 140, b: 10, l: 10 },
|
||||
hovermode: 'closest',
|
||||
}, CFG);
|
||||
|
||||
// Click to navigate to draft detail
|
||||
document.getElementById('simGraph').on('plotly_click', function(data) {
|
||||
const pt = data.points[0];
|
||||
if (pt.customdata) {
|
||||
window.location.href = '/drafts/' + pt.customdata;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initial render
|
||||
renderGraph(0.75);
|
||||
|
||||
// Threshold slider
|
||||
const slider = document.getElementById('thresholdSlider');
|
||||
const label = document.getElementById('thresholdLabel');
|
||||
slider.addEventListener('input', function() {
|
||||
const val = parseFloat(this.value);
|
||||
label.textContent = val.toFixed(2);
|
||||
renderGraph(val);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
241
src/webui/templates/timeline.html
Normal file
241
src/webui/templates/timeline.html
Normal file
@@ -0,0 +1,241 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "timeline" %}
|
||||
|
||||
{% block title %}Timeline — IETF Draft Analyzer{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">Timeline Animation</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">Watch the AI/agent draft landscape evolve month by month</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats summary -->
|
||||
<div class="grid grid-cols-3 gap-4 mb-6" id="statCards">
|
||||
</div>
|
||||
|
||||
<!-- Animated t-SNE map -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5 mb-6" id="tsneSection">
|
||||
<h2 class="text-sm font-semibold text-slate-300 mb-1">Animated Embedding Landscape</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">t-SNE projection with cumulative drafts per month. Color = category, size = composite score. Press Play to animate.</p>
|
||||
<div id="monthBadge" class="text-center mb-2">
|
||||
<span class="inline-block bg-slate-800 border border-slate-700 rounded-lg px-4 py-1.5 text-sm font-mono text-blue-400"></span>
|
||||
</div>
|
||||
<div id="tsneAnim" style="height: 560px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Stacked area chart -->
|
||||
<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">Category Submissions Over Time</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">Stacked area chart showing draft submissions by category per month.</p>
|
||||
<div id="stackedArea" style="height: 400px;"></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
const PLOTLY_LAYOUT = {
|
||||
paper_bgcolor: 'transparent', plot_bgcolor: 'rgba(15,23,42,0.5)',
|
||||
font: { color: '#94a3b8', family: 'Inter, system-ui, sans-serif', size: 12 },
|
||||
margin: { t: 20, r: 20, b: 50, l: 50 },
|
||||
xaxis: { gridcolor: '#1e293b', zerolinecolor: '#334155' },
|
||||
yaxis: { gridcolor: '#1e293b', zerolinecolor: '#334155' },
|
||||
};
|
||||
const CFG = { responsive: true, displayModeBar: false };
|
||||
|
||||
const PALETTE = [
|
||||
'#3b82f6', '#ef4444', '#22c55e', '#a855f7', '#f59e0b',
|
||||
'#06b6d4', '#ec4899', '#84cc16', '#f97316', '#8b5cf6',
|
||||
'#14b8a6', '#e11d48', '#64748b', '#eab308', '#6366f1',
|
||||
];
|
||||
|
||||
const animData = {{ animation | tojson }};
|
||||
const points = animData.points;
|
||||
const months = animData.months;
|
||||
const catMonthly = animData.category_monthly;
|
||||
|
||||
if (points.length > 0 && months.length > 0) {
|
||||
|
||||
// --- Stat cards ---
|
||||
const firstMonth = months[0];
|
||||
const lastMonth = months[months.length - 1];
|
||||
const allCats = [...new Set(points.map(p => p.category))];
|
||||
document.getElementById('statCards').innerHTML = `
|
||||
<div class="stat-card rounded-xl border border-slate-800 p-5 relative overflow-hidden">
|
||||
<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">${months.length}</div>
|
||||
<div class="text-xs text-slate-400 mt-1 uppercase tracking-wider">Months Span</div>
|
||||
<div class="text-xs text-slate-500 mt-0.5">${firstMonth} to ${lastMonth}</div>
|
||||
</div>
|
||||
<div class="stat-card rounded-xl border border-slate-800 p-5 relative overflow-hidden">
|
||||
<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">${points.length}</div>
|
||||
<div class="text-xs text-slate-400 mt-1 uppercase tracking-wider">Total Drafts</div>
|
||||
</div>
|
||||
<div class="stat-card rounded-xl border border-slate-800 p-5 relative overflow-hidden">
|
||||
<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">${allCats.length}</div>
|
||||
<div class="text-xs text-slate-400 mt-1 uppercase tracking-wider">Categories</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// --- Build category list sorted by frequency ---
|
||||
const catCounts = {};
|
||||
points.forEach(p => { catCounts[p.category] = (catCounts[p.category] || 0) + 1; });
|
||||
const catList = Object.keys(catCounts).sort((a, b) => catCounts[b] - catCounts[a]);
|
||||
const catColor = {};
|
||||
catList.forEach((c, i) => { catColor[c] = PALETTE[i % PALETTE.length]; });
|
||||
|
||||
// --- Helper: build traces for points up to a given month ---
|
||||
function buildTraces(upToMonth) {
|
||||
const filtered = points.filter(p => p.month <= upToMonth);
|
||||
const groups = {};
|
||||
filtered.forEach(p => {
|
||||
if (!groups[p.category]) groups[p.category] = { x: [], y: [], size: [], text: [], names: [] };
|
||||
groups[p.category].x.push(p.x);
|
||||
groups[p.category].y.push(p.y);
|
||||
groups[p.category].size.push(Math.max(p.score * 4, 6));
|
||||
groups[p.category].text.push(p.title);
|
||||
groups[p.category].names.push(p.name);
|
||||
});
|
||||
return catList.map(cat => {
|
||||
const g = groups[cat] || { x: [], y: [], size: [], text: [], names: [] };
|
||||
return {
|
||||
x: g.x, y: g.y, text: g.text, name: cat,
|
||||
customdata: g.names,
|
||||
mode: 'markers', type: 'scatter',
|
||||
marker: {
|
||||
size: g.size,
|
||||
color: catColor[cat],
|
||||
opacity: 0.8,
|
||||
line: { width: 0.5, color: 'rgba(255,255,255,0.15)' },
|
||||
},
|
||||
hovertemplate: '<b>%{text}</b><extra>' + cat + '</extra>',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// --- Build frames ---
|
||||
const frames = months.map(month => {
|
||||
const cumCount = points.filter(p => p.month <= month).length;
|
||||
return {
|
||||
name: month,
|
||||
data: buildTraces(month),
|
||||
};
|
||||
});
|
||||
|
||||
// --- Initial plot (first month) ---
|
||||
const firstTraces = buildTraces(months[0]);
|
||||
const firstCount = points.filter(p => p.month <= months[0]).length;
|
||||
|
||||
// Slider steps
|
||||
const sliderSteps = months.map(month => ({
|
||||
method: 'animate',
|
||||
label: month,
|
||||
args: [[month], { frame: { duration: 500, redraw: true }, transition: { duration: 300 }, mode: 'immediate' }],
|
||||
}));
|
||||
|
||||
const layout = {
|
||||
...PLOTLY_LAYOUT,
|
||||
xaxis: { visible: false, showgrid: false, zeroline: false },
|
||||
yaxis: { visible: false, showgrid: false, zeroline: false },
|
||||
legend: { font: { size: 10, color: '#94a3b8' }, bgcolor: 'transparent' },
|
||||
hovermode: 'closest',
|
||||
margin: { t: 40, r: 20, b: 60, l: 20 },
|
||||
updatemenus: [{
|
||||
type: 'buttons', showactive: false, x: 0.05, y: 1.08,
|
||||
buttons: [
|
||||
{
|
||||
label: '▶ Play',
|
||||
method: 'animate',
|
||||
args: [null, { frame: { duration: 500, redraw: true }, transition: { duration: 300 }, fromcurrent: true }]
|
||||
},
|
||||
{
|
||||
label: '◼ Pause',
|
||||
method: 'animate',
|
||||
args: [[null], { frame: { duration: 0, redraw: true }, mode: 'immediate' }]
|
||||
}
|
||||
]
|
||||
}],
|
||||
sliders: [{
|
||||
active: 0,
|
||||
steps: sliderSteps,
|
||||
x: 0.05, len: 0.9,
|
||||
xanchor: 'left',
|
||||
y: -0.02,
|
||||
yanchor: 'top',
|
||||
pad: { t: 30, b: 10 },
|
||||
currentvalue: { visible: false },
|
||||
transition: { duration: 300 },
|
||||
font: { size: 9, color: '#64748b' },
|
||||
bgcolor: '#1e293b',
|
||||
activebgcolor: '#3b82f6',
|
||||
bordercolor: '#334155',
|
||||
borderwidth: 1,
|
||||
ticklen: 4,
|
||||
tickcolor: '#475569',
|
||||
}],
|
||||
};
|
||||
|
||||
Plotly.newPlot('tsneAnim', firstTraces, layout, CFG).then(() => {
|
||||
Plotly.addFrames('tsneAnim', frames);
|
||||
});
|
||||
|
||||
// Update badge on animation frame
|
||||
const badge = document.querySelector('#monthBadge span');
|
||||
badge.textContent = `Month: ${months[0]} (${firstCount} drafts)`;
|
||||
|
||||
document.getElementById('tsneAnim').on('plotly_animatingframe', function(ev) {
|
||||
const month = ev.name;
|
||||
const cumCount = points.filter(p => p.month <= month).length;
|
||||
badge.textContent = `Month: ${month} (${cumCount} drafts)`;
|
||||
});
|
||||
|
||||
// Click to navigate
|
||||
document.getElementById('tsneAnim').on('plotly_click', function(data) {
|
||||
const pt = data.points[0];
|
||||
if (pt.customdata) {
|
||||
window.location.href = '/drafts/' + pt.customdata;
|
||||
}
|
||||
});
|
||||
|
||||
// --- Stacked area chart ---
|
||||
// Collect all categories across all months
|
||||
const areaCats = {};
|
||||
Object.values(catMonthly).forEach(mc => {
|
||||
Object.keys(mc).forEach(c => { areaCats[c] = true; });
|
||||
});
|
||||
// Sort by total count
|
||||
const areaCatList = Object.keys(areaCats).sort((a, b) => {
|
||||
const totalA = months.reduce((s, m) => s + ((catMonthly[m] || {})[a] || 0), 0);
|
||||
const totalB = months.reduce((s, m) => s + ((catMonthly[m] || {})[b] || 0), 0);
|
||||
return totalB - totalA;
|
||||
});
|
||||
|
||||
const areaTraces = areaCatList.map((cat, i) => ({
|
||||
x: months,
|
||||
y: months.map(m => (catMonthly[m] || {})[cat] || 0),
|
||||
name: cat,
|
||||
type: 'scatter',
|
||||
mode: 'lines',
|
||||
stackgroup: 'one',
|
||||
line: { width: 0.5, color: catColor[cat] || PALETTE[i % PALETTE.length] },
|
||||
fillcolor: (catColor[cat] || PALETTE[i % PALETTE.length]) + '80',
|
||||
hovertemplate: '%{x}<br>' + cat + ': %{y}<extra></extra>',
|
||||
}));
|
||||
|
||||
Plotly.newPlot('stackedArea', areaTraces, {
|
||||
...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.25, x: 0.5, xanchor: 'center' },
|
||||
hovermode: 'x unified',
|
||||
margin: { t: 20, r: 20, b: 80, l: 50 },
|
||||
}, CFG);
|
||||
|
||||
} else {
|
||||
document.getElementById('tsneSection').innerHTML = '<p class="text-slate-500 text-sm text-center py-20">No timeline animation data available. Run the analysis pipeline first.</p>';
|
||||
document.getElementById('stackedArea').innerHTML = '<p class="text-slate-500 text-sm text-center py-20">No data available.</p>';
|
||||
document.getElementById('statCards').style.display = 'none';
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user