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:
2026-03-07 20:52:56 +01:00
parent da2a989744
commit 757b781c67
33 changed files with 4253 additions and 170 deletions

View 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">&harr;</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 %}