Fix remaining critical, high, and medium issues from 4-perspective review
Critical fixes:
- Fix rating clamp range 1-10 → 1-5 (actual scale)
- Add `ietf ideas convergence` command (SequenceMatcher at 0.75 threshold)
- Fix "628 cross-org ideas" → 130 (verified from current DB) across 8 files
Security fixes:
- Sanitize FTS5 query input (strip special chars + boolean operators)
- Add rate limiting (10 req/min/IP) on Claude-calling endpoints
- Change <path:name> → <string:name> on draft routes
Codebase fixes:
- Add Database context manager (__enter__/__exit__)
- Wire false_positive filtering into queries (exclude by default in web UI)
- Fix Post 3 arithmetic ("~300" → "~409" distinct proposals)
Content & licensing:
- Add MIT LICENSE file
- Add IPR/FRAND notes (BCP 79, RFC 8179) to Posts 03 and 07
- Qualify "4:1 safety ratio" with monthly variation in 6 remaining files
- Add "Data as of March 2026" freeze-date headers to all 10 blog posts
- Hedge causal language in Post 04
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -229,7 +229,7 @@ class Analyzer:
|
||||
raise SystemExit(1)
|
||||
|
||||
@staticmethod
|
||||
def _clamp_rating(value, default: int = 3, lo: int = 1, hi: int = 10) -> int:
|
||||
def _clamp_rating(value, default: int = 3, lo: int = 1, hi: int = 5) -> int:
|
||||
"""Clamp a rating value to [lo, hi] integers."""
|
||||
try:
|
||||
return max(lo, min(hi, int(value)))
|
||||
|
||||
@@ -1421,6 +1421,103 @@ def ideas_filter(min_score: int, dry_run: bool):
|
||||
db.close()
|
||||
|
||||
|
||||
@ideas.command("convergence")
|
||||
@click.option("--threshold", "-t", default=0.75, help="SequenceMatcher ratio threshold (0-1)")
|
||||
@click.option("--limit", "-n", default=50, help="Max results to show")
|
||||
@click.option("--list-all", is_flag=True, help="List all convergent idea pairs")
|
||||
def ideas_convergence(threshold: float, limit: int, list_all: bool):
|
||||
"""Find cross-org convergent ideas using SequenceMatcher fuzzy matching.
|
||||
|
||||
Groups ideas by fuzzy title similarity, then filters to ideas where
|
||||
2+ distinct organizations independently propose similar concepts.
|
||||
"""
|
||||
from collections import defaultdict
|
||||
from difflib import SequenceMatcher
|
||||
from .orgs import normalize_org
|
||||
|
||||
cfg = _get_config()
|
||||
db = Database(cfg)
|
||||
|
||||
try:
|
||||
all_ideas = db.all_ideas()
|
||||
if not all_ideas:
|
||||
console.print("[yellow]No ideas extracted yet. Run `ietf ideas --all` first.[/]")
|
||||
return
|
||||
|
||||
# Build draft -> org mapping
|
||||
draft_orgs: dict[str, set[str]] = defaultdict(set)
|
||||
rows = db.conn.execute(
|
||||
"""SELECT da.draft_name, a.affiliation
|
||||
FROM draft_authors da
|
||||
JOIN authors a ON da.person_id = a.person_id
|
||||
WHERE a.affiliation != ''"""
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
org = normalize_org(r["affiliation"])
|
||||
if org and org != "Independent":
|
||||
draft_orgs[r["draft_name"]].add(org)
|
||||
|
||||
# Group similar ideas by fuzzy title matching
|
||||
idea_groups: list[dict] = []
|
||||
for idea in all_ideas:
|
||||
title_lower = idea["title"].lower().strip()
|
||||
matched = False
|
||||
for group in idea_groups:
|
||||
ratio = SequenceMatcher(None, title_lower, group["canonical"]).ratio()
|
||||
if ratio >= threshold:
|
||||
group["ideas"].append(idea)
|
||||
group["drafts"].add(idea["draft_name"])
|
||||
group["orgs"].update(draft_orgs.get(idea["draft_name"], set()))
|
||||
matched = True
|
||||
break
|
||||
if not matched:
|
||||
idea_groups.append({
|
||||
"canonical": title_lower,
|
||||
"title": idea["title"],
|
||||
"ideas": [idea],
|
||||
"drafts": {idea["draft_name"]},
|
||||
"orgs": set(draft_orgs.get(idea["draft_name"], set())),
|
||||
})
|
||||
|
||||
# Filter to cross-org ideas (2+ orgs)
|
||||
cross_org = [g for g in idea_groups if len(g["orgs"]) >= 2]
|
||||
cross_org.sort(key=lambda g: (-len(g["orgs"]), -len(g["drafts"])))
|
||||
|
||||
console.print(f"\n[bold]Cross-Organization Idea Convergence[/]")
|
||||
console.print(f"Threshold: {threshold} | {len(all_ideas)} ideas | "
|
||||
f"{len(idea_groups)} unique clusters | "
|
||||
f"[bold green]{len(cross_org)}[/] cross-org convergent\n")
|
||||
|
||||
if not cross_org:
|
||||
console.print("[yellow]No cross-org convergent ideas at this threshold.[/]")
|
||||
return
|
||||
|
||||
show_n = len(cross_org) if list_all else min(limit, len(cross_org))
|
||||
table = Table(title=f"Cross-Org Convergent Ideas (showing {show_n} of {len(cross_org)})")
|
||||
table.add_column("#", justify="right", width=4)
|
||||
table.add_column("Idea", style="bold", max_width=40)
|
||||
table.add_column("Orgs", justify="right", width=5)
|
||||
table.add_column("Drafts", justify="right", width=6)
|
||||
table.add_column("Organizations", max_width=50)
|
||||
|
||||
for rank, g in enumerate(cross_org[:show_n], 1):
|
||||
org_list = ", ".join(sorted(g["orgs"])[:5])
|
||||
if len(g["orgs"]) > 5:
|
||||
org_list += f" +{len(g['orgs']) - 5}"
|
||||
table.add_row(
|
||||
str(rank), g["title"][:40], str(len(g["orgs"])),
|
||||
str(len(g["drafts"])), org_list,
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print(f"\n[bold]Summary[/]: {len(cross_org)} cross-org convergent ideas "
|
||||
f"out of {len(idea_groups)} unique clusters "
|
||||
f"({100 * len(cross_org) / len(idea_groups):.0f}%)")
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ── dedup-ideas ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -286,6 +286,12 @@ class Database:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
self.close()
|
||||
|
||||
# --- Drafts ---
|
||||
|
||||
def upsert_draft(self, draft: Draft) -> None:
|
||||
@@ -343,8 +349,16 @@ class Database:
|
||||
).fetchall()
|
||||
return [self._row_to_draft(r) for r in rows]
|
||||
|
||||
def count_drafts(self) -> int:
|
||||
return self.conn.execute("SELECT COUNT(*) FROM drafts").fetchone()[0]
|
||||
def count_drafts(self, include_false_positives: bool = True) -> int:
|
||||
if include_false_positives:
|
||||
return self.conn.execute("SELECT COUNT(*) FROM drafts").fetchone()[0]
|
||||
return self.conn.execute(
|
||||
"""SELECT COUNT(*) FROM drafts d
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM ratings r
|
||||
WHERE r.draft_name = d.name AND r.false_positive = 1
|
||||
)"""
|
||||
).fetchone()[0]
|
||||
|
||||
def search_drafts(self, query: str, limit: int = 50) -> list[Draft]:
|
||||
rows = self.conn.execute(
|
||||
@@ -408,13 +422,17 @@ class Database:
|
||||
).fetchall()
|
||||
return [self._row_to_draft(r) for r in rows]
|
||||
|
||||
def drafts_with_ratings(self, limit: int = 200) -> list[tuple[Draft, Rating]]:
|
||||
def drafts_with_ratings(
|
||||
self, limit: int = 200, include_false_positives: bool = False,
|
||||
) -> list[tuple[Draft, Rating]]:
|
||||
fp_clause = "" if include_false_positives else "WHERE COALESCE(r.false_positive, 0) = 0"
|
||||
rows = self.conn.execute(
|
||||
"""SELECT d.*, r.novelty, r.maturity, r.overlap, r.momentum, r.relevance,
|
||||
f"""SELECT d.*, r.novelty, r.maturity, r.overlap, r.momentum, r.relevance,
|
||||
r.summary, r.novelty_note, r.maturity_note, r.overlap_note,
|
||||
r.momentum_note, r.relevance_note, r.categories as r_categories, r.rated_at
|
||||
FROM drafts d
|
||||
JOIN ratings r ON d.name = r.draft_name
|
||||
{fp_clause}
|
||||
ORDER BY (r.novelty * 0.30 + r.relevance * 0.25 + r.maturity * 0.20
|
||||
+ r.momentum * 0.15 + (6 - r.overlap) * 0.10) DESC
|
||||
LIMIT ?""",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from collections import defaultdict
|
||||
|
||||
import numpy as np
|
||||
@@ -94,19 +95,30 @@ class HybridSearch:
|
||||
|
||||
return merged[:top_k]
|
||||
|
||||
@staticmethod
|
||||
def sanitize_fts_query(query: str) -> str:
|
||||
"""Sanitize a query string for safe use in FTS5 MATCH expressions.
|
||||
|
||||
Strips special FTS5 characters and operators (NEAR, OR, AND, NOT)
|
||||
that could cause injection or query syntax errors.
|
||||
"""
|
||||
# Strip all non-alphanumeric, non-whitespace characters
|
||||
cleaned = re.sub(r'[^\w\s]', '', query)
|
||||
# Remove FTS5 boolean operators when used as standalone words
|
||||
cleaned = re.sub(r'\b(NEAR|OR|AND|NOT)\b', '', cleaned, flags=re.IGNORECASE)
|
||||
# Collapse whitespace
|
||||
cleaned = re.sub(r'\s+', ' ', cleaned).strip()
|
||||
return cleaned
|
||||
|
||||
def _fts_search(self, query: str, limit: int = 20) -> list[dict]:
|
||||
"""Run FTS5 keyword search, return ranked results."""
|
||||
safe_query = self.sanitize_fts_query(query)
|
||||
if not safe_query:
|
||||
return []
|
||||
try:
|
||||
drafts = self.db.search_drafts(query, limit=limit)
|
||||
drafts = self.db.search_drafts(safe_query, limit=limit)
|
||||
except Exception:
|
||||
# FTS5 can fail on certain query syntax; fallback to simpler search
|
||||
# Try wrapping each word with quotes for literal matching
|
||||
words = query.split()
|
||||
safe_query = " OR ".join(f'"{w}"' for w in words if w.strip())
|
||||
try:
|
||||
drafts = self.db.search_drafts(safe_query, limit=limit)
|
||||
except Exception:
|
||||
return []
|
||||
return []
|
||||
|
||||
results = []
|
||||
for rank, draft in enumerate(drafts):
|
||||
|
||||
@@ -15,6 +15,9 @@ sys.path.insert(0, str(_project_root / "src"))
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import time
|
||||
import functools
|
||||
from collections import defaultdict
|
||||
|
||||
from flask import Flask, render_template, request, jsonify, abort, g, Response
|
||||
|
||||
@@ -50,6 +53,7 @@ from webui.data import (
|
||||
get_comparison_data,
|
||||
get_ask_search,
|
||||
get_ask_synthesize,
|
||||
get_category_summary,
|
||||
global_search,
|
||||
)
|
||||
|
||||
@@ -70,6 +74,29 @@ _analytics_db = str(_project_root / "data" / "analytics.db")
|
||||
init_analytics(app, db_path=_analytics_db)
|
||||
|
||||
|
||||
# --- Rate limiting for Claude-calling endpoints ---
|
||||
|
||||
_rate_limit_store: dict[str, list[float]] = defaultdict(list)
|
||||
_RATE_LIMIT_MAX = 10 # max requests
|
||||
_RATE_LIMIT_WINDOW = 60 # per 60 seconds
|
||||
|
||||
|
||||
def rate_limit(f):
|
||||
"""Simple in-memory rate limiter: max 10 requests per minute per IP."""
|
||||
@functools.wraps(f)
|
||||
def wrapper(*args, **kwargs):
|
||||
ip = request.remote_addr or "unknown"
|
||||
now = time.time()
|
||||
# Prune timestamps outside the sliding window
|
||||
timestamps = _rate_limit_store[ip]
|
||||
_rate_limit_store[ip] = [t for t in timestamps if now - t < _RATE_LIMIT_WINDOW]
|
||||
if len(_rate_limit_store[ip]) >= _RATE_LIMIT_MAX:
|
||||
return jsonify({"error": "Rate limit exceeded. Try again later."}), 429
|
||||
_rate_limit_store[ip].append(now)
|
||||
return f(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
# --- Database lifecycle (per-request to avoid SQLite threading issues) ---
|
||||
|
||||
|
||||
@@ -127,10 +154,12 @@ def drafts():
|
||||
source=source,
|
||||
)
|
||||
categories = get_category_counts(db())
|
||||
cat_summary = get_category_summary(db(), category) if category else None
|
||||
return render_template(
|
||||
"drafts.html",
|
||||
result=result,
|
||||
categories=categories,
|
||||
cat_summary=cat_summary,
|
||||
search=search,
|
||||
current_cat=category,
|
||||
current_source=source,
|
||||
@@ -140,7 +169,7 @@ def drafts():
|
||||
)
|
||||
|
||||
|
||||
@app.route("/drafts/<path:name>")
|
||||
@app.route("/drafts/<string:name>")
|
||||
def draft_detail(name: str):
|
||||
database = db()
|
||||
detail = get_draft_detail(database, name)
|
||||
@@ -321,8 +350,11 @@ def analytics_dashboard():
|
||||
|
||||
@app.route("/about")
|
||||
def about():
|
||||
from ietf_analyzer.config import Config
|
||||
cfg = Config.load()
|
||||
stats = get_overview_stats(db())
|
||||
return render_template("about.html", stats=stats)
|
||||
return render_template("about.html", stats=stats, search_keywords=cfg.search_keywords,
|
||||
fetch_since=cfg.fetch_since)
|
||||
|
||||
|
||||
@app.route("/impressum")
|
||||
@@ -356,6 +388,7 @@ def ask_page():
|
||||
|
||||
@app.route("/api/ask/synthesize", methods=["POST"])
|
||||
@admin_required
|
||||
@rate_limit
|
||||
def api_ask_synthesize():
|
||||
"""Synthesize an answer via Claude (costs tokens, cached permanently). Returns JSON."""
|
||||
data = request.get_json(force=True, silent=True)
|
||||
@@ -392,6 +425,7 @@ def compare_page():
|
||||
|
||||
@app.route("/api/compare", methods=["POST"])
|
||||
@admin_required
|
||||
@rate_limit
|
||||
def api_compare():
|
||||
"""Run Claude comparison for drafts. Returns JSON with comparison text."""
|
||||
req_data = request.get_json(force=True, silent=True)
|
||||
@@ -572,7 +606,7 @@ def api_monitor():
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@app.route("/api/drafts/<path:name>")
|
||||
@app.route("/api/drafts/<string:name>")
|
||||
def api_draft_detail(name: str):
|
||||
detail = get_draft_detail(db(), name)
|
||||
if not detail:
|
||||
@@ -589,7 +623,7 @@ def api_categories():
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@app.route("/api/drafts/<path:name>/annotate", methods=["POST"])
|
||||
@app.route("/api/drafts/<string:name>/annotate", methods=["POST"])
|
||||
@admin_required
|
||||
def api_annotate(name: str):
|
||||
"""Add or update annotation for a draft."""
|
||||
|
||||
@@ -8,7 +8,9 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from collections import Counter, defaultdict
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
# Add project root to path so we can import ietf_analyzer
|
||||
@@ -19,6 +21,33 @@ if str(_project_root) not in sys.path:
|
||||
from ietf_analyzer.config import Config
|
||||
from ietf_analyzer.db import Database
|
||||
|
||||
def _extract_month(time_str: str | None) -> str:
|
||||
"""Normalize a date string to YYYY-MM format."""
|
||||
if not time_str:
|
||||
return "unknown"
|
||||
if len(time_str) >= 7 and time_str[4] == '-':
|
||||
return time_str[:7] # Already YYYY-MM-DD
|
||||
if len(time_str) >= 6 and time_str[:4].isdigit():
|
||||
return time_str[:4] + '-' + time_str[4:6] # YYYYMMDD → YYYY-MM
|
||||
return time_str[:7]
|
||||
|
||||
|
||||
# Simple TTL cache for expensive computations (t-SNE, clustering, similarity)
|
||||
_cache: dict[str, tuple[float, object]] = {}
|
||||
_CACHE_TTL = 300 # 5 minutes
|
||||
|
||||
|
||||
def _cached(key: str, fn, ttl: float = _CACHE_TTL):
|
||||
"""Return cached result or compute and cache it."""
|
||||
now = time.monotonic()
|
||||
if key in _cache:
|
||||
ts, val = _cache[key]
|
||||
if now - ts < ttl:
|
||||
return val
|
||||
val = fn()
|
||||
_cache[key] = (now, val)
|
||||
return val
|
||||
|
||||
|
||||
def get_db() -> Database:
|
||||
"""Get a Database instance using default config."""
|
||||
@@ -27,15 +56,22 @@ def get_db() -> Database:
|
||||
|
||||
|
||||
def get_overview_stats(db: Database) -> dict:
|
||||
"""Return high-level stats for the dashboard home page."""
|
||||
total_drafts = db.count_drafts()
|
||||
rated_pairs = db.drafts_with_ratings(limit=1000)
|
||||
"""Return high-level stats for the dashboard home page.
|
||||
|
||||
Excludes drafts flagged as false positives from rated counts.
|
||||
"""
|
||||
total_drafts = db.count_drafts(include_false_positives=False)
|
||||
rated_pairs = db.drafts_with_ratings(limit=1000) # already excludes FPs
|
||||
rated_count = len(rated_pairs)
|
||||
author_count = db.author_count()
|
||||
idea_count = db.idea_count()
|
||||
gaps = db.all_gaps()
|
||||
input_tok, output_tok = db.total_tokens_used()
|
||||
|
||||
# Count false positives separately for transparency
|
||||
total_all = db.count_drafts(include_false_positives=True)
|
||||
false_positive_count = total_all - total_drafts
|
||||
|
||||
return {
|
||||
"total_drafts": total_drafts,
|
||||
"rated_count": rated_count,
|
||||
@@ -44,6 +80,7 @@ def get_overview_stats(db: Database) -> dict:
|
||||
"gap_count": len(gaps),
|
||||
"input_tokens": input_tok,
|
||||
"output_tokens": output_tok,
|
||||
"false_positive_count": false_positive_count,
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +94,106 @@ def get_category_counts(db: Database) -> dict[str, int]:
|
||||
return dict(counts.most_common())
|
||||
|
||||
|
||||
def get_category_summary(db: Database, category: str) -> dict | None:
|
||||
"""Build a data-driven summary for a category. Returns None if category not found."""
|
||||
pairs = db.drafts_with_ratings(limit=2000)
|
||||
all_authors = db.top_authors(limit=500)
|
||||
|
||||
# Filter to drafts in this category
|
||||
cat_pairs = [(d, r) for d, r in pairs if category in r.categories]
|
||||
if not cat_pairs:
|
||||
return None
|
||||
|
||||
# Author lookup: draft_name -> [author names]
|
||||
author_drafts_map: dict[str, list[str]] = defaultdict(list)
|
||||
for name, aff, cnt, drafts in all_authors:
|
||||
for dn in drafts:
|
||||
author_drafts_map[dn].append(name)
|
||||
|
||||
# Dimension averages
|
||||
n = len(cat_pairs)
|
||||
avg = lambda vals: round(sum(vals) / len(vals), 1) if vals else 0
|
||||
novelty_vals = [r.novelty for _, r in cat_pairs]
|
||||
maturity_vals = [r.maturity for _, r in cat_pairs]
|
||||
overlap_vals = [r.overlap for _, r in cat_pairs]
|
||||
momentum_vals = [r.momentum for _, r in cat_pairs]
|
||||
relevance_vals = [r.relevance for _, r in cat_pairs]
|
||||
scores = [r.composite_score for _, r in cat_pairs]
|
||||
|
||||
# Top drafts
|
||||
sorted_pairs = sorted(cat_pairs, key=lambda p: p[1].composite_score, reverse=True)
|
||||
top_3 = [(d.name, d.title, round(r.composite_score, 1)) for d, r in sorted_pairs[:3]]
|
||||
|
||||
# Top authors in this category
|
||||
author_counter: Counter = Counter()
|
||||
org_counter: Counter = Counter()
|
||||
author_aff: dict[str, str] = {}
|
||||
for name, aff, cnt, drafts in all_authors:
|
||||
author_aff[name] = aff or ""
|
||||
for d, r in cat_pairs:
|
||||
for a in author_drafts_map.get(d.name, []):
|
||||
author_counter[a] += 1
|
||||
if author_aff.get(a):
|
||||
org_counter[author_aff[a]] += 1
|
||||
top_authors = author_counter.most_common(5)
|
||||
top_orgs = org_counter.most_common(5)
|
||||
|
||||
# Strongest and weakest dimensions
|
||||
dim_avgs = {
|
||||
"Novelty": avg(novelty_vals),
|
||||
"Maturity": avg(maturity_vals),
|
||||
"Overlap": avg(overlap_vals),
|
||||
"Momentum": avg(momentum_vals),
|
||||
"Relevance": avg(relevance_vals),
|
||||
}
|
||||
strongest = max(dim_avgs, key=dim_avgs.get)
|
||||
weakest = min(dim_avgs, key=dim_avgs.get)
|
||||
|
||||
# Activity trend: how many are recent (last 6 months)?
|
||||
recent = sum(1 for d, _ in cat_pairs if d.time and d.time >= "2025-09")
|
||||
total_all = len(pairs)
|
||||
|
||||
# Build text summary
|
||||
lines = []
|
||||
lines.append(f"**{n} drafts** ({n * 100 // total_all}% of all rated drafts) "
|
||||
f"with an average composite score of **{avg(scores):.1f}/5.0**.")
|
||||
|
||||
# Dimension profile
|
||||
lines.append(f"Strongest dimension: **{strongest}** ({dim_avgs[strongest]}), "
|
||||
f"weakest: **{weakest}** ({dim_avgs[weakest]}).")
|
||||
|
||||
# Maturity vs novelty insight
|
||||
if dim_avgs["Maturity"] < 2.5 and dim_avgs["Novelty"] >= 3.0:
|
||||
lines.append("This category has **high novelty but low maturity** — many early-stage proposals with fresh ideas that haven't been fully developed yet.")
|
||||
elif dim_avgs["Maturity"] >= 3.0 and dim_avgs["Novelty"] < 2.5:
|
||||
lines.append("This category is **mature but less novel** — established approaches being refined rather than introducing fundamentally new concepts.")
|
||||
elif dim_avgs["Maturity"] >= 3.0 and dim_avgs["Novelty"] >= 3.0:
|
||||
lines.append("This category shows **both high novelty and maturity** — well-developed proposals with genuinely new contributions.")
|
||||
|
||||
# Overlap insight
|
||||
if dim_avgs["Overlap"] >= 3.5:
|
||||
lines.append(f"High overlap ({dim_avgs['Overlap']}) suggests **significant duplication** — multiple drafts cover similar ground, which may indicate convergence or fragmentation.")
|
||||
elif dim_avgs["Overlap"] <= 2.0:
|
||||
lines.append(f"Low overlap ({dim_avgs['Overlap']}) indicates **diverse approaches** — drafts in this category tackle distinct problems with little redundancy.")
|
||||
|
||||
# Activity
|
||||
if recent > 0:
|
||||
lines.append(f"**{recent} draft{'s' if recent != 1 else ''}** submitted in the last 6 months, "
|
||||
f"suggesting {'active' if recent >= 3 else 'moderate'} development.")
|
||||
|
||||
return {
|
||||
"text": " ".join(lines),
|
||||
"count": n,
|
||||
"avg_score": avg(scores),
|
||||
"dimensions": dim_avgs,
|
||||
"top_drafts": top_3,
|
||||
"top_authors": top_authors,
|
||||
"top_orgs": top_orgs,
|
||||
"strongest": strongest,
|
||||
"weakest": weakest,
|
||||
}
|
||||
|
||||
|
||||
def get_drafts_page(
|
||||
db: Database,
|
||||
page: int = 1,
|
||||
@@ -235,6 +372,7 @@ def get_rating_distributions(db: Database) -> dict:
|
||||
"scores": [],
|
||||
"categories": [],
|
||||
"names": [],
|
||||
"sources": [],
|
||||
}
|
||||
for draft, rating in pairs:
|
||||
dims["novelty"].append(rating.novelty)
|
||||
@@ -245,6 +383,7 @@ def get_rating_distributions(db: Database) -> dict:
|
||||
dims["scores"].append(round(rating.composite_score, 2))
|
||||
dims["categories"].append(rating.categories[0] if rating.categories else "Other")
|
||||
dims["names"].append(draft.name)
|
||||
dims["sources"].append(getattr(draft, "source", "ietf") or "ietf")
|
||||
return dims
|
||||
|
||||
|
||||
@@ -256,7 +395,7 @@ def get_timeline_data(db: Database) -> dict:
|
||||
|
||||
month_cat: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
||||
for d in all_drafts:
|
||||
month = d.time[:7] if d.time else "unknown"
|
||||
month = _extract_month(d.time)
|
||||
r = rating_map.get(d.name)
|
||||
if r:
|
||||
cat = r.categories[0] if r.categories else "Other"
|
||||
@@ -423,6 +562,11 @@ def get_coauthor_network(db: Database, min_shared: int = 1) -> dict:
|
||||
|
||||
|
||||
def get_similarity_graph(db: Database, threshold: float = 0.75) -> dict:
|
||||
"""Return draft similarity network (cached)."""
|
||||
return _cached(f"similarity_{threshold}", lambda: _compute_similarity_graph(db, threshold))
|
||||
|
||||
|
||||
def _compute_similarity_graph(db: Database, threshold: float = 0.75) -> dict:
|
||||
"""Return draft similarity network for force-directed graph.
|
||||
|
||||
Returns {nodes: [{name, title, category, score}],
|
||||
@@ -496,6 +640,11 @@ def get_cross_org_data(db: Database, limit: int = 20) -> list[dict]:
|
||||
|
||||
|
||||
def get_author_network_full(db: Database) -> dict:
|
||||
"""Return author network (cached for 5 min)."""
|
||||
return _cached("author_network", lambda: _compute_author_network_full(db))
|
||||
|
||||
|
||||
def _compute_author_network_full(db: Database) -> dict:
|
||||
"""Return enriched co-authorship network with avg scores and cluster info.
|
||||
|
||||
Returns {
|
||||
@@ -596,6 +745,11 @@ def get_author_network_full(db: Database) -> dict:
|
||||
|
||||
|
||||
def get_idea_clusters(db: Database) -> dict:
|
||||
"""Cluster ideas (cached for 5 min)."""
|
||||
return _cached("idea_clusters", lambda: _compute_idea_clusters(db))
|
||||
|
||||
|
||||
def _compute_idea_clusters(db: Database) -> dict:
|
||||
"""Cluster ideas by embedding similarity, return clusters + t-SNE scatter.
|
||||
|
||||
Uses Ward linkage on L2-normalized embeddings (approximates cosine) with
|
||||
@@ -752,6 +906,11 @@ def get_idea_clusters(db: Database) -> dict:
|
||||
|
||||
|
||||
def get_timeline_animation_data(db: Database) -> dict:
|
||||
"""Timeline animation (cached for 5 min)."""
|
||||
return _cached("timeline_animation", lambda: _compute_timeline_animation_data(db))
|
||||
|
||||
|
||||
def _compute_timeline_animation_data(db: Database) -> dict:
|
||||
"""Compute t-SNE on all drafts, return points with month info + category_monthly.
|
||||
|
||||
t-SNE is computed once on ALL drafts so coordinates are stable across
|
||||
@@ -791,7 +950,7 @@ def get_timeline_animation_data(db: Database) -> dict:
|
||||
for i, name in enumerate(names):
|
||||
r = rating_map[name]
|
||||
d = draft_map.get(name)
|
||||
month = (d.time[:7] if d and d.time else "unknown")
|
||||
month = _extract_month(d.time if d else None)
|
||||
cat = r.categories[0] if r.categories else "Other"
|
||||
month_set.add(month)
|
||||
category_monthly[month][cat] += 1
|
||||
@@ -856,6 +1015,11 @@ def get_monitor_status(db: Database) -> dict:
|
||||
|
||||
|
||||
def get_citation_graph(db: Database, min_refs: int = 2) -> dict:
|
||||
"""Return citation graph (cached for 5 min)."""
|
||||
return _cached(f"citation_graph_{min_refs}", lambda: _compute_citation_graph(db, min_refs))
|
||||
|
||||
|
||||
def _compute_citation_graph(db: Database, min_refs: int = 2) -> dict:
|
||||
"""Return citation network data for force-directed graph.
|
||||
|
||||
Returns {nodes: [{id, type, title, influence, ...}],
|
||||
@@ -980,7 +1144,12 @@ def global_search(db: Database, query: str) -> dict:
|
||||
|
||||
# 1. Drafts via FTS5
|
||||
try:
|
||||
fts_query = " ".join(f'"{w}"' for w in q.split() if w)
|
||||
import re
|
||||
fts_query = re.sub(r'[^\w\s]', '', q)
|
||||
fts_query = re.sub(r'\b(NEAR|OR|AND|NOT)\b', '', fts_query, flags=re.IGNORECASE)
|
||||
fts_query = re.sub(r'\s+', ' ', fts_query).strip()
|
||||
if not fts_query:
|
||||
raise ValueError("empty query after sanitization")
|
||||
rows = db.conn.execute(
|
||||
"""SELECT d.name, d.title, d.abstract, d.time, d."group"
|
||||
FROM drafts d
|
||||
@@ -1067,10 +1236,12 @@ def global_search(db: Database, query: str) -> dict:
|
||||
|
||||
|
||||
def get_landscape_tsne(db: Database) -> list[dict]:
|
||||
"""Compute t-SNE from embeddings, return [{name, title, x, y, category, score}].
|
||||
"""Compute t-SNE (cached for 5 min)."""
|
||||
return _cached("landscape_tsne", lambda: _compute_landscape_tsne(db))
|
||||
|
||||
Uses cached coordinates if available, otherwise computes fresh.
|
||||
"""
|
||||
|
||||
def _compute_landscape_tsne(db: Database) -> list[dict]:
|
||||
"""Compute t-SNE from embeddings, return [{name, title, x, y, category, score}]."""
|
||||
import numpy as np
|
||||
|
||||
embeddings = db.all_embeddings()
|
||||
|
||||
@@ -51,6 +51,72 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-3">Data Collection Methodology</h2>
|
||||
<p class="text-sm text-slate-400 leading-relaxed mb-4">
|
||||
Drafts are discovered by searching the
|
||||
<a href="https://datatracker.ietf.org" class="text-blue-400 hover:text-blue-300 transition">IETF Datatracker API</a>
|
||||
for documents whose abstract contains any of the following keywords.
|
||||
Only drafts submitted since <span class="text-slate-200 font-medium">{{ fetch_since }}</span> are included.
|
||||
</p>
|
||||
|
||||
<h3 class="text-sm font-semibold text-slate-300 mb-2">Search Keywords</h3>
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
{% for kw in search_keywords %}
|
||||
<span class="px-2.5 py-1 bg-blue-500/10 text-blue-400 border border-blue-500/20 rounded-md text-xs font-mono">{{ kw }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<h3 class="text-sm font-semibold text-slate-300 mb-2">Analysis Pipeline</h3>
|
||||
<div class="text-sm text-slate-400 leading-relaxed space-y-2">
|
||||
<p><span class="text-slate-200 font-medium">1. Fetch</span> — Query Datatracker API for each keyword, deduplicate by draft name, download full text.</p>
|
||||
<p><span class="text-slate-200 font-medium">2. Rate</span> — Claude rates each draft on 5 dimensions (novelty, maturity, overlap, momentum, relevance) from 1–5, with per-dimension explanations.</p>
|
||||
<p><span class="text-slate-200 font-medium">3. Categorize</span> — Claude assigns one or more topic categories (e.g., "A2A protocols", "Agent identity/auth").</p>
|
||||
<p><span class="text-slate-200 font-medium">4. Extract Ideas</span> — Claude extracts distinct technical ideas from each draft, with novelty scores.</p>
|
||||
<p><span class="text-slate-200 font-medium">5. Embed</span> — Ollama generates vector embeddings for similarity analysis and clustering.</p>
|
||||
<p><span class="text-slate-200 font-medium">6. Author Network</span> — Author and affiliation data fetched from Datatracker to build collaboration graphs.</p>
|
||||
<p><span class="text-slate-200 font-medium">7. Gap Analysis</span> — Claude identifies areas where no existing draft adequately addresses a need.</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 p-3 bg-slate-800/50 rounded-lg border border-slate-700/50">
|
||||
<p class="text-xs text-slate-500">
|
||||
<span class="text-amber-400/70 font-semibold">Note on keyword selection:</span>
|
||||
Keywords determine which drafts are included. Broad terms like "agent" and "autonomous" cast a wide net
|
||||
(catching some tangentially related drafts), while specific terms like "ai-agent" and "agentic" target
|
||||
the core AI agent space. The false-positive flag in ratings helps filter out irrelevant matches.
|
||||
Suggestions for additional keywords are welcome.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-3">Scoring Methodology</h2>
|
||||
<div class="text-sm text-slate-400 leading-relaxed space-y-3">
|
||||
<p>Each draft is rated by Claude AI on five dimensions, scored from 1 (lowest) to 5 (highest):</p>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-slate-700 text-left text-xs text-slate-500">
|
||||
<th class="py-2 pr-4 font-medium">Dimension</th>
|
||||
<th class="py-2 font-medium">What it measures</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800/50">
|
||||
<tr><td class="py-2 pr-4 text-slate-300 font-medium">Novelty</td><td class="py-2">Originality of contribution. Does it introduce genuinely new ideas?</td></tr>
|
||||
<tr><td class="py-2 pr-4 text-slate-300 font-medium">Maturity</td><td class="py-2">Completeness of the specification. Ready for implementation?</td></tr>
|
||||
<tr><td class="py-2 pr-4 text-slate-300 font-medium">Overlap</td><td class="py-2">Duplication with other drafts. High = redundant. <em>Inverted in composite score.</em></td></tr>
|
||||
<tr><td class="py-2 pr-4 text-slate-300 font-medium">Momentum</td><td class="py-2">Activity level. Revisions, WG adoption, multi-org authorship.</td></tr>
|
||||
<tr><td class="py-2 pr-4 text-slate-300 font-medium">Relevance</td><td class="py-2">How directly related to AI agent infrastructure.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="mt-2">
|
||||
<span class="text-slate-200 font-medium">Composite score</span> = (novelty + maturity + (5 - overlap) + momentum + relevance) / 5.
|
||||
Overlap is inverted so lower overlap contributes positively.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-3">Tech Stack</h2>
|
||||
<ul class="text-sm text-slate-400 space-y-2">
|
||||
|
||||
205
src/webui/templates/analytics.html
Normal file
205
src/webui/templates/analytics.html
Normal file
@@ -0,0 +1,205 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "analytics" %}
|
||||
|
||||
{% block title %}Analytics — IETF Draft Analyzer{% endblock %}
|
||||
|
||||
{% block extra_head %}<script src="/static/js/plotly.min.js"></script>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-white">Site Analytics</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">GDPR-compliant traffic overview. No cookies, no personal data stored. Visitor uniqueness estimated via daily-rotating salted hashes (cannot be correlated across days).</p>
|
||||
</div>
|
||||
|
||||
<!-- Stat cards -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-4 mb-8">
|
||||
<div class="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-2xl font-bold text-blue-400">{{ data.stats.total_views }}</div>
|
||||
<div class="text-xs text-slate-400 mt-1 uppercase tracking-wider">Total Views</div>
|
||||
</div>
|
||||
<div class="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-2xl font-bold text-emerald-400">{{ data.stats.total_visitors }}</div>
|
||||
<div class="text-xs text-slate-400 mt-1 uppercase tracking-wider">Total Visits</div>
|
||||
</div>
|
||||
<div class="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-2xl font-bold text-purple-400">{{ data.stats.total_downloads }}</div>
|
||||
<div class="text-xs text-slate-400 mt-1 uppercase tracking-wider">Downloads</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-800 p-4 relative overflow-hidden">
|
||||
<div class="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-amber-500 to-amber-400"></div>
|
||||
<div class="text-2xl font-bold text-amber-400">{{ data.stats.today_views }}</div>
|
||||
<div class="text-xs text-slate-400 mt-1 uppercase tracking-wider">Today Views</div>
|
||||
</div>
|
||||
<div class="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-cyan-500 to-cyan-400"></div>
|
||||
<div class="text-2xl font-bold text-cyan-400">{{ data.stats.today_visitors }}</div>
|
||||
<div class="text-xs text-slate-400 mt-1 uppercase tracking-wider">Today Visitors</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-800 p-4 relative overflow-hidden">
|
||||
<div class="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-rose-500 to-rose-400"></div>
|
||||
<div class="text-2xl font-bold text-rose-400">{{ data.stats.week_views }}</div>
|
||||
<div class="text-xs text-slate-400 mt-1 uppercase tracking-wider">7-Day Views</div>
|
||||
</div>
|
||||
<div class="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-indigo-500 to-indigo-400"></div>
|
||||
<div class="text-2xl font-bold text-indigo-400">{{ data.stats.month_views }}</div>
|
||||
<div class="text-xs text-slate-400 mt-1 uppercase tracking-wider">30-Day Views</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts row 1: Daily traffic + Hourly pattern -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-300 mb-3">Daily Traffic (Last 30 Days)</h2>
|
||||
<div id="dailyChart" style="height: 300px;"></div>
|
||||
</div>
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-300 mb-3">Hourly Pattern (Last 7 Days)</h2>
|
||||
<div id="hourlyChart" style="height: 300px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts row 2: Top pages + Top referrers -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-300 mb-3">Top Pages (Last 30 Days)</h2>
|
||||
{% if data.top_pages %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-slate-700 text-slate-400 text-xs uppercase">
|
||||
<th class="text-left py-2 pr-4">Path</th>
|
||||
<th class="text-right py-2 px-2">Views</th>
|
||||
<th class="text-right py-2 pl-2">Visitors</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for page in data.top_pages %}
|
||||
<tr class="border-b border-slate-800/50 hover:bg-slate-800/30">
|
||||
<td class="py-1.5 pr-4 text-slate-300 font-mono text-xs truncate max-w-xs">{{ page.path }}</td>
|
||||
<td class="py-1.5 px-2 text-right text-blue-400">{{ page.views }}</td>
|
||||
<td class="py-1.5 pl-2 text-right text-emerald-400">{{ page.visitors }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-slate-500 text-sm text-center mt-10">No page view data yet</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-300 mb-3">Top Referrers (Last 30 Days)</h2>
|
||||
{% if data.top_referrers %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-slate-700 text-slate-400 text-xs uppercase">
|
||||
<th class="text-left py-2 pr-4">Source</th>
|
||||
<th class="text-right py-2">Visits</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ref in data.top_referrers %}
|
||||
<tr class="border-b border-slate-800/50 hover:bg-slate-800/30">
|
||||
<td class="py-1.5 pr-4 text-slate-300 text-xs">{{ ref.referrer }}</td>
|
||||
<td class="py-1.5 text-right text-amber-400">{{ ref.count }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-slate-500 text-sm text-center mt-10">No referrer data yet</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Downloads over time -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-300 mb-3">Downloads Over Time</h2>
|
||||
<div id="downloadsChart" style="height: 250px;"></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
const PLOTLY_LAYOUT = {
|
||||
paper_bgcolor: 'transparent',
|
||||
plot_bgcolor: 'transparent',
|
||||
font: { color: '#94a3b8', family: 'Inter, system-ui, sans-serif', size: 12 },
|
||||
margin: { t: 20, r: 20, b: 40, l: 50 },
|
||||
xaxis: { gridcolor: '#1e293b', zerolinecolor: '#334155' },
|
||||
yaxis: { gridcolor: '#1e293b', zerolinecolor: '#334155' },
|
||||
};
|
||||
const PLOTLY_CONFIG = { responsive: true, displayModeBar: false };
|
||||
|
||||
// --- Daily traffic ---
|
||||
const daily = {{ data.daily | tojson }};
|
||||
if (daily.dates && daily.dates.length > 0) {
|
||||
Plotly.newPlot('dailyChart', [
|
||||
{
|
||||
x: daily.dates, y: daily.views,
|
||||
type: 'scatter', mode: 'lines+markers',
|
||||
name: 'Views',
|
||||
line: { color: '#3b82f6', width: 2 },
|
||||
marker: { size: 4 },
|
||||
hovertemplate: '%{x}<br>Views: %{y}<extra></extra>',
|
||||
},
|
||||
{
|
||||
x: daily.dates, y: daily.visitors,
|
||||
type: 'scatter', mode: 'lines+markers',
|
||||
name: 'Visitors',
|
||||
line: { color: '#22c55e', width: 2 },
|
||||
marker: { size: 4 },
|
||||
hovertemplate: '%{x}<br>Visitors: %{y}<extra></extra>',
|
||||
},
|
||||
], {
|
||||
...PLOTLY_LAYOUT,
|
||||
legend: { font: { size: 10, color: '#94a3b8' }, orientation: 'h', y: 1.1 },
|
||||
xaxis: { ...PLOTLY_LAYOUT.xaxis, title: { text: 'Date', font: { size: 11 } } },
|
||||
yaxis: { ...PLOTLY_LAYOUT.yaxis, title: { text: 'Count', font: { size: 11 } } },
|
||||
}, PLOTLY_CONFIG);
|
||||
} else {
|
||||
document.getElementById('dailyChart').innerHTML = '<p class="text-slate-500 text-sm text-center mt-20">No traffic data yet — check back after some visits</p>';
|
||||
}
|
||||
|
||||
// --- Hourly pattern ---
|
||||
const hourly = {{ data.hourly | tojson }};
|
||||
if (hourly.hours) {
|
||||
const hourLabels = hourly.hours.map(h => h.toString().padStart(2, '0') + ':00');
|
||||
Plotly.newPlot('hourlyChart', [{
|
||||
x: hourLabels, y: hourly.views,
|
||||
type: 'bar',
|
||||
marker: { color: 'rgba(168, 85, 247, 0.7)', line: { color: '#a855f7', width: 1 } },
|
||||
hovertemplate: '%{x}<br>Views: %{y}<extra></extra>',
|
||||
}], {
|
||||
...PLOTLY_LAYOUT,
|
||||
xaxis: { ...PLOTLY_LAYOUT.xaxis, title: { text: 'Hour (UTC)', font: { size: 11 } } },
|
||||
yaxis: { ...PLOTLY_LAYOUT.yaxis, title: { text: 'Views', font: { size: 11 } } },
|
||||
}, PLOTLY_CONFIG);
|
||||
} else {
|
||||
document.getElementById('hourlyChart').innerHTML = '<p class="text-slate-500 text-sm text-center mt-20">No hourly data yet</p>';
|
||||
}
|
||||
|
||||
// --- Downloads ---
|
||||
const downloads = {{ data.downloads_daily | tojson }};
|
||||
if (downloads.dates && downloads.dates.length > 0) {
|
||||
Plotly.newPlot('downloadsChart', [{
|
||||
x: downloads.dates, y: downloads.counts,
|
||||
type: 'bar',
|
||||
marker: { color: 'rgba(245, 158, 11, 0.7)', line: { color: '#f59e0b', width: 1 } },
|
||||
hovertemplate: '%{x}<br>Downloads: %{y}<extra></extra>',
|
||||
}], {
|
||||
...PLOTLY_LAYOUT,
|
||||
xaxis: { ...PLOTLY_LAYOUT.xaxis, title: { text: 'Date', font: { size: 11 } } },
|
||||
yaxis: { ...PLOTLY_LAYOUT.yaxis, title: { text: 'Downloads', font: { size: 11 } } },
|
||||
}, PLOTLY_CONFIG);
|
||||
} else {
|
||||
document.getElementById('downloadsChart').innerHTML = '<p class="text-slate-500 text-sm text-center mt-20">No downloads yet</p>';
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -102,6 +102,7 @@
|
||||
<div class="text-slate-300 text-sm leading-relaxed whitespace-pre-line">{{ result.answer }}</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{% if is_admin %}
|
||||
<!-- Synthesize button (costs tokens, result is cached permanently) -->
|
||||
<div class="answer-card rounded-xl border border-slate-800 p-5 mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -118,6 +119,7 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
{% block title %}Author Network — IETF Draft Analyzer{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<script src="/static/js/plotly.min.js"></script>
|
||||
<script src="/static/js/d3.v7.min.js"></script>
|
||||
<style>
|
||||
#networkSvg {
|
||||
@@ -38,7 +39,7 @@
|
||||
{% block content %}
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">Author Network</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">Interactive collaboration graph of {{ network.nodes | length }} authors across {{ orgs | length }} organizations</p>
|
||||
<p class="text-slate-400 text-sm mt-1">Interactive collaboration graph of {{ network.nodes | length }} authors across {{ orgs | length }} organizations. Authors are connected when they co-authored 2+ drafts together. Node size reflects number of drafts authored; color represents organization. Clusters are detected via connected-component analysis (BFS) — authors in the same cluster share direct or indirect co-authorship links.</p>
|
||||
</div>
|
||||
|
||||
<!-- Summary stats -->
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}IETF Draft Analyzer{% endblock %}</title>
|
||||
<script src="/static/js/tailwind.js"></script>
|
||||
<script src="/static/js/plotly.min.js"></script>
|
||||
<link rel="stylesheet" href="/static/css/fonts.css">
|
||||
<script>
|
||||
tailwind.config = {
|
||||
@@ -118,10 +117,12 @@
|
||||
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2z"/><circle cx="19" cy="19" r="3" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
||||
Idea Clusters
|
||||
</a>
|
||||
{% if is_admin %}
|
||||
<a href="/gaps" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'gaps' }}">
|
||||
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"/></svg>
|
||||
Gap Explorer
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="/timeline" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'timeline' }}">
|
||||
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
Timeline
|
||||
@@ -142,10 +143,16 @@
|
||||
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
|
||||
Authors
|
||||
</a>
|
||||
{% if is_admin %}
|
||||
<a href="/monitor" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'monitor' }}">
|
||||
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5.636 18.364a9 9 0 010-12.728m12.728 0a9 9 0 010 12.728m-9.9-2.829a5 5 0 010-7.07m7.072 0a5 5 0 010 7.07M13 12a1 1 0 11-2 0 1 1 0 012 0z"/></svg>
|
||||
Monitor
|
||||
</a>
|
||||
<a href="/admin/analytics" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'analytics' }}">
|
||||
<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 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
|
||||
Analytics
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class="border-t border-slate-800 mt-4 pt-4">
|
||||
<a href="/about" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'about' }}">
|
||||
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
@@ -159,6 +166,13 @@
|
||||
<a href="/impressum" class="hover:text-slate-400 transition">Impressum</a>
|
||||
<a href="/datenschutz" class="hover:text-slate-400 transition">Datenschutz</a>
|
||||
</div>
|
||||
{% if is_admin %}
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span class="inline-block w-1.5 h-1.5 rounded-full bg-green-500"></span>
|
||||
<span class="text-green-500">Admin</span>
|
||||
<a href="/admin/logout" class="text-slate-600 hover:text-red-400 transition ml-auto">Logout</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
{% 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>
|
||||
<p class="text-slate-400 text-sm mt-1">Cross-reference network: {{ graph.stats.draft_count }} drafts referencing {{ graph.stats.rfc_count }} RFCs. References are extracted from each draft's text (RFC mentions, draft citations, BCP references). Node size reflects influence — how many other documents cite it. Highly-cited RFCs represent foundational standards that AI/agent drafts build upon.</p>
|
||||
</div>
|
||||
|
||||
<!-- Summary stats -->
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
<svg class="w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
|
||||
AI Rating Analysis
|
||||
</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">Rated by Claude AI on five dimensions (1–5 scale). The composite score is a weighted average. Ratings are generated from the draft's abstract and full text.</p>
|
||||
{% if draft.rating.summary %}
|
||||
<p class="text-sm text-slate-400 mb-5 leading-relaxed">{{ draft.rating.summary }}</p>
|
||||
{% endif %}
|
||||
@@ -140,6 +141,7 @@
|
||||
<svg class="w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>
|
||||
Extracted Ideas <span class="text-slate-600 font-normal">({{ draft.ideas|length }})</span>
|
||||
</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">Technical ideas extracted by Claude AI from the draft text. Each idea is classified by type (protocol, mechanism, framework, architecture) and rated for novelty (N:1–5).</p>
|
||||
<div class="space-y-3">
|
||||
{% for idea in draft.ideas %}
|
||||
<div class="bg-slate-800/30 rounded-lg p-4 border border-slate-800/50">
|
||||
@@ -174,7 +176,8 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Annotation (notes & tags) -->
|
||||
<!-- Annotation (notes & tags) — admin only -->
|
||||
{% if is_admin %}
|
||||
<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>
|
||||
@@ -207,6 +210,7 @@
|
||||
</button>
|
||||
<div id="saveStatus" class="text-xs text-center mt-2 text-slate-600"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Right column: Sidebar -->
|
||||
@@ -242,6 +246,7 @@
|
||||
<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>
|
||||
<p class="text-xs text-slate-500 mb-2">Estimates how close a draft is to becoming a standard, based on six factors: working group adoption, revision count, reference density, citation count, author track record, and momentum signals. Score 0–100.</p>
|
||||
<!-- 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
|
||||
|
||||
@@ -53,6 +53,11 @@
|
||||
color: #4ade80;
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
.source-generated {
|
||||
background: rgba(168, 85, 247, 0.15);
|
||||
color: #c084fc;
|
||||
border: 1px solid rgba(168, 85, 247, 0.3);
|
||||
}
|
||||
.cat-pill {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
@@ -122,7 +127,7 @@
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">Draft Explorer</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">Browse, search, and filter {{ result.total }} rated Internet-Drafts on AI and agent topics.</p>
|
||||
<p class="text-slate-400 text-sm mt-1">Browse, search, and filter {{ result.total }} rated Internet-Drafts on AI and agent topics. Each draft is scored 1–5 across five dimensions: <b>Nov</b>(elty) = originality, <b>Mat</b>(urity) = specification completeness, <b>Rel</b>(evance) = importance to AI agents, <b>Mom</b>(entum) = adoption traction, <b>Ovl</b> (overlap) = uniqueness vs other drafts. <b>Rdy</b> = standards readiness (0–100). Search works across draft names, titles, summaries, and author names.</p>
|
||||
</div>
|
||||
|
||||
<!-- Filter Bar -->
|
||||
@@ -157,6 +162,7 @@
|
||||
<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>
|
||||
<option value="generated" {% if current_source == 'generated' %}selected{% endif %}>Generated</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Sort -->
|
||||
@@ -224,6 +230,64 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Category Summary -->
|
||||
{% if cat_summary and current_cat %}
|
||||
<div class="bg-slate-900/80 rounded-xl border border-slate-800 p-5 mb-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-1">
|
||||
<h2 class="text-lg font-semibold text-white mb-2">{{ current_cat }}</h2>
|
||||
<p class="text-sm text-slate-300 leading-relaxed">{{ cat_summary.text }}</p>
|
||||
</div>
|
||||
<div class="flex-shrink-0 text-right">
|
||||
<div class="text-3xl font-bold text-blue-400">{{ cat_summary.avg_score }}<span class="text-base text-slate-500">/5</span></div>
|
||||
<div class="text-[10px] text-slate-500 uppercase tracking-wider">Avg Score</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4 pt-4 border-t border-slate-800/60">
|
||||
<!-- Dimension profile -->
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Rating Profile</h3>
|
||||
{% for dim, val in cat_summary.dimensions.items() %}
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-xs text-slate-500 w-20">{{ dim }}</span>
|
||||
<div class="flex-1 bg-slate-800 rounded-full h-1.5 overflow-hidden">
|
||||
<div class="h-full rounded-full {% if val >= 3.5 %}bg-emerald-500{% elif val >= 2.5 %}bg-amber-500{% else %}bg-red-500{% endif %}"
|
||||
style="width: {{ (val / 5 * 100)|round }}%"></div>
|
||||
</div>
|
||||
<span class="text-xs font-mono {% if val >= 3.5 %}text-emerald-400{% elif val >= 2.5 %}text-amber-400{% else %}text-red-400{% endif %} w-6 text-right">{{ val }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Top drafts -->
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Top Drafts</h3>
|
||||
{% for name, title, score in cat_summary.top_drafts %}
|
||||
<div class="mb-1.5">
|
||||
<a href="/drafts/{{ name }}" class="text-xs text-blue-400 hover:text-blue-300 transition">{{ title|truncate(50) }}</a>
|
||||
<span class="text-xs text-slate-600 ml-1">{{ score }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Top authors + orgs -->
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Key Contributors</h3>
|
||||
{% for author, count in cat_summary.top_authors %}
|
||||
<div class="text-xs text-slate-300 mb-0.5">{{ author }} <span class="text-slate-600">({{ count }})</span></div>
|
||||
{% endfor %}
|
||||
{% if cat_summary.top_orgs %}
|
||||
<h3 class="text-xs font-semibold text-slate-400 uppercase tracking-wider mt-3 mb-1">Organizations</h3>
|
||||
{% for org, count in cat_summary.top_orgs[:3] %}
|
||||
<div class="text-xs text-slate-300 mb-0.5">{{ org }} <span class="text-slate-600">({{ count }})</span></div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Results count + Compare button -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<p class="text-sm text-slate-500">
|
||||
@@ -234,12 +298,14 @@
|
||||
{% if min_score > 0 %} with score >= <span class="text-blue-400">{{ min_score }}</span>{% endif %}
|
||||
</p>
|
||||
<div class="flex items-center gap-3">
|
||||
{% if is_admin %}
|
||||
<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>
|
||||
{% endif %}
|
||||
{% if result.pages > 1 %}
|
||||
<p class="text-xs text-slate-600">Page {{ result.page }} of {{ result.pages }}</p>
|
||||
{% endif %}
|
||||
@@ -268,9 +334,11 @@
|
||||
</a>
|
||||
</th>
|
||||
{% endmacro %}
|
||||
{% if is_admin %}
|
||||
<th class="px-2 py-3 w-8">
|
||||
<span class="text-xs text-slate-600" title="Select drafts to compare">Cmp</span>
|
||||
</th>
|
||||
{% endif %}
|
||||
{{ sort_header("score", "Score", "w-20") }}
|
||||
{{ sort_header("name", "Draft") }}
|
||||
{{ sort_header("date", "Date", "w-24 hidden md:table-cell") }}
|
||||
@@ -286,11 +354,13 @@
|
||||
<tbody class="divide-y divide-slate-800/30">
|
||||
{% for d in result.drafts %}
|
||||
<tr class="draft-row">
|
||||
{% if is_admin %}
|
||||
<!-- 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>
|
||||
{% endif %}
|
||||
<!-- 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 %}">
|
||||
@@ -370,7 +440,7 @@
|
||||
{% endfor %}
|
||||
{% if not result.drafts %}
|
||||
<tr>
|
||||
<td colspan="11" class="px-4 py-12 text-center text-slate-500">
|
||||
<td colspan="{{ 11 if is_admin else 10 }}" 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>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
{% block content %}
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">Gap Explorer</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">{{ gaps | length }} identified gaps in AI/agent standards coverage — click any gap to explore details or generate a draft</p>
|
||||
<p class="text-slate-400 text-sm mt-1">{{ gaps | length }} identified gaps in AI/agent standards coverage. Gaps are identified by Claude AI analyzing the full corpus of drafts to find areas where important problems lack adequate proposals. Severity reflects urgency: <span class="text-red-400">critical</span> = blocking issue with no draft addressing it, <span class="text-orange-400">high</span> = partially addressed but incomplete, <span class="text-yellow-400">medium</span> = some coverage exists but more work needed, <span class="text-green-400">low</span> = minor gap or niche concern.</p>
|
||||
</div>
|
||||
|
||||
<!-- Action bar -->
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
|
||||
{% block title %}Idea Clusters — IETF Draft Analyzer{% endblock %}
|
||||
|
||||
{% block extra_head %}<script src="/static/js/plotly.min.js"></script>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">Idea Clusters</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">Extracted ideas grouped by semantic similarity — enriched with WG and category data</p>
|
||||
<p class="text-slate-400 text-sm mt-1">Extracted ideas grouped by semantic similarity — enriched with WG and category data. Ideas are embedded using Ollama (nomic-embed-text), then clustered via DBSCAN so that semantically related ideas from different drafts are grouped together. "Cross-WG" clusters indicate ideas that span multiple IETF working groups — potential coordination points.</p>
|
||||
</div>
|
||||
|
||||
<div id="emptyState" class="hidden">
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
|
||||
{% block title %}Ideas — IETF Draft Analyzer{% endblock %}
|
||||
|
||||
{% block extra_head %}<script src="/static/js/plotly.min.js"></script>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">Extracted Ideas</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">{{ data.total }} technical ideas extracted from rated drafts</p>
|
||||
<p class="text-slate-400 text-sm mt-1">{{ data.total }} technical ideas extracted from rated drafts. Claude AI reads each draft and identifies distinct technical contributions — protocols, mechanisms, frameworks, and architectures. Each idea receives a novelty score (N:1–5) indicating how original it is compared to existing work.</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats header -->
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
{% block title %}Landscape — IETF Draft Analyzer{% endblock %}
|
||||
|
||||
{% block extra_head %}<script src="/static/js/plotly.min.js"></script>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">Draft Landscape</h1>
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
|
||||
{% block title %}Monitor — IETF Draft Analyzer{% endblock %}
|
||||
|
||||
{% block extra_head %}<script src="/static/js/plotly.min.js"></script>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">Live Monitor</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">Track automated monitoring runs and pipeline status</p>
|
||||
<p class="text-slate-400 text-sm mt-1">Track automated monitoring runs and pipeline status. The pipeline fetches new drafts from the IETF Datatracker, rates them with Claude AI, generates embeddings with Ollama, and extracts ideas. Unprocessed counts show drafts waiting at each stage.</p>
|
||||
</div>
|
||||
|
||||
<div id="monitor-app"></div>
|
||||
|
||||
@@ -3,10 +3,20 @@
|
||||
|
||||
{% block title %}Overview — IETF Draft Analyzer{% endblock %}
|
||||
|
||||
{% block extra_head %}<script src="/static/js/plotly.min.js"></script>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-white">Dashboard Overview</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">IETF AI/Agent Internet-Drafts at a glance</p>
|
||||
<div class="mb-8 flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">Dashboard Overview</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">IETF AI/Agent Internet-Drafts at a glance. Drafts are fetched from the IETF Datatracker, then analyzed by Claude AI across five dimensions (novelty, maturity, overlap, momentum, relevance) to produce a composite score from 1.0 to 5.0.</p>
|
||||
</div>
|
||||
<a href="/export/obsidian"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-purple-600/80 hover:bg-purple-500 text-white text-sm font-medium rounded-lg transition-colors flex-shrink-0"
|
||||
title="Download all research data as an Obsidian vault with interlinked notes, Mermaid charts, and YAML frontmatter">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
||||
Download for Obsidian
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Stat cards -->
|
||||
@@ -41,11 +51,13 @@
|
||||
<!-- Charts row 1: Score distribution + Category donut -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-300 mb-3">Composite Score Distribution</h2>
|
||||
<h2 class="text-sm font-semibold text-slate-300 mb-1">Composite Score Distribution</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">Weighted average of five AI-rated dimensions (novelty 20%, maturity 20%, uniqueness 20%, momentum 20%, relevance 20%). Higher scores indicate drafts that are novel, mature, unique, gaining traction, and highly relevant to AI agent infrastructure.</p>
|
||||
<div id="scoreHist" style="height: 300px;"></div>
|
||||
</div>
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-300 mb-3">Drafts by Category</h2>
|
||||
<h2 class="text-sm font-semibold text-slate-300 mb-1">Drafts by Category</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">Categories are assigned by Claude during analysis. A draft can belong to multiple categories (e.g., both "A2A protocols" and "AI safety/alignment").</p>
|
||||
<div id="categoryPie" style="height: 300px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,22 +3,26 @@
|
||||
|
||||
{% block title %}Ratings — IETF Draft Analyzer{% endblock %}
|
||||
|
||||
{% block extra_head %}<script src="/static/js/plotly.min.js"></script>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">Rating Analytics</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">Distribution and analysis of AI-generated ratings</p>
|
||||
<p class="text-slate-400 text-sm mt-1">Distribution and analysis of AI-generated ratings across five dimensions. Each draft is rated 1–5 on novelty, maturity, overlap, momentum, and relevance by Claude AI, then combined into a weighted composite score.</p>
|
||||
</div>
|
||||
|
||||
<!-- Score Distribution -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5 mb-6">
|
||||
<h2 class="text-sm font-semibold text-slate-300 mb-3">Composite Score Distribution</h2>
|
||||
<h2 class="text-sm font-semibold text-slate-300 mb-1">Composite Score Distribution</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">The composite score is a weighted average of all five dimensions (each 20%). Scores range from 1.0 (low) to 5.0 (high). Most drafts cluster in the 2.0–3.5 range.</p>
|
||||
<div id="scoreHist" style="height: 300px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<!-- Dimension Box Plots -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-300 mb-3">Score Distributions by Dimension</h2>
|
||||
<h2 class="text-sm font-semibold text-slate-300 mb-1">Score Distributions by Dimension</h2>
|
||||
<p class="text-xs text-slate-500 mb-3"><b>Novelty</b>: originality of ideas. <b>Maturity</b>: completeness and specification detail. <b>Overlap</b>: redundancy with other drafts (high = more unique). <b>Momentum</b>: adoption likelihood and community traction. <b>Relevance</b>: importance to AI agent infrastructure.</p>
|
||||
<div id="dimDist" style="height: 350px;"></div>
|
||||
</div>
|
||||
<!-- Category Radar -->
|
||||
@@ -30,7 +34,8 @@
|
||||
|
||||
<!-- Scatter: novelty vs maturity -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5 mb-6">
|
||||
<h2 class="text-sm font-semibold text-slate-300 mb-3">Novelty vs Maturity (bubble = relevance)</h2>
|
||||
<h2 class="text-sm font-semibold text-slate-300 mb-1">Novelty vs Maturity (bubble = relevance)</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">Each dot is a rated draft. Drafts in the top-right corner are both novel and mature — prime candidates for standardization. Bubble size reflects relevance. Click a point to view the draft.</p>
|
||||
<div id="scatter" style="height: 450px;"></div>
|
||||
</div>
|
||||
|
||||
@@ -38,6 +43,7 @@
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
|
||||
<div class="p-4 border-b border-slate-800">
|
||||
<h2 class="text-sm font-semibold text-slate-300">Top 20 Drafts by Composite Score</h2>
|
||||
<p class="text-xs text-slate-500 mt-1">Highest-rated drafts across all dimensions. Green (4+) = strong, amber (3) = moderate, grey (<3) = needs improvement.</p>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
@@ -166,6 +172,7 @@ document.getElementById('scatter').on('plotly_click', function(data) {
|
||||
momentum: dist.momentum[i],
|
||||
overlap: dist.overlap[i],
|
||||
category: dist.categories[i],
|
||||
source: (dist.sources || [])[i] || 'ietf',
|
||||
}));
|
||||
drafts.sort((a, b) => b.score - a.score);
|
||||
|
||||
@@ -185,12 +192,13 @@ document.getElementById('scatter').on('plotly_click', function(data) {
|
||||
|
||||
top20.forEach((d, i) => {
|
||||
const shortName = d.name.replace('draft-', '').substring(0, 40);
|
||||
const sourceBadge = d.source !== 'ietf' ? ` <span style="display:inline-block;padding:1px 5px;border-radius:4px;font-size:0.55rem;font-weight:600;background:rgba(168,85,247,0.15);color:#c084fc;border:1px solid rgba(168,85,247,0.3);vertical-align:middle">${d.source.toUpperCase()}</span>` : '';
|
||||
const row = document.createElement('tr');
|
||||
row.className = 'hover:bg-slate-800/50 transition';
|
||||
row.innerHTML = `
|
||||
<td class="px-4 py-3 text-slate-500 font-mono text-xs">${i + 1}</td>
|
||||
<td class="px-4 py-3">
|
||||
<a href="/drafts/${d.name}" class="text-blue-400 hover:text-blue-300 transition text-xs font-mono">${shortName}</a>
|
||||
<a href="/drafts/${d.name}" class="text-blue-400 hover:text-blue-300 transition text-xs font-mono">${shortName}</a>${sourceBadge}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span class="score-badge ${scoreClass(d.score)}">${d.score.toFixed(2)}</span>
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
|
||||
{% block title %}Similarity — IETF Draft Analyzer{% endblock %}
|
||||
|
||||
{% block extra_head %}<script src="/static/js/plotly.min.js"></script>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">Draft Similarity Graph</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">Force-directed graph of draft-to-draft semantic similarity based on embeddings</p>
|
||||
<p class="text-slate-400 text-sm mt-1">Force-directed graph of draft-to-draft semantic similarity. Each draft is embedded using Ollama (nomic-embed-text) and cosine similarity is computed between all pairs. Edges are drawn between drafts exceeding the similarity threshold — tightly connected clusters indicate drafts covering similar topics. Use the slider to adjust the threshold: higher values show only the strongest relationships.</p>
|
||||
</div>
|
||||
|
||||
<!-- Summary stats -->
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
{% block title %}Timeline — IETF Draft Analyzer{% endblock %}
|
||||
|
||||
{% block extra_head %}<script src="/static/js/plotly.min.js"></script>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">Timeline Animation</h1>
|
||||
@@ -53,6 +55,22 @@ const points = animData.points;
|
||||
const months = animData.months;
|
||||
const catMonthly = animData.category_monthly;
|
||||
|
||||
const MONTH_NAMES = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||
function fmtMonth(ym) {
|
||||
if (!ym) return ym;
|
||||
let y, m;
|
||||
if (ym.includes('-')) {
|
||||
[y, m] = ym.split('-');
|
||||
} else if (ym.length >= 6) {
|
||||
y = ym.slice(0, 4);
|
||||
m = ym.slice(4, 6);
|
||||
} else {
|
||||
return ym;
|
||||
}
|
||||
const mi = parseInt(m, 10) - 1;
|
||||
return (MONTH_NAMES[mi] || m) + ' ' + y;
|
||||
}
|
||||
|
||||
if (points.length > 0 && months.length > 0) {
|
||||
|
||||
// --- Stat cards ---
|
||||
@@ -64,7 +82,7 @@ if (points.length > 0 && months.length > 0) {
|
||||
<div class="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-blue-500 to-blue-400"></div>
|
||||
<div class="text-3xl font-bold text-blue-400">${months.length}</div>
|
||||
<div class="text-xs text-slate-400 mt-1 uppercase tracking-wider">Months Span</div>
|
||||
<div class="text-xs text-slate-500 mt-0.5">${firstMonth} to ${lastMonth}</div>
|
||||
<div class="text-xs text-slate-500 mt-0.5">${fmtMonth(firstMonth)} – ${fmtMonth(lastMonth)}</div>
|
||||
</div>
|
||||
<div class="stat-card rounded-xl border border-slate-800 p-5 relative overflow-hidden">
|
||||
<div class="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-emerald-500 to-emerald-400"></div>
|
||||
@@ -130,7 +148,7 @@ if (points.length > 0 && months.length > 0) {
|
||||
// Slider steps
|
||||
const sliderSteps = months.map(month => ({
|
||||
method: 'animate',
|
||||
label: month,
|
||||
label: fmtMonth(month),
|
||||
args: [[month], { frame: { duration: 500, redraw: true }, transition: { duration: 300 }, mode: 'immediate' }],
|
||||
}));
|
||||
|
||||
@@ -182,12 +200,12 @@ if (points.length > 0 && months.length > 0) {
|
||||
|
||||
// Update badge on animation frame
|
||||
const badge = document.querySelector('#monthBadge span');
|
||||
badge.textContent = `Month: ${months[0]} (${firstCount} drafts)`;
|
||||
badge.textContent = `${fmtMonth(months[0])} — ${firstCount} drafts`;
|
||||
|
||||
document.getElementById('tsneAnim').on('plotly_animatingframe', function(ev) {
|
||||
const month = ev.name;
|
||||
const cumCount = points.filter(p => p.month <= month).length;
|
||||
badge.textContent = `Month: ${month} (${cumCount} drafts)`;
|
||||
badge.textContent = `${fmtMonth(month)} — ${cumCount} drafts`;
|
||||
});
|
||||
|
||||
// Click to navigate
|
||||
@@ -211,8 +229,9 @@ if (points.length > 0 && months.length > 0) {
|
||||
return totalB - totalA;
|
||||
});
|
||||
|
||||
const monthLabels = months.map(fmtMonth);
|
||||
const areaTraces = areaCatList.map((cat, i) => ({
|
||||
x: months,
|
||||
x: monthLabels,
|
||||
y: months.map(m => (catMonthly[m] || {})[cat] || 0),
|
||||
name: cat,
|
||||
type: 'scatter',
|
||||
|
||||
Reference in New Issue
Block a user