"""Config, pipeline, observatory, monitor, and auto-heal commands.""" from __future__ import annotations from pathlib import Path import click from rich.table import Table from .common import console, pass_cfg_db, _get_config from ..config import Config from ..db import Database def register(main): """Register all admin commands with the main CLI group.""" main.add_command(config_cmd) main.add_command(pipeline) main.add_command(observatory) main.add_command(monitor) main.add_command(auto) # ── config ─────────────────────────────────────────────────────────────────── @click.command("config") @click.option("--set", "set_key", nargs=2, help="Set a config key (e.g. --set claude_model claude-opus-4-20250514)") @click.option("--show", is_flag=True, help="Show effective config with env var sources noted") def config_cmd(set_key: tuple[str, str] | None, show: bool): """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 env_sources = cfg.env_sources() for key, val in asdict(cfg).items(): source_note = "" if key in env_sources: source_note = f" [yellow](from ${env_sources[key]})[/]" console.print(f" [bold]{key}:[/] {val}{source_note}") if env_sources: console.print(f"\n [dim]({len(env_sources)} value(s) overridden by environment variables)[/]") # Note about ANTHROPIC_API_KEY import os if os.environ.get("ANTHROPIC_API_KEY"): console.print(" [dim]ANTHROPIC_API_KEY is set in environment[/]") else: console.print(" [dim]ANTHROPIC_API_KEY is NOT set in environment[/]") # ── pipeline ──────────────────────────────────────────────────────────────── @click.group() def pipeline(): """Gap-to-Draft generation pipeline.""" pass @pipeline.command("context") @click.argument("gap_topic") def pipeline_context(gap_topic: str): """Preview assembled context for a gap topic (dry run).""" from ..pipeline import ContextBuilder cfg = _get_config() db = Database(cfg) try: builder = ContextBuilder(cfg, db) ctx = builder.build_context(gap_topic) console.print(f"\n[bold]Context for gap: {gap_topic}[/]\n") gap = ctx.get("gap") if gap: console.print(f"[cyan]Gap:[/] {gap.get('topic', '?')}") console.print(f" {gap.get('description', '')[:200]}") console.print(f" Severity: {gap.get('severity', '?')}") ideas = ctx.get("ideas", []) console.print(f"\n[cyan]Convergent ideas:[/] {len(ideas)}") for idea in ideas[:10]: console.print(f" - {idea.get('title', '?')}: {idea.get('description', '')[:80]}") rfcs = ctx.get("rfc_foundations", []) console.print(f"\n[cyan]RFC foundations:[/] {len(rfcs)}") for ref_id, count in rfcs[:10]: console.print(f" - RFC {ref_id} (cited by {count} drafts)") similar = ctx.get("similar_drafts", []) console.print(f"\n[cyan]Similar existing drafts:[/] {len(similar)}") for name, score in similar[:8]: console.print(f" - {name} (similarity: {score:.3f})") top_rated = ctx.get("top_rated", []) console.print(f"\n[cyan]Top-rated in category:[/] {len(top_rated)}") wg_ctx = ctx.get("wg_context", []) adopted = [w for w in wg_ctx if w.get("wg_adopted")] console.print(f"\n[cyan]WG context:[/] {len(adopted)} WG-adopted drafts") vision = ctx.get("ecosystem_vision", "") if vision: console.print(f"\n[cyan]Ecosystem vision:[/] {len(vision)} chars loaded") finally: db.close() @pipeline.command("generate") @click.argument("gap_topic") @click.option("--cheap/--quality", default=False, help="Use Haiku (cheap) or Sonnet (quality)") @click.option("--dry-run", is_flag=True, help="Show outline only, don't generate sections") @click.option("--family", "family_name", default="", help="Family name for multi-draft generation") def pipeline_generate(gap_topic: str, cheap: bool, dry_run: bool, family_name: str): """Generate a single draft from a gap topic.""" from ..analyzer import Analyzer from ..pipeline import PipelineGenerator, ContextBuilder cfg = _get_config() db = Database(cfg) analyzer = Analyzer(cfg, db) try: builder = ContextBuilder(cfg, db) generator = PipelineGenerator(cfg, db, analyzer) ctx = builder.build_context(gap_topic) console.print(f"[bold]Generating draft for gap: {gap_topic}[/]") outline = generator.generate_outline(ctx, cheap=cheap) console.print(f" Title: [cyan]{outline.get('title', '?')}[/]") console.print(f" Sections: {len(outline.get('sections', []))}") console.print(f" Target WG: {outline.get('target_wg', '?')}") if dry_run: import json console.print("\n[bold]Outline (dry run):[/]") console.print(json.dumps(outline, indent=2)) return result = generator.generate_full(gap_topic, cheap=cheap) console.print(f"\n[bold green]Draft generated![/]") console.print(f" ID: {result.get('id', '?')}") console.print(f" Draft name: {result.get('draft_name', '?')}") # Export text file output_dir = Path(cfg.data_dir) / "reports" / "generated-drafts" output_dir.mkdir(parents=True, exist_ok=True) draft_name = result.get("draft_name", "draft-unknown") out_path = output_dir / f"{draft_name}.txt" if result.get("full_text"): out_path.write_text(result["full_text"]) console.print(f" Saved: {out_path}") finally: db.close() @pipeline.command("family") @click.option("--name", "family_name", default="agent-ecosystem", help="Family name") @click.option("--cheap/--quality", default=False, help="Use Haiku (cheap) or Sonnet (quality)") def pipeline_family(family_name: str, cheap: bool): """Generate the full 5-draft ecosystem family.""" from ..analyzer import Analyzer from ..pipeline import FamilyCoordinator cfg = _get_config() db = Database(cfg) analyzer = Analyzer(cfg, db) try: coordinator = FamilyCoordinator(cfg, db, analyzer) console.print(f"[bold]Generating draft family: {family_name}[/]\n") results = coordinator.generate_family(family_name=family_name, cheap=cheap) console.print(f"\n[bold green]Generated {len(results)} drafts![/]") # Export all output_dir = Path(cfg.data_dir) / "reports" / "generated-drafts" output_dir.mkdir(parents=True, exist_ok=True) for r in results: draft_name = r.get("draft_name", "draft-unknown") if r.get("full_text"): out_path = output_dir / f"{draft_name}.txt" out_path.write_text(r["full_text"]) console.print(f" [green]{r.get('family_role', '?')}[/] -> {out_path}") # Family summary summary_path = output_dir / "family-summary.md" lines = [f"# Draft Family: {family_name}\n"] for r in results: lines.append(f"## {r.get('family_role', '?')}: {r.get('title', '?')}") lines.append(f"- Draft: `{r.get('draft_name', '?')}`") lines.append(f"- Gap: {r.get('gap_topic', '?')}") lines.append(f"- Sections: {len(r.get('sections', []))}") lines.append("") summary_path.write_text("\n".join(lines)) console.print(f"\n Summary: {summary_path}") # Consistency check consistency = coordinator.check_consistency(family_name) if consistency.get("issues"): console.print(f"\n[yellow]Consistency issues:[/]") for issue in consistency["issues"]: console.print(f" - {issue}") else: console.print(f"\n[green]No consistency issues found[/]") finally: db.close() @pipeline.command("quality") @click.argument("draft_id", type=int) def pipeline_quality(draft_id: int): """Run quality gates on a generated draft.""" from ..analyzer import Analyzer from ..pipeline import QualityGates cfg = _get_config() db = Database(cfg) analyzer = Analyzer(cfg, db) try: gates = QualityGates(cfg, db, analyzer) console.print(f"[bold]Running quality gates on draft #{draft_id}[/]\n") results = gates.run_all(draft_id) for gate_name, result in results.items(): status = "[green]PASS[/]" if result["passed"] else "[red]FAIL[/]" console.print(f" {status} {gate_name}: {result.get('details', '')[:100]}") if "score" in result: console.print(f" Score: {result['score']:.2f}") finally: db.close() @pipeline.command("status") def pipeline_status(): """Show pipeline health: processing stages, generated drafts, and API cost.""" cfg = _get_config() db = Database(cfg) try: # Pipeline health overview total = db.count_drafts() rated_count = len(db.drafts_with_ratings(limit=10000)) unrated = len(db.unrated_drafts(limit=10000)) unembedded = len(db.drafts_without_embeddings(limit=10000)) embedded_count = total - unembedded no_ideas = len(db.drafts_without_ideas(limit=10000)) ideas_count = total - no_ideas idea_total = db.idea_count() gap_count = len(db.all_gaps()) input_tok, output_tok = db.total_tokens_used() est_cost = (input_tok * 3.0 / 1_000_000) + (output_tok * 15.0 / 1_000_000) # Last update snapshots = db.get_snapshots(limit=1) last_update = snapshots[0]["snapshot_at"][:19] if snapshots else "never" console.print("\n[bold]Pipeline Status[/]\n") console.print(f" Total documents: [bold]{total}[/]") console.print(f" Last update: {last_update}") console.print() # Stage table stage_table = Table(title="Processing Stages") stage_table.add_column("Stage", width=20) stage_table.add_column("Done", justify="right", width=8) stage_table.add_column("Missing", justify="right", width=8) stage_table.add_column("Progress", width=20) def bar(done, total_n): pct = int(done / total_n * 100) if total_n > 0 else 0 filled = pct // 5 return f"[green]{'#' * filled}[/][dim]{'.' * (20 - filled)}[/] {pct}%" stage_table.add_row("Rated", str(rated_count), str(unrated), bar(rated_count, total)) stage_table.add_row("Embedded", str(embedded_count), str(unembedded), bar(embedded_count, total)) stage_table.add_row("Ideas extracted", str(ideas_count), str(no_ideas), bar(ideas_count, total)) console.print(stage_table) console.print(f"\n Total ideas: [bold]{idea_total}[/]") console.print(f" Gaps identified: [bold]{gap_count}[/]") console.print(f"\n API tokens: {input_tok:,} in + {output_tok:,} out") console.print(f" Estimated cost: [bold]${est_cost:.2f}[/]") # Generated drafts gen_drafts = db.get_generated_drafts() if gen_drafts: console.print() table = Table(title=f"Generated Drafts ({len(gen_drafts)})") table.add_column("ID", justify="right", width=4) table.add_column("Draft Name", style="cyan") table.add_column("Gap Topic") table.add_column("Family", width=15) table.add_column("Status", width=10) table.add_column("Quality", justify="right", width=7) table.add_column("Created", width=10) for d in gen_drafts: table.add_row( str(d["id"]), d["draft_name"], d["gap_topic"][:30], d.get("family_name", ""), d.get("status", "?"), f"{d.get('quality_score', 0):.1f}" if d.get("quality_score") else "-", (d.get("created_at") or "")[:10], ) console.print(table) finally: db.close() @pipeline.command("export") @click.argument("draft_id", type=int) @click.option("--output", "-o", help="Output file path") def pipeline_export(draft_id: int, output: str | None): """Export a generated draft as I-D text.""" cfg = _get_config() db = Database(cfg) try: draft = db.get_generated_draft(draft_id) if not draft: console.print(f"[red]Draft #{draft_id} not found[/]") return text = draft.get("full_text", "") if not text: console.print(f"[red]Draft #{draft_id} has no generated text[/]") return if output: out_path = Path(output) else: output_dir = Path(cfg.data_dir) / "reports" / "generated-drafts" output_dir.mkdir(parents=True, exist_ok=True) out_path = output_dir / f"{draft['draft_name']}.txt" out_path.write_text(text) console.print(f"Exported: [bold green]{out_path}[/]") finally: db.close() # ── observatory ───────────────────────────────────────────────────────────── @click.group() def observatory(): """Living Standards Observatory — monitor AI standards across bodies.""" pass @observatory.command("update") @click.option("--source", "-s", default=None, help="Comma-separated sources (e.g. ietf,w3c)") @click.option("--full/--delta", default=False, help="Full refresh or delta only") @click.option("--dry-run", is_flag=True, default=False, help="Show what would happen without making changes") def observatory_update(source: str | None, full: bool, dry_run: bool): """Fetch, analyze, and update the observatory.""" from ..observatory import Observatory cfg = _get_config() db = Database(cfg) try: if dry_run: obs = Observatory(cfg, db) else: from ..analyzer import Analyzer analyzer = Analyzer(cfg, db) obs = Observatory(cfg, db, analyzer) sources = source.split(",") if source else None mode = "full" if full else "delta" console.print(f"[bold]Observatory update[/] ({mode}{' [DRY RUN]' if dry_run else ''})") result = obs.update(sources=sources, full=full, dry_run=dry_run) if not dry_run: console.print(f"\n[bold green]Update complete![/]") console.print(f" New docs: {result.get('new_docs', 0)}") console.print(f" Analyzed: {result.get('analyzed', 0)}") console.print(f" Embedded: {result.get('embedded', 0)}") console.print(f" Ideas extracted: {result.get('ideas', 0)}") if result.get("gaps_changed"): console.print(f" Gaps re-analyzed: yes") if result.get("errors"): console.print(f"\n [yellow]Errors ({len(result['errors'])}):[/]") for err in result["errors"]: console.print(f" - {err}") finally: db.close() @observatory.command("dashboard") def observatory_dashboard(): """Regenerate the static dashboard site.""" from ..dashboard import DashboardGenerator cfg = _get_config() db = Database(cfg) try: gen = DashboardGenerator(cfg, db) path = gen.generate() console.print(f"[bold green]Dashboard generated:[/] {path}") console.print(f" Open: file://{path}/index.html") finally: db.close() @observatory.command("status") def observatory_status(): """Show observatory status — doc counts, sources, last update.""" from ..observatory import Observatory cfg = _get_config() db = Database(cfg) try: obs = Observatory(cfg, db) status = obs.status() console.print(f"\n[bold]Observatory Status[/]\n") console.print(f" Total documents: [bold]{status.get('total_docs', 0)}[/]") console.print(f" Unrated: {status.get('unrated', 0)}") console.print(f" Unembedded: {status.get('unembedded', 0)}") console.print(f" Gaps: {status.get('gaps', 0)}") sources = status.get("sources", {}) if sources: console.print(f"\n [bold]Sources:[/]") for name, count in sources.items(): console.print(f" {name}: {count} docs") last_update = status.get("last_update") if last_update: console.print(f"\n Last update: {last_update[:10]}") console.print(f" Snapshots: {status.get('snapshots', 0)}") finally: db.close() @observatory.command("snapshot") def observatory_snapshot(): """Record current state as a snapshot.""" cfg = _get_config() db = Database(cfg) try: snap_id = db.create_snapshot() gaps = db.all_gaps() if gaps: db.record_gap_history(snap_id, gaps) console.print(f"[bold green]Snapshot #{snap_id} created[/] ({db.count_drafts()} docs, {len(gaps)} gaps)") finally: db.close() @observatory.command("diff") @click.option("--since", help="Show changes since this date (YYYY-MM-DD)") def observatory_diff(since: str | None): """Show what changed since a date.""" from ..observatory import Observatory cfg = _get_config() db = Database(cfg) try: obs = Observatory(cfg, db) result = obs.diff(since=since) console.print(f"\n[bold]Observatory Diff[/]") if since: console.print(f" Since: {result.get('since', since)}") new_docs = result.get("new_docs", []) console.print(f" New documents: {result.get('new_doc_count', len(new_docs))}") gap_changes = result.get("gap_changes", []) console.print(f" Gap history entries: {len(gap_changes)}") if new_docs: console.print(f"\n [bold]New documents:[/]") for doc in new_docs[:20]: d = dict(doc) if not isinstance(doc, dict) else doc console.print(f" [{d.get('source', '?')}] {d.get('name', '?')}: {d.get('title', '')[:60]}") finally: db.close() # ── monitor ───────────────────────────────────────────────────────────── @click.group() def monitor(): """Monitor IETF Datatracker for new AI/agent drafts.""" pass @monitor.command("run") @click.option("--analyze/--no-analyze", default=True, help="Analyze new drafts") @click.option("--embed/--no-embed", default=True, help="Generate embeddings") @click.option("--ideas/--no-ideas", default=True, help="Extract ideas") def monitor_run(analyze, embed, ideas): """Run one monitoring cycle: fetch -> analyze -> embed -> ideas.""" from ..analyzer import Analyzer from ..embeddings import Embedder from ..fetcher import Fetcher cfg = _get_config() db = Database(cfg) run_id = db.start_monitor_run() stats = { "new_drafts_found": 0, "drafts_analyzed": 0, "drafts_embedded": 0, "ideas_extracted": 0, } try: console.print("[bold]Monitor run started[/]") # Determine since date from last successful run last_run = db.get_last_successful_run() since = last_run["completed_at"][:10] if last_run and last_run.get("completed_at") else cfg.fetch_since console.print(f" Fetching drafts since: [cyan]{since}[/]") # Fetch new drafts fetcher = Fetcher(cfg) try: existing_count = db.count_drafts() drafts = fetcher.search_drafts(keywords=list(cfg.search_keywords), since=since) for draft in drafts: db.upsert_draft(draft) # Download text for any missing missing_text = db.drafts_without_text() if missing_text: console.print(f" Downloading text for [bold]{len(missing_text)}[/] drafts...") texts = fetcher.download_texts(missing_text) for name, text in texts.items(): draft = db.get_draft(name) if draft: draft.full_text = text db.upsert_draft(draft) finally: fetcher.close() new_count = db.count_drafts() - existing_count stats["new_drafts_found"] = max(new_count, 0) console.print(f" New drafts found: [bold green]{stats['new_drafts_found']}[/]") # Analyze unrated drafts if analyze: unrated = db.unrated_drafts(limit=200) if unrated: console.print(f" Analyzing [bold]{len(unrated)}[/] unrated drafts...") analyzer = Analyzer(cfg, db) count = analyzer.rate_all_unrated(limit=200) stats["drafts_analyzed"] = count console.print(f" Analyzed: [bold green]{count}[/]") # Embed missing drafts if embed: missing_embed = db.drafts_without_embeddings(limit=500) if missing_embed: console.print(f" Embedding [bold]{len(missing_embed)}[/] drafts...") embedder = Embedder(cfg, db) count = embedder.embed_all_missing() stats["drafts_embedded"] = count console.print(f" Embedded: [bold green]{count}[/]") # Extract ideas if ideas: missing_ideas = db.drafts_without_ideas(limit=500) if missing_ideas: console.print(f" Extracting ideas from [bold]{len(missing_ideas)}[/] drafts...") analyzer = Analyzer(cfg, db) count = analyzer.extract_all_ideas(limit=500, batch_size=5, cheap=True) stats["ideas_extracted"] = count console.print(f" Ideas extracted from: [bold green]{count}[/] drafts") db.complete_monitor_run(run_id, stats) console.print("\n[bold green]Monitor run completed successfully[/]") except Exception as e: db.fail_monitor_run(run_id, str(e)) console.print(f"\n[bold red]Monitor run failed:[/] {e}") raise finally: db.close() @monitor.command("status") def monitor_status(): """Show monitoring status and recent runs.""" cfg = _get_config() db = Database(cfg) try: runs = db.get_monitor_runs(limit=20) last = db.get_last_successful_run() # Unprocessed counts unrated = len(db.unrated_drafts(limit=9999)) unembedded = len(db.drafts_without_embeddings(limit=9999)) no_ideas = len(db.drafts_without_ideas(limit=9999)) console.print("\n[bold]Monitor Status[/]\n") if last: console.print(f" Last successful run: [green]{last['completed_at']}[/]") console.print(f" Duration: {last['duration_seconds']:.1f}s") console.print(f" New drafts: {last['new_drafts_found']}") else: console.print(" [yellow]No successful runs yet[/]") console.print(f"\n[bold]Unprocessed[/]") console.print(f" Unrated: [{'yellow' if unrated > 0 else 'green'}]{unrated}[/]") console.print(f" Unembedded: [{'yellow' if unembedded > 0 else 'green'}]{unembedded}[/]") console.print(f" No ideas: [{'yellow' if no_ideas > 0 else 'green'}]{no_ideas}[/]") if runs: console.print(f"\n[bold]Recent Runs[/] ({len(runs)} total)\n") table = Table() table.add_column("#", justify="right", width=4) table.add_column("Started", width=20) table.add_column("Duration", justify="right", width=8) table.add_column("Status", width=10) table.add_column("New", justify="right", width=5) table.add_column("Analyzed", justify="right", width=8) table.add_column("Embedded", justify="right", width=8) table.add_column("Ideas", justify="right", width=6) for r in runs: status_style = {"completed": "green", "failed": "red", "running": "yellow"}.get(r["status"], "dim") table.add_row( str(r["id"]), r["started_at"][:19] if r["started_at"] else "", f"{r['duration_seconds']:.1f}s" if r["duration_seconds"] else "-", f"[{status_style}]{r['status']}[/{status_style}]", str(r["new_drafts_found"]), str(r["drafts_analyzed"]), str(r["drafts_embedded"]), str(r["ideas_extracted"]), ) console.print(table) finally: db.close() # ── auto ───────────────────────────────────────────────────────────────────── @click.command("auto") @click.option("--cost-limit", default=2.0, help="Auto-approve operations under this USD amount (default: $2)") @click.option("--yes", "-y", is_flag=True, help="Skip all confirmation prompts") @click.option("--dry-run", is_flag=True, help="Show what would be done without doing it") @click.option("--source", "-s", default=None, help="Limit to specific source (ietf,w3c,etsi,iso,itu)") def auto(cost_limit: float, yes: bool, dry_run: bool, source: str | None): """Auto-heal: fetch, analyze, embed, extract ideas, and update gaps. Automatically processes all unrated, unembedded, and idea-less drafts across all sources. Uses cheap models (Haiku) for bulk operations. Operations estimated above --cost-limit require confirmation. Examples: ietf auto # run full pipeline, auto-approve under $2 ietf auto --dry-run # show plan without executing ietf auto -s iso # only process ISO drafts ietf auto --cost-limit 5 # raise approval threshold to $5 ietf auto -y # skip all prompts (for cron) """ cfg = Config.load() db = Database(cfg) try: _auto_heal(cfg, db, cost_limit=cost_limit, yes=yes, dry_run=dry_run, source_filter=source) finally: db.close() def _estimate_cost(n_drafts: int, operation: str) -> float: """Estimate USD cost for an operation. Conservative estimates.""" costs = { "analyze_cheap": n_drafts * 0.0005, "analyze_quality": n_drafts * 0.005, "ideas_cheap": n_drafts * 0.001, "ideas_quality": n_drafts * 0.008, "gaps": 0.05, "embed": 0.0, "authors": 0.0, "fetch": 0.0, } return costs.get(operation, 0.0) def _auto_heal(cfg, db, cost_limit: float, yes: bool, dry_run: bool, source_filter: str | None): """Run the full auto-heal pipeline.""" import time as _time from rich.panel import Panel steps: list[dict] = [] total_cost = 0.0 # ── Step 1: Fetch new drafts from all sources ── sources = [source_filter] if source_filter else cfg.observatory_sources steps.append({ "name": f"Fetch new drafts from {', '.join(sources)}", "sources": sources, "cost": 0.0, "action": "fetch", }) # ── Step 2: Analyze unrated drafts ── unrated = db.unrated_drafts(limit=10000) if source_filter: unrated = [d for d in unrated if (d.source or "ietf") == source_filter] n_unrated = len(unrated) analyze_cost = _estimate_cost(n_unrated, "analyze_cheap") steps.append({ "name": f"Analyze {n_unrated} unrated drafts (Haiku)", "count": n_unrated, "cost": analyze_cost, "action": "analyze", }) total_cost += analyze_cost # ── Step 3: Fetch authors ── missing_authors = db.conn.execute( "SELECT COUNT(*) FROM drafts WHERE name NOT IN (SELECT DISTINCT draft_name FROM draft_authors)" ).fetchone()[0] steps.append({ "name": f"Fetch authors for {missing_authors} drafts", "count": missing_authors, "cost": 0.0, "action": "authors", }) # ── Step 4: Embed missing drafts ── missing_embed = db.drafts_without_embeddings(limit=10000) if source_filter: source_names = {row[0] for row in db.conn.execute( "SELECT name FROM drafts WHERE source = ?", (source_filter,) ).fetchall()} missing_embed = [n for n in missing_embed if n in source_names] n_embed = len(missing_embed) steps.append({ "name": f"Embed {n_embed} drafts (Ollama, free)", "count": n_embed, "cost": 0.0, "action": "embed", }) # ── Step 5: Extract ideas ── missing_ideas = db.drafts_without_ideas(limit=10000) if source_filter: if not source_names: source_names = {row[0] for row in db.conn.execute( "SELECT name FROM drafts WHERE source = ?", (source_filter,) ).fetchall()} missing_ideas = [n for n in missing_ideas if n in source_names] n_ideas = len(missing_ideas) ideas_cost = _estimate_cost(n_ideas, "ideas_cheap") steps.append({ "name": f"Extract ideas from {n_ideas} drafts (Haiku)", "count": n_ideas, "cost": ideas_cost, "action": "ideas", }) total_cost += ideas_cost # ── Step 6: Refresh gaps ── gap_cost = _estimate_cost(0, "gaps") steps.append({ "name": "Refresh gap analysis", "cost": gap_cost, "action": "gaps", }) total_cost += gap_cost # ── Show plan ── plan_lines = [] for s in steps: count = s.get("count", 1) if count == 0: plan_lines.append(f" [dim]SKIP[/] {s['name']}") else: cost_str = f" [yellow]~${s['cost']:.2f}[/]" if s["cost"] > 0 else "" plan_lines.append(f" [green]RUN[/] {s['name']}{cost_str}") auto_approved = total_cost <= cost_limit plan_lines.append(f"\n [bold]Estimated total cost: ${total_cost:.2f}[/]") if auto_approved: plan_lines.append(f" [green]Auto-approved (under ${cost_limit:.2f} limit)[/]") else: plan_lines.append(f" [yellow]Requires approval (over ${cost_limit:.2f} limit)[/]") console.print(Panel("\n".join(plan_lines), title="Auto-Heal Plan")) if dry_run: console.print("[bold yellow]DRY RUN[/] — no changes made.") return # ── Approval ── if not auto_approved and not yes: if not click.confirm(f"Estimated cost ${total_cost:.2f} exceeds ${cost_limit:.2f} limit. Proceed?"): console.print("[yellow]Aborted.[/]") return # ── Execute ── start = _time.time() for step in steps: action = step["action"] count = step.get("count", 0) if action == "fetch": console.print(f"\n[bold cyan]>>> Fetching from {step['sources']}...[/]") from ..sources import get_fetcher from ..observatory import _doc_to_draft for src_name in step["sources"]: try: fetcher = get_fetcher(src_name, cfg) before = db.count_drafts() results = fetcher.search(keywords=cfg.search_keywords) for doc in results: db.upsert_draft(_doc_to_draft(doc)) after = db.count_drafts() new = after - before console.print(f" [{src_name}] +{new} new drafts") fetcher.close() except Exception as e: console.print(f" [{src_name}] [red]Error: {e}[/]") elif action == "analyze" and count > 0: console.print(f"\n[bold cyan]>>> Analyzing {count} drafts (Haiku)...[/]") from ..analyzer import Analyzer analyzer = Analyzer(cfg, db) orig_model = cfg.claude_model cfg.claude_model = cfg.claude_model_cheap try: done = analyzer.rate_all_unrated(limit=count) console.print(f" Analyzed [bold green]{done}[/] drafts") finally: cfg.claude_model = orig_model elif action == "authors" and count > 0: console.print(f"\n[bold cyan]>>> Fetching authors for {count} drafts...[/]") from ..authors import AuthorNetwork author_net = AuthorNetwork(cfg, db) done = author_net.fetch_all_authors() console.print(f" Fetched authors for [bold green]{done}[/] drafts") elif action == "embed" and count > 0: console.print(f"\n[bold cyan]>>> Embedding {count} drafts (Ollama)...[/]") from ..embeddings import Embedder with Embedder(cfg, db) as embedder: done = embedder.embed_all_missing() console.print(f" Embedded [bold green]{done}[/] drafts") elif action == "ideas" and count > 0: console.print(f"\n[bold cyan]>>> Extracting ideas from {count} drafts (Haiku)...[/]") from ..analyzer import Analyzer analyzer = Analyzer(cfg, db) done = analyzer.extract_all_ideas(limit=count, batch_size=5, cheap=True) console.print(f" Extracted ideas from [bold green]{done}[/] drafts") elif action == "gaps": console.print(f"\n[bold cyan]>>> Refreshing gap analysis...[/]") from ..analyzer import Analyzer analyzer = Analyzer(cfg, db) gaps = analyzer.gap_analysis() if gaps: console.print(f" Found [bold green]{len(gaps)}[/] gaps") elapsed = _time.time() - start console.print(f"\n[bold green]Auto-heal complete![/] ({elapsed:.1f}s, ~${total_cost:.2f})") # Show final counts total = db.count_drafts() rated = db.conn.execute("SELECT COUNT(*) FROM ratings").fetchone()[0] embedded = db.conn.execute("SELECT COUNT(*) FROM embeddings").fetchone()[0] idea_count = db.conn.execute("SELECT COUNT(*) FROM ideas").fetchone()[0] gap_count = db.conn.execute("SELECT COUNT(*) FROM gaps").fetchone()[0] console.print(f" Drafts: {total} | Rated: {rated} | Embedded: {embedded} | Ideas: {idea_count} | Gaps: {gap_count}") by_source = db.conn.execute( "SELECT source, COUNT(*) FROM drafts GROUP BY source ORDER BY COUNT(*) DESC" ).fetchall() source_str = " | ".join(f"{s}: {c}" for s, c in by_source) console.print(f" Sources: {source_str}")