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:
153
src/webui/templates/ask.html
Normal file
153
src/webui/templates/ask.html
Normal file
@@ -0,0 +1,153 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "ask" %}
|
||||
|
||||
{% block title %}Ask — IETF Draft Analyzer{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
.ask-input {
|
||||
background: linear-gradient(135deg, rgba(30, 41, 59, 0.8), rgba(30, 41, 59, 0.4));
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.answer-card {
|
||||
background: linear-gradient(135deg, rgba(30, 41, 59, 0.8), rgba(30, 41, 59, 0.4));
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.source-row {
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.source-row:hover {
|
||||
background: rgba(59, 130, 246, 0.05);
|
||||
}
|
||||
.loading-spinner {
|
||||
border: 3px solid rgba(59, 130, 246, 0.2);
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
animation: spin 0.8s linear infinite;
|
||||
display: inline-block;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Header -->
|
||||
<div class="mb-8 text-center">
|
||||
<h1 class="text-3xl font-bold text-white">Ask the Draft Corpus</h1>
|
||||
<p class="text-slate-400 text-sm mt-2">Ask natural language questions about IETF AI/agent drafts. Answers are synthesized from the most relevant documents.</p>
|
||||
</div>
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div class="max-w-3xl mx-auto mb-8">
|
||||
<form method="get" action="/ask" id="askForm">
|
||||
<div class="ask-input rounded-xl border border-slate-700 p-2 flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-slate-500 ml-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<input type="text" name="q" value="{{ question }}" placeholder="Which drafts address agent authentication? What approaches exist for agent delegation?"
|
||||
class="flex-1 bg-transparent border-0 px-3 py-3 text-base text-slate-200 placeholder-slate-500 focus:outline-none"
|
||||
autofocus>
|
||||
<button type="submit" class="px-6 py-3 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-500 transition-colors flex-shrink-0">
|
||||
Ask
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 mt-3 px-2">
|
||||
<label class="text-xs text-slate-500 flex items-center gap-1.5">
|
||||
<span>Sources:</span>
|
||||
<select name="top" class="bg-slate-800/60 border border-slate-700 rounded px-2 py-1 text-xs text-slate-300 focus:outline-none">
|
||||
<option value="3" {% if request.args.get('top', '5') == '3' %}selected{% endif %}>3</option>
|
||||
<option value="5" {% if request.args.get('top', '5') == '5' %}selected{% endif %}>5</option>
|
||||
<option value="10" {% if request.args.get('top', '5') == '10' %}selected{% endif %}>10</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="text-xs text-slate-600">Combines keyword search + semantic similarity</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Example questions (show when no query) -->
|
||||
{% if not question %}
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wide mb-3 font-medium">Example questions</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{% set examples = [
|
||||
"Which drafts address agent authentication and identity?",
|
||||
"What are the competing approaches to agent-to-agent communication?",
|
||||
"How do safety mechanisms work across different proposals?",
|
||||
"What protocols exist for AI model serving and inference?",
|
||||
"Which drafts propose agent discovery or registration systems?",
|
||||
"What are the main gaps in autonomous network operations?",
|
||||
] %}
|
||||
{% for q in examples %}
|
||||
<a href="/ask?q={{ q | urlencode }}" class="ask-input rounded-lg border border-slate-800 px-4 py-3 text-sm text-slate-400 hover:text-blue-400 hover:border-slate-700 transition">
|
||||
{{ q }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Answer -->
|
||||
{% if result %}
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<!-- Synthesized answer -->
|
||||
<div class="answer-card rounded-xl border border-slate-800 p-6 mb-6">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<svg class="w-5 h-5 text-blue-400" 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>
|
||||
<h2 class="text-lg font-semibold text-white">Answer</h2>
|
||||
</div>
|
||||
<div class="text-slate-300 text-sm leading-relaxed whitespace-pre-line">{{ result.answer }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Source drafts -->
|
||||
{% if result.sources %}
|
||||
<div class="answer-card rounded-xl border border-slate-800 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-slate-800">
|
||||
<h3 class="text-sm font-semibold text-slate-300">Source Drafts ({{ result.sources|length }})</h3>
|
||||
</div>
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-slate-800/50 bg-slate-900/40">
|
||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-slate-500 uppercase w-8">#</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-slate-500 uppercase">Draft</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-slate-500 uppercase w-20">Match</th>
|
||||
<th class="px-4 py-2.5 text-right text-xs font-medium text-slate-500 uppercase w-16">Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800/30">
|
||||
{% for src in result.sources %}
|
||||
<tr class="source-row">
|
||||
<td class="px-4 py-3 text-slate-600">{{ loop.index }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<a href="/drafts/{{ src.name }}" class="text-blue-400 hover:text-blue-300 font-medium transition">
|
||||
{{ src.title }}
|
||||
</a>
|
||||
<div class="text-xs text-slate-600 mt-0.5 font-mono">{{ src.name }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
{% if src.match_type == 'both' %}
|
||||
<span class="inline-block px-2 py-0.5 rounded text-xs font-medium bg-green-900/30 text-green-400 border border-green-800/30">both</span>
|
||||
{% elif src.match_type == 'semantic' %}
|
||||
<span class="inline-block px-2 py-0.5 rounded text-xs font-medium bg-purple-900/30 text-purple-400 border border-purple-800/30">semantic</span>
|
||||
{% else %}
|
||||
<span class="inline-block px-2 py-0.5 rounded text-xs font-medium bg-slate-800/50 text-slate-400 border border-slate-700/30">keyword</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right font-mono text-xs text-slate-400">
|
||||
{{ "%.3f"|format(src.similarity) if src.similarity else "-" }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -82,11 +82,26 @@
|
||||
<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>
|
||||
<!-- Global Search -->
|
||||
<div class="px-4 pt-4 pb-2">
|
||||
<form action="/search" method="get" class="relative">
|
||||
<input type="text" name="q" placeholder="Search everything..."
|
||||
value="{{ request.args.get('q', '') if request else '' }}"
|
||||
class="w-full bg-slate-800/60 border border-slate-700 rounded-lg pl-9 pr-3 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">
|
||||
<svg class="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" 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>
|
||||
</form>
|
||||
</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="/ask" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'ask' }}">
|
||||
<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="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
Ask
|
||||
</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
|
||||
@@ -119,6 +134,10 @@
|
||||
<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="/citations" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'citations' }}">
|
||||
<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.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>
|
||||
Citations
|
||||
</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
|
||||
|
||||
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 %}
|
||||
220
src/webui/templates/comparison.html
Normal file
220
src/webui/templates/comparison.html
Normal file
@@ -0,0 +1,220 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "drafts" %}
|
||||
|
||||
{% block title %}Compare Drafts — IETF Draft Analyzer{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
.compare-card {
|
||||
background: linear-gradient(135deg, rgba(30, 41, 59, 0.8), rgba(30, 41, 59, 0.4));
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.idea-shared { background: rgba(34, 197, 94, 0.1); border-color: rgba(34, 197, 94, 0.2); }
|
||||
.idea-unique { background: rgba(59, 130, 246, 0.1); border-color: rgba(59, 130, 246, 0.2); }
|
||||
.ref-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);
|
||||
}
|
||||
.loading-spinner {
|
||||
border: 3px solid rgba(59, 130, 246, 0.2);
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
animation: spin 0.8s linear infinite;
|
||||
display: inline-block;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">Compare Drafts</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">Side-by-side analysis of selected drafts: shared ideas, references, and AI-generated comparison.</p>
|
||||
</div>
|
||||
|
||||
{% if not data %}
|
||||
<!-- No data yet — show instructions -->
|
||||
<div class="compare-card rounded-xl border border-slate-800 p-8 text-center max-w-xl mx-auto">
|
||||
<svg class="w-12 h-12 mx-auto mb-4 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
|
||||
</svg>
|
||||
{% if names and names|length < 2 %}
|
||||
<p class="text-slate-400 text-sm mb-4">Need at least 2 valid draft names to compare.</p>
|
||||
{% else %}
|
||||
<p class="text-slate-400 text-sm mb-4">Select drafts to compare from the <a href="/drafts" class="text-blue-400 hover:text-blue-300">Draft Explorer</a>, or enter draft names below.</p>
|
||||
{% endif %}
|
||||
<form method="get" action="/compare" class="mt-4">
|
||||
<input type="text" name="drafts" placeholder="draft-name-1, draft-name-2, ..."
|
||||
value="{{ names | join(', ') if names else '' }}"
|
||||
class="w-full bg-slate-800/60 border border-slate-700 rounded-lg px-4 py-2.5 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:border-blue-500 mb-3">
|
||||
<button type="submit" class="px-6 py-2.5 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-500 transition-colors">
|
||||
Compare
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
<!-- Draft cards side by side -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-{{ data.drafts|length }} gap-4 mb-6">
|
||||
{% for draft in data.drafts %}
|
||||
<div class="compare-card rounded-xl border border-slate-800 p-5">
|
||||
<a href="/drafts/{{ draft.name }}" class="text-blue-400 hover:text-blue-300 font-semibold text-sm transition">
|
||||
{{ draft.title }}
|
||||
</a>
|
||||
<div class="text-xs text-slate-600 font-mono mt-1">{{ draft.name }}</div>
|
||||
<div class="text-xs text-slate-500 mt-2 line-clamp-3">{{ draft.abstract[:200] }}</div>
|
||||
|
||||
{% if draft.rating %}
|
||||
<!-- Rating radar -->
|
||||
<div class="mt-3 grid grid-cols-5 gap-1 text-center">
|
||||
{% for dim, label in [('novelty', 'Nov'), ('maturity', 'Mat'), ('relevance', 'Rel'), ('momentum', 'Mom'), ('overlap', 'Ovl')] %}
|
||||
<div>
|
||||
<div class="text-xs text-slate-500">{{ label }}</div>
|
||||
<div class="text-sm font-semibold {% if draft.rating[dim] >= 4 %}text-green-400{% elif draft.rating[dim] >= 3 %}text-yellow-400{% else %}text-red-400{% endif %}">
|
||||
{{ draft.rating[dim] }}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="text-center mt-2">
|
||||
<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>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Pairwise similarities -->
|
||||
{% if data.similarities %}
|
||||
<div class="compare-card rounded-xl border border-slate-800 p-5 mb-6">
|
||||
<h3 class="text-sm font-semibold text-slate-300 mb-3">Pairwise Embedding Similarity</h3>
|
||||
<div class="space-y-2">
|
||||
{% for sim in data.similarities %}
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xs font-mono text-slate-400 w-1/3 truncate">{{ sim.a.split('-')[-1][:20] }}</span>
|
||||
<span class="text-xs text-slate-600">↔</span>
|
||||
<span class="text-xs font-mono text-slate-400 w-1/3 truncate">{{ sim.b.split('-')[-1][:20] }}</span>
|
||||
<div class="flex-1 h-2 bg-slate-800 rounded overflow-hidden">
|
||||
<div class="h-full rounded {% if sim.similarity >= 0.85 %}bg-green-500{% elif sim.similarity >= 0.7 %}bg-yellow-500{% else %}bg-blue-500{% endif %}"
|
||||
style="width: {{ (sim.similarity * 100)|int }}%"></div>
|
||||
</div>
|
||||
<span class="text-xs font-mono font-semibold w-12 text-right {% if sim.similarity >= 0.85 %}text-green-400{% elif sim.similarity >= 0.7 %}text-yellow-400{% else %}text-blue-400{% endif %}">
|
||||
{{ "%.3f"|format(sim.similarity) }}
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Shared Ideas -->
|
||||
{% if data.shared_ideas %}
|
||||
<div class="compare-card rounded-xl border border-slate-800 p-5 mb-6">
|
||||
<h3 class="text-sm font-semibold text-green-400 mb-3">Shared Ideas ({{ data.shared_ideas|length }})</h3>
|
||||
<div class="space-y-2">
|
||||
{% for idea in data.shared_ideas %}
|
||||
<div class="idea-shared rounded-lg border p-3">
|
||||
<div class="text-sm text-slate-200 font-medium">{{ idea.title }}</div>
|
||||
<div class="text-xs text-slate-500 mt-1">Found in: {{ idea.drafts | join(', ') }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Unique Ideas per draft -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-{{ data.drafts|length }} gap-4 mb-6">
|
||||
{% for draft in data.drafts %}
|
||||
<div class="compare-card rounded-xl border border-slate-800 p-5">
|
||||
<h3 class="text-sm font-semibold text-blue-400 mb-3">
|
||||
Unique Ideas: {{ draft.name.split('-')[-1][:20] }}
|
||||
<span class="text-slate-600 font-normal">({{ data.unique_ideas.get(draft.name, [])|length }})</span>
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
{% for idea in data.unique_ideas.get(draft.name, [])[:10] %}
|
||||
<div class="idea-unique rounded-lg border p-2.5">
|
||||
<div class="text-xs text-slate-300 font-medium">{{ idea.title }}</div>
|
||||
{% if idea.description %}
|
||||
<div class="text-xs text-slate-500 mt-0.5 line-clamp-2">{{ idea.description }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if data.unique_ideas.get(draft.name, [])|length == 0 %}
|
||||
<div class="text-xs text-slate-600 italic">No unique ideas extracted</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Shared References -->
|
||||
{% if data.shared_refs %}
|
||||
<div class="compare-card rounded-xl border border-slate-800 p-5 mb-6">
|
||||
<h3 class="text-sm font-semibold text-slate-300 mb-3">Shared References ({{ data.shared_refs|length }})</h3>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{% for ref in data.shared_refs %}
|
||||
<span class="ref-pill">{{ ref.type|upper }} {{ ref.id }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Claude Comparison (lazy-loaded) -->
|
||||
<div class="compare-card rounded-xl border border-slate-800 p-5 mb-6" id="comparisonSection">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-sm font-semibold text-slate-300">AI Comparison Summary</h3>
|
||||
<button onclick="runComparison()" id="compareBtn"
|
||||
class="px-4 py-1.5 bg-blue-600 text-white rounded-lg text-xs font-medium hover:bg-blue-500 transition-colors">
|
||||
Generate Comparison
|
||||
</button>
|
||||
</div>
|
||||
<div id="comparisonResult" class="text-sm text-slate-400">
|
||||
Click "Generate Comparison" to get a Claude-powered analysis of these drafts.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
{% if data %}
|
||||
<script>
|
||||
async function runComparison() {
|
||||
const btn = document.getElementById('compareBtn');
|
||||
const result = document.getElementById('comparisonResult');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="loading-spinner"></span> Analyzing...';
|
||||
result.innerHTML = '<div class="flex items-center gap-2"><span class="loading-spinner"></span> <span class="text-slate-500">Generating comparison...</span></div>';
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/compare', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({drafts: {{ data.drafts | map(attribute='name') | list | tojson }}})
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.error) {
|
||||
result.innerHTML = '<div class="text-red-400">Error: ' + data.error + '</div>';
|
||||
} else {
|
||||
result.innerHTML = '<div class="text-slate-300 whitespace-pre-line leading-relaxed">' + data.text + '</div>';
|
||||
}
|
||||
} catch (e) {
|
||||
result.innerHTML = '<div class="text-red-400">Error: ' + e.message + '</div>';
|
||||
}
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Regenerate';
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -156,6 +156,14 @@
|
||||
{{ idea.type }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if idea.novelty_score is not none and idea.novelty_score %}
|
||||
<span class="flex-shrink-0 px-1.5 py-0.5 rounded text-[10px] font-mono
|
||||
{% if idea.novelty_score >= 4 %}bg-green-500/20 text-green-400
|
||||
{% elif idea.novelty_score >= 3 %}bg-amber-500/20 text-amber-400
|
||||
{% elif idea.novelty_score >= 2 %}bg-orange-500/20 text-orange-400
|
||||
{% else %}bg-red-500/20 text-red-400{% endif %}"
|
||||
title="Novelty score">N:{{ idea.novelty_score }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if idea.description %}
|
||||
<p class="text-xs text-slate-500 leading-relaxed mt-1">{{ idea.description }}</p>
|
||||
@@ -165,6 +173,40 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Annotation (notes & tags) -->
|
||||
<div class="detail-card rounded-xl border border-slate-800 p-6" id="annotationSection">
|
||||
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
Notes & Tags
|
||||
</h2>
|
||||
<div class="mb-3">
|
||||
<textarea id="annotNote" rows="3" placeholder="Add a private note about this draft..."
|
||||
class="w-full bg-slate-800/60 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:border-blue-500 resize-y">{{ draft.annotation.note if draft.annotation else '' }}</textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="flex flex-wrap gap-1.5 mb-2" id="tagContainer">
|
||||
{% if draft.annotation and draft.annotation.tags %}
|
||||
{% for tag in draft.annotation.tags %}
|
||||
<span class="tag-chip inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-blue-500/20 text-blue-400 border border-blue-500/30">
|
||||
{{ tag }}
|
||||
<button onclick="removeTag('{{ tag }}')" class="hover:text-red-400 transition ml-0.5">×</button>
|
||||
</span>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<input type="text" id="newTag" placeholder="Add tag..." maxlength="30"
|
||||
class="flex-1 bg-slate-800/60 border border-slate-700 rounded-lg px-3 py-1.5 text-xs text-slate-200 placeholder-slate-500 focus:outline-none focus:border-blue-500"
|
||||
onkeydown="if(event.key==='Enter'){event.preventDefault();addTag();}">
|
||||
<button onclick="addTag()" class="px-3 py-1.5 bg-blue-600 text-white rounded-lg text-xs font-medium hover:bg-blue-500 transition">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="saveAnnotation()" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 text-slate-300 rounded-lg text-xs font-medium hover:border-blue-500 hover:text-blue-400 transition" id="saveBtn">
|
||||
Save Note
|
||||
</button>
|
||||
<div id="saveStatus" class="text-xs text-center mt-2 text-slate-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right column: Sidebar -->
|
||||
@@ -193,6 +235,42 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Readiness Score -->
|
||||
{% if draft.readiness and draft.readiness.score > 0 %}
|
||||
<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="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/></svg>
|
||||
Standards Readiness
|
||||
</h2>
|
||||
<!-- Gauge -->
|
||||
<div class="relative w-full h-6 bg-slate-800 rounded-full overflow-hidden mb-2">
|
||||
<div class="h-full rounded-full transition-all duration-700
|
||||
{% if draft.readiness.score >= 60 %}bg-gradient-to-r from-green-600 to-green-400
|
||||
{% elif draft.readiness.score >= 35 %}bg-gradient-to-r from-amber-600 to-amber-400
|
||||
{% else %}bg-gradient-to-r from-red-600 to-red-400{% endif %}"
|
||||
style="width: {{ draft.readiness.score }}%"></div>
|
||||
<div class="absolute inset-0 flex items-center justify-center text-xs font-bold text-white">
|
||||
{{ draft.readiness.score }}/100
|
||||
</div>
|
||||
</div>
|
||||
<!-- Factor breakdown -->
|
||||
<div class="space-y-1.5 mt-3">
|
||||
{% for key, f in draft.readiness.factors.items() %}
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="text-slate-500">{{ f.label }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-slate-600 font-mono text-[10px]">{{ f.detail }}</span>
|
||||
<span class="font-mono font-medium
|
||||
{% if f.value >= 0.7 %}text-green-400
|
||||
{% elif f.value >= 0.4 %}text-amber-400
|
||||
{% else %}text-red-400{% endif %}">+{{ f.contribution }}</span>
|
||||
</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">
|
||||
@@ -308,3 +386,76 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
const draftName = {{ draft.name | tojson }};
|
||||
|
||||
function addTag() {
|
||||
const input = document.getElementById('newTag');
|
||||
const tag = input.value.trim();
|
||||
if (!tag) return;
|
||||
input.value = '';
|
||||
fetch(`/api/drafts/${encodeURIComponent(draftName)}/annotate`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({add_tag: tag}),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) renderTags(data.annotation.tags);
|
||||
});
|
||||
}
|
||||
|
||||
function removeTag(tag) {
|
||||
fetch(`/api/drafts/${encodeURIComponent(draftName)}/annotate`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({remove_tag: tag}),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) renderTags(data.annotation.tags);
|
||||
});
|
||||
}
|
||||
|
||||
function renderTags(tags) {
|
||||
const container = document.getElementById('tagContainer');
|
||||
container.innerHTML = tags.map(t =>
|
||||
`<span class="tag-chip inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-blue-500/20 text-blue-400 border border-blue-500/30">
|
||||
${t}
|
||||
<button onclick="removeTag('${t}')" class="hover:text-red-400 transition ml-0.5">×</button>
|
||||
</span>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
function saveAnnotation() {
|
||||
const note = document.getElementById('annotNote').value;
|
||||
const btn = document.getElementById('saveBtn');
|
||||
const status = document.getElementById('saveStatus');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Saving...';
|
||||
fetch(`/api/drafts/${encodeURIComponent(draftName)}/annotate`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({note: note}),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Save Note';
|
||||
if (data.success) {
|
||||
status.textContent = 'Saved';
|
||||
status.className = 'text-xs text-center mt-2 text-green-400';
|
||||
setTimeout(() => { status.textContent = ''; status.className = 'text-xs text-center mt-2 text-slate-600'; }, 2000);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Save Note';
|
||||
status.textContent = 'Error saving';
|
||||
status.className = 'text-xs text-center mt-2 text-red-400';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -33,6 +33,26 @@
|
||||
.dim-fill-high { background: #4ade80; }
|
||||
.dim-fill-mid { background: #facc15; }
|
||||
.dim-fill-low { background: #f87171; }
|
||||
.source-badge {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.source-ietf {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #60a5fa;
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
.source-w3c {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #4ade80;
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
.cat-pill {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
@@ -128,6 +148,17 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<!-- Source dropdown -->
|
||||
<div class="min-w-[120px]">
|
||||
<label class="block text-xs font-medium text-slate-500 mb-1.5">Source</label>
|
||||
<select name="source"
|
||||
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 sources</option>
|
||||
<option value="ietf" {% if current_source == 'ietf' %}selected{% endif %}>IETF</option>
|
||||
<option value="w3c" {% if current_source == 'w3c' %}selected{% endif %}>W3C</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Sort -->
|
||||
<div class="min-w-[150px]">
|
||||
<label class="block text-xs font-medium text-slate-500 mb-1.5">Sort by</label>
|
||||
@@ -141,6 +172,7 @@
|
||||
<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="readiness" {% if sort == 'readiness' %}selected{% endif %}>Readiness</option>
|
||||
<option value="name" {% if sort == 'name' %}selected{% endif %}>Name</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -178,10 +210,10 @@
|
||||
{% 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 | urlencode }}&min_score={{ min_score }}&sort={{ sort }}&dir={{ sort_dir }}"
|
||||
<a href="/drafts?q={{ search | urlencode }}&min_score={{ min_score }}&sort={{ sort }}&dir={{ sort_dir }}&source={{ current_source }}"
|
||||
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 | urlencode }}&min_score={{ min_score }}&sort={{ sort }}&dir={{ sort_dir }}"
|
||||
<a href="/drafts?cat={{ cat }}&q={{ search | urlencode }}&min_score={{ min_score }}&sort={{ sort }}&dir={{ sort_dir }}&source={{ current_source }}"
|
||||
class="cat-pill {% if current_cat == cat %}cat-pill-active{% endif %}">
|
||||
{{ cat }} <span class="opacity-50">{{ count }}</span>
|
||||
</a>
|
||||
@@ -192,7 +224,7 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Results count -->
|
||||
<!-- Results count + Compare button -->
|
||||
<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
|
||||
@@ -201,9 +233,17 @@
|
||||
{% 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 class="flex items-center gap-3">
|
||||
<span id="compareCount" class="text-xs text-slate-600 hidden"><span id="compareNum">0</span> selected</span>
|
||||
<button onclick="goCompare()" id="compareBtn"
|
||||
class="px-4 py-1.5 bg-slate-800 text-slate-500 rounded-lg text-xs font-medium border border-slate-700 cursor-not-allowed transition-colors hidden"
|
||||
disabled>
|
||||
Compare Selected
|
||||
</button>
|
||||
{% if result.pages > 1 %}
|
||||
<p class="text-xs text-slate-600">Page {{ result.page }} of {{ result.pages }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Draft Table -->
|
||||
@@ -216,7 +256,7 @@
|
||||
{% 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 }}"
|
||||
<a href="/drafts?q={{ search }}&cat={{ current_cat }}&min_score={{ min_score }}&sort={{ field }}&dir={{ next_dir }}&source={{ current_source }}"
|
||||
class="hover:text-blue-400 transition inline-flex items-center gap-1"
|
||||
{% if title %}title="{{ title }}"{% endif %}>
|
||||
{{ label }}
|
||||
@@ -228,6 +268,9 @@
|
||||
</a>
|
||||
</th>
|
||||
{% endmacro %}
|
||||
<th class="px-2 py-3 w-8">
|
||||
<span class="text-xs text-slate-600" title="Select drafts to compare">Cmp</span>
|
||||
</th>
|
||||
{{ sort_header("score", "Score", "w-20") }}
|
||||
{{ sort_header("name", "Draft") }}
|
||||
{{ sort_header("date", "Date", "w-24 hidden md:table-cell") }}
|
||||
@@ -236,23 +279,32 @@
|
||||
{{ 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") }}
|
||||
{{ sort_header("readiness", "Rdy", "w-20 hidden xl:table-cell", "Standards Readiness") }}
|
||||
<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">
|
||||
<!-- Compare checkbox -->
|
||||
<td class="px-2 py-3 text-center">
|
||||
<input type="checkbox" class="compare-check rounded border-slate-600 bg-slate-800 text-blue-500 focus:ring-blue-500/30 focus:ring-offset-0 w-3.5 h-3.5 cursor-pointer"
|
||||
data-name="{{ d.name }}" onchange="updateCompare()">
|
||||
</td>
|
||||
<!-- 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 -->
|
||||
<!-- Draft name + title + source badge -->
|
||||
<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="flex items-center gap-1.5">
|
||||
<a href="/drafts/{{ d.name }}" class="text-blue-400 hover:text-blue-300 font-medium text-sm transition">
|
||||
{{ d.title }}
|
||||
</a>
|
||||
<span class="source-badge source-{{ d.source|default('ietf') }}">{{ (d.source|default('ietf'))|upper }}</span>
|
||||
</div>
|
||||
<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>
|
||||
@@ -293,6 +345,16 @@
|
||||
<span class="text-xs text-slate-500 font-mono w-4 text-right">{{ d.overlap }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<!-- Readiness -->
|
||||
<td class="px-4 py-3 hidden xl:table-cell">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="dim-bar-bg" style="width: 50px;">
|
||||
<span class="dim-bar-fill {% if d.readiness >= 50 %}dim-fill-high{% elif d.readiness >= 25 %}dim-fill-mid{% else %}dim-fill-low{% endif %}"
|
||||
style="width: {{ (d.readiness)|int }}%"></span>
|
||||
</span>
|
||||
<span class="text-xs text-slate-500 font-mono w-6 text-right">{{ d.readiness|int }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<!-- Categories -->
|
||||
<td class="px-4 py-3 hidden md:table-cell">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
@@ -308,7 +370,7 @@
|
||||
{% endfor %}
|
||||
{% if not result.drafts %}
|
||||
<tr>
|
||||
<td colspan="9" class="px-4 py-12 text-center text-slate-500">
|
||||
<td colspan="11" 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>
|
||||
@@ -326,7 +388,7 @@
|
||||
{% 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 | urlencode }}&cat={{ current_cat | urlencode }}&min_score={{ min_score }}&sort={{ sort }}&dir={{ sort_dir }}"
|
||||
<a href="/drafts?page={{ result.page - 1 }}&q={{ search | urlencode }}&cat={{ current_cat | urlencode }}&min_score={{ min_score }}&sort={{ sort }}&dir={{ sort_dir }}&source={{ current_source }}"
|
||||
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
|
||||
@@ -337,7 +399,7 @@
|
||||
{% set end_page = [result.pages, result.page + 2]|min %}
|
||||
|
||||
{% if start_page > 1 %}
|
||||
<a href="/drafts?page=1&q={{ search | urlencode }}&cat={{ current_cat | urlencode }}&min_score={{ min_score }}&sort={{ sort }}&dir={{ sort_dir }}"
|
||||
<a href="/drafts?page=1&q={{ search | urlencode }}&cat={{ current_cat | urlencode }}&min_score={{ min_score }}&sort={{ sort }}&dir={{ sort_dir }}&source={{ current_source }}"
|
||||
class="page-btn page-btn-inactive">1</a>
|
||||
{% if start_page > 2 %}<span class="text-slate-600 px-1">...</span>{% endif %}
|
||||
{% endif %}
|
||||
@@ -346,19 +408,19 @@
|
||||
{% if p == result.page %}
|
||||
<span class="page-btn page-btn-active">{{ p }}</span>
|
||||
{% else %}
|
||||
<a href="/drafts?page={{ p }}&q={{ search | urlencode }}&cat={{ current_cat | urlencode }}&min_score={{ min_score }}&sort={{ sort }}&dir={{ sort_dir }}"
|
||||
<a href="/drafts?page={{ p }}&q={{ search | urlencode }}&cat={{ current_cat | urlencode }}&min_score={{ min_score }}&sort={{ sort }}&dir={{ sort_dir }}&source={{ current_source }}"
|
||||
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 | urlencode }}&cat={{ current_cat | urlencode }}&min_score={{ min_score }}&sort={{ sort }}&dir={{ sort_dir }}"
|
||||
<a href="/drafts?page={{ result.pages }}&q={{ search | urlencode }}&cat={{ current_cat | urlencode }}&min_score={{ min_score }}&sort={{ sort }}&dir={{ sort_dir }}&source={{ current_source }}"
|
||||
class="page-btn page-btn-inactive">{{ result.pages }}</a>
|
||||
{% endif %}
|
||||
|
||||
{% if result.page < result.pages %}
|
||||
<a href="/drafts?page={{ result.page + 1 }}&q={{ search | urlencode }}&cat={{ current_cat | urlencode }}&min_score={{ min_score }}&sort={{ sort }}&dir={{ sort_dir }}"
|
||||
<a href="/drafts?page={{ result.page + 1 }}&q={{ search | urlencode }}&cat={{ current_cat | urlencode }}&min_score={{ min_score }}&sort={{ sort }}&dir={{ sort_dir }}&source={{ current_source }}"
|
||||
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>
|
||||
@@ -366,4 +428,46 @@
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
function updateCompare() {
|
||||
const checks = document.querySelectorAll('.compare-check:checked');
|
||||
const btn = document.getElementById('compareBtn');
|
||||
const count = document.getElementById('compareCount');
|
||||
const num = document.getElementById('compareNum');
|
||||
const n = checks.length;
|
||||
num.textContent = n;
|
||||
|
||||
if (n >= 2) {
|
||||
btn.classList.remove('hidden', 'bg-slate-800', 'text-slate-500', 'cursor-not-allowed');
|
||||
btn.classList.add('bg-blue-600', 'text-white', 'hover:bg-blue-500', 'cursor-pointer');
|
||||
btn.disabled = false;
|
||||
count.classList.remove('hidden');
|
||||
} else {
|
||||
btn.classList.add('hidden');
|
||||
count.classList.add('hidden');
|
||||
btn.disabled = true;
|
||||
}
|
||||
// Show button area once at least 1 is selected
|
||||
if (n >= 1) {
|
||||
btn.classList.remove('hidden');
|
||||
count.classList.remove('hidden');
|
||||
if (n < 2) {
|
||||
btn.classList.add('bg-slate-800', 'text-slate-500', 'cursor-not-allowed');
|
||||
btn.classList.remove('bg-blue-600', 'text-white', 'hover:bg-blue-500', 'cursor-pointer');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function goCompare() {
|
||||
const checks = document.querySelectorAll('.compare-check:checked');
|
||||
const names = Array.from(checks).map(c => c.dataset.name);
|
||||
if (names.length >= 2) {
|
||||
window.location.href = '/compare?drafts=' + encodeURIComponent(names.join(','));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -55,9 +55,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gap cards sorted by severity -->
|
||||
<div class="space-y-4">
|
||||
{% for gap in gaps | sort(attribute='severity') %}
|
||||
<!-- Gap cards sorted by severity (critical first) -->
|
||||
<div class="space-y-4" id="gapList">
|
||||
{% for gap in gaps %}
|
||||
<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
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
{% 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>
|
||||
<p class="text-slate-400 text-sm mt-1">Extracted ideas grouped by semantic similarity — enriched with WG and category data</p>
|
||||
</div>
|
||||
|
||||
<div id="emptyState" class="hidden">
|
||||
@@ -21,19 +21,30 @@
|
||||
|
||||
<div id="clusterContent" class="hidden">
|
||||
<!-- Stat cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 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-xs text-slate-500 uppercase tracking-wide">Total Ideas</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-xs text-slate-500 uppercase tracking-wide">Clusters</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-xs text-slate-500 uppercase tracking-wide">Avg Size</p>
|
||||
<p class="text-2xl font-bold text-white mt-1" id="statAvgSize">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">Cross-WG Clusters</p>
|
||||
<p class="text-2xl font-bold text-amber-400 mt-1" id="statCrossWg">0</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter bar -->
|
||||
<div class="flex flex-wrap gap-3 mb-6">
|
||||
<button id="filterAll" onclick="filterClusters('all')" class="px-3 py-1.5 text-xs rounded-lg bg-blue-600 text-white">All</button>
|
||||
<button id="filterCrossWg" onclick="filterClusters('cross_wg')" class="px-3 py-1.5 text-xs rounded-lg bg-slate-800 text-slate-400 hover:text-white">Cross-WG only</button>
|
||||
<button id="filterLarge" onclick="filterClusters('large')" class="px-3 py-1.5 text-xs rounded-lg bg-slate-800 text-slate-400 hover:text-white">Large (10+)</button>
|
||||
</div>
|
||||
|
||||
<!-- t-SNE Scatter -->
|
||||
@@ -46,7 +57,7 @@
|
||||
<!-- 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>
|
||||
<p class="text-xs text-slate-500 mb-3">Treemap showing relative sizes of each idea cluster. Amber borders = cross-WG clusters.</p>
|
||||
<div id="treemapPlot" style="height: 450px;"></div>
|
||||
</div>
|
||||
|
||||
@@ -72,6 +83,9 @@ const PALETTE = [
|
||||
'#3b82f6', '#ef4444', '#22c55e', '#a855f7', '#f59e0b',
|
||||
'#06b6d4', '#ec4899', '#84cc16', '#f97316', '#8b5cf6',
|
||||
'#14b8a6', '#e11d48', '#64748b', '#eab308', '#6366f1',
|
||||
'#fb923c', '#2dd4bf', '#c084fc', '#facc15', '#4ade80',
|
||||
'#f472b6', '#38bdf8', '#a3e635', '#fb7185', '#818cf8',
|
||||
'#34d399', '#fbbf24', '#e879f9', '#22d3ee', '#a78bfa',
|
||||
];
|
||||
|
||||
const data = {{ clusters | tojson }};
|
||||
@@ -81,46 +95,42 @@ if (data.empty) {
|
||||
} else {
|
||||
document.getElementById('clusterContent').classList.remove('hidden');
|
||||
|
||||
// Stats
|
||||
const stats = data.stats;
|
||||
const crossWgCount = data.clusters.filter(c => c.cross_wg).length;
|
||||
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';
|
||||
document.getElementById('statCrossWg').textContent = crossWgCount;
|
||||
|
||||
// --- 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: [] };
|
||||
if (!groups[pt.cluster_id]) groups[pt.cluster_id] = { x: [], y: [], text: [], names: [], wgs: [] };
|
||||
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
|
||||
groups[pt.cluster_id].wgs.push(pt.wg || 'none');
|
||||
});
|
||||
|
||||
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}`;
|
||||
const hoverTexts = g.text.map((t, j) => `${t}<br><span style="color:#64748b">${g.wgs[j]}</span>`);
|
||||
return {
|
||||
x: g.x, y: g.y, text: g.text, name: theme,
|
||||
x: g.x, y: g.y, text: hoverTexts, name: theme,
|
||||
customdata: g.names,
|
||||
mode: 'markers', type: 'scatter',
|
||||
marker: {
|
||||
size: 6,
|
||||
size: 7,
|
||||
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>',
|
||||
hovertemplate: '%{text}<extra>%{customdata}</extra>',
|
||||
};
|
||||
});
|
||||
|
||||
@@ -135,26 +145,33 @@ if (data.empty) {
|
||||
|
||||
document.getElementById('scatterPlot').on('plotly_click', function(ev) {
|
||||
const pt = ev.points[0];
|
||||
if (pt.customdata) {
|
||||
window.location.href = '/drafts/' + pt.customdata;
|
||||
}
|
||||
if (pt.customdata) window.location.href = '/drafts/' + pt.customdata;
|
||||
});
|
||||
}
|
||||
|
||||
// --- Treemap ---
|
||||
if (data.clusters.length > 0) {
|
||||
const labels = data.clusters.map(c => c.theme);
|
||||
const labels = data.clusters.map(c => c.cross_wg ? `${c.theme} ⚡` : c.theme);
|
||||
const values = data.clusters.map(c => c.size);
|
||||
const colors = data.clusters.map((_, i) => PALETTE[i % PALETTE.length]);
|
||||
const colors = data.clusters.map((c, i) => c.cross_wg
|
||||
? PALETTE[i % PALETTE.length] : PALETTE[i % PALETTE.length]);
|
||||
const hoverTexts = data.clusters.map(c => {
|
||||
const wgs = (c.wgs || []).filter(w => w.wg !== 'none').map(w => `${w.wg}(${w.count})`).join(', ');
|
||||
const cats = (c.categories || []).map(cat => cat.cat).join(', ');
|
||||
return `<b>${c.theme}</b><br>${c.size} ideas, ${c.drafts.length} drafts` +
|
||||
(wgs ? `<br>WGs: ${wgs}` : '') +
|
||||
(cats ? `<br>Categories: ${cats}` : '');
|
||||
});
|
||||
|
||||
Plotly.newPlot('treemapPlot', [{
|
||||
type: 'treemap',
|
||||
labels: labels,
|
||||
parents: labels.map(() => ''),
|
||||
values: values,
|
||||
text: hoverTexts,
|
||||
textinfo: 'label+value',
|
||||
marker: { colors: colors },
|
||||
hovertemplate: '<b>%{label}</b><br>%{value} ideas<extra></extra>',
|
||||
hovertemplate: '%{text}<extra></extra>',
|
||||
}], {
|
||||
...PLOTLY_LAYOUT,
|
||||
margin: { t: 10, r: 10, b: 10, l: 10 },
|
||||
@@ -163,38 +180,90 @@ if (data.empty) {
|
||||
|
||||
// --- 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>` : '';
|
||||
function renderCards(filter) {
|
||||
grid.innerHTML = '';
|
||||
data.clusters.forEach((cluster, i) => {
|
||||
if (filter === 'cross_wg' && !cluster.cross_wg) return;
|
||||
if (filter === 'large' && cluster.size < 10) return;
|
||||
|
||||
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);
|
||||
});
|
||||
const color = PALETTE[i % PALETTE.length];
|
||||
const topIdeas = cluster.ideas.slice(0, 5);
|
||||
const ideaListHtml = topIdeas.map(idea =>
|
||||
`<li class="text-xs text-slate-400 truncate" title="${idea.description || idea.title}">
|
||||
<span class="text-slate-300">${idea.title}</span>
|
||||
</li>`
|
||||
).join('');
|
||||
const extraCount = cluster.size - topIdeas.length;
|
||||
const extraHtml = extraCount > 0
|
||||
? `<li class="text-xs text-slate-600">+${extraCount} more</li>` : '';
|
||||
|
||||
// WG badges
|
||||
const wgBadges = (cluster.wgs || []).filter(w => w.wg !== 'none').map(w =>
|
||||
`<span class="inline-block bg-amber-900/30 text-amber-400 text-xs px-2 py-0.5 rounded border border-amber-800/30">${w.wg} (${w.count})</span>`
|
||||
).join(' ');
|
||||
const noneCount = (cluster.wgs || []).find(w => w.wg === 'none');
|
||||
const noneHtml = noneCount
|
||||
? `<span class="text-xs text-slate-600">${noneCount.count} individual</span>` : '';
|
||||
|
||||
// Category badges
|
||||
const catBadges = (cluster.categories || []).map(c =>
|
||||
`<span class="inline-block bg-slate-800 text-slate-400 text-xs px-2 py-0.5 rounded">${c.cat}</span>`
|
||||
).join(' ');
|
||||
|
||||
// Draft badges
|
||||
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-[160px]" title="${d}">${d.replace('draft-', '').substring(0, 22)}</a>`
|
||||
).join(' ');
|
||||
const extraDrafts = cluster.drafts.length > 4
|
||||
? `<span class="text-xs text-slate-600">+${cluster.drafts.length - 4}</span>` : '';
|
||||
|
||||
const crossBadge = cluster.cross_wg
|
||||
? `<span class="text-xs bg-amber-900/30 text-amber-400 px-1.5 py-0.5 rounded">cross-WG</span>` : '';
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'bg-slate-900 rounded-xl border p-5 ' +
|
||||
(cluster.cross_wg ? 'border-amber-800/40' : 'border-slate-800');
|
||||
card.innerHTML = `
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-3 h-3 rounded-full flex-shrink-0" style="background: ${color}"></div>
|
||||
<h3 class="text-sm font-semibold text-white truncate">${cluster.theme}</h3>
|
||||
${crossBadge}
|
||||
<span class="ml-auto text-xs text-slate-500 flex-shrink-0">${cluster.size} ideas</span>
|
||||
</div>
|
||||
<ul class="space-y-1 mb-3">${ideaListHtml}${extraHtml}</ul>
|
||||
${(wgBadges || noneHtml) ? `<div class="mb-2"><p class="text-xs text-slate-500 mb-1">Working Groups</p><div class="flex flex-wrap gap-1">${wgBadges} ${noneHtml}</div></div>` : ''}
|
||||
${catBadges ? `<div class="mb-2"><p class="text-xs text-slate-500 mb-1">Categories</p><div class="flex flex-wrap gap-1">${catBadges}</div></div>` : ''}
|
||||
<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);
|
||||
});
|
||||
}
|
||||
|
||||
renderCards('all');
|
||||
|
||||
// Filter buttons
|
||||
window.filterClusters = function(filter) {
|
||||
document.querySelectorAll('[id^="filter"]').forEach(b => {
|
||||
b.className = b.id === 'filter' + filter.charAt(0).toUpperCase() + filter.slice(1).replace('_w', 'W').replace('_', '')
|
||||
? 'px-3 py-1.5 text-xs rounded-lg bg-blue-600 text-white'
|
||||
: 'px-3 py-1.5 text-xs rounded-lg bg-slate-800 text-slate-400 hover:text-white';
|
||||
});
|
||||
// Simpler: just match by id
|
||||
['filterAll', 'filterCrossWg', 'filterLarge'].forEach(id => {
|
||||
const btn = document.getElementById(id);
|
||||
const isActive = (filter === 'all' && id === 'filterAll') ||
|
||||
(filter === 'cross_wg' && id === 'filterCrossWg') ||
|
||||
(filter === 'large' && id === 'filterLarge');
|
||||
btn.className = isActive
|
||||
? 'px-3 py-1.5 text-xs rounded-lg bg-blue-600 text-white'
|
||||
: 'px-3 py-1.5 text-xs rounded-lg bg-slate-800 text-slate-400 hover:text-white';
|
||||
});
|
||||
renderCards(filter);
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -64,6 +64,14 @@
|
||||
{% 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 %}
|
||||
{% if idea.novelty_score is not none and idea.novelty_score %}
|
||||
<span class="px-1.5 py-0.5 rounded text-[10px] font-mono
|
||||
{% if idea.novelty_score >= 4 %}bg-green-500/20 text-green-400
|
||||
{% elif idea.novelty_score >= 3 %}bg-amber-500/20 text-amber-400
|
||||
{% elif idea.novelty_score >= 2 %}bg-orange-500/20 text-orange-400
|
||||
{% else %}bg-red-500/20 text-red-400{% endif %}"
|
||||
title="Novelty score (1-5)">N:{{ idea.novelty_score }}</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>
|
||||
|
||||
@@ -111,6 +111,71 @@ html += `
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Pipeline progress
|
||||
const pl = data.pipeline || {};
|
||||
const cost = data.cost || {};
|
||||
if (pl.total_drafts) {
|
||||
const pctRated = Math.round((pl.rated / pl.total_drafts) * 100);
|
||||
const pctEmbedded = Math.round((pl.embedded / pl.total_drafts) * 100);
|
||||
const pctIdeas = Math.round((pl.with_ideas / pl.total_drafts) * 100);
|
||||
|
||||
function progressBar(pct, color) {
|
||||
return `<div class="w-full bg-slate-800 rounded-full h-2.5 mt-1.5">
|
||||
<div class="h-2.5 rounded-full ${color}" style="width: ${pct}%"></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
html += `
|
||||
<h2 class="text-lg font-semibold text-white mb-3">Pipeline Progress</h2>
|
||||
<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-4">
|
||||
<div class="flex justify-between items-baseline">
|
||||
<span class="text-xs text-slate-400">Rated</span>
|
||||
<span class="text-sm font-bold text-blue-400">${pl.rated} / ${pl.total_drafts}</span>
|
||||
</div>
|
||||
${progressBar(pctRated, 'bg-blue-500')}
|
||||
<div class="text-xs text-slate-600 mt-1 text-right">${pctRated}%</div>
|
||||
</div>
|
||||
<div class="stat-card rounded-xl border border-slate-800 p-4">
|
||||
<div class="flex justify-between items-baseline">
|
||||
<span class="text-xs text-slate-400">Embedded</span>
|
||||
<span class="text-sm font-bold text-purple-400">${pl.embedded} / ${pl.total_drafts}</span>
|
||||
</div>
|
||||
${progressBar(pctEmbedded, 'bg-purple-500')}
|
||||
<div class="text-xs text-slate-600 mt-1 text-right">${pctEmbedded}%</div>
|
||||
</div>
|
||||
<div class="stat-card rounded-xl border border-slate-800 p-4">
|
||||
<div class="flex justify-between items-baseline">
|
||||
<span class="text-xs text-slate-400">Ideas Extracted</span>
|
||||
<span class="text-sm font-bold text-green-400">${pl.with_ideas} / ${pl.total_drafts}</span>
|
||||
</div>
|
||||
${progressBar(pctIdeas, 'bg-green-500')}
|
||||
<div class="text-xs text-slate-600 mt-1 text-right">${pctIdeas}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<div class="stat-card rounded-xl border border-slate-800 p-4">
|
||||
<div class="text-2xl font-bold text-slate-200">${pl.total_drafts}</div>
|
||||
<div class="text-xs text-slate-400 mt-1">Total Documents</div>
|
||||
</div>
|
||||
<div class="stat-card rounded-xl border border-slate-800 p-4">
|
||||
<div class="text-2xl font-bold text-slate-200">${pl.idea_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-2xl font-bold text-slate-200">${pl.gap_count}</div>
|
||||
<div class="text-xs text-slate-400 mt-1">Gaps Identified</div>
|
||||
</div>
|
||||
<div class="stat-card rounded-xl border border-slate-800 p-4">
|
||||
<div class="text-xl font-bold text-amber-400">$${cost.estimated_usd || '0.00'}</div>
|
||||
<div class="text-xs text-slate-400 mt-1">Est. API Cost</div>
|
||||
<div class="text-xs text-slate-600 mt-0.5">${(cost.input_tokens || 0).toLocaleString()} in / ${(cost.output_tokens || 0).toLocaleString()} out</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// New drafts over time chart
|
||||
const runs = data.runs.slice().reverse(); // chronological order
|
||||
if (runs.length > 1) {
|
||||
|
||||
149
src/webui/templates/search_results.html
Normal file
149
src/webui/templates/search_results.html
Normal file
@@ -0,0 +1,149 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "search" %}
|
||||
|
||||
{% block title %}Search: {{ query }} — IETF Draft Analyzer{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">Search Results</h1>
|
||||
{% if query %}
|
||||
<p class="text-slate-400 text-sm mt-1">
|
||||
Found <span class="text-slate-300 font-medium">{{ total }}</span> results for
|
||||
"<span class="text-blue-400">{{ query }}</span>"
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="text-slate-400 text-sm mt-1">Enter a search query to find drafts, ideas, authors, and gaps.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Search form -->
|
||||
<div class="mb-8">
|
||||
<form action="/search" method="get" class="flex gap-3 max-w-xl">
|
||||
<input type="text" name="q" value="{{ query }}" placeholder="Search drafts, ideas, authors, gaps..."
|
||||
autofocus
|
||||
class="flex-1 bg-slate-800/60 border border-slate-700 rounded-lg px-4 py-2.5 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition">
|
||||
<button type="submit" class="px-5 py-2.5 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-500 transition-colors">
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if query %}
|
||||
|
||||
<!-- Drafts -->
|
||||
{% if results.drafts %}
|
||||
<div class="mb-8">
|
||||
<h2 class="text-lg font-semibold text-white mb-3 flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-blue-400" 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>
|
||||
Drafts <span class="text-sm font-normal text-slate-500">({{ results.drafts|length }})</span>
|
||||
</h2>
|
||||
<div class="bg-slate-900/60 rounded-xl border border-slate-800 divide-y divide-slate-800/30">
|
||||
{% for d in results.drafts %}
|
||||
<div class="px-5 py-3 hover:bg-slate-800/30 transition">
|
||||
<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.abstract %}
|
||||
<p class="text-xs text-slate-500 mt-1 line-clamp-2">{{ d.abstract }}</p>
|
||||
{% endif %}
|
||||
<div class="flex gap-3 mt-1 text-xs text-slate-600">
|
||||
{% if d.date %}<span>{{ d.date[:10] }}</span>{% endif %}
|
||||
<span>{{ d.group }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Ideas -->
|
||||
{% if results.ideas %}
|
||||
<div class="mb-8">
|
||||
<h2 class="text-lg font-semibold text-white mb-3 flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-yellow-400" 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 <span class="text-sm font-normal text-slate-500">({{ results.ideas|length }})</span>
|
||||
</h2>
|
||||
<div class="bg-slate-900/60 rounded-xl border border-slate-800 divide-y divide-slate-800/30">
|
||||
{% for idea in results.ideas %}
|
||||
<div class="px-5 py-3 hover:bg-slate-800/30 transition">
|
||||
<div class="text-sm text-slate-200 font-medium">{{ idea.title }}</div>
|
||||
{% if idea.description %}
|
||||
<p class="text-xs text-slate-500 mt-1 line-clamp-2">{{ idea.description }}</p>
|
||||
{% endif %}
|
||||
<div class="flex gap-3 mt-1 text-xs text-slate-600">
|
||||
{% if idea.type %}<span class="text-slate-500">{{ idea.type }}</span>{% endif %}
|
||||
<a href="/drafts/{{ idea.draft_name }}" class="text-blue-500 hover:text-blue-400">{{ idea.draft_name }}</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Authors -->
|
||||
{% if results.authors %}
|
||||
<div class="mb-8">
|
||||
<h2 class="text-lg font-semibold text-white mb-3 flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-green-400" 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-sm font-normal text-slate-500">({{ results.authors|length }})</span>
|
||||
</h2>
|
||||
<div class="bg-slate-900/60 rounded-xl border border-slate-800">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-0 divide-y md:divide-y-0 divide-slate-800/30">
|
||||
{% for author in results.authors %}
|
||||
<div class="px-5 py-3 hover:bg-slate-800/30 transition {% if not loop.last %}border-b md:border-b-0 md:border-r border-slate-800/30{% endif %}">
|
||||
<div class="text-sm text-slate-200 font-medium">{{ author.name }}</div>
|
||||
{% if author.affiliation %}
|
||||
<div class="text-xs text-slate-500 mt-0.5">{{ author.affiliation }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Gaps -->
|
||||
{% if results.gaps %}
|
||||
<div class="mb-8">
|
||||
<h2 class="text-lg font-semibold text-white mb-3 flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-red-400" 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>
|
||||
Gaps <span class="text-sm font-normal text-slate-500">({{ results.gaps|length }})</span>
|
||||
</h2>
|
||||
<div class="bg-slate-900/60 rounded-xl border border-slate-800 divide-y divide-slate-800/30">
|
||||
{% for gap in results.gaps %}
|
||||
<div class="px-5 py-3 hover:bg-slate-800/30 transition">
|
||||
<a href="/gaps/{{ gap.id }}" class="text-blue-400 hover:text-blue-300 font-medium text-sm transition">
|
||||
{{ gap.topic }}
|
||||
</a>
|
||||
{% if gap.description %}
|
||||
<p class="text-xs text-slate-500 mt-1 line-clamp-2">{{ gap.description }}</p>
|
||||
{% endif %}
|
||||
<div class="flex gap-3 mt-1 text-xs">
|
||||
{% if gap.category %}<span class="text-slate-500">{{ gap.category }}</span>{% endif %}
|
||||
{% if gap.severity %}
|
||||
<span class="{% if gap.severity == 'high' %}text-red-400{% elif gap.severity == 'medium' %}text-yellow-400{% else %}text-green-400{% endif %}">
|
||||
{{ gap.severity }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- No results -->
|
||||
{% if total == 0 %}
|
||||
<div class="text-center py-16">
|
||||
<svg class="w-16 h-16 mx-auto mb-4 text-slate-700" 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-slate-500 text-sm">No results found for "<span class="text-slate-400">{{ query }}</span>"</p>
|
||||
<p class="text-slate-600 text-xs mt-2">Try different keywords or check the spelling.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user