|
| 1 | +"""Review command -- generate comprehensive AI paper review.""" |
| 2 | + |
| 3 | +from pathlib import Path |
| 4 | +from typing import Optional |
| 5 | + |
| 6 | +import typer |
| 7 | +from rich.progress import ( |
| 8 | + BarColumn, |
| 9 | + Progress, |
| 10 | + SpinnerColumn, |
| 11 | + TaskProgressColumn, |
| 12 | + TextColumn, |
| 13 | + TimeElapsedColumn, |
| 14 | +) |
| 15 | + |
| 16 | +from ..core.models import Language, ReviewSectionType |
| 17 | +from ..services.paper_service import PaperService |
| 18 | +from ..services.review_service import PaperReviewService |
| 19 | +from ..services.settings_service import SettingsService |
| 20 | +from ..utils.display import console, print_error, print_info, print_success |
| 21 | + |
| 22 | +# Human-readable names for review sections |
| 23 | +_SECTION_NAMES: dict[ReviewSectionType, str] = { |
| 24 | + ReviewSectionType.EXECUTIVE_SUMMARY: "Executive Summary", |
| 25 | + ReviewSectionType.KEY_CONTRIBUTIONS: "Key Contributions", |
| 26 | + ReviewSectionType.SECTION_SUMMARIES: "Section Summaries", |
| 27 | + ReviewSectionType.METHODOLOGY: "Methodology Analysis", |
| 28 | + ReviewSectionType.MATH_FORMULATIONS: "Math Formulations", |
| 29 | + ReviewSectionType.FIGURES: "Figure Descriptions", |
| 30 | + ReviewSectionType.TABLES: "Table Descriptions", |
| 31 | + ReviewSectionType.EXPERIMENTAL_RESULTS: "Experimental Results", |
| 32 | + ReviewSectionType.STRENGTHS_WEAKNESSES: "Strengths & Weaknesses", |
| 33 | + ReviewSectionType.RELATED_WORK: "Related Work", |
| 34 | + ReviewSectionType.GLOSSARY: "Glossary", |
| 35 | + ReviewSectionType.QUESTIONS: "Questions", |
| 36 | +} |
| 37 | + |
| 38 | + |
| 39 | +def review( |
| 40 | + arxiv_id: str = typer.Argument(..., help="arXiv ID (e.g., 2401.00001)"), |
| 41 | + output: Optional[Path] = typer.Option( |
| 42 | + None, "--output", "-o", help="Save review to file (default: print to console)" |
| 43 | + ), |
| 44 | + force: bool = typer.Option( |
| 45 | + False, "--force", "-f", help="Regenerate all sections (ignore cache)" |
| 46 | + ), |
| 47 | + translate: bool = typer.Option( |
| 48 | + False, "--translate", "-t", help="Translate review to configured language" |
| 49 | + ), |
| 50 | + language: Optional[str] = typer.Option( |
| 51 | + None, "--language", "-L", help="Target language code (e.g., 'ko')" |
| 52 | + ), |
| 53 | + no_full_text: bool = typer.Option( |
| 54 | + False, "--no-full-text", help="Skip full text extraction, use abstract only" |
| 55 | + ), |
| 56 | + status: bool = typer.Option( |
| 57 | + False, "--status", "-s", help="Show cached review status without generating" |
| 58 | + ), |
| 59 | + delete: bool = typer.Option( |
| 60 | + False, "--delete", help="Delete cached review for this paper" |
| 61 | + ), |
| 62 | +): |
| 63 | + """Generate a comprehensive AI review of an arXiv paper. |
| 64 | +
|
| 65 | + Fetches the full paper text when possible (via arxiv-doc-builder), |
| 66 | + then analyzes each section with AI to produce a detailed Markdown review. |
| 67 | + Reviews are cached section-by-section -- interrupted reviews resume |
| 68 | + automatically. |
| 69 | +
|
| 70 | + Examples: |
| 71 | + axp review 2401.00001 |
| 72 | + axp review 2401.00001 -o review.md |
| 73 | + axp review 2401.00001 --force --translate |
| 74 | + axp review 2401.00001 --status |
| 75 | + """ |
| 76 | + review_service = PaperReviewService() |
| 77 | + |
| 78 | + # Handle --delete |
| 79 | + if delete: |
| 80 | + if review_service.delete_review(arxiv_id): |
| 81 | + print_success(f"Deleted cached review for {arxiv_id}") |
| 82 | + else: |
| 83 | + print_info(f"No cached review found for {arxiv_id}") |
| 84 | + return |
| 85 | + |
| 86 | + # Handle --status |
| 87 | + if status: |
| 88 | + cached = review_service.get_cached_review(arxiv_id) |
| 89 | + if cached is None: |
| 90 | + print_info(f"No cached review for {arxiv_id}") |
| 91 | + else: |
| 92 | + total = len(ReviewSectionType) |
| 93 | + done = len(cached.sections) |
| 94 | + console.print(f"[bold]Review status for {arxiv_id}[/bold]") |
| 95 | + console.print(f"Sections: {done}/{total}") |
| 96 | + for st in ReviewSectionType: |
| 97 | + if st in cached.sections: |
| 98 | + icon = "[green]\u2714[/green]" |
| 99 | + else: |
| 100 | + icon = "[dim]\u2022[/dim]" |
| 101 | + console.print( |
| 102 | + f" {icon} {_SECTION_NAMES.get(st, st.value)}" |
| 103 | + ) |
| 104 | + return |
| 105 | + |
| 106 | + # Fetch paper metadata |
| 107 | + paper_service = PaperService() |
| 108 | + |
| 109 | + with Progress( |
| 110 | + SpinnerColumn(), |
| 111 | + TextColumn("[progress.description]{task.description}"), |
| 112 | + console=console, |
| 113 | + ) as progress: |
| 114 | + progress.add_task("Fetching paper metadata...", total=None) |
| 115 | + paper = paper_service.get_paper(arxiv_id) |
| 116 | + |
| 117 | + if not paper: |
| 118 | + print_error(f"Paper not found: {arxiv_id}") |
| 119 | + raise typer.Exit(1) |
| 120 | + |
| 121 | + console.print(f"\n[bold]{paper.title}[/bold]") |
| 122 | + console.print(f"[dim]{', '.join(paper.authors[:5])}[/dim]\n") |
| 123 | + |
| 124 | + # If --no-full-text, skip extraction |
| 125 | + if no_full_text: |
| 126 | + review_service._extract_full_text = lambda _: None # type: ignore[assignment] |
| 127 | + |
| 128 | + # Generate review with progress bar |
| 129 | + succeeded = 0 |
| 130 | + failed = 0 |
| 131 | + |
| 132 | + with Progress( |
| 133 | + SpinnerColumn(), |
| 134 | + TextColumn("[progress.description]{task.description}"), |
| 135 | + BarColumn(), |
| 136 | + TaskProgressColumn(), |
| 137 | + TimeElapsedColumn(), |
| 138 | + console=console, |
| 139 | + ) as progress: |
| 140 | + task = progress.add_task( |
| 141 | + "Generating review...", total=len(ReviewSectionType) |
| 142 | + ) |
| 143 | + |
| 144 | + def on_start( |
| 145 | + section_type: ReviewSectionType, idx: int, total: int |
| 146 | + ) -> None: |
| 147 | + name = _SECTION_NAMES.get(section_type, section_type.value) |
| 148 | + progress.update(task, description=f"[cyan]{name}[/cyan]...") |
| 149 | + |
| 150 | + def on_complete(section_type: ReviewSectionType, success: bool) -> None: |
| 151 | + nonlocal succeeded, failed |
| 152 | + if success: |
| 153 | + succeeded += 1 |
| 154 | + else: |
| 155 | + failed += 1 |
| 156 | + progress.advance(task) |
| 157 | + |
| 158 | + paper_review = review_service.generate_review( |
| 159 | + paper=paper, |
| 160 | + force=force, |
| 161 | + on_section_start=on_start, |
| 162 | + on_section_complete=on_complete, |
| 163 | + ) |
| 164 | + |
| 165 | + if not paper_review: |
| 166 | + print_error("Review generation failed completely.") |
| 167 | + raise typer.Exit(1) |
| 168 | + |
| 169 | + # Report results |
| 170 | + print_info(f"Sections: {succeeded} succeeded, {failed} failed") |
| 171 | + if paper_review.source_type == "abstract": |
| 172 | + print_info( |
| 173 | + "Note: Full text was not available." |
| 174 | + " Review is based on abstract only." |
| 175 | + ) |
| 176 | + |
| 177 | + # Resolve language |
| 178 | + target_lang = Language.EN |
| 179 | + if translate or language: |
| 180 | + if language: |
| 181 | + try: |
| 182 | + target_lang = Language(language) |
| 183 | + except ValueError: |
| 184 | + supported = ", ".join(lang.value for lang in Language) |
| 185 | + print_error( |
| 186 | + f"Unknown language: {language}. Supported: {supported}" |
| 187 | + ) |
| 188 | + raise typer.Exit(1) |
| 189 | + else: |
| 190 | + target_lang = SettingsService().get_language() |
| 191 | + |
| 192 | + # Render markdown |
| 193 | + markdown = review_service.render_markdown(paper_review, language=target_lang) |
| 194 | + |
| 195 | + # Output |
| 196 | + if output: |
| 197 | + output.parent.mkdir(parents=True, exist_ok=True) |
| 198 | + output.write_text(markdown, encoding="utf-8") |
| 199 | + print_success(f"Review saved: {output}") |
| 200 | + else: |
| 201 | + console.print() |
| 202 | + from rich.markdown import Markdown |
| 203 | + |
| 204 | + console.print(Markdown(markdown)) |
0 commit comments