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

@@ -12,7 +12,11 @@ from pathlib import Path
_project_root = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(_project_root / "src"))
from flask import Flask, render_template, request, jsonify, abort, g
import csv
import io
import json
from flask import Flask, render_template, request, jsonify, abort, g, Response
from webui.data import (
get_db,
@@ -39,6 +43,10 @@ from webui.data import (
get_idea_clusters,
get_monitor_status,
get_author_network_full,
get_citation_graph,
get_comparison_data,
get_ask_data,
global_search,
)
app = Flask(
@@ -91,6 +99,7 @@ def drafts():
page = request.args.get("page", 1, type=int)
search = request.args.get("q", "")
category = request.args.get("cat", "")
source = request.args.get("source", "")
min_score = request.args.get("min_score", 0.0, type=float)
sort = request.args.get("sort", "score")
sort_dir = request.args.get("dir", "desc")
@@ -103,6 +112,7 @@ def drafts():
min_score=min_score,
sort=sort,
sort_dir=sort_dir,
source=source,
)
categories = get_category_counts(db())
return render_template(
@@ -111,6 +121,7 @@ def drafts():
categories=categories,
search=search,
current_cat=category,
current_source=source,
min_score=min_score,
sort=sort,
sort_dir=sort_dir,
@@ -272,6 +283,12 @@ def authors():
)
@app.route("/citations")
def citations():
graph = get_citation_graph(db())
return render_template("citations.html", graph=graph)
@app.route("/monitor")
def monitor_page():
status = get_monitor_status(db())
@@ -294,21 +311,121 @@ def datenschutz():
return render_template("datenschutz.html")
@app.route("/search")
def search():
q = request.args.get("q", "").strip()
results = global_search(db(), q) if q else {"drafts": [], "ideas": [], "authors": [], "gaps": []}
total = sum(len(v) for v in results.values())
return render_template("search_results.html", query=q, results=results, total=total)
@app.route("/ask")
def ask_page():
question = request.args.get("q", "")
result = None
if question:
top_k = request.args.get("top", 5, type=int)
result = get_ask_data(db(), question, top_k=top_k)
return render_template("ask.html", question=question, result=result)
@app.route("/api/ask", methods=["POST"])
def api_ask():
"""Answer a question via hybrid search + Claude. Returns JSON."""
data = request.get_json(force=True, silent=True)
if not data or "question" not in data:
return jsonify({"error": "Missing 'question' in request body"}), 400
question = data["question"]
top_k = data.get("top_k", 5)
cheap = data.get("cheap", True)
result = get_ask_data(db(), question, top_k=top_k, cheap=cheap)
return jsonify(result)
@app.route("/compare")
def compare_page():
draft_names = request.args.get("drafts", "")
names = [n.strip() for n in draft_names.split(",") if n.strip()] if draft_names else []
data = None
if len(names) >= 2:
data = get_comparison_data(db(), names)
return render_template("comparison.html", names=names, data=data)
@app.route("/api/compare", methods=["POST"])
def api_compare():
"""Run Claude comparison for drafts. Returns JSON with comparison text."""
req_data = request.get_json(force=True, silent=True)
if not req_data or "drafts" not in req_data:
return jsonify({"error": "Missing 'drafts' in request body"}), 400
names = req_data["drafts"]
if len(names) < 2:
return jsonify({"error": "Need at least 2 drafts to compare"}), 400
try:
from ietf_analyzer.config import Config
from ietf_analyzer.analyzer import Analyzer
cfg = Config.load()
database = db()
analyzer = Analyzer(cfg, database)
result = analyzer.compare_drafts(names)
return jsonify(result)
except Exception as e:
return jsonify({"error": str(e)}), 500
# --- API endpoints for AJAX (used by client-side charts) ---
def _to_csv_response(rows: list[dict], filename: str = "export.csv") -> Response:
"""Convert a list of dicts to a CSV download response."""
if not rows:
return Response("", mimetype="text/csv",
headers={"Content-Disposition": f"attachment; filename={filename}"})
si = io.StringIO()
writer = csv.DictWriter(si, fieldnames=rows[0].keys())
writer.writeheader()
for row in rows:
# Flatten any list/dict values to JSON strings
flat = {}
for k, v in row.items():
if isinstance(v, (list, dict)):
flat[k] = json.dumps(v)
else:
flat[k] = v
writer.writerow(flat)
return Response(si.getvalue(), mimetype="text/csv",
headers={"Content-Disposition": f"attachment; filename={filename}"})
def _results_to_csv(results: dict) -> Response:
"""Convert global search results (multi-category) to a single CSV."""
rows = []
for category, items in results.items():
for item in items:
row = {"_category": category}
row.update(item)
rows.append(row)
return _to_csv_response(rows, "search_results.csv")
@app.route("/api/drafts")
def api_drafts():
page = request.args.get("page", 1, type=int)
search = request.args.get("q", "")
category = request.args.get("cat", "")
source = request.args.get("source", "")
min_score = request.args.get("min_score", 0.0, type=float)
sort = request.args.get("sort", "score")
sort_dir = request.args.get("dir", "desc")
return jsonify(
get_drafts_page(db(), page=page, search=search, category=category,
min_score=min_score, sort=sort, sort_dir=sort_dir)
)
data = get_drafts_page(db(), page=page, search=search, category=category,
min_score=min_score, sort=sort, sort_dir=sort_dir,
source=source)
if request.args.get("format") == "csv":
return _to_csv_response(data.get("drafts", []), "drafts.csv")
return jsonify(data)
@app.route("/api/stats")
@@ -321,6 +438,148 @@ def api_author_network():
return jsonify(get_author_network_full(db()))
@app.route("/api/citations")
def api_citations():
min_refs = request.args.get("min_refs", 2, type=int)
return jsonify(get_citation_graph(db(), min_refs=min_refs))
@app.route("/api/search")
def api_search():
q = request.args.get("q", "").strip()
results = global_search(db(), q) if q else {"drafts": [], "ideas": [], "authors": [], "gaps": []}
if request.args.get("format") == "csv":
return _results_to_csv(results)
return jsonify(results)
@app.route("/api/ideas")
def api_ideas():
data = get_ideas_by_type(db())
if request.args.get("format") == "csv":
return _to_csv_response(data.get("ideas", []), "ideas.csv")
return jsonify(data)
@app.route("/api/gaps")
def api_gaps():
data = get_all_gaps(db())
if request.args.get("format") == "csv":
return _to_csv_response(data, "gaps.csv")
return jsonify(data)
@app.route("/api/gaps/<int:gap_id>")
def api_gap_detail(gap_id: int):
gap = get_gap_detail(db(), gap_id)
if not gap:
return jsonify({"error": "Gap not found"}), 404
return jsonify(gap)
@app.route("/api/ratings")
def api_ratings():
data = get_rating_distributions(db())
if request.args.get("format") == "csv":
# Transpose columnar data to rows
rows = []
for i in range(len(data.get("names", []))):
rows.append({
"name": data["names"][i],
"score": data["scores"][i],
"novelty": data["novelty"][i],
"maturity": data["maturity"][i],
"overlap": data["overlap"][i],
"momentum": data["momentum"][i],
"relevance": data["relevance"][i],
"category": data["categories"][i],
})
return _to_csv_response(rows, "ratings.csv")
return jsonify(data)
@app.route("/api/timeline")
def api_timeline():
data = get_timeline_data(db())
return jsonify(data)
@app.route("/api/landscape")
def api_landscape():
data = get_landscape_tsne(db())
if request.args.get("format") == "csv":
return _to_csv_response(data, "landscape.csv")
return jsonify(data)
@app.route("/api/similarity")
def api_similarity():
data = get_similarity_graph(db())
return jsonify(data)
@app.route("/api/idea-clusters")
def api_idea_clusters():
data = get_idea_clusters(db())
return jsonify(data)
@app.route("/api/monitor")
def api_monitor():
data = get_monitor_status(db())
return jsonify(data)
@app.route("/api/drafts/<path:name>")
def api_draft_detail(name: str):
detail = get_draft_detail(db(), name)
if not detail:
return jsonify({"error": "Draft not found"}), 404
return jsonify(detail)
@app.route("/api/categories")
def api_categories():
data = get_category_counts(db())
if request.args.get("format") == "csv":
rows = [{"category": k, "count": v} for k, v in data.items()]
return _to_csv_response(rows, "categories.csv")
return jsonify(data)
@app.route("/api/drafts/<path:name>/annotate", methods=["POST"])
def api_annotate(name: str):
"""Add or update annotation for a draft."""
import json as _json
database = db()
draft = database.get_draft(name)
if not draft:
return jsonify({"error": "Draft not found"}), 404
data = request.get_json(force=True, silent=True)
if not data:
return jsonify({"error": "Invalid JSON body"}), 400
note = data.get("note")
tags = data.get("tags")
add_tag = data.get("add_tag")
remove_tag = data.get("remove_tag")
# Handle add/remove tag operations
if add_tag or remove_tag:
existing = database.get_annotation(name)
current_tags = existing["tags"] if existing else []
if add_tag and add_tag not in current_tags:
current_tags.append(add_tag)
if remove_tag and remove_tag in current_tags:
current_tags.remove(remove_tag)
tags = current_tags
database.upsert_annotation(name, note=note, tags=tags)
annotation = database.get_annotation(name)
return jsonify({"success": True, "annotation": annotation})
if __name__ == "__main__":
print("Starting IETF Draft Analyzer Dashboard on http://127.0.0.1:5000")
app.run(debug=True, host="127.0.0.1", port=5000)

View File

@@ -66,6 +66,7 @@ def get_drafts_page(
min_score: float = 0.0,
sort: str = "score",
sort_dir: str = "desc",
source: str = "",
) -> dict:
"""Return a paginated, filtered list of drafts with ratings.
@@ -80,6 +81,8 @@ def get_drafts_page(
continue
if category and category not in rating.categories:
continue
if source and draft.source != source:
continue
if search:
haystack = f"{draft.name} {draft.title} {rating.summary}".lower()
if not all(w in haystack for w in search.lower().split()):
@@ -96,6 +99,9 @@ def get_drafts_page(
"relevance": lambda p: p[1].relevance,
"overlap": lambda p: p[1].overlap,
"momentum": lambda p: p[1].momentum,
"readiness": lambda p: (1.0 if p[0].name.startswith("draft-ietf-") else 0.0) * 0.25 +
min(int(p[0].rev or "0") / 5.0, 1.0) * 0.15 +
((p[1].momentum - 1) / 4.0) * 0.15,
}
key_fn = sort_keys.get(sort, sort_keys["score"])
reverse = sort_dir == "desc"
@@ -107,15 +113,23 @@ def get_drafts_page(
start = (page - 1) * per_page
page_items = filtered[start : start + per_page]
# Pre-compute readiness for page items (lightweight version)
from ietf_analyzer.readiness import compute_readiness
readiness_cache = {}
for draft, rating in page_items:
readiness_cache[draft.name] = compute_readiness(db, draft.name)
drafts = []
for draft, rating in page_items:
r_score = readiness_cache.get(draft.name, {}).get("score", 0)
drafts.append({
"name": draft.name,
"title": draft.title,
"date": draft.date,
"url": draft.datatracker_url,
"url": draft.source_url if draft.source != "ietf" else draft.datatracker_url,
"pages": draft.pages or 0,
"group": draft.group or "individual",
"source": draft.source or "ietf",
"score": round(rating.composite_score, 2),
"novelty": rating.novelty,
"maturity": rating.maturity,
@@ -124,6 +138,7 @@ def get_drafts_page(
"relevance": rating.relevance,
"categories": rating.categories,
"summary": rating.summary,
"readiness": r_score,
})
return {
@@ -185,6 +200,14 @@ def get_draft_detail(db: Database, name: str) -> dict | None:
"categories": rating.categories,
}
# Readiness score
from ietf_analyzer.readiness import compute_readiness
result["readiness"] = compute_readiness(db, name)
# Annotation
annotation = db.get_annotation(name)
result["annotation"] = annotation
return result
@@ -253,8 +276,11 @@ def get_ideas_by_type(db: Database) -> dict:
def get_all_gaps(db: Database) -> list[dict]:
"""Return all gap analysis results."""
return db.all_gaps()
"""Return all gap analysis results, sorted by severity (critical first)."""
_sev_order = {"critical": 0, "high": 1, "medium": 2, "low": 3}
gaps = db.all_gaps()
gaps.sort(key=lambda g: _sev_order.get(g.get("severity", "low"), 99))
return gaps
def get_gap_detail(db: Database, gap_id: int) -> dict | None:
@@ -775,17 +801,252 @@ def get_monitor_status(db: Database) -> dict:
"""Return monitoring status data for dashboard."""
runs = db.get_monitor_runs(limit=20)
last = runs[0] if runs else None
total_drafts = db.count_drafts()
rated_count = len(db.drafts_with_ratings(limit=10000))
unrated = len(db.unrated_drafts(limit=9999))
unembedded = len(db.drafts_without_embeddings(limit=9999))
embedded_count = total_drafts - unembedded
no_ideas = len(db.drafts_without_ideas(limit=9999))
ideas_count = total_drafts - no_ideas
idea_total = db.idea_count()
gap_count = len(db.all_gaps())
input_tok, output_tok = db.total_tokens_used()
# Estimate cost (Sonnet pricing: $3/M input, $15/M output)
est_cost = (input_tok * 3.0 / 1_000_000) + (output_tok * 15.0 / 1_000_000)
return {
"last_run": last,
"runs": runs,
"unprocessed": {"unrated": unrated, "unembedded": unembedded, "no_ideas": no_ideas},
"total_runs": len(runs),
"pipeline": {
"total_drafts": total_drafts,
"rated": rated_count,
"embedded": embedded_count,
"with_ideas": ideas_count,
"idea_total": idea_total,
"gap_count": gap_count,
},
"cost": {
"input_tokens": input_tok,
"output_tokens": output_tok,
"estimated_usd": round(est_cost, 2),
},
}
def get_citation_graph(db: Database, min_refs: int = 2) -> dict:
"""Return citation network data for force-directed graph.
Returns {nodes: [{id, type, title, influence, ...}],
edges: [{source, target}],
stats: {node_count, edge_count, ...}}
"""
# Get all references
rows = db.conn.execute(
"SELECT draft_name, ref_type, ref_id FROM draft_refs"
).fetchall()
# Count in-degree for each referenced item
in_degree: dict[str, int] = Counter()
edges_raw = []
for r in rows:
ref_key = f"{r['ref_type']}:{r['ref_id']}"
in_degree[ref_key] += 1
edges_raw.append((r["draft_name"], ref_key))
# Also count drafts as source nodes
draft_out: dict[str, int] = Counter()
for draft_name, _ in edges_raw:
draft_out[draft_name] += 1
# Get draft titles for labeling
draft_rows = db.conn.execute("SELECT name, title FROM drafts").fetchall()
draft_titles = {r["name"]: r["title"] for r in draft_rows}
# Get rating categories for draft coloring
rating_rows = db.conn.execute("SELECT draft_name, categories FROM ratings").fetchall()
draft_cats = {}
for r in rating_rows:
try:
cats = json.loads(r["categories"]) if r["categories"] else []
draft_cats[r["draft_name"]] = cats[0] if cats else "Other"
except Exception:
draft_cats[r["draft_name"]] = "Other"
# Filter: keep RFCs with min_refs+ references and all drafts that reference them
top_refs = {k: v for k, v in in_degree.items() if v >= min_refs}
# Build node set
node_set = set()
filtered_edges = []
for draft_name, ref_key in edges_raw:
if ref_key in top_refs:
node_set.add(draft_name)
node_set.add(ref_key)
filtered_edges.append({"source": draft_name, "target": ref_key})
# Limit to ~200 nodes max for readability
if len(node_set) > 250:
# Keep only refs with higher in-degree
sorted_refs = sorted(top_refs.items(), key=lambda x: x[1], reverse=True)
keep_refs = set(k for k, _ in sorted_refs[:80])
node_set = set()
filtered_edges = []
for draft_name, ref_key in edges_raw:
if ref_key in keep_refs:
node_set.add(draft_name)
node_set.add(ref_key)
filtered_edges.append({"source": draft_name, "target": ref_key})
# Build nodes
nodes = []
for nid in node_set:
if ":" in nid and not nid.startswith("draft-"):
# It's a reference node (rfc:1234, bcp:14, etc.)
ref_type, ref_id = nid.split(":", 1)
influence = in_degree.get(nid, 0)
if ref_type == "rfc":
try:
title = f"RFC {int(ref_id)}"
except ValueError:
title = f"RFC {ref_id}"
else:
title = f"{ref_type.upper()} {ref_id}"
nodes.append({
"id": nid,
"type": ref_type,
"title": title,
"influence": influence,
"ref_id": ref_id,
})
else:
# It's a draft node
influence = in_degree.get(nid, 0) + draft_out.get(nid, 0)
nodes.append({
"id": nid,
"type": "draft",
"title": draft_titles.get(nid, nid),
"influence": draft_out.get(nid, 0),
"category": draft_cats.get(nid, "Other"),
})
# Stats
rfc_count = sum(1 for n in nodes if n["type"] == "rfc")
draft_count = sum(1 for n in nodes if n["type"] == "draft")
return {
"nodes": nodes,
"edges": filtered_edges,
"stats": {
"node_count": len(nodes),
"edge_count": len(filtered_edges),
"rfc_count": rfc_count,
"draft_count": draft_count,
},
}
def global_search(db: Database, query: str) -> dict:
"""Search across drafts (FTS5), ideas, authors, and gaps.
Returns {drafts: [...], ideas: [...], authors: [...], gaps: [...]}.
"""
results: dict = {"drafts": [], "ideas": [], "authors": [], "gaps": []}
if not query or not query.strip():
return results
q = query.strip()
# 1. Drafts via FTS5
try:
fts_query = " ".join(f'"{w}"' for w in q.split() if w)
rows = db.conn.execute(
"""SELECT d.name, d.title, d.abstract, d.time, d."group"
FROM drafts d
JOIN drafts_fts f ON d.rowid = f.rowid
WHERE drafts_fts MATCH ?
ORDER BY rank
LIMIT 50""",
(fts_query,),
).fetchall()
for r in rows:
results["drafts"].append({
"name": r["name"],
"title": r["title"],
"abstract": (r["abstract"] or "")[:200],
"date": r["time"],
"group": r["group"] or "individual",
})
except Exception:
# FTS5 match can fail on certain query syntax; fall back to LIKE
like = f"%{q}%"
rows = db.conn.execute(
"""SELECT name, title, abstract, time, "group" FROM drafts
WHERE title LIKE ? OR name LIKE ? OR abstract LIKE ?
LIMIT 50""",
(like, like, like),
).fetchall()
for r in rows:
results["drafts"].append({
"name": r["name"],
"title": r["title"],
"abstract": (r["abstract"] or "")[:200],
"date": r["time"],
"group": r["group"] or "individual",
})
# 2. Ideas via LIKE
like = f"%{q}%"
rows = db.conn.execute(
"""SELECT id, title, description, idea_type, draft_name FROM ideas
WHERE title LIKE ? OR description LIKE ?
ORDER BY id LIMIT 50""",
(like, like),
).fetchall()
for r in rows:
results["ideas"].append({
"id": r["id"],
"title": r["title"],
"description": (r["description"] or "")[:200],
"type": r["idea_type"],
"draft_name": r["draft_name"],
})
# 3. Authors via LIKE
rows = db.conn.execute(
"""SELECT person_id, name, affiliation FROM authors
WHERE name LIKE ? OR affiliation LIKE ?
ORDER BY name LIMIT 50""",
(like, like),
).fetchall()
for r in rows:
results["authors"].append({
"person_id": r["person_id"],
"name": r["name"],
"affiliation": r["affiliation"] or "",
})
# 4. Gaps via LIKE
rows = db.conn.execute(
"""SELECT id, topic, description, category, severity FROM gaps
WHERE topic LIKE ? OR description LIKE ?
ORDER BY id LIMIT 50""",
(like, like),
).fetchall()
for r in rows:
results["gaps"].append({
"id": r["id"],
"topic": r["topic"],
"description": (r["description"] or "")[:200],
"category": r["category"],
"severity": r["severity"],
})
return results
def get_landscape_tsne(db: Database) -> list[dict]:
"""Compute t-SNE from embeddings, return [{name, title, x, y, category, score}].
@@ -829,3 +1090,116 @@ def get_landscape_tsne(db: Database) -> list[dict]:
"score": round(r.composite_score, 2),
})
return result
def get_comparison_data(db: Database, names: list[str]) -> dict | None:
"""Get comparison data for a list of drafts.
Returns {
drafts: [{name, title, abstract, rating, ideas, refs, ...}],
shared_ideas: [{title, drafts: [name,...]}],
unique_ideas: {name: [{title, description}]},
shared_refs: [{type, id, drafts: [name,...]}],
unique_refs: {name: [{type, id}]},
similarities: [{a, b, similarity}],
comparison_text: str | None,
}
"""
import numpy as np
drafts_data = []
all_ideas: dict[str, list[dict]] = {}
all_refs: dict[str, list[tuple[str, str]]] = {}
for name in names:
detail = get_draft_detail(db, name)
if not detail:
continue
drafts_data.append(detail)
all_ideas[name] = detail.get("ideas", [])
all_refs[name] = [(r["type"], r["id"]) for r in detail.get("refs", [])]
if len(drafts_data) < 2:
return None
# Find shared vs unique ideas (by title similarity)
idea_title_drafts: dict[str, list[str]] = {}
for name, ideas in all_ideas.items():
for idea in ideas:
title_lower = idea["title"].lower().strip()
if title_lower not in idea_title_drafts:
idea_title_drafts[title_lower] = []
idea_title_drafts[title_lower].append(name)
shared_ideas = [
{"title": title, "drafts": draft_list}
for title, draft_list in idea_title_drafts.items()
if len(set(draft_list)) > 1
]
unique_ideas: dict[str, list[dict]] = {}
for name, ideas in all_ideas.items():
unique = []
for idea in ideas:
title_lower = idea["title"].lower().strip()
if len(set(idea_title_drafts.get(title_lower, []))) <= 1:
unique.append({"title": idea["title"], "description": idea.get("description", "")})
unique_ideas[name] = unique
# Find shared vs unique references
ref_drafts: dict[tuple[str, str], list[str]] = {}
for name, refs in all_refs.items():
for ref in refs:
if ref not in ref_drafts:
ref_drafts[ref] = []
ref_drafts[ref].append(name)
shared_refs = [
{"type": ref[0], "id": ref[1], "drafts": draft_list}
for ref, draft_list in ref_drafts.items()
if len(set(draft_list)) > 1
]
unique_refs: dict[str, list[dict]] = {}
for name, refs in all_refs.items():
unique = []
for ref in refs:
if len(set(ref_drafts.get(ref, []))) <= 1:
unique.append({"type": ref[0], "id": ref[1]})
unique_refs[name] = unique
# Pairwise embedding similarities
embeddings = db.all_embeddings()
similarities = []
valid_names = [d["name"] for d in drafts_data]
for i in range(len(valid_names)):
for j in range(i + 1, len(valid_names)):
a, b = valid_names[i], valid_names[j]
if a in embeddings and b in embeddings:
vec_a = embeddings[a]
vec_b = embeddings[b]
dot = np.dot(vec_a, vec_b)
norm = np.linalg.norm(vec_a) * np.linalg.norm(vec_b)
sim = float(dot / norm) if norm > 0 else 0.0
similarities.append({"a": a, "b": b, "similarity": round(sim, 4)})
return {
"drafts": drafts_data,
"shared_ideas": shared_ideas,
"unique_ideas": unique_ideas,
"shared_refs": shared_refs,
"unique_refs": unique_refs,
"similarities": similarities,
"comparison_text": None,
}
def get_ask_data(db: Database, question: str, top_k: int = 5, cheap: bool = True) -> dict:
"""Run hybrid search + Claude synthesis for a question.
Returns {answer: str, sources: [{name, title, similarity, excerpt}]}.
"""
from ietf_analyzer.config import Config
from ietf_analyzer.search import HybridSearch
config = Config.load()
searcher = HybridSearch(config, db)
return searcher.ask(question, top_k=top_k, cheap=cheap)

View 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 %}

View File

@@ -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

View 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 %}

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 %}

View File

@@ -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">&times;</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">&times;</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 %}

View File

@@ -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 %}

View File

@@ -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

View File

@@ -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 %}

View File

@@ -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>

View File

@@ -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) {

View 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 %}