MHamdan's picture
Deploy Research-Link-AI (Docker Space, offline demo)
a753e74 verified
Raw
History Blame Contribute Delete
15.9 kB
"""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/<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 ""))
@app.command()
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.")
@app.command()
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}")
@app.command()
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}")
@app.command()
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)
@app.command()
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}")
@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()