IETF Draft Analyzer v0.1.0 — track, categorize, and rate AI/agent drafts

Python CLI tool that fetches AI/agent-related Internet-Drafts from the IETF
Datatracker, rates them using Claude, generates embeddings via Ollama for
similarity/clustering, and produces markdown reports.

Features:
- Fetch drafts by keyword from Datatracker API with full text download
- Batch analysis with Claude (token-optimized, responses cached in SQLite)
- Embedding-based similarity search and overlap cluster detection
- Reports: overview, landscape by category, overlap clusters, weekly digest
- SQLite with FTS5 for full-text search across 260 tracked drafts

Initial analysis of 260 drafts reveals OAuth agent auth (13 drafts) and
agent gateway/collaboration (10 drafts) as the most crowded clusters,
while AI safety/alignment is underserved with the highest quality scores.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 00:36:45 +01:00
commit 6771a4c235
17 changed files with 2823 additions and 0 deletions

405
src/ietf_analyzer/cli.py Normal file
View File

@@ -0,0 +1,405 @@
"""CLI entry point — all user-facing commands."""
from __future__ import annotations
import click
from rich.console import Console
from rich.table import Table
from .config import Config
from .db import Database
console = Console()
def _get_config() -> Config:
cfg = Config.load()
return cfg
@click.group()
@click.version_option(version="0.1.0")
def main():
"""IETF Draft Analyzer — track, categorize, and rate AI/agent Internet-Drafts."""
pass
# ── fetch ────────────────────────────────────────────────────────────────────
@main.command()
@click.option("--keywords", "-k", multiple=True, help="Extra keywords to search for")
@click.option("--since", "-s", help="Only fetch drafts newer than this date (YYYY-MM-DD)")
@click.option("--download-text/--no-download-text", default=True, help="Download full text of drafts")
def fetch(keywords: tuple[str, ...], since: str | None, download_text: bool):
"""Fetch AI/agent drafts from IETF Datatracker."""
from .fetcher import Fetcher
cfg = _get_config()
db = Database(cfg)
fetcher = Fetcher(cfg)
kw_list = list(cfg.search_keywords)
if keywords:
kw_list.extend(keywords)
try:
drafts = fetcher.search_drafts(keywords=kw_list, since=since)
for draft in drafts:
db.upsert_draft(draft)
console.print(f"Stored [bold green]{len(drafts)}[/] drafts in database")
if download_text:
missing = db.drafts_without_text()
if missing:
console.print(f"Downloading text for [bold]{len(missing)}[/] drafts...")
texts = fetcher.download_texts(missing)
for name, text in texts.items():
draft = db.get_draft(name)
if draft:
draft.full_text = text
db.upsert_draft(draft)
finally:
fetcher.close()
db.close()
# ── list ─────────────────────────────────────────────────────────────────────
@main.command("list")
@click.option("--limit", "-n", default=30, help="Number of drafts to show")
@click.option("--sort", "-s", default="time DESC", help="Sort order (e.g. 'time DESC', 'name ASC')")
def list_drafts(limit: int, sort: str):
"""List tracked drafts."""
cfg = _get_config()
db = Database(cfg)
try:
drafts = db.list_drafts(limit=limit, order_by=sort)
total = db.count_drafts()
table = Table(title=f"Tracked Drafts ({total} total, showing {len(drafts)})")
table.add_column("Date", style="dim", width=10)
table.add_column("Name", style="cyan", max_width=55)
table.add_column("Title", max_width=50)
table.add_column("Pg", justify="right", width=4)
table.add_column("Text", justify="center", width=4)
table.add_column("Rated", justify="center", width=5)
for d in drafts:
has_text = "\u2713" if d.full_text else ""
rated = "\u2713" if db.get_rating(d.name) else ""
table.add_row(d.date, d.name, d.title[:50], str(d.pages or ""), has_text, rated)
console.print(table)
finally:
db.close()
# ── search ───────────────────────────────────────────────────────────────────
@main.command()
@click.argument("query")
@click.option("--limit", "-n", default=20, help="Max results")
def search(query: str, limit: int):
"""Full-text search across stored drafts."""
cfg = _get_config()
db = Database(cfg)
try:
results = db.search_drafts(query, limit=limit)
if not results:
console.print(f"No results for [bold]{query}[/]")
return
table = Table(title=f"Search: {query} ({len(results)} results)")
table.add_column("Date", style="dim", width=10)
table.add_column("Name", style="cyan")
table.add_column("Title")
for d in results:
table.add_row(d.date, d.name, d.title[:60])
console.print(table)
finally:
db.close()
# ── show ─────────────────────────────────────────────────────────────────────
@main.command()
@click.argument("name")
def show(name: str):
"""Show detailed info for a draft."""
from .reports import Reporter
cfg = _get_config()
db = Database(cfg)
reporter = Reporter(cfg, db)
try:
draft = db.get_draft(name)
if draft is None:
console.print(f"[red]Draft not found: {name}[/]")
return
rating = db.get_rating(name)
console.print(f"\n[bold]{draft.title}[/]")
console.print(f"[dim]{draft.name}[/] rev {draft.rev} | {draft.date} | {draft.pages or '?'} pages")
console.print(f"Group: {draft.group or 'individual'} | {draft.datatracker_url}")
console.print(f"\n[italic]{draft.abstract}[/]\n")
if rating:
console.print("[bold]AI Assessment[/]")
console.print(f" Score: [bold green]{rating.composite_score:.1f}[/]")
console.print(f" Summary: {rating.summary}\n")
table = Table(show_header=True)
table.add_column("Dimension", width=12)
table.add_column("Score", justify="center", width=7)
table.add_column("Notes")
table.add_row("Novelty", f"{rating.novelty}/5", rating.novelty_note)
table.add_row("Maturity", f"{rating.maturity}/5", rating.maturity_note)
table.add_row("Overlap", f"{rating.overlap}/5", rating.overlap_note)
table.add_row("Momentum", f"{rating.momentum}/5", rating.momentum_note)
table.add_row("Relevance", f"{rating.relevance}/5", rating.relevance_note)
console.print(table)
if rating.categories:
console.print(f"\nCategories: {', '.join(rating.categories)}")
else:
console.print("[dim]Not yet rated — run: ietf analyze {name}[/]")
# Save detailed report too
path = reporter.draft_detail(name)
if path:
console.print(f"\n[dim]Report saved: {path}[/]")
finally:
db.close()
# ── analyze ──────────────────────────────────────────────────────────────────
@main.command()
@click.argument("name", required=False)
@click.option("--all", "analyze_all", is_flag=True, help="Analyze all unrated drafts")
@click.option("--limit", "-n", default=50, help="Max drafts to analyze (with --all)")
def analyze(name: str | None, analyze_all: bool, limit: int):
"""Analyze and rate drafts using Claude."""
from .analyzer import Analyzer
cfg = _get_config()
db = Database(cfg)
analyzer = Analyzer(cfg, db)
try:
if analyze_all:
count = analyzer.rate_all_unrated(limit=limit)
console.print(f"Analyzed [bold green]{count}[/] drafts")
elif name:
rating = analyzer.rate_draft(name)
if rating:
console.print(f"\n[bold green]Rating for {name}:[/]")
console.print(f" Score: {rating.composite_score:.1f}")
console.print(f" Summary: {rating.summary}")
console.print(f" Novelty={rating.novelty} Maturity={rating.maturity} "
f"Overlap={rating.overlap} Momentum={rating.momentum} "
f"Relevance={rating.relevance}")
else:
console.print("[red]Analysis failed[/]")
else:
console.print("Provide a draft name or use --all")
finally:
db.close()
# ── compare ──────────────────────────────────────────────────────────────────
@main.command()
@click.argument("names", nargs=-1, required=True)
def compare(names: tuple[str, ...]):
"""Compare multiple drafts for overlap and unique contributions."""
from .analyzer import Analyzer
cfg = _get_config()
db = Database(cfg)
analyzer = Analyzer(cfg, db)
try:
result = analyzer.compare_drafts(list(names))
console.print(result)
finally:
db.close()
# ── embed ────────────────────────────────────────────────────────────────────
@main.command()
def embed():
"""Generate embeddings for all drafts (requires Ollama)."""
from .embeddings import Embedder
cfg = _get_config()
db = Database(cfg)
embedder = Embedder(cfg, db)
try:
count = embedder.embed_all_missing()
console.print(f"Embedded [bold green]{count}[/] drafts")
finally:
db.close()
# ── similar ──────────────────────────────────────────────────────────────────
@main.command()
@click.argument("name")
@click.option("--top", "-n", default=10, help="Number of similar drafts to show")
def similar(name: str, top: int):
"""Find drafts most similar to a given draft."""
from .embeddings import Embedder
cfg = _get_config()
db = Database(cfg)
embedder = Embedder(cfg, db)
try:
results = embedder.find_similar(name, top_n=top)
if not results:
console.print(f"[yellow]No similar drafts found (need embeddings — run `ietf embed` first)[/]")
return
table = Table(title=f"Drafts similar to {name}")
table.add_column("Similarity", justify="right", width=10)
table.add_column("Draft", style="cyan")
table.add_column("Title")
for sim_name, score in results:
draft = db.get_draft(sim_name)
title = draft.title[:60] if draft else ""
table.add_row(f"{score:.3f}", sim_name, title)
console.print(table)
finally:
db.close()
# ── clusters ─────────────────────────────────────────────────────────────────
@main.command()
@click.option("--threshold", "-t", default=0.85, help="Similarity threshold for clustering")
def clusters(threshold: float):
"""Find clusters of highly similar (potentially overlapping) drafts."""
from .embeddings import Embedder
cfg = _get_config()
db = Database(cfg)
embedder = Embedder(cfg, db)
try:
cluster_list = embedder.find_clusters(threshold=threshold)
if not cluster_list:
console.print("No clusters found at this threshold.")
return
console.print(f"\n[bold]Found {len(cluster_list)} clusters[/] (threshold={threshold})\n")
for i, cluster in enumerate(cluster_list, 1):
console.print(f"[bold cyan]Cluster {i}[/] ({len(cluster)} drafts):")
for name in cluster:
draft = db.get_draft(name)
title = draft.title[:60] if draft else ""
console.print(f" - {name} [dim]{title}[/]")
console.print()
finally:
db.close()
# ── report ───────────────────────────────────────────────────────────────────
@main.group()
def report():
"""Generate markdown reports."""
pass
@report.command()
def overview():
"""Overview table of all rated drafts."""
from .reports import Reporter
cfg = _get_config()
db = Database(cfg)
reporter = Reporter(cfg, db)
try:
path = reporter.overview()
console.print(f"Report saved: [bold]{path}[/]")
finally:
db.close()
@report.command()
def landscape():
"""Category-grouped landscape view."""
from .reports import Reporter
cfg = _get_config()
db = Database(cfg)
reporter = Reporter(cfg, db)
try:
path = reporter.landscape()
console.print(f"Report saved: [bold]{path}[/]")
finally:
db.close()
@report.command()
@click.option("--days", "-d", default=7, help="Look back N days")
def digest(days: int):
"""What's new digest."""
from .reports import Reporter
cfg = _get_config()
db = Database(cfg)
reporter = Reporter(cfg, db)
try:
path = reporter.digest(since_days=days)
console.print(f"Report saved: [bold]{path}[/]")
finally:
db.close()
# ── config ───────────────────────────────────────────────────────────────────
@main.command("config")
@click.option("--set", "set_key", nargs=2, help="Set a config key (e.g. --set claude_model claude-opus-4-20250514)")
def config_cmd(set_key: tuple[str, str] | None):
"""Show or modify configuration."""
from dataclasses import asdict
cfg = _get_config()
if set_key:
key, value = set_key
if hasattr(cfg, key):
# Coerce types
current = getattr(cfg, key)
if isinstance(current, float):
value = float(value)
elif isinstance(current, int):
value = int(value)
elif isinstance(current, list):
import json
value = json.loads(value)
setattr(cfg, key, value)
cfg.save()
console.print(f"Set [bold]{key}[/] = {value}")
else:
console.print(f"[red]Unknown config key: {key}[/]")
else:
from dataclasses import asdict
for key, val in asdict(cfg).items():
console.print(f" [bold]{key}:[/] {val}")