Spaces:
Runtime error
Runtime error
| """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() | |
| def main( | |
| version: bool | None = typer.Option( | |
| None, "--version", "-v", callback=version_callback, is_eager=True, | |
| help="Show version and exit." | |
| ) | |
| ): | |
| pass | |
| 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) | |
| 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 | |
| def enrich( | |
| module_dir: Path = typer.Argument(..., help="Path to a papers/<slug>/ 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 "")) | |
| def discover( | |
| module_dir: Path = typer.Argument(..., help="Path to a papers/<slug>/ 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.") | |
| def study( | |
| module_dir: Path = typer.Argument(..., help="Path to a papers/<slug>/ 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}") | |
| def review( | |
| module_dir: Path = typer.Argument(..., help="Path to a papers/<slug>/ 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}") | |
| def export( | |
| module_dir: Path = typer.Argument(..., help="Path to a papers/<slug>/ 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<!-- {name} -->\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) | |
| def index( | |
| papers_dir: Path = typer.Argument(Path("papers"), help="Papers workspace root"), | |
| ): | |
| """(Re)build the GitHub-ready workspace index at <papers_dir>/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}") | |
| 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() | |