"""ResearchLink AI CLI.""" from __future__ import annotations from pathlib import Path import typer import yaml from rich.console import Console from rich.panel import Panel from researchlink import __product__, __version__ app = typer.Typer( name="researchlink", help=f"{__product__} — Multi-agent research digest platform", add_completion=False, ) console = Console() def version_callback(value: bool): if value: console.print(f"[bold]{__product__}[/bold] v{__version__}") raise typer.Exit() @app.callback() def main( version: bool | None = typer.Option( None, "--version", "-v", callback=version_callback, is_eager=True, help="Show version and exit." ) ): pass @app.command() def ingest( pdf: Path | None = typer.Option(None, "--pdf", help="Path to paper PDF"), pdf_url: str | None = typer.Option(None, "--pdf-url", help="URL to download paper PDF"), paper_url: str | None = typer.Option(None, "--paper-url", help="Paper landing page URL"), github_url: str | None = typer.Option(None, "--github-url", help="GitHub repository URL"), arxiv: str | None = typer.Option(None, "--arxiv", help="arXiv ID or abs/pdf URL (e.g. 1706.03762)"), doi: str | None = typer.Option(None, "--doi", help="DOI (e.g. 10.1109/CVPR.2016.90)"), metadata: Path | None = typer.Option( None, "--metadata", help="Manual metadata YAML/JSON file (highest-trust source)"), bibtex: str | None = typer.Option(None, "--bibtex", help="BibTeX string"), author_notes: str | None = typer.Option(None, "--author-notes", help="Author notes"), output: Path = typer.Option(Path("papers"), "--output", "-o", help="Output directory"), config: Path | None = typer.Option(None, "--config", "-c", help="YAML config file"), force: bool = typer.Option(False, "--force", help="Overwrite existing files"), dry_run: bool = typer.Option(False, "--dry-run", help="Show resolved inputs; run nothing"), verbose: bool = typer.Option(False, "--verbose", help="Verbose agent output"), ): """ Ingest a research paper and generate a GitHub-ready paper digest module. Examples: researchlink ingest --pdf paper.pdf --paper-url https://... --github-url https://... researchlink ingest --arxiv 1706.03762 researchlink ingest --doi 10.1109/CVPR.2016.90 researchlink ingest --metadata paper_meta.yml researchlink ingest --config examples/marl_iotp_input.yml """ from researchlink.pipeline import run_pipeline console.print( Panel( f"[bold cyan]{__product__}[/bold cyan] v{__version__}\n" "Transforming research papers into structured digest modules.", title="ResearchLink AI", ) ) raw_inputs: dict = {} # Load config file first (CLI flags override) if config: if not config.exists(): console.print(f"[red]Config file not found: {config}[/red]") raise typer.Exit(1) with open(config) as f: raw_inputs = yaml.safe_load(f) or {} console.print(f"[dim]Loaded config: {config}[/dim]") # CLI flags override config values if pdf: raw_inputs["paper_pdf_path"] = str(pdf) if pdf_url: raw_inputs["paper_pdf_url"] = pdf_url if paper_url: raw_inputs["paper_url"] = paper_url if github_url: raw_inputs["github_url"] = github_url if arxiv: raw_inputs["arxiv_id"] = arxiv if doi: raw_inputs["doi"] = doi if metadata: if not metadata.exists(): console.print(f"[red]Metadata file not found: {metadata}[/red]") raise typer.Exit(1) import json as _json text = metadata.read_text() try: raw_inputs["manual_metadata"] = ( _json.loads(text) if metadata.suffix == ".json" else yaml.safe_load(text) ) or {} except Exception as e: console.print(f"[red]Failed to parse metadata file: {e}[/red]") raise typer.Exit(1) console.print(f"[dim]Loaded manual metadata: {metadata}[/dim]") if bibtex: raw_inputs["bibtex"] = bibtex if author_notes: raw_inputs["author_notes"] = author_notes if output != Path("papers") or "target_output_dir" not in raw_inputs: raw_inputs["target_output_dir"] = str(output) if force: raw_inputs["force_overwrite"] = True if dry_run: redacted = {k: v for k, v in raw_inputs.items() if k != "manual_metadata"} if "manual_metadata" in raw_inputs: redacted["manual_metadata"] = f"<{len(raw_inputs['manual_metadata'])} keys>" console.print("[yellow]--dry-run:[/yellow] resolved inputs:") for k, v in redacted.items(): console.print(f" {k} = {v}") console.print("[dim]No pipeline run.[/dim]") return try: report = run_pipeline(raw_inputs, verbose=verbose) if not report.success: raise typer.Exit(1) except KeyboardInterrupt: console.print("\n[yellow]Interrupted by user.[/yellow]") raise typer.Exit(130) except Exception as e: console.print(f"[red]Pipeline error: {e}[/red]") if verbose: import traceback traceback.print_exc() raise typer.Exit(1) @app.command() def validate( output_dir: Path = typer.Argument(..., help="Path to a generated paper module directory"), ): """Validate a generated paper module directory for completeness.""" from researchlink.validators.output_validator import validate_paper_module results = validate_paper_module(output_dir) for item in results: icon = "[green]✓[/green]" if item["ok"] else "[red]✗[/red]" console.print(f" {icon} {item['message']}") missing = [r for r in results if not r["ok"]] if missing: console.print(f"\n[yellow]{len(missing)} validation issue(s) found.[/yellow]") raise typer.Exit(1) else: console.print("\n[green]All validation checks passed.[/green]") def _module_dir(path: Path) -> Path: """Resolve and validate a paper-module directory (or exit).""" if not path.exists() or not path.is_dir(): console.print(f"[red]Module directory not found: {path}[/red]") raise typer.Exit(1) return path @app.command() def enrich( module_dir: Path = typer.Argument(..., help="Path to a papers// module"), dry_run: bool = typer.Option(False, "--dry-run", help="Show what would happen; write nothing"), verbose: bool = typer.Option(False, "--verbose", help="Verbose output"), ): """Re-resolve provenance metadata for a module and rewrite metadata.json / sources.json.""" import json as _json from researchlink.services.metadata_sources import resolve_metadata from researchlink.services.module_io import load_metadata from researchlink.services.paper_module import build_metadata_json mod = _module_dir(module_dir) meta = load_metadata(mod) console.print(f"[dim]Enriching:[/dim] {meta.title} ({meta.slug})") if dry_run: console.print("[yellow]--dry-run:[/yellow] would resolve sources and rewrite " "metadata.json + sources.json") return prov = resolve_metadata( arxiv_id=meta.arxiv_id, doi=meta.doi, title=meta.title, paper_url=meta.paper_url, user_fields={"title": meta.title, "year": meta.year, "venue": meta.venue, "authors": meta.authors_provisional or None, "doi": meta.doi, "arxiv_id": meta.arxiv_id, "url": meta.paper_url, "code_url": meta.code_url}, ) payload, sources = build_metadata_json(prov, meta) (mod / "metadata.json").write_text(_json.dumps(payload, indent=2, ensure_ascii=False) + "\n") (mod / "sources.json").write_text(_json.dumps(sources, indent=2, ensure_ascii=False) + "\n") conflicts = prov.conflicts() console.print(f"[green]Enriched.[/green] Sources: {[s.name for s in prov.sources]}" + (f" [yellow]conflicts: {conflicts}[/yellow]" if conflicts else "")) @app.command() def discover( module_dir: Path = typer.Argument(..., help="Path to a papers// module"), max_papers: int = typer.Option(30, "--max-papers", help="Max related papers"), online: bool = typer.Option(False, "--online", help="Query the web (default: offline references)"), dry_run: bool = typer.Option(False, "--dry-run", help="Show what would happen; write nothing"), verbose: bool = typer.Option(False, "--verbose", help="Verbose output"), ): """Discover related work and (re)write related_work.md + literature_map.json.""" import json as _json from researchlink.services.discovery import discover as _discover from researchlink.services.discovery import related_work_markdown from researchlink.services.module_io import load_extraction, load_metadata, write_module_file mod = _module_dir(module_dir) meta = load_metadata(mod) extraction = load_extraction(mod) mode = "online" if online else "offline (references)" console.print(f"[dim]Discovering ({mode}):[/dim] {meta.title}") if dry_run: console.print(f"[yellow]--dry-run:[/yellow] would discover up to {max_papers} papers " "and write related_work.md + literature_map.json") return related, lit_map = _discover(meta, extraction, max_papers=max_papers, offline=not online) write_module_file(mod, "related_work.md", related_work_markdown(meta, related, lit_map)) (mod / "literature_map.json").write_text( _json.dumps(lit_map.model_dump(mode="json"), indent=2, ensure_ascii=False) + "\n") console.print(f"[green]Discovered[/green] {len(related)} related papers; " f"map: {len(lit_map.nodes)} nodes.") @app.command() def study( module_dir: Path = typer.Argument(..., help="Path to a papers// module"), dry_run: bool = typer.Option(False, "--dry-run", help="Show what would happen; write nothing"), verbose: bool = typer.Option(False, "--verbose", help="Verbose output"), ): """Regenerate study_notes.md from the module's extracted content.""" from researchlink.agents.teaching_path_agent import TeachingPathAgent from researchlink.services.module_io import load_extraction, load_metadata, write_module_file mod = _module_dir(module_dir) meta = load_metadata(mod) if dry_run: console.print("[yellow]--dry-run:[/yellow] would regenerate study_notes.md") return files = TeachingPathAgent(verbose=verbose).run(meta, load_extraction(mod)) for name, content in files.items(): dest = write_module_file(mod, name, content) console.print(f"[green]Wrote[/green] {dest.name}") @app.command() def review( module_dir: Path = typer.Argument(..., help="Path to a papers// module"), dry_run: bool = typer.Option(False, "--dry-run", help="Show what would happen; write nothing"), verbose: bool = typer.Option(False, "--verbose", help="Verbose output"), ): """Regenerate review.md and claims.md (grounded) from the module.""" from researchlink.agents.reviewer_agent import ReviewerAgent from researchlink.schemas.citations import CitationReport from researchlink.services.module_io import load_extraction, load_metadata, write_module_file mod = _module_dir(module_dir) meta = load_metadata(mod) if dry_run: console.print("[yellow]--dry-run:[/yellow] would regenerate review.md + claims.md") return extraction = load_extraction(mod) existing = {"digest.md": (mod / "summary.md").read_text(encoding="utf-8", errors="replace") if (mod / "summary.md").exists() else ""} result = ReviewerAgent(verbose=verbose).run(meta, CitationReport(), existing, extraction) result.pop("quality_flags", None) for name, content in result.items(): dest = write_module_file(mod, name, content) console.print(f"[green]Wrote[/green] {dest.name}") @app.command() def export( module_dir: Path = typer.Argument(..., help="Path to a papers// module"), fmt: str = typer.Option("markdown", "--format", "-f", help="markdown | json | bibtex"), out: Path | None = typer.Option(None, "--out", help="Write to file instead of stdout"), dry_run: bool = typer.Option(False, "--dry-run", help="Show what would happen; write nothing"), verbose: bool = typer.Option(False, "--verbose", help="Verbose output"), ): """Export a module as consolidated markdown, JSON, or BibTeX.""" import json as _json mod = _module_dir(module_dir) if fmt not in ("markdown", "json", "bibtex"): console.print(f"[red]Unknown format: {fmt} (use markdown|json|bibtex)[/red]") raise typer.Exit(1) if fmt == "bibtex": bib = mod / "bibtex.bib" content = bib.read_text(encoding="utf-8") if bib.exists() else "% no bibtex.bib found\n" elif fmt == "json": meta_path = mod / "metadata.json" payload = { "metadata": _json.loads(meta_path.read_text()) if meta_path.exists() else {}, "files": sorted(p.relative_to(mod).as_posix() for p in mod.rglob("*") if p.is_file()), } content = _json.dumps(payload, indent=2, ensure_ascii=False) else: # markdown — concatenate spec docs in reading order order = ["README.md", "summary.md", "paper.md", "claims.md", "related_work.md", "implementation.md", "reproduction.md", "study_notes.md", "review.md"] parts = [] for name in order: p = mod / name if p.exists(): parts.append(f"\n\n---\n\n\n\n" + p.read_text(encoding="utf-8")) content = "".join(parts).lstrip() if dry_run: console.print(f"[yellow]--dry-run:[/yellow] would export {fmt} " f"({len(content)} chars){f' to {out}' if out else ' to stdout'}") return if out: out.write_text(content, encoding="utf-8") console.print(f"[green]Exported[/green] {fmt} → {out}") else: console.print(content) @app.command() def index( papers_dir: Path = typer.Argument(Path("papers"), help="Papers workspace root"), ): """(Re)build the GitHub-ready workspace index at /README.md.""" from researchlink.services.workspace_index import write_workspace_index if not papers_dir.exists(): console.print(f"[red]Papers directory not found: {papers_dir}[/red]") raise typer.Exit(1) dest = write_workspace_index(papers_dir) console.print(f"[green]Wrote workspace index:[/green] {dest}") @app.command() def serve( host: str = typer.Option("127.0.0.1", "--host", help="Bind host"), port: int = typer.Option(7860, "--port", "-p", help="Bind port"), reload: bool = typer.Option(False, "--reload", help="Auto-reload on code changes (dev mode)"), ): """ Start the ResearchLink AI web server. Opens a browser-based UI for paper ingestion, provider management, and results viewing. Example: researchlink serve --port 7860 """ try: import uvicorn except ImportError: console.print("[red]uvicorn not installed. Run: pip install uvicorn[standard][/red]") raise typer.Exit(1) console.print( Panel( f"[bold cyan]{__product__}[/bold cyan] v{__version__}\n" f"Web UI → [link=http://{host}:{port}]http://{host}:{port}[/link]\n" f"Settings → [link=http://{host}:{port}/settings]http://{host}:{port}/settings[/link]", title="ResearchLink AI — Web Server", ) ) uvicorn.run( "researchlink.api.app:app", host=host, port=port, reload=reload, log_level="info", ) if __name__ == "__main__": app()