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>
406 lines
14 KiB
Python
406 lines
14 KiB
Python
"""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}")
|