"""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}")