Platform upgrade: semantic search, citations, readiness, tests, Docker
Major features added by 5 parallel agent teams: - Semantic "Ask" (NL queries via FTS5 + embeddings + Claude synthesis) - Global search across drafts, ideas, authors, gaps - REST API expansion (14 endpoints, up from 3) with CSV/JSON export - Citation graph visualization (D3.js, 440 nodes, 2422 edges) - Standards readiness scoring (0-100 composite from 6 factors) - Side-by-side draft comparison view with shared/unique analysis - Annotation system (notes + tags per draft, DB-persisted) - Docker deployment (Dockerfile + docker-compose with Ollama) - Scheduled updates (cron script with log rotation) - Pipeline health dashboard (stage progress bars, cost tracking) - Test suite foundation (54 pytest tests covering DB, models, web data) Fixes: compare_drafts() stubbed→working, get_authors_for_draft() bug, source-aware analysis prompts, config env var overrides + validation, resilient batch error handling with --retry-failed, observatory --dry-run Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
392
src/webui/templates/citations.html
Normal file
392
src/webui/templates/citations.html
Normal file
@@ -0,0 +1,392 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "citations" %}
|
||||
|
||||
{% block title %}Citation Graph — IETF Draft Analyzer{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<script src="/static/js/d3.v7.min.js"></script>
|
||||
<style>
|
||||
#citationSvg {
|
||||
width: 100%;
|
||||
height: 650px;
|
||||
cursor: grab;
|
||||
}
|
||||
#citationSvg:active { cursor: grabbing; }
|
||||
#citationSvg .node { cursor: pointer; }
|
||||
#citationSvg .node circle { stroke: rgba(255,255,255,0.15); stroke-width: 1.5px; transition: r 0.2s; }
|
||||
#citationSvg .node:hover circle { stroke: #60a5fa; stroke-width: 2.5px; }
|
||||
#citationSvg .node text { pointer-events: none; }
|
||||
#citationSvg .link { stroke-opacity: 0.15; }
|
||||
#citationSvg .link:hover { stroke-opacity: 0.5; }
|
||||
.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: 320px; opacity: 0; transition: opacity 0.15s;
|
||||
}
|
||||
.tooltip-card.visible { opacity: 1; }
|
||||
.legend-swatch { width: 12px; height: 12px; border-radius: 3px; display: inline-block; }
|
||||
.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">Citation Graph</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">Cross-reference network: {{ graph.stats.draft_count }} drafts referencing {{ graph.stats.rfc_count }} RFCs</p>
|
||||
</div>
|
||||
|
||||
<!-- Summary stats -->
|
||||
<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 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">Drafts</div>
|
||||
<div class="text-2xl font-bold text-white mt-1">{{ graph.stats.draft_count }}</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-orange-500 to-orange-400"></div>
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wider">Referenced RFCs</div>
|
||||
<div class="text-2xl font-bold text-white mt-1">{{ graph.stats.rfc_count }}</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">Total Nodes</div>
|
||||
<div class="text-2xl font-bold text-white mt-1">{{ graph.stats.node_count }}</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">Citation Links</div>
|
||||
<div class="text-2xl font-bold text-white mt-1">{{ graph.stats.edge_count }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- D3 Force-directed Citation 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">Cross-Reference Network</h2>
|
||||
<p class="text-xs text-slate-500 mt-0.5">
|
||||
<span class="inline-block w-2.5 h-2.5 rounded-full bg-blue-500 align-middle mr-1"></span>Drafts
|
||||
<span class="inline-block w-2.5 h-2.5 rounded-full bg-orange-500 align-middle mr-1 ml-3"></span>RFCs
|
||||
— Node size = influence (in-degree). Drag 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="filterCategory" 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 Categories</option>
|
||||
</select>
|
||||
<label class="text-xs text-slate-500 ml-2">Min refs:</label>
|
||||
<input type="range" id="minRefsSlider" min="1" max="15" value="2" class="w-20" style="accent-color: #3b82f6;">
|
||||
<span id="minRefsVal" class="text-xs text-blue-400 font-mono w-4">2</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<svg id="citationSvg"></svg>
|
||||
<div id="tooltip" class="tooltip-card"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Referenced RFCs Table -->
|
||||
<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">Most Referenced RFCs</h2>
|
||||
<p class="text-xs text-slate-500 mt-0.5">RFCs cited by the most drafts in the corpus</p>
|
||||
</div>
|
||||
<div class="overflow-x-auto max-h-[500px] overflow-y-auto">
|
||||
<table class="w-full text-sm" id="rfcTable">
|
||||
<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">#</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-slate-400">RFC</th>
|
||||
<th class="px-4 py-2.5 text-right text-xs font-medium text-slate-400">Cited By</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800/50" id="rfcBody">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
const graph = {{ graph | tojson }};
|
||||
|
||||
const PALETTE = [
|
||||
'#3b82f6', '#ef4444', '#22c55e', '#a855f7', '#f59e0b',
|
||||
'#06b6d4', '#ec4899', '#84cc16', '#f97316', '#8b5cf6',
|
||||
];
|
||||
|
||||
// ===========================================================
|
||||
// D3.js Force-Directed Citation Network
|
||||
// ===========================================================
|
||||
(function() {
|
||||
if (graph.nodes.length === 0) {
|
||||
document.getElementById('citationSvg').outerHTML =
|
||||
'<p class="text-slate-500 text-sm text-center py-20">No citation data available</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const svg = d3.select('#citationSvg');
|
||||
const container = svg.node().parentElement;
|
||||
const width = container.clientWidth;
|
||||
const height = 650;
|
||||
svg.attr('viewBox', [0, 0, width, height]);
|
||||
|
||||
// Collect categories for filter dropdown
|
||||
const categories = new Set();
|
||||
graph.nodes.forEach(n => {
|
||||
if (n.category && n.type === 'draft') categories.add(n.category);
|
||||
});
|
||||
const catSelect = document.getElementById('filterCategory');
|
||||
[...categories].sort().forEach(cat => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = cat;
|
||||
opt.textContent = cat;
|
||||
catSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
// Build RFC table
|
||||
const rfcNodes = graph.nodes
|
||||
.filter(n => n.type === 'rfc')
|
||||
.sort((a, b) => b.influence - a.influence);
|
||||
const rfcBody = document.getElementById('rfcBody');
|
||||
rfcNodes.forEach((rfc, i) => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.className = 'hover:bg-slate-800/50 transition';
|
||||
tr.innerHTML = `
|
||||
<td class="px-4 py-2.5 text-slate-500 text-xs">${i + 1}</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<a href="https://www.rfc-editor.org/rfc/rfc${parseInt(rfc.ref_id)}" target="_blank" rel="noopener"
|
||||
class="text-orange-400 hover:text-orange-300 transition font-medium text-sm">${rfc.title}</a>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-right">
|
||||
<span class="px-2 py-0.5 rounded-full text-xs font-medium
|
||||
${rfc.influence >= 10 ? 'bg-orange-500/20 text-orange-400' :
|
||||
rfc.influence >= 5 ? 'bg-blue-500/20 text-blue-400' :
|
||||
'bg-slate-700/50 text-slate-400'}">
|
||||
${rfc.influence}
|
||||
</span>
|
||||
</td>
|
||||
`;
|
||||
rfcBody.appendChild(tr);
|
||||
});
|
||||
|
||||
// Prepare simulation data
|
||||
const nodes = graph.nodes.map(n => ({...n}));
|
||||
const links = graph.edges.map(e => ({source: e.source, target: e.target}));
|
||||
|
||||
// Size scale
|
||||
const maxInfluence = d3.max(nodes, n => n.influence) || 1;
|
||||
const rScale = d3.scaleSqrt().domain([0, maxInfluence]).range([3, 24]);
|
||||
|
||||
// Color: drafts = blue, rfcs = orange, others = amber
|
||||
function nodeColor(n) {
|
||||
if (n.type === 'rfc') return '#f59e0b';
|
||||
if (n.type === 'bcp') return '#eab308';
|
||||
return '#3b82f6';
|
||||
}
|
||||
|
||||
// Force simulation
|
||||
const simulation = d3.forceSimulation(nodes)
|
||||
.force('link', d3.forceLink(links)
|
||||
.id(d => d.id)
|
||||
.distance(60)
|
||||
.strength(0.15)
|
||||
)
|
||||
.force('charge', d3.forceManyBody().strength(-80).distanceMax(350))
|
||||
.force('center', d3.forceCenter(width / 2, height / 2))
|
||||
.force('collision', d3.forceCollide().radius(d => rScale(d.influence) + 2))
|
||||
.force('x', d3.forceX(width / 2).strength(0.04))
|
||||
.force('y', d3.forceY(height / 2).strength(0.04));
|
||||
|
||||
// Zoom behavior
|
||||
const g = svg.append('g');
|
||||
const zoom = d3.zoom()
|
||||
.scaleExtent([0.15, 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', 0.8);
|
||||
|
||||
// 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.influence))
|
||||
.attr('fill', d => nodeColor(d))
|
||||
.attr('opacity', 0.85);
|
||||
|
||||
// Labels for high-influence nodes
|
||||
node.filter(d => d.influence >= 5)
|
||||
.append('text')
|
||||
.text(d => {
|
||||
if (d.type === 'rfc') return d.title;
|
||||
const name = d.id.replace(/^draft-/, '');
|
||||
return name.length > 20 ? name.slice(0, 18) + '..' : name;
|
||||
})
|
||||
.attr('dy', d => -(rScale(d.influence) + 4))
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('fill', '#94a3b8')
|
||||
.attr('font-size', '8px')
|
||||
.attr('font-family', 'Inter, system-ui, sans-serif');
|
||||
|
||||
// Tooltip
|
||||
const tooltip = document.getElementById('tooltip');
|
||||
|
||||
node.on('mouseover', function(event, d) {
|
||||
const typeLabel = d.type === 'rfc' ? 'RFC' : d.type === 'bcp' ? 'BCP' : 'Draft';
|
||||
const catLine = d.category ? `<div class="text-slate-500 text-xs mb-1">${d.category}</div>` : '';
|
||||
tooltip.innerHTML = `
|
||||
<div class="font-semibold text-white mb-1">${d.title}</div>
|
||||
${catLine}
|
||||
<div class="flex gap-4 text-xs">
|
||||
<span class="text-slate-400">${typeLabel}</span>
|
||||
<span><span class="${d.type === 'rfc' ? 'text-orange-400' : 'text-blue-400'} font-medium">${d.influence}</span> ${d.type === 'draft' ? 'outgoing refs' : 'citing drafts'}</span>
|
||||
</div>
|
||||
`;
|
||||
tooltip.classList.add('visible');
|
||||
|
||||
// Highlight connected nodes
|
||||
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.1);
|
||||
node.selectAll('text')
|
||||
.attr('opacity', n => connected.has(n.id) ? 1 : 0.1);
|
||||
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.6 : 0.02;
|
||||
});
|
||||
})
|
||||
.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.15);
|
||||
})
|
||||
.on('click', function(event, d) {
|
||||
if (d.type === 'rfc') {
|
||||
window.open(`https://www.rfc-editor.org/rfc/rfc${parseInt(d.ref_id)}`, '_blank');
|
||||
} else if (d.type === 'draft') {
|
||||
window.open(`/drafts/${encodeURIComponent(d.id)}`, '_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;
|
||||
}
|
||||
|
||||
// Category filter
|
||||
catSelect.addEventListener('change', function() {
|
||||
const cat = this.value;
|
||||
if (!cat) {
|
||||
node.select('circle').attr('opacity', 0.85);
|
||||
node.selectAll('text').attr('opacity', 1);
|
||||
link.attr('stroke-opacity', 0.15);
|
||||
return;
|
||||
}
|
||||
const inCat = new Set();
|
||||
nodes.forEach(n => {
|
||||
if (n.type === 'draft' && n.category === cat) inCat.add(n.id);
|
||||
});
|
||||
// Also include RFCs referenced by those drafts
|
||||
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 (inCat.has(sid)) inCat.add(tid);
|
||||
});
|
||||
node.select('circle')
|
||||
.attr('opacity', n => inCat.has(n.id) ? 1 : 0.05);
|
||||
node.selectAll('text')
|
||||
.attr('opacity', n => inCat.has(n.id) ? 1 : 0.05);
|
||||
link.attr('stroke-opacity', l => {
|
||||
const sid = typeof l.source === 'object' ? l.source.id : l.source;
|
||||
return inCat.has(sid) ? 0.5 : 0.01;
|
||||
});
|
||||
});
|
||||
|
||||
// Min refs slider (client-side filter)
|
||||
const slider = document.getElementById('minRefsSlider');
|
||||
const sliderVal = document.getElementById('minRefsVal');
|
||||
slider.addEventListener('input', function() {
|
||||
sliderVal.textContent = this.value;
|
||||
const minR = parseInt(this.value);
|
||||
// Show/hide RFC nodes by influence
|
||||
node.select('circle')
|
||||
.attr('opacity', n => {
|
||||
if (n.type === 'draft') return 0.85;
|
||||
return n.influence >= minR ? 0.85 : 0.05;
|
||||
});
|
||||
node.selectAll('text')
|
||||
.attr('opacity', n => {
|
||||
if (n.type === 'draft') return 1;
|
||||
return n.influence >= minR ? 1 : 0.05;
|
||||
});
|
||||
// Filter edges
|
||||
const visibleRfcs = new Set(nodes.filter(n => n.type !== 'draft' && n.influence >= minR).map(n => n.id));
|
||||
link.attr('stroke-opacity', l => {
|
||||
const tid = typeof l.target === 'object' ? l.target.id : l.target;
|
||||
return visibleRfcs.has(tid) ? 0.15 : 0.01;
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user