From 802b5d5c4d0b6661dfbdc1462b2c99c5f31ebcdf Mon Sep 17 00:00:00 2001 From: Axect Date: Sat, 7 Feb 2026 14:50:47 +0800 Subject: [PATCH 1/6] Add ruff config, PyPI metadata, py.typed marker, and CHANGELOG - Add ruff linter/formatter with target-version py311 and select rules - Add PyPI classifiers, keywords, and project URLs - Add ruff to dev dependencies - Create py.typed PEP 561 marker for type checker support - Create CHANGELOG.md following Keep a Changelog format - Add .ruff_cache/ to .gitignore Co-Authored-By: Claude Opus 4.6 --- .gitignore | 3 +++ CHANGELOG.md | 23 +++++++++++++++++++++++ pyproject.toml | 35 +++++++++++++++++++++++++++++++++++ src/arxiv_explorer/py.typed | 0 uv.lock | 27 +++++++++++++++++++++++++++ 5 files changed, 88 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 src/arxiv_explorer/py.typed diff --git a/.gitignore b/.gitignore index 0fb0377..9c96b70 100644 --- a/.gitignore +++ b/.gitignore @@ -15,5 +15,8 @@ wheels/ # Claude Code local settings .claude/settings.local.json +# Linter cache +.ruff_cache/ + # Paper build outputs (arxiv-doc-builder) [0-9][0-9][0-9][0-9].[0-9]*/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0ba2908 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,23 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] - 2025-06-01 + +### Added + +- Personalized paper recommendations using TF-IDF content similarity, category matching, keyword matching, and recency scoring +- CLI interface (`axp`) with commands for daily papers, search, preferences, reading lists, notes, and export +- TUI interface (`axp tui`) with five tabs: Daily, Search, Lists, Notes, Prefs +- AI-powered summarization and translation via pluggable providers (Gemini, Claude, Codex, Ollama, OpenCode, custom) +- Reading list management with status tracking (unread, reading, completed) +- Paper notes with typed categories (general, question, insight, todo) +- Export to Markdown, JSON, and CSV formats +- SQLite-backed paper cache to reduce arXiv API calls +- Integration with [arxivterminal](https://github.com/Axect/arxivterminal) (read-only) and [arxiv-doc-builder](https://github.com/Axect/arxiv-doc-builder) +- Multi-language support (English, Korean) + +[0.1.0]: https://github.com/Axect/arXiv_explorer/releases/tag/v0.1.0 diff --git a/pyproject.toml b/pyproject.toml index 083f2b0..fd790d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,29 @@ authors = [ { name = "Axect", email = "axect.tg@proton.me" }, ] requires-python = ">=3.11" +keywords = [ + "arxiv", + "papers", + "recommendation", + "research", + "cli", + "tui", + "machine-learning", + "academic", + "science", +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: Console", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Scientific/Engineering", + "Topic :: Text Processing :: General", + "Typing :: Typed", +] dependencies = [ "typer>=0.9.0", "rich>=13.0.0", @@ -24,6 +47,9 @@ axp = "arxiv_explorer.cli.main:app" [project.urls] Homepage = "https://github.com/Axect/arXiv_explorer" Repository = "https://github.com/Axect/arXiv_explorer" +Documentation = "https://github.com/Axect/arXiv_explorer#readme" +Issues = "https://github.com/Axect/arXiv_explorer/issues" +Changelog = "https://github.com/Axect/arXiv_explorer/blob/main/CHANGELOG.md" [build-system] requires = ["hatchling"] @@ -36,4 +62,13 @@ packages = ["src/arxiv_explorer"] dev = [ "pytest>=7.4.0", "pytest-cov>=4.1.0", + "ruff>=0.8.0", ] + +[tool.ruff] +target-version = "py311" +line-length = 100 + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F", "B", "I"] +ignore = ["E501", "B008"] diff --git a/src/arxiv_explorer/py.typed b/src/arxiv_explorer/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/uv.lock b/uv.lock index d1aeafc..e2401ad 100644 --- a/uv.lock +++ b/uv.lock @@ -33,6 +33,7 @@ dependencies = [ dev = [ { name = "pytest" }, { name = "pytest-cov" }, + { name = "ruff" }, ] [package.metadata] @@ -50,6 +51,7 @@ requires-dist = [ dev = [ { name = "pytest", specifier = ">=7.4.0" }, { name = "pytest-cov", specifier = ">=4.1.0" }, + { name = "ruff", specifier = ">=0.8.0" }, ] [[package]] @@ -458,6 +460,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" }, ] +[[package]] +name = "ruff" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/39/5cee96809fbca590abea6b46c6d1c586b49663d1d2830a751cc8fc42c666/ruff-0.15.0.tar.gz", hash = "sha256:6bdea47cdbea30d40f8f8d7d69c0854ba7c15420ec75a26f463290949d7f7e9a", size = 4524893, upload-time = "2026-02-03T17:53:35.357Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/88/3fd1b0aa4b6330d6aaa63a285bc96c9f71970351579152d231ed90914586/ruff-0.15.0-py3-none-linux_armv6l.whl", hash = "sha256:aac4ebaa612a82b23d45964586f24ae9bc23ca101919f5590bdb368d74ad5455", size = 10354332, upload-time = "2026-02-03T17:52:54.892Z" }, + { url = "https://files.pythonhosted.org/packages/72/f6/62e173fbb7eb75cc29fe2576a1e20f0a46f671a2587b5f604bfb0eaf5f6f/ruff-0.15.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dcd4be7cc75cfbbca24a98d04d0b9b36a270d0833241f776b788d59f4142b14d", size = 10767189, upload-time = "2026-02-03T17:53:19.778Z" }, + { url = "https://files.pythonhosted.org/packages/99/e4/968ae17b676d1d2ff101d56dc69cf333e3a4c985e1ec23803df84fc7bf9e/ruff-0.15.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d747e3319b2bce179c7c1eaad3d884dc0a199b5f4d5187620530adf9105268ce", size = 10075384, upload-time = "2026-02-03T17:53:29.241Z" }, + { url = "https://files.pythonhosted.org/packages/a2/bf/9843c6044ab9e20af879c751487e61333ca79a2c8c3058b15722386b8cae/ruff-0.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:650bd9c56ae03102c51a5e4b554d74d825ff3abe4db22b90fd32d816c2e90621", size = 10481363, upload-time = "2026-02-03T17:52:43.332Z" }, + { url = "https://files.pythonhosted.org/packages/55/d9/4ada5ccf4cd1f532db1c8d44b6f664f2208d3d93acbeec18f82315e15193/ruff-0.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6664b7eac559e3048223a2da77769c2f92b43a6dfd4720cef42654299a599c9", size = 10187736, upload-time = "2026-02-03T17:53:00.522Z" }, + { url = "https://files.pythonhosted.org/packages/86/e2/f25eaecd446af7bb132af0a1d5b135a62971a41f5366ff41d06d25e77a91/ruff-0.15.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f811f97b0f092b35320d1556f3353bf238763420ade5d9e62ebd2b73f2ff179", size = 10968415, upload-time = "2026-02-03T17:53:15.705Z" }, + { url = "https://files.pythonhosted.org/packages/e7/dc/f06a8558d06333bf79b497d29a50c3a673d9251214e0d7ec78f90b30aa79/ruff-0.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761ec0a66680fab6454236635a39abaf14198818c8cdf691e036f4bc0f406b2d", size = 11809643, upload-time = "2026-02-03T17:53:23.031Z" }, + { url = "https://files.pythonhosted.org/packages/dd/45/0ece8db2c474ad7df13af3a6d50f76e22a09d078af63078f005057ca59eb/ruff-0.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:940f11c2604d317e797b289f4f9f3fa5555ffe4fb574b55ed006c3d9b6f0eb78", size = 11234787, upload-time = "2026-02-03T17:52:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/8a/d9/0e3a81467a120fd265658d127db648e4d3acfe3e4f6f5d4ea79fac47e587/ruff-0.15.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcbca3d40558789126da91d7ef9a7c87772ee107033db7191edefa34e2c7f1b4", size = 11112797, upload-time = "2026-02-03T17:52:49.274Z" }, + { url = "https://files.pythonhosted.org/packages/b2/cb/8c0b3b0c692683f8ff31351dfb6241047fa873a4481a76df4335a8bff716/ruff-0.15.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a121a96db1d75fa3eb39c4539e607f628920dd72ff1f7c5ee4f1b768ac62d6e", size = 11033133, upload-time = "2026-02-03T17:53:33.105Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5e/23b87370cf0f9081a8c89a753e69a4e8778805b8802ccfe175cc410e50b9/ruff-0.15.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5298d518e493061f2eabd4abd067c7e4fb89e2f63291c94332e35631c07c3662", size = 10442646, upload-time = "2026-02-03T17:53:06.278Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9a/3c94de5ce642830167e6d00b5c75aacd73e6347b4c7fc6828699b150a5ee/ruff-0.15.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afb6e603d6375ff0d6b0cee563fa21ab570fd15e65c852cb24922cef25050cf1", size = 10195750, upload-time = "2026-02-03T17:53:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/30/15/e396325080d600b436acc970848d69df9c13977942fb62bb8722d729bee8/ruff-0.15.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:77e515f6b15f828b94dc17d2b4ace334c9ddb7d9468c54b2f9ed2b9c1593ef16", size = 10676120, upload-time = "2026-02-03T17:53:09.363Z" }, + { url = "https://files.pythonhosted.org/packages/8d/c9/229a23d52a2983de1ad0fb0ee37d36e0257e6f28bfd6b498ee2c76361874/ruff-0.15.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6f6e80850a01eb13b3e42ee0ebdf6e4497151b48c35051aab51c101266d187a3", size = 11201636, upload-time = "2026-02-03T17:52:57.281Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b0/69adf22f4e24f3677208adb715c578266842e6e6a3cc77483f48dd999ede/ruff-0.15.0-py3-none-win32.whl", hash = "sha256:238a717ef803e501b6d51e0bdd0d2c6e8513fe9eec14002445134d3907cd46c3", size = 10465945, upload-time = "2026-02-03T17:53:12.591Z" }, + { url = "https://files.pythonhosted.org/packages/51/ad/f813b6e2c97e9b4598be25e94a9147b9af7e60523b0cb5d94d307c15229d/ruff-0.15.0-py3-none-win_amd64.whl", hash = "sha256:dd5e4d3301dc01de614da3cdffc33d4b1b96fb89e45721f1598e5532ccf78b18", size = 11564657, upload-time = "2026-02-03T17:52:51.893Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b0/2d823f6e77ebe560f4e397d078487e8d52c1516b331e3521bc75db4272ca/ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a", size = 10865753, upload-time = "2026-02-03T17:53:03.014Z" }, +] + [[package]] name = "scikit-learn" version = "1.8.0" From c95be57637f201cced7ac342dedd978c36d2b6b0 Mon Sep 17 00:00:00 2001 From: Axect Date: Sat, 7 Feb 2026 14:50:55 +0800 Subject: [PATCH 2/6] Fix lint issues and apply ruff formatting across codebase - Fix B904: add 'from None' to re-raised exceptions in except clauses - Fix E741: rename ambiguous variable 'l' to descriptive names - Fix F821: add missing sqlite3 import in arxiv_client - Fix F841: remove unused variable assignment in preference_service - Fix F401: remove unused imports (auto-fixed by ruff) - Fix I001: sort and organize import blocks (auto-fixed by ruff) - Apply ruff format to all source files Co-Authored-By: Claude Opus 4.6 --- src/arxiv_explorer/__init__.py | 1 + src/arxiv_explorer/cli/config.py | 23 +++--- src/arxiv_explorer/cli/daily.py | 29 +++++-- src/arxiv_explorer/cli/export.py | 71 +++++++++------- src/arxiv_explorer/cli/lists.py | 8 +- src/arxiv_explorer/cli/main.py | 9 ++- src/arxiv_explorer/cli/notes.py | 12 ++- src/arxiv_explorer/cli/preferences.py | 3 +- src/arxiv_explorer/cli/search.py | 3 +- src/arxiv_explorer/core/__init__.py | 39 ++++++--- src/arxiv_explorer/core/config.py | 4 +- src/arxiv_explorer/core/database.py | 2 +- src/arxiv_explorer/core/models.py | 13 ++- src/arxiv_explorer/services/arxiv_client.py | 16 ++-- src/arxiv_explorer/services/notes_service.py | 9 +-- src/arxiv_explorer/services/paper_service.py | 1 + .../services/preference_service.py | 39 ++++----- src/arxiv_explorer/services/providers.py | 10 ++- .../services/reading_list_service.py | 39 ++++----- src/arxiv_explorer/services/recommendation.py | 14 ++-- .../services/settings_service.py | 6 +- src/arxiv_explorer/services/summarization.py | 14 ++-- src/arxiv_explorer/services/translation.py | 5 +- src/arxiv_explorer/tui/app.py | 9 +-- src/arxiv_explorer/tui/screens/daily.py | 7 +- src/arxiv_explorer/tui/screens/list_create.py | 8 +- src/arxiv_explorer/tui/screens/list_picker.py | 12 +-- src/arxiv_explorer/tui/screens/note_input.py | 8 +- src/arxiv_explorer/tui/screens/notes.py | 10 +-- .../tui/screens/paper_detail.py | 20 ++--- src/arxiv_explorer/tui/screens/preferences.py | 16 ++-- .../tui/screens/reading_lists.py | 20 +++-- src/arxiv_explorer/tui/screens/search.py | 19 ++--- src/arxiv_explorer/tui/widgets/paper_panel.py | 8 +- src/arxiv_explorer/tui/widgets/paper_table.py | 6 +- src/arxiv_explorer/tui/workers.py | 4 +- src/arxiv_explorer/utils/display.py | 81 +++++++++++-------- 37 files changed, 331 insertions(+), 267 deletions(-) diff --git a/src/arxiv_explorer/__init__.py b/src/arxiv_explorer/__init__.py index 4982264..3bd1320 100644 --- a/src/arxiv_explorer/__init__.py +++ b/src/arxiv_explorer/__init__.py @@ -1,2 +1,3 @@ """arXiv Explorer - Personalized paper recommendation system.""" + __version__ = "0.1.0" diff --git a/src/arxiv_explorer/cli/config.py b/src/arxiv_explorer/cli/config.py index a6558ae..92e5d37 100644 --- a/src/arxiv_explorer/cli/config.py +++ b/src/arxiv_explorer/cli/config.py @@ -1,10 +1,11 @@ """AI configuration commands.""" + import typer from ..core.models import AIProviderType, Language +from ..services.providers import PROVIDERS, get_provider from ..services.settings_service import SettingsService -from ..services.providers import get_provider, PROVIDERS -from ..utils.display import print_success, print_error, print_info, console +from ..utils.display import console, print_error, print_info, print_success app = typer.Typer( help="AI provider configuration", @@ -44,7 +45,9 @@ def show(): provider = get_provider(ptype) available = provider.is_available() status = "[green]available[/green]" if available else "[red]not found[/red]" - current = " [yellow]← current[/yellow]" if ptype.value == all_settings["ai_provider"] else "" + current = ( + " [yellow]← current[/yellow]" if ptype.value == all_settings["ai_provider"] else "" + ) cli_name = provider.cli_command or "(not set)" console.print(f" {ptype.value:8s} ({cli_name}) {status}{current}") @@ -61,7 +64,7 @@ def set_provider( except ValueError: valid = ", ".join(p.value for p in AIProviderType) print_error(f"Unknown provider: {name}. Valid: {valid}") - raise typer.Exit(1) + raise typer.Exit(1) from None settings = SettingsService() if provider_type == AIProviderType.CUSTOM and not settings.get("custom_command"): @@ -106,17 +109,15 @@ def set_timeout( @app.command("set-language") def set_language( - lang: str = typer.Argument( - ..., help="Language code (en, ko)" - ), + lang: str = typer.Argument(..., help="Language code (en, ko)"), ): """Change display language.""" try: language = Language(lang.lower()) except ValueError: - valid = ", ".join(l.value for l in Language) + valid = ", ".join(lang_.value for lang_ in Language) print_error(f"Unknown language: {lang}. Valid: {valid}") - raise typer.Exit(1) + raise typer.Exit(1) from None settings = SettingsService() settings.set("language", language.value) @@ -125,9 +126,7 @@ def set_language( @app.command("set-custom") def set_custom( - template: str = typer.Argument( - ..., help="Command template (e.g. 'my-ai --prompt {prompt}')" - ), + template: str = typer.Argument(..., help="Command template (e.g. 'my-ai --prompt {prompt}')"), ): """Set custom AI command template. diff --git a/src/arxiv_explorer/cli/daily.py b/src/arxiv_explorer/cli/daily.py index 10206e3..85011b4 100644 --- a/src/arxiv_explorer/cli/daily.py +++ b/src/arxiv_explorer/cli/daily.py @@ -1,5 +1,7 @@ """Daily paper commands.""" + from typing import Optional + import typer from rich.progress import Progress, SpinnerColumn, TextColumn @@ -8,15 +10,21 @@ from ..services.summarization import SummarizationService from ..services.translation import TranslationService from ..utils.display import ( - console, print_paper_list, print_paper_detail, - print_success, print_error, print_info + console, + print_error, + print_info, + print_paper_detail, + print_paper_list, + print_success, ) def daily( days: int = typer.Option(1, "--days", "-d", help="Number of days to fetch"), summarize: bool = typer.Option(False, "--summarize", "-s", help="Generate summaries"), - detailed: bool = typer.Option(False, "--detailed", help="Generate detailed summaries (use with --summarize)"), + detailed: bool = typer.Option( + False, "--detailed", help="Generate detailed summaries (use with --summarize)" + ), limit: int = typer.Option(20, "--limit", "-l", help="Maximum number of results"), ): """Fetch today's/recent papers (personalized ranking).""" @@ -139,6 +147,7 @@ def like( if note: from .notes import _add_note + _add_note(arxiv_id, note, "general") @@ -152,9 +161,13 @@ def dislike( def show( - arxiv_id: Optional[str] = typer.Argument(None, help="arXiv ID (if omitted, shows recently liked papers)"), + arxiv_id: Optional[str] = typer.Argument( + None, help="arXiv ID (if omitted, shows recently liked papers)" + ), summary: bool = typer.Option(False, "--summary", "-s", help="Include summary"), - detailed: bool = typer.Option(False, "--detailed", "-d", help="Generate detailed summary (longer summary and analysis)"), + detailed: bool = typer.Option( + False, "--detailed", "-d", help="Generate detailed summary (longer summary and analysis)" + ), translate: bool = typer.Option(False, "--translate", "-t", help="Include translation"), ): """View paper details.""" @@ -172,7 +185,7 @@ def show( console.print(" With summary: [cyan]axp show 2602.04878v1 --summary[/cyan]") console.print(" Detailed summary: [cyan]axp show 2602.04878v1 --detailed[/cyan]") console.print(" Fetch recent papers: [cyan]axp daily --days 7[/cyan]") - console.print(" Search by keyword: [cyan]axp search \"quantum computing\"[/cyan]") + console.print(' Search by keyword: [cyan]axp search "quantum computing"[/cyan]') return console.print("[bold]Recently liked papers:[/bold]\n") @@ -191,7 +204,9 @@ def show( paper_summary = None if summary or detailed: summarizer = SummarizationService() - paper_summary = summarizer.summarize(arxiv_id, paper.title, paper.abstract, detailed=detailed) + paper_summary = summarizer.summarize( + arxiv_id, paper.title, paper.abstract, detailed=detailed + ) paper_translation = None if translate: diff --git a/src/arxiv_explorer/cli/export.py b/src/arxiv_explorer/cli/export.py index 7361421..214f355 100644 --- a/src/arxiv_explorer/cli/export.py +++ b/src/arxiv_explorer/cli/export.py @@ -1,13 +1,15 @@ """Export commands.""" + import json from pathlib import Path from typing import Optional + import typer +from ..services.paper_service import PaperService from ..services.preference_service import PreferenceService from ..services.reading_list_service import ReadingListService -from ..services.paper_service import PaperService -from ..utils.display import console, print_success, print_error, print_info +from ..utils.display import console, print_error, print_info, print_success app = typer.Typer(help="Export") @@ -36,24 +38,30 @@ def export_interesting( # Format output if format == "json": - content = json.dumps([ - { - "arxiv_id": p.arxiv_id, - "title": p.title, - "authors": p.authors, - "categories": p.categories, - "published": p.published.isoformat(), - "pdf_url": p.pdf_url, - } - for p in papers - ], indent=2, ensure_ascii=False) + content = json.dumps( + [ + { + "arxiv_id": p.arxiv_id, + "title": p.title, + "authors": p.authors, + "categories": p.categories, + "published": p.published.isoformat(), + "pdf_url": p.pdf_url, + } + for p in papers + ], + indent=2, + ensure_ascii=False, + ) elif format == "csv": lines = ["arxiv_id,title,authors,categories,published,pdf_url"] for p in papers: authors = "; ".join(p.authors) cats = "; ".join(p.categories) - lines.append(f'"{p.arxiv_id}","{p.title}","{authors}","{cats}","{p.published.date()}","{p.pdf_url}"') + lines.append( + f'"{p.arxiv_id}","{p.title}","{authors}","{cats}","{p.published.date()}","{p.pdf_url}"' + ) content = "\n".join(lines) else: # markdown @@ -101,19 +109,23 @@ def export_list( papers_with_status.append((paper, lp.status)) if format == "json": - content = json.dumps({ - "name": reading_list.name, - "description": reading_list.description, - "papers": [ - { - "arxiv_id": p.arxiv_id, - "title": p.title, - "status": s.value, - "pdf_url": p.pdf_url, - } - for p, s in papers_with_status - ] - }, indent=2, ensure_ascii=False) + content = json.dumps( + { + "name": reading_list.name, + "description": reading_list.description, + "papers": [ + { + "arxiv_id": p.arxiv_id, + "title": p.title, + "status": s.value, + "pdf_url": p.pdf_url, + } + for p, s in papers_with_status + ], + }, + indent=2, + ensure_ascii=False, + ) else: # markdown lines = [f"# {reading_list.name}\n"] @@ -140,7 +152,10 @@ def export_markdown( """Convert paper to Markdown (via arxiv-doc-builder).""" import subprocess - script_path = Path(__file__).parent.parent.parent.parent.parent / ".claude/skills/arxiv-doc-builder/scripts/convert_paper.py" + script_path = ( + Path(__file__).parent.parent.parent.parent.parent + / ".claude/skills/arxiv-doc-builder/scripts/convert_paper.py" + ) if not script_path.exists(): print_error("arxiv-doc-builder script not found.") diff --git a/src/arxiv_explorer/cli/lists.py b/src/arxiv_explorer/cli/lists.py index eceaed0..c0c04d5 100644 --- a/src/arxiv_explorer/cli/lists.py +++ b/src/arxiv_explorer/cli/lists.py @@ -1,10 +1,12 @@ """Reading list commands.""" + from typing import Optional + import typer -from ..services.reading_list_service import ReadingListService from ..core.models import ReadingStatus -from ..utils.display import console, print_success, print_error +from ..services.reading_list_service import ReadingListService +from ..utils.display import console, print_error, print_success app = typer.Typer( help="Reading list management", @@ -83,7 +85,7 @@ def status( status_enum = ReadingStatus(new_status) except ValueError: print_error(f"Invalid status: {new_status}") - raise typer.Exit(1) + raise typer.Exit(1) from None service = ReadingListService() if service.update_status(arxiv_id, status_enum): diff --git a/src/arxiv_explorer/cli/main.py b/src/arxiv_explorer/cli/main.py index 4122dbd..9bad0a5 100644 --- a/src/arxiv_explorer/cli/main.py +++ b/src/arxiv_explorer/cli/main.py @@ -1,4 +1,5 @@ """CLI main entry point.""" + import typer from rich.console import Console @@ -16,6 +17,7 @@ def version_callback(value: bool): if value: from .. import __version__ + console.print(f"arXiv Explorer v{__version__}") raise typer.Exit() @@ -23,7 +25,9 @@ def version_callback(value: bool): @app.callback() def main( version: bool = typer.Option( - None, "--version", "-v", + None, + "--version", + "-v", callback=version_callback, is_eager=True, help="Show version", @@ -35,7 +39,7 @@ def main( # Import and register subcommands -from . import daily, search, preferences, lists, notes, export, config +from . import config, daily, export, lists, notes, preferences, search # noqa: E402 app.add_typer(preferences.app, name="prefs", help="Preference management") app.add_typer(lists.app, name="list", help="Reading list management") @@ -57,6 +61,7 @@ def main( def tui(): """Launch TUI mode.""" from ..tui.app import launch_tui + launch_tui() diff --git a/src/arxiv_explorer/cli/notes.py b/src/arxiv_explorer/cli/notes.py index 19965e8..2b193ed 100644 --- a/src/arxiv_explorer/cli/notes.py +++ b/src/arxiv_explorer/cli/notes.py @@ -1,10 +1,12 @@ """Note commands.""" + from typing import Optional + import typer -from ..services.notes_service import NotesService from ..core.models import NoteType -from ..utils.display import console, print_success, print_error +from ..services.notes_service import NotesService +from ..utils.display import console, print_error, print_success app = typer.Typer( help="Paper note management", @@ -36,7 +38,9 @@ def _add_note(arxiv_id: str, content: str, note_type: str) -> None: def add( arxiv_id: str = typer.Argument(..., help="arXiv ID"), content: str = typer.Argument(..., help="Note content"), - note_type: str = typer.Option("general", "--type", "-t", help="Type (general/question/insight/todo)"), + note_type: str = typer.Option( + "general", "--type", "-t", help="Type (general/question/insight/todo)" + ), ): """Add a note to a paper.""" _add_note(arxiv_id, content, note_type) @@ -82,7 +86,7 @@ def list_notes( type_enum = NoteType(note_type) except ValueError: print_error(f"Invalid type: {note_type}") - raise typer.Exit(1) + raise typer.Exit(1) from None notes = service.get_notes(note_type=type_enum) diff --git a/src/arxiv_explorer/cli/preferences.py b/src/arxiv_explorer/cli/preferences.py index e6660e8..0a6f2a5 100644 --- a/src/arxiv_explorer/cli/preferences.py +++ b/src/arxiv_explorer/cli/preferences.py @@ -1,8 +1,9 @@ """Preference commands.""" + import typer from ..services.preference_service import PreferenceService -from ..utils.display import print_categories, print_success, print_error, console +from ..utils.display import console, print_categories, print_error, print_success app = typer.Typer( help="User preference management", diff --git a/src/arxiv_explorer/cli/search.py b/src/arxiv_explorer/cli/search.py index 73cb9de..12f30b8 100644 --- a/src/arxiv_explorer/cli/search.py +++ b/src/arxiv_explorer/cli/search.py @@ -1,8 +1,9 @@ """Search commands.""" + import typer from ..services.paper_service import PaperService -from ..utils.display import print_paper_list, print_info +from ..utils.display import print_info, print_paper_list def search( diff --git a/src/arxiv_explorer/core/__init__.py b/src/arxiv_explorer/core/__init__.py index 9e60a9f..c4be466 100644 --- a/src/arxiv_explorer/core/__init__.py +++ b/src/arxiv_explorer/core/__init__.py @@ -1,16 +1,37 @@ """Core module.""" + from .config import Config, get_config -from .database import init_db, get_connection +from .database import get_connection, init_db from .models import ( - Paper, PreferredCategory, PaperInteraction, PaperSummary, - ReadingList, ReadingListPaper, PaperNote, KeywordInterest, - RecommendedPaper, InteractionType, ReadingStatus, NoteType + InteractionType, + KeywordInterest, + NoteType, + Paper, + PaperInteraction, + PaperNote, + PaperSummary, + PreferredCategory, + ReadingList, + ReadingListPaper, + ReadingStatus, + RecommendedPaper, ) __all__ = [ - "Config", "get_config", - "init_db", "get_connection", - "Paper", "PreferredCategory", "PaperInteraction", "PaperSummary", - "ReadingList", "ReadingListPaper", "PaperNote", "KeywordInterest", - "RecommendedPaper", "InteractionType", "ReadingStatus", "NoteType", + "Config", + "get_config", + "init_db", + "get_connection", + "Paper", + "PreferredCategory", + "PaperInteraction", + "PaperSummary", + "ReadingList", + "ReadingListPaper", + "PaperNote", + "KeywordInterest", + "RecommendedPaper", + "InteractionType", + "ReadingStatus", + "NoteType", ] diff --git a/src/arxiv_explorer/core/config.py b/src/arxiv_explorer/core/config.py index 618f37e..402a4c1 100644 --- a/src/arxiv_explorer/core/config.py +++ b/src/arxiv_explorer/core/config.py @@ -1,12 +1,14 @@ """Configuration management.""" + import os -from pathlib import Path from dataclasses import dataclass +from pathlib import Path @dataclass class Config: """Application configuration.""" + # Database path db_path: Path diff --git a/src/arxiv_explorer/core/database.py b/src/arxiv_explorer/core/database.py index b0b1d0c..9a4e2dd 100644 --- a/src/arxiv_explorer/core/database.py +++ b/src/arxiv_explorer/core/database.py @@ -1,4 +1,5 @@ """Database management.""" + import sqlite3 from contextlib import contextmanager from pathlib import Path @@ -6,7 +7,6 @@ from .config import get_config - SCHEMA = """ -- Preferred categories CREATE TABLE IF NOT EXISTS preferred_categories ( diff --git a/src/arxiv_explorer/core/models.py b/src/arxiv_explorer/core/models.py index f719b5b..c70c0fb 100644 --- a/src/arxiv_explorer/core/models.py +++ b/src/arxiv_explorer/core/models.py @@ -1,8 +1,9 @@ """Data model definitions.""" + from dataclasses import dataclass, field from datetime import datetime -from typing import Optional from enum import Enum +from typing import Optional class InteractionType(str, Enum): @@ -40,6 +41,7 @@ class Language(str, Enum): @dataclass class Paper: """Paper data model.""" + arxiv_id: str title: str abstract: str @@ -57,6 +59,7 @@ def primary_category(self) -> str: @dataclass class PreferredCategory: """Preferred category.""" + id: int category: str priority: int = 1 @@ -66,6 +69,7 @@ class PreferredCategory: @dataclass class PaperInteraction: """Paper interaction record.""" + id: int arxiv_id: str interaction_type: InteractionType @@ -75,6 +79,7 @@ class PaperInteraction: @dataclass class PaperSummary: """Paper summary cache.""" + id: int arxiv_id: str summary_short: str @@ -86,6 +91,7 @@ class PaperSummary: @dataclass class PaperTranslation: """Cached paper translation.""" + id: int arxiv_id: str target_language: Language @@ -97,6 +103,7 @@ class PaperTranslation: @dataclass class ReadingList: """Reading list.""" + id: int name: str description: Optional[str] = None @@ -106,6 +113,7 @@ class ReadingList: @dataclass class ReadingListPaper: """Paper in a reading list.""" + id: int list_id: int arxiv_id: str @@ -117,6 +125,7 @@ class ReadingListPaper: @dataclass class PaperNote: """Paper note.""" + id: int arxiv_id: str note_type: NoteType @@ -127,6 +136,7 @@ class PaperNote: @dataclass class KeywordInterest: """Keyword interest.""" + id: int keyword: str weight: float = 1.0 @@ -136,6 +146,7 @@ class KeywordInterest: @dataclass class RecommendedPaper: """Recommended paper with score.""" + paper: Paper score: float summary: Optional[PaperSummary] = None diff --git a/src/arxiv_explorer/services/arxiv_client.py b/src/arxiv_explorer/services/arxiv_client.py index f11da72..35062b5 100644 --- a/src/arxiv_explorer/services/arxiv_client.py +++ b/src/arxiv_explorer/services/arxiv_client.py @@ -1,17 +1,16 @@ """arXiv API client.""" + import json +import sqlite3 import time from datetime import datetime, timedelta -from typing import Iterator -import xml.etree.ElementTree as ET -import httpx import feedparser +import httpx from ..core.database import get_connection from ..core.models import Paper - ARXIV_API_URL = "https://export.arxiv.org/api/query" RATE_LIMIT_SECONDS = 3 @@ -72,10 +71,7 @@ def fetch_by_category( papers = self.search(cat_query, max_results=max_results) # Filter by date - return [ - p for p in papers - if p.published >= start_date - ] + return [p for p in papers if p.published >= start_date] def get_paper(self, arxiv_id: str) -> Paper | None: """Get a specific paper (cache-first).""" @@ -108,9 +104,7 @@ def get_papers_cached_batch(self, arxiv_ids: list[str]) -> dict[str, Paper]: def _get_cached(self, arxiv_id: str) -> Paper | None: """Look up a single paper from DB.""" with get_connection() as conn: - row = conn.execute( - "SELECT * FROM papers WHERE arxiv_id = ?", (arxiv_id,) - ).fetchone() + row = conn.execute("SELECT * FROM papers WHERE arxiv_id = ?", (arxiv_id,)).fetchone() if row is None: return None return self._row_to_paper(row) diff --git a/src/arxiv_explorer/services/notes_service.py b/src/arxiv_explorer/services/notes_service.py index 3e4e114..cac8434 100644 --- a/src/arxiv_explorer/services/notes_service.py +++ b/src/arxiv_explorer/services/notes_service.py @@ -1,9 +1,10 @@ """Notes service.""" + from datetime import datetime from typing import Optional from ..core.database import get_connection -from ..core.models import PaperNote, NoteType +from ..core.models import NoteType, PaperNote class NotesService: @@ -20,7 +21,7 @@ def add_note( cursor = conn.execute( """INSERT INTO paper_notes (arxiv_id, note_type, content) VALUES (?, ?, ?)""", - (arxiv_id, note_type.value, content) + (arxiv_id, note_type.value, content), ) conn.commit() @@ -35,9 +36,7 @@ def add_note( def delete_note(self, note_id: int) -> bool: """Delete a note.""" with get_connection() as conn: - cursor = conn.execute( - "DELETE FROM paper_notes WHERE id = ?", (note_id,) - ) + cursor = conn.execute("DELETE FROM paper_notes WHERE id = ?", (note_id,)) conn.commit() return cursor.rowcount > 0 diff --git a/src/arxiv_explorer/services/paper_service.py b/src/arxiv_explorer/services/paper_service.py index f9739e3..1f1109a 100644 --- a/src/arxiv_explorer/services/paper_service.py +++ b/src/arxiv_explorer/services/paper_service.py @@ -1,4 +1,5 @@ """Paper service.""" + from ..core.models import Paper, RecommendedPaper from .arxiv_client import ArxivClient from .preference_service import PreferenceService diff --git a/src/arxiv_explorer/services/preference_service.py b/src/arxiv_explorer/services/preference_service.py index cba79f6..58d9a96 100644 --- a/src/arxiv_explorer/services/preference_service.py +++ b/src/arxiv_explorer/services/preference_service.py @@ -1,12 +1,9 @@ """User preference service.""" -import json + from datetime import datetime from ..core.database import get_connection -from ..core.models import ( - PreferredCategory, PaperInteraction, KeywordInterest, - InteractionType -) +from ..core.models import InteractionType, KeywordInterest, PreferredCategory class PreferenceService: @@ -17,17 +14,16 @@ class PreferenceService: def add_category(self, category: str, priority: int = 1) -> PreferredCategory: """Add a preferred category.""" with get_connection() as conn: - cursor = conn.execute( + conn.execute( """INSERT INTO preferred_categories (category, priority) VALUES (?, ?) ON CONFLICT(category) DO UPDATE SET priority = ?""", - (category, priority, priority) + (category, priority, priority), ) conn.commit() row = conn.execute( - "SELECT * FROM preferred_categories WHERE category = ?", - (category,) + "SELECT * FROM preferred_categories WHERE category = ?", (category,) ).fetchone() return PreferredCategory( @@ -41,8 +37,7 @@ def remove_category(self, category: str) -> bool: """Remove a preferred category.""" with get_connection() as conn: cursor = conn.execute( - "DELETE FROM preferred_categories WHERE category = ?", - (category,) + "DELETE FROM preferred_categories WHERE category = ?", (category,) ) conn.commit() return cursor.rowcount > 0 @@ -73,13 +68,13 @@ def mark_interesting(self, arxiv_id: str) -> None: conn.execute( """DELETE FROM paper_interactions WHERE arxiv_id = ? AND interaction_type = ?""", - (arxiv_id, InteractionType.NOT_INTERESTING.value) + (arxiv_id, InteractionType.NOT_INTERESTING.value), ) # Add interesting conn.execute( """INSERT OR REPLACE INTO paper_interactions (arxiv_id, interaction_type) VALUES (?, ?)""", - (arxiv_id, InteractionType.INTERESTING.value) + (arxiv_id, InteractionType.INTERESTING.value), ) conn.commit() @@ -90,13 +85,13 @@ def mark_not_interesting(self, arxiv_id: str) -> None: conn.execute( """DELETE FROM paper_interactions WHERE arxiv_id = ? AND interaction_type = ?""", - (arxiv_id, InteractionType.INTERESTING.value) + (arxiv_id, InteractionType.INTERESTING.value), ) # Add not_interesting conn.execute( """INSERT OR REPLACE INTO paper_interactions (arxiv_id, interaction_type) VALUES (?, ?)""", - (arxiv_id, InteractionType.NOT_INTERESTING.value) + (arxiv_id, InteractionType.NOT_INTERESTING.value), ) conn.commit() @@ -107,7 +102,7 @@ def get_interesting_papers(self) -> list[str]: """SELECT arxiv_id FROM paper_interactions WHERE interaction_type = ? ORDER BY created_at DESC""", - (InteractionType.INTERESTING.value,) + (InteractionType.INTERESTING.value,), ).fetchall() return [row["arxiv_id"] for row in rows] @@ -115,8 +110,7 @@ def get_interaction(self, arxiv_id: str) -> InteractionType | None: """Get the interaction status of a paper.""" with get_connection() as conn: row = conn.execute( - "SELECT interaction_type FROM paper_interactions WHERE arxiv_id = ?", - (arxiv_id,) + "SELECT interaction_type FROM paper_interactions WHERE arxiv_id = ?", (arxiv_id,) ).fetchone() if row: @@ -132,7 +126,7 @@ def add_keyword(self, keyword: str, weight: float = 1.0) -> None: """INSERT INTO keyword_interests (keyword, weight, source) VALUES (?, ?, 'explicit') ON CONFLICT(keyword) DO UPDATE SET weight = ?""", - (keyword.lower(), weight, weight) + (keyword.lower(), weight, weight), ) conn.commit() @@ -140,8 +134,7 @@ def remove_keyword(self, keyword: str) -> bool: """Remove a keyword interest.""" with get_connection() as conn: cursor = conn.execute( - "DELETE FROM keyword_interests WHERE keyword = ?", - (keyword.lower(),) + "DELETE FROM keyword_interests WHERE keyword = ?", (keyword.lower(),) ) conn.commit() return cursor.rowcount > 0 @@ -149,9 +142,7 @@ def remove_keyword(self, keyword: str) -> bool: def get_keywords(self) -> list[KeywordInterest]: """Get the list of keyword interests.""" with get_connection() as conn: - rows = conn.execute( - "SELECT * FROM keyword_interests ORDER BY weight DESC" - ).fetchall() + rows = conn.execute("SELECT * FROM keyword_interests ORDER BY weight DESC").fetchall() return [ KeywordInterest( diff --git a/src/arxiv_explorer/services/providers.py b/src/arxiv_explorer/services/providers.py index 1298995..91950ac 100644 --- a/src/arxiv_explorer/services/providers.py +++ b/src/arxiv_explorer/services/providers.py @@ -1,4 +1,5 @@ """AI provider abstraction.""" + import shlex import shutil import subprocess @@ -36,7 +37,10 @@ def invoke(self, prompt: str, model: str = "", timeout: int = 120) -> str | None cmd = self.build_command(prompt, model) try: result = subprocess.run( - cmd, capture_output=True, text=True, timeout=timeout, + cmd, + capture_output=True, + text=True, + timeout=timeout, ) if result.returncode != 0: return None @@ -75,6 +79,7 @@ def build_command(self, prompt: str, model: str = "") -> list[str]: class CodexProvider(AIProvider): """OpenAI provider via Codex CLI.""" + provider_type = AIProviderType.OPENAI cli_command = "codex" default_model = "" @@ -100,6 +105,7 @@ def build_command(self, prompt: str, model: str = "") -> list[str]: class OpencodeProvider(AIProvider): """OpenCode CLI provider.""" + provider_type = AIProviderType.OPENCODE cli_command = "opencode" default_model = "" @@ -115,6 +121,7 @@ def build_command(self, prompt: str, model: str = "") -> list[str]: class CustomProvider(AIProvider): """Custom CLI template provider.""" + provider_type = AIProviderType.CUSTOM cli_command = "" default_model = "" @@ -159,6 +166,7 @@ def get_provider(provider_type: AIProviderType) -> AIProvider: provider = PROVIDERS[provider_type] if provider_type == AIProviderType.CUSTOM: from .settings_service import SettingsService + template = SettingsService().get("custom_command") provider.configure(template) return provider diff --git a/src/arxiv_explorer/services/reading_list_service.py b/src/arxiv_explorer/services/reading_list_service.py index cefa40b..bcb9e34 100644 --- a/src/arxiv_explorer/services/reading_list_service.py +++ b/src/arxiv_explorer/services/reading_list_service.py @@ -1,4 +1,5 @@ """Reading list service.""" + from datetime import datetime from typing import Optional @@ -13,14 +14,11 @@ def create_list(self, name: str, description: Optional[str] = None) -> ReadingLi """Create a reading list.""" with get_connection() as conn: conn.execute( - "INSERT INTO reading_lists (name, description) VALUES (?, ?)", - (name, description) + "INSERT INTO reading_lists (name, description) VALUES (?, ?)", (name, description) ) conn.commit() - row = conn.execute( - "SELECT * FROM reading_lists WHERE name = ?", (name,) - ).fetchone() + row = conn.execute("SELECT * FROM reading_lists WHERE name = ?", (name,)).fetchone() return ReadingList( id=row["id"], @@ -32,18 +30,14 @@ def create_list(self, name: str, description: Optional[str] = None) -> ReadingLi def delete_list(self, name: str) -> bool: """Delete a reading list.""" with get_connection() as conn: - cursor = conn.execute( - "DELETE FROM reading_lists WHERE name = ?", (name,) - ) + cursor = conn.execute("DELETE FROM reading_lists WHERE name = ?", (name,)) conn.commit() return cursor.rowcount > 0 def get_list(self, name: str) -> Optional[ReadingList]: """Get a reading list by name.""" with get_connection() as conn: - row = conn.execute( - "SELECT * FROM reading_lists WHERE name = ?", (name,) - ).fetchone() + row = conn.execute("SELECT * FROM reading_lists WHERE name = ?", (name,)).fetchone() if row: return ReadingList( @@ -57,9 +51,7 @@ def get_list(self, name: str) -> Optional[ReadingList]: def get_all_lists(self) -> list[ReadingList]: """Get all reading lists.""" with get_connection() as conn: - rows = conn.execute( - "SELECT * FROM reading_lists ORDER BY created_at DESC" - ).fetchall() + rows = conn.execute("SELECT * FROM reading_lists ORDER BY created_at DESC").fetchall() return [ ReadingList( @@ -79,15 +71,18 @@ def add_paper(self, list_name: str, arxiv_id: str) -> bool: with get_connection() as conn: # Get the maximum position - max_pos = conn.execute( - "SELECT MAX(position) FROM reading_list_papers WHERE list_id = ?", - (reading_list.id,) - ).fetchone()[0] or 0 + max_pos = ( + conn.execute( + "SELECT MAX(position) FROM reading_list_papers WHERE list_id = ?", + (reading_list.id,), + ).fetchone()[0] + or 0 + ) conn.execute( """INSERT OR IGNORE INTO reading_list_papers (list_id, arxiv_id, position) VALUES (?, ?, ?)""", - (reading_list.id, arxiv_id, max_pos + 1) + (reading_list.id, arxiv_id, max_pos + 1), ) conn.commit() return True @@ -101,7 +96,7 @@ def remove_paper(self, list_name: str, arxiv_id: str) -> bool: with get_connection() as conn: cursor = conn.execute( "DELETE FROM reading_list_papers WHERE list_id = ? AND arxiv_id = ?", - (reading_list.id, arxiv_id) + (reading_list.id, arxiv_id), ) conn.commit() return cursor.rowcount > 0 @@ -111,7 +106,7 @@ def update_status(self, arxiv_id: str, status: ReadingStatus) -> bool: with get_connection() as conn: cursor = conn.execute( "UPDATE reading_list_papers SET status = ? WHERE arxiv_id = ?", - (status.value, arxiv_id) + (status.value, arxiv_id), ) conn.commit() return cursor.rowcount > 0 @@ -126,7 +121,7 @@ def get_papers(self, list_name: str) -> list[ReadingListPaper]: rows = conn.execute( """SELECT * FROM reading_list_papers WHERE list_id = ? ORDER BY position""", - (reading_list.id,) + (reading_list.id,), ).fetchall() return [ diff --git a/src/arxiv_explorer/services/recommendation.py b/src/arxiv_explorer/services/recommendation.py index 9794622..149c49c 100644 --- a/src/arxiv_explorer/services/recommendation.py +++ b/src/arxiv_explorer/services/recommendation.py @@ -1,11 +1,13 @@ """Recommendation engine.""" + from datetime import datetime + import numpy as np from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity -from ..core.models import Paper, RecommendedPaper, PreferredCategory, KeywordInterest from ..core.config import get_config +from ..core.models import KeywordInterest, Paper, PreferredCategory, RecommendedPaper class RecommendationEngine: @@ -31,10 +33,7 @@ def build_user_profile( return None # Combine paper text - documents = [ - f"{p.title} {p.abstract}" - for p in liked_papers - ] + documents = [f"{p.title} {p.abstract}" for p in liked_papers] # Compute TF-IDF vectors if not self._is_fitted: @@ -71,10 +70,7 @@ def score_papers( if user_profile is not None and self._is_fitted: doc = f"{paper.title} {paper.abstract}" paper_vector = self.vectorizer.transform([doc]) - content_sim = cosine_similarity( - user_profile.reshape(1, -1), - paper_vector - )[0, 0] + content_sim = cosine_similarity(user_profile.reshape(1, -1), paper_vector)[0, 0] score += content_sim * config.content_weight # 2. Category matching diff --git a/src/arxiv_explorer/services/settings_service.py b/src/arxiv_explorer/services/settings_service.py index 72d47f5..275da0b 100644 --- a/src/arxiv_explorer/services/settings_service.py +++ b/src/arxiv_explorer/services/settings_service.py @@ -1,8 +1,8 @@ """App settings service.""" + from ..core.database import get_connection from ..core.models import AIProviderType, Language - DEFAULTS: dict[str, str] = { "ai_provider": AIProviderType.GEMINI.value, "ai_model": "", @@ -18,9 +18,7 @@ class SettingsService: def get(self, key: str) -> str: """Get a setting value (returns default if not found).""" with get_connection() as conn: - row = conn.execute( - "SELECT value FROM app_settings WHERE key = ?", (key,) - ).fetchone() + row = conn.execute("SELECT value FROM app_settings WHERE key = ?", (key,)).fetchone() if row: return row["value"] return DEFAULTS.get(key, "") diff --git a/src/arxiv_explorer/services/summarization.py b/src/arxiv_explorer/services/summarization.py index 541d658..fc726cb 100644 --- a/src/arxiv_explorer/services/summarization.py +++ b/src/arxiv_explorer/services/summarization.py @@ -1,17 +1,20 @@ """Summarization service using AI providers.""" + import json from datetime import datetime from ..core.database import get_connection from ..core.models import PaperSummary -from .settings_service import SettingsService from .providers import get_provider +from .settings_service import SettingsService class SummarizationService: """Paper summarization using AI CLI providers.""" - def summarize(self, arxiv_id: str, title: str, abstract: str, detailed: bool = False) -> PaperSummary | None: + def summarize( + self, arxiv_id: str, title: str, abstract: str, detailed: bool = False + ) -> PaperSummary | None: """Generate a paper summary.""" # Check cache cached = self._get_cached(arxiv_id) @@ -80,6 +83,7 @@ def summarize(self, arxiv_id: str, title: str, abstract: str, detailed: bool = F except json.JSONDecodeError as e: # JSON parse failure - print debug info and return None import sys + if "--verbose" in sys.argv or "-v" in sys.argv: print(f"\nSummary generation failed ({arxiv_id}): JSON parse error") print(f"Error: {e}") @@ -103,6 +107,7 @@ def summarize(self, arxiv_id: str, title: str, abstract: str, detailed: bool = F except Exception as e: # Other error - fail silently import sys + if "--verbose" in sys.argv or "-v" in sys.argv: print(f"\nError during summary generation ({arxiv_id}): {e}") return None @@ -111,8 +116,7 @@ def _get_cached(self, arxiv_id: str) -> PaperSummary | None: """Retrieve a cached summary.""" with get_connection() as conn: row = conn.execute( - "SELECT * FROM paper_summaries WHERE arxiv_id = ?", - (arxiv_id,) + "SELECT * FROM paper_summaries WHERE arxiv_id = ?", (arxiv_id,) ).fetchone() if row: @@ -138,6 +142,6 @@ def _save_cache(self, summary: PaperSummary) -> None: summary.summary_short, summary.summary_detailed, json.dumps(summary.key_findings), - ) + ), ) conn.commit() diff --git a/src/arxiv_explorer/services/translation.py b/src/arxiv_explorer/services/translation.py index 14dbdfb..365b11a 100644 --- a/src/arxiv_explorer/services/translation.py +++ b/src/arxiv_explorer/services/translation.py @@ -1,11 +1,12 @@ """Translation service using AI providers.""" + import json from datetime import datetime from ..core.database import get_connection from ..core.models import Language, PaperTranslation -from .settings_service import SettingsService from .providers import get_provider +from .settings_service import SettingsService # Language display names for prompts _LANG_NAMES: dict[Language, str] = { @@ -90,6 +91,7 @@ def translate( data = json.loads(output) except json.JSONDecodeError as e: import sys + if "--verbose" in sys.argv or "-v" in sys.argv: print(f"\nTranslation failed ({arxiv_id}): JSON parse error") print(f"Error: {e}") @@ -110,6 +112,7 @@ def translate( except Exception as e: import sys + if "--verbose" in sys.argv or "-v" in sys.argv: print(f"\nTranslation error ({arxiv_id}): {e}") return None diff --git a/src/arxiv_explorer/tui/app.py b/src/arxiv_explorer/tui/app.py index 1ba9644..e646e99 100644 --- a/src/arxiv_explorer/tui/app.py +++ b/src/arxiv_explorer/tui/app.py @@ -6,15 +6,14 @@ from textual.app import App, ComposeResult from textual.binding import Binding -from textual.widgets import Header, Footer, TabbedContent, TabPane +from textual.widgets import Footer, Header, TabbedContent, TabPane -from .workers import ServiceBridge from .screens.daily import DailyPane -from .screens.search import SearchPane -from .screens.reading_lists import ReadingListsPane from .screens.notes import NotesPane from .screens.preferences import PreferencesPane - +from .screens.reading_lists import ReadingListsPane +from .screens.search import SearchPane +from .workers import ServiceBridge CSS_PATH = Path(__file__).parent / "styles" / "app.tcss" diff --git a/src/arxiv_explorer/tui/screens/daily.py b/src/arxiv_explorer/tui/screens/daily.py index 2bcd4d5..9723309 100644 --- a/src/arxiv_explorer/tui/screens/daily.py +++ b/src/arxiv_explorer/tui/screens/daily.py @@ -5,11 +5,11 @@ from textual import on, work from textual.app import ComposeResult from textual.containers import Horizontal, Vertical -from textual.widgets import Static, Select, Button +from textual.widgets import Button, Select, Static -from ..widgets.paper_table import PaperTable -from ..widgets.paper_panel import PaperPanel from ...core.models import RecommendedPaper +from ..widgets.paper_panel import PaperPanel +from ..widgets.paper_table import PaperTable class DailyPane(Vertical): @@ -131,6 +131,7 @@ def _on_paper_highlighted(self, event: PaperTable.PaperHighlighted) -> None: @on(PaperTable.PaperSelected) def _on_paper_selected(self, event: PaperTable.PaperSelected) -> None: from .paper_detail import PaperDetailScreen + self.app.push_screen(PaperDetailScreen(event.paper)) def _fetch_papers(self) -> None: diff --git a/src/arxiv_explorer/tui/screens/list_create.py b/src/arxiv_explorer/tui/screens/list_create.py index d730dae..52328a6 100644 --- a/src/arxiv_explorer/tui/screens/list_create.py +++ b/src/arxiv_explorer/tui/screens/list_create.py @@ -4,9 +4,9 @@ from textual import on, work from textual.app import ComposeResult -from textual.containers import Vertical, Horizontal +from textual.containers import Horizontal, Vertical from textual.screen import ModalScreen -from textual.widgets import Static, Input, Button +from textual.widgets import Button, Input, Static class ListCreateScreen(ModalScreen): @@ -55,6 +55,4 @@ def _do_create(self, name: str, desc: str | None) -> None: self.app.call_from_thread(self.app.notify, f"List created: {name}") self.app.call_from_thread(self.dismiss, True) # True = creation success result except Exception as e: - self.app.call_from_thread( - self.app.notify, f"Creation failed: {e}", severity="error" - ) + self.app.call_from_thread(self.app.notify, f"Creation failed: {e}", severity="error") diff --git a/src/arxiv_explorer/tui/screens/list_picker.py b/src/arxiv_explorer/tui/screens/list_picker.py index 1714e59..8479782 100644 --- a/src/arxiv_explorer/tui/screens/list_picker.py +++ b/src/arxiv_explorer/tui/screens/list_picker.py @@ -4,9 +4,9 @@ from textual import on, work from textual.app import ComposeResult -from textual.containers import Vertical, Horizontal +from textual.containers import Horizontal, Vertical from textual.screen import ModalScreen -from textual.widgets import Static, ListView, ListItem, Label, Button +from textual.widgets import Button, Label, ListItem, ListView, Static class ListPickerScreen(ModalScreen): @@ -44,9 +44,7 @@ def _populate_lists(self, lists) -> None: self._lists = lists for i, rl in enumerate(lists): desc = f" — {rl.description}" if rl.description else "" - view.append( - ListItem(Label(f"{rl.name}{desc}"), id=f"lp-{i}") - ) + view.append(ListItem(Label(f"{rl.name}{desc}"), id=f"lp-{i}")) @on(ListView.Selected, "#list-picker-view") def _on_list_selected(self, event: ListView.Selected) -> None: @@ -70,7 +68,5 @@ def _do_add(self, list_name: str) -> None: f"Added {self.arxiv_id} to '{list_name}'", ) else: - self.app.call_from_thread( - self.app.notify, "Add failed", severity="warning" - ) + self.app.call_from_thread(self.app.notify, "Add failed", severity="warning") self.app.call_from_thread(self.dismiss) diff --git a/src/arxiv_explorer/tui/screens/note_input.py b/src/arxiv_explorer/tui/screens/note_input.py index d38311d..326eeae 100644 --- a/src/arxiv_explorer/tui/screens/note_input.py +++ b/src/arxiv_explorer/tui/screens/note_input.py @@ -4,9 +4,9 @@ from textual import on, work from textual.app import ComposeResult -from textual.containers import Vertical, Horizontal +from textual.containers import Horizontal, Vertical from textual.screen import ModalScreen -from textual.widgets import Static, Input, Select, Button +from textual.widgets import Button, Input, Select, Static from ...core.models import NoteType @@ -53,7 +53,9 @@ def _save_note(self) -> None: return note_type_select = self.query_one("#note-type", Select) - note_type = note_type_select.value if note_type_select.value != Select.BLANK else NoteType.GENERAL + note_type = ( + note_type_select.value if note_type_select.value != Select.BLANK else NoteType.GENERAL + ) self._do_save(content, note_type) @work(thread=True, group="note-save") diff --git a/src/arxiv_explorer/tui/screens/notes.py b/src/arxiv_explorer/tui/screens/notes.py index 43d74da..5db1eaf 100644 --- a/src/arxiv_explorer/tui/screens/notes.py +++ b/src/arxiv_explorer/tui/screens/notes.py @@ -5,7 +5,7 @@ from textual import on, work from textual.app import ComposeResult from textual.containers import Horizontal, Vertical -from textual.widgets import Static, ListView, ListItem, Label, DataTable +from textual.widgets import DataTable, Label, ListItem, ListView, Static from ...core.models import PaperNote @@ -143,9 +143,7 @@ def _populate_notes(self, notes: list[PaperNote]) -> None: def _show_notes_for_paper(self, arxiv_id: str) -> None: notes = self._paper_groups.get(arxiv_id, []) - self.query_one("#notes-right-title", Static).update( - f"{arxiv_id} — {len(notes)} note(s)" - ) + self.query_one("#notes-right-title", Static).update(f"{arxiv_id} — {len(notes)} note(s)") table = self.query_one("#notes-detail-table", DataTable) table.clear() for i, n in enumerate(notes, 1): @@ -160,6 +158,4 @@ def _do_delete_note(self, note_id: int) -> None: self.app.call_from_thread(self.app.notify, "Note deleted") self._load_notes() else: - self.app.call_from_thread( - self.app.notify, "Delete failed", severity="warning" - ) + self.app.call_from_thread(self.app.notify, "Delete failed", severity="warning") diff --git a/src/arxiv_explorer/tui/screens/paper_detail.py b/src/arxiv_explorer/tui/screens/paper_detail.py index 2584134..0ac60f1 100644 --- a/src/arxiv_explorer/tui/screens/paper_detail.py +++ b/src/arxiv_explorer/tui/screens/paper_detail.py @@ -4,11 +4,11 @@ from textual import on, work from textual.app import ComposeResult -from textual.containers import Vertical, Horizontal, VerticalScroll +from textual.containers import Horizontal, Vertical, VerticalScroll from textual.screen import ModalScreen -from textual.widgets import Static, Button +from textual.widgets import Button, Static -from ...core.models import RecommendedPaper, PaperSummary, PaperTranslation +from ...core.models import PaperSummary, PaperTranslation, RecommendedPaper class PaperDetailScreen(ModalScreen): @@ -127,9 +127,7 @@ def action_like(self) -> None: @work(thread=True, group="detail-interaction") def _do_like(self) -> None: self.app.bridge.preferences.mark_interesting(self.rec.paper.arxiv_id) - self.app.call_from_thread( - self.app.notify, f"Liked {self.rec.paper.arxiv_id}" - ) + self.app.call_from_thread(self.app.notify, f"Liked {self.rec.paper.arxiv_id}") def action_dislike(self) -> None: self._do_dislike() @@ -137,9 +135,7 @@ def action_dislike(self) -> None: @work(thread=True, group="detail-interaction") def _do_dislike(self) -> None: self.app.bridge.preferences.mark_not_interesting(self.rec.paper.arxiv_id) - self.app.call_from_thread( - self.app.notify, f"Disliked {self.rec.paper.arxiv_id}" - ) + self.app.call_from_thread(self.app.notify, f"Disliked {self.rec.paper.arxiv_id}") def action_summarize(self) -> None: self.app.notify("Generating summary...") @@ -161,6 +157,7 @@ def _do_summarize(self) -> None: def action_add_note(self) -> None: from .note_input import NoteInputScreen + self.app.push_screen(NoteInputScreen(self.rec.paper.arxiv_id)) def action_translate(self) -> None: @@ -177,9 +174,7 @@ def _do_translate(self) -> None: self.app.call_from_thread(self._render_translation, translation) self.app.call_from_thread(self.app.notify, "Translation complete") else: - self.app.call_from_thread( - self.app.notify, "Translation failed", severity="warning" - ) + self.app.call_from_thread(self.app.notify, "Translation failed", severity="warning") def _render_translation(self, translation: PaperTranslation) -> None: lines = [ @@ -192,4 +187,5 @@ def _render_translation(self, translation: PaperTranslation) -> None: def action_add_to_list(self) -> None: from .list_picker import ListPickerScreen + self.app.push_screen(ListPickerScreen(self.rec.paper.arxiv_id)) diff --git a/src/arxiv_explorer/tui/screens/preferences.py b/src/arxiv_explorer/tui/screens/preferences.py index 938ce56..8394793 100644 --- a/src/arxiv_explorer/tui/screens/preferences.py +++ b/src/arxiv_explorer/tui/screens/preferences.py @@ -5,9 +5,9 @@ from textual import on, work from textual.app import ComposeResult from textual.containers import Horizontal, Vertical -from textual.widgets import Static, DataTable, Input, Button, Select +from textual.widgets import Button, DataTable, Input, Select, Static -from ...core.models import PreferredCategory, KeywordInterest, AIProviderType, Language +from ...core.models import AIProviderType, KeywordInterest, Language, PreferredCategory from ...services.providers import get_provider @@ -103,7 +103,9 @@ def compose(self) -> ComposeResult: yield DataTable(id="cat-table", cursor_type="row", zebra_stripes=True) with Horizontal(classes="pref-input-row"): yield Input(placeholder="Category (e.g. cs.AI)", id="cat-input") - yield Input(placeholder="Priority", id="cat-priority", type="integer", value="1") + yield Input( + placeholder="Priority", id="cat-priority", type="integer", value="1" + ) yield Button("+", id="cat-add", variant="primary") yield Button("Del", id="cat-del", variant="error") @@ -130,7 +132,7 @@ def compose(self) -> ComposeResult: ) yield Static("Lang:", classes="config-label") yield Select( - [(l.value, l.value) for l in Language], + [(lang.value, lang.value) for lang in Language], id="language-select", prompt="Language", ) @@ -293,7 +295,9 @@ def _load_ai_provider(self) -> None: current = self.app.bridge.settings.get_provider() current_lang = self.app.bridge.settings.get_language() status_text = self._build_status_text() - self.app.call_from_thread(self._apply_loaded_settings, current.value, current_lang.value, status_text) + self.app.call_from_thread( + self._apply_loaded_settings, current.value, current_lang.value, status_text + ) def _apply_loaded_settings(self, provider_val: str, lang_val: str, status_text: str) -> None: with self.prevent(Select.Changed): @@ -306,7 +310,7 @@ def _build_status_text(self) -> str: current = settings.get_provider() provider = get_provider(current) available = provider.is_available() - avail_str = "[green]available[/green]" if available else f"[red]not found[/red]" + avail_str = "[green]available[/green]" if available else "[red]not found[/red]" model = settings.get_model() or "(default)" lang = settings.get_language() return ( diff --git a/src/arxiv_explorer/tui/screens/reading_lists.py b/src/arxiv_explorer/tui/screens/reading_lists.py index 8402a3f..f1b4d86 100644 --- a/src/arxiv_explorer/tui/screens/reading_lists.py +++ b/src/arxiv_explorer/tui/screens/reading_lists.py @@ -5,7 +5,7 @@ from textual import on, work from textual.app import ComposeResult from textual.containers import Horizontal, Vertical -from textual.widgets import Static, ListView, ListItem, Label, DataTable, Button, Select +from textual.widgets import Button, DataTable, Label, ListItem, ListView, Select, Static from ...core.models import ReadingList, ReadingListPaper, ReadingStatus @@ -123,9 +123,11 @@ def action_refresh(self) -> None: def action_create_list(self) -> None: from .list_create import ListCreateScreen + def on_dismiss(result) -> None: if result: self._load_lists() + self.app.push_screen(ListCreateScreen(), callback=on_dismiss) def action_delete_item(self) -> None: @@ -171,9 +173,7 @@ def _populate_lists(self, lists: list[ReadingList]) -> None: view.clear() for i, rl in enumerate(lists): desc = f" ({rl.description})" if rl.description else "" - view.append( - ListItem(Label(f"{rl.name}{desc}"), id=f"rll-{i}") - ) + view.append(ListItem(Label(f"{rl.name}{desc}"), id=f"rll-{i}")) if not lists: self.query_one("#rl-right-title", Static).update("No lists — press [c] to create") @@ -205,9 +205,7 @@ def _do_delete_list(self, name: str) -> None: self._current_list = None self._load_lists() else: - self.app.call_from_thread( - self.app.notify, "Delete failed", severity="warning" - ) + self.app.call_from_thread(self.app.notify, "Delete failed", severity="warning") def _change_status(self) -> None: if not self._current_list or not self._papers: @@ -226,15 +224,15 @@ def _change_status(self) -> None: return status_select = self.query_one("#rl-status-select", Select) - status = status_select.value if status_select.value != Select.BLANK else ReadingStatus.UNREAD + status = ( + status_select.value if status_select.value != Select.BLANK else ReadingStatus.UNREAD + ) self._do_change_status(paper.arxiv_id, status) @work(thread=True, group="rl-status") def _do_change_status(self, arxiv_id: str, status: ReadingStatus) -> None: self.app.bridge.reading_lists.update_status(arxiv_id, status) - self.app.call_from_thread( - self.app.notify, f"{arxiv_id} → {status.value}" - ) + self.app.call_from_thread(self.app.notify, f"{arxiv_id} → {status.value}") self._load_papers() def _remove_current_paper(self) -> None: diff --git a/src/arxiv_explorer/tui/screens/search.py b/src/arxiv_explorer/tui/screens/search.py index e0965d6..0e60118 100644 --- a/src/arxiv_explorer/tui/screens/search.py +++ b/src/arxiv_explorer/tui/screens/search.py @@ -5,11 +5,11 @@ from textual import on, work from textual.app import ComposeResult from textual.containers import Horizontal, Vertical -from textual.widgets import Static, Input, Button +from textual.widgets import Button, Input, Static -from ..widgets.paper_table import PaperTable -from ..widgets.paper_panel import PaperPanel from ...core.models import RecommendedPaper +from ..widgets.paper_panel import PaperPanel +from ..widgets.paper_table import PaperTable class SearchPane(Vertical): @@ -129,6 +129,7 @@ def _on_paper_highlighted(self, event: PaperTable.PaperHighlighted) -> None: @on(PaperTable.PaperSelected) def _on_paper_selected(self, event: PaperTable.PaperSelected) -> None: from .paper_detail import PaperDetailScreen + self.app.push_screen(PaperDetailScreen(event.paper)) def _run_search(self, query: str) -> None: @@ -185,9 +186,7 @@ def action_like(self) -> None: @work(thread=True, group="s-interaction") def _do_like(self, rec: RecommendedPaper) -> None: self.app.bridge.preferences.mark_interesting(rec.paper.arxiv_id) - self.app.call_from_thread( - self.app.notify, f"Liked {rec.paper.arxiv_id}" - ) + self.app.call_from_thread(self.app.notify, f"Liked {rec.paper.arxiv_id}") def action_dislike(self) -> None: rec = self._get_current() @@ -198,9 +197,7 @@ def action_dislike(self) -> None: @work(thread=True, group="s-interaction") def _do_dislike(self, rec: RecommendedPaper) -> None: self.app.bridge.preferences.mark_not_interesting(rec.paper.arxiv_id) - self.app.call_from_thread( - self.app.notify, f"Disliked {rec.paper.arxiv_id}" - ) + self.app.call_from_thread(self.app.notify, f"Disliked {rec.paper.arxiv_id}") def action_summarize(self) -> None: rec = self._get_current() @@ -245,9 +242,7 @@ def _do_translate(self, rec: RecommendedPaper) -> None: self.app.call_from_thread(self._show_translation, rec, translation) else: self.app.call_from_thread(self._set_status, "Translation failed") - self.app.call_from_thread( - self.app.notify, "Translation failed", severity="warning" - ) + self.app.call_from_thread(self.app.notify, "Translation failed", severity="warning") def _show_translation(self, rec, translation) -> None: panel = self.query_one("#search-panel", PaperPanel) diff --git a/src/arxiv_explorer/tui/widgets/paper_panel.py b/src/arxiv_explorer/tui/widgets/paper_panel.py index fe61e0b..8245911 100644 --- a/src/arxiv_explorer/tui/widgets/paper_panel.py +++ b/src/arxiv_explorer/tui/widgets/paper_panel.py @@ -4,9 +4,9 @@ from textual.app import ComposeResult from textual.containers import VerticalScroll -from textual.widgets import Static, Markdown +from textual.widgets import Static -from ...core.models import Paper, PaperSummary, PaperTranslation, RecommendedPaper +from ...core.models import PaperSummary, PaperTranslation, RecommendedPaper class PaperPanel(VerticalScroll): @@ -88,7 +88,7 @@ def show_summary(self, summary: PaperSummary) -> None: marker = "\n━━━ Summary ━━━" base = self._current_text if marker in base: - base = base[:base.index(marker)] + base = base[: base.index(marker)] summary_lines = self._format_summary(summary) self._current_text = base + "\n".join(summary_lines) @@ -100,7 +100,7 @@ def show_translation(self, translation: PaperTranslation) -> None: marker = "\n━━━ Translation ━━━" base = self._current_text if marker in base: - base = base[:base.index(marker)] + base = base[: base.index(marker)] translation_lines = self._format_translation(translation) self._current_text = base + "\n".join(translation_lines) diff --git a/src/arxiv_explorer/tui/widgets/paper_table.py b/src/arxiv_explorer/tui/widgets/paper_table.py index 73cd798..5425c9b 100644 --- a/src/arxiv_explorer/tui/widgets/paper_table.py +++ b/src/arxiv_explorer/tui/widgets/paper_table.py @@ -4,9 +4,9 @@ from textual import on from textual.app import ComposeResult -from textual.message import Message -from textual.widgets import DataTable, Static, LoadingIndicator from textual.containers import Vertical +from textual.message import Message +from textual.widgets import DataTable, LoadingIndicator, Static from ...core.models import RecommendedPaper @@ -39,12 +39,14 @@ class PaperTable(Vertical): class PaperSelected(Message): """Paper selected (Enter).""" + def __init__(self, paper: RecommendedPaper) -> None: super().__init__() self.paper = paper class PaperHighlighted(Message): """Paper cursor moved (highlight).""" + def __init__(self, paper: RecommendedPaper) -> None: super().__init__() self.paper = paper diff --git a/src/arxiv_explorer/tui/workers.py b/src/arxiv_explorer/tui/workers.py index 879d545..ef6f4ed 100644 --- a/src/arxiv_explorer/tui/workers.py +++ b/src/arxiv_explorer/tui/workers.py @@ -1,12 +1,12 @@ """Service facade — holds all service instances.""" +from ..services.notes_service import NotesService from ..services.paper_service import PaperService from ..services.preference_service import PreferenceService from ..services.reading_list_service import ReadingListService -from ..services.notes_service import NotesService +from ..services.settings_service import SettingsService from ..services.summarization import SummarizationService from ..services.translation import TranslationService -from ..services.settings_service import SettingsService class ServiceBridge: diff --git a/src/arxiv_explorer/utils/display.py b/src/arxiv_explorer/utils/display.py index 820365a..9cf717e 100644 --- a/src/arxiv_explorer/utils/display.py +++ b/src/arxiv_explorer/utils/display.py @@ -1,12 +1,11 @@ """Console output utilities.""" + +from rich import box from rich.console import Console -from rich.table import Table from rich.panel import Panel -from rich.text import Text -from rich import box - -from ..core.models import RecommendedPaper, Paper, PaperSummary, PaperTranslation, ReadingList +from rich.table import Table +from ..core.models import Paper, PaperSummary, PaperTranslation, RecommendedPaper console = Console() @@ -52,11 +51,13 @@ def print_paper_detail( ) -> None: """Display paper details.""" # Title - console.print(Panel( - f"[bold]{paper.title}[/bold]", - title=f"[green]{paper.arxiv_id}[/green]", - border_style="blue", - )) + console.print( + Panel( + f"[bold]{paper.title}[/bold]", + title=f"[green]{paper.arxiv_id}[/green]", + border_style="blue", + ) + ) # Metadata console.print(f"[cyan]Authors:[/cyan] {', '.join(paper.authors[:5])}") @@ -68,20 +69,24 @@ def print_paper_detail( # Summary if summary: console.print() - console.print(Panel( - summary.summary_short, - title="[yellow]Summary[/yellow]", - border_style="yellow", - )) + console.print( + Panel( + summary.summary_short, + title="[yellow]Summary[/yellow]", + border_style="yellow", + ) + ) # Detailed summary if summary.summary_detailed: console.print() - console.print(Panel( - summary.summary_detailed, - title="[cyan]Detailed Summary[/cyan]", - border_style="cyan", - )) + console.print( + Panel( + summary.summary_detailed, + title="[cyan]Detailed Summary[/cyan]", + border_style="cyan", + ) + ) if summary.key_findings: console.print() @@ -91,26 +96,32 @@ def print_paper_detail( # Abstract console.print() - console.print(Panel( - paper.abstract, - title="[dim]Abstract[/dim]", - border_style="dim", - )) + console.print( + Panel( + paper.abstract, + title="[dim]Abstract[/dim]", + border_style="dim", + ) + ) # Translation if translation: console.print() - console.print(Panel( - translation.translated_title, - title="[magenta]Translated Title[/magenta]", - border_style="magenta", - )) + console.print( + Panel( + translation.translated_title, + title="[magenta]Translated Title[/magenta]", + border_style="magenta", + ) + ) console.print() - console.print(Panel( - translation.translated_abstract, - title="[magenta]Translated Abstract[/magenta]", - border_style="magenta", - )) + console.print( + Panel( + translation.translated_abstract, + title="[magenta]Translated Abstract[/magenta]", + border_style="magenta", + ) + ) def print_categories(categories: list) -> None: From 98dd5be2f211512cbd0f3651c2d76024a76a1643 Mon Sep 17 00:00:00 2001 From: Axect Date: Sat, 7 Feb 2026 14:51:02 +0800 Subject: [PATCH 3/6] Add test suite for models, database, recommendation, and preferences - Add conftest.py with tmp_config fixture using monkeypatch for DB isolation - Add test_models.py: enum values, dataclass defaults, Paper.primary_category - Add test_database.py: table creation, idempotency, indexes, row factory - Add test_recommendation.py: category/keyword/recency scoring, sort order, user profile building, content similarity - Add test_preference_service.py: category/keyword CRUD, like/dislike interactions, mutual exclusion 43 tests, all passing. Co-Authored-By: Claude Opus 4.6 --- tests/__init__.py | 0 tests/conftest.py | 95 ++++++++++++++++++ tests/test_database.py | 97 ++++++++++++++++++ tests/test_models.py | 96 ++++++++++++++++++ tests/test_preference_service.py | 136 ++++++++++++++++++++++++++ tests/test_recommendation.py | 163 +++++++++++++++++++++++++++++++ 6 files changed, 587 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_database.py create mode 100644 tests/test_models.py create mode 100644 tests/test_preference_service.py create mode 100644 tests/test_recommendation.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e0ce24b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,95 @@ +"""Shared test fixtures.""" + +from datetime import datetime +from pathlib import Path + +import pytest + +from arxiv_explorer.core.config import Config +from arxiv_explorer.core.database import init_db +from arxiv_explorer.core.models import KeywordInterest, Paper, PreferredCategory + + +@pytest.fixture() +def tmp_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Config: + """Create an isolated Config pointing to a temp database.""" + db_path = tmp_path / "test.db" + config = Config( + db_path=db_path, + arxivterminal_db_path=tmp_path / "arxivterminal.db", + ) + + def _get_config() -> Config: + return config + + # Patch every module that imports get_config at the top level + monkeypatch.setattr("arxiv_explorer.core.database.get_config", _get_config) + monkeypatch.setattr("arxiv_explorer.services.recommendation.get_config", _get_config) + + # Reset the global config singleton so it doesn't leak between tests + monkeypatch.setattr("arxiv_explorer.core.config._config", config) + + init_db(db_path) + return config + + +@pytest.fixture() +def sample_paper() -> Paper: + """A minimal Paper for testing.""" + return Paper( + arxiv_id="2401.00001", + title="Deep Learning for Particle Physics", + abstract="We present a novel deep learning approach to jet classification in high energy physics.", + authors=["Alice", "Bob"], + categories=["hep-ph", "cs.LG"], + published=datetime(2024, 1, 1), + ) + + +@pytest.fixture() +def sample_papers() -> list[Paper]: + """A list of diverse papers for recommendation testing.""" + return [ + Paper( + arxiv_id="2401.00001", + title="Deep Learning for Particle Physics", + abstract="We present a novel deep learning approach to jet classification.", + authors=["Alice"], + categories=["hep-ph", "cs.LG"], + published=datetime(2024, 1, 1), + ), + Paper( + arxiv_id="2401.00002", + title="Quantum Computing Survey", + abstract="A comprehensive survey of quantum computing algorithms and applications.", + authors=["Bob"], + categories=["quant-ph", "cs.CC"], + published=datetime(2024, 1, 5), + ), + Paper( + arxiv_id="2401.00003", + title="Reinforcement Learning in Robotics", + abstract="Applying reinforcement learning to autonomous robot navigation.", + authors=["Charlie"], + categories=["cs.AI", "cs.RO"], + published=datetime(2024, 1, 10), + ), + ] + + +@pytest.fixture() +def sample_categories() -> list[PreferredCategory]: + """Sample preferred categories for scoring tests.""" + return [ + PreferredCategory(id=1, category="hep-ph", priority=2), + PreferredCategory(id=2, category="cs.AI", priority=1), + ] + + +@pytest.fixture() +def sample_keywords() -> list[KeywordInterest]: + """Sample keywords for scoring tests.""" + return [ + KeywordInterest(id=1, keyword="deep learning", weight=1.5), + KeywordInterest(id=2, keyword="quantum", weight=1.0), + ] diff --git a/tests/test_database.py b/tests/test_database.py new file mode 100644 index 0000000..3fa1e8a --- /dev/null +++ b/tests/test_database.py @@ -0,0 +1,97 @@ +"""Tests for database initialization and connection management.""" + +import sqlite3 + +import pytest + +from arxiv_explorer.core.config import Config +from arxiv_explorer.core.database import get_connection, init_db + +EXPECTED_TABLES = { + "preferred_categories", + "paper_interactions", + "paper_summaries", + "reading_lists", + "reading_list_papers", + "paper_notes", + "keyword_interests", + "paper_translations", + "app_settings", + "papers", +} + + +class TestInitDb: + """Tests for init_db().""" + + def test_creates_all_tables(self, tmp_config: Config): + with get_connection() as conn: + rows = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'" + ).fetchall() + tables = {row["name"] for row in rows} + + assert tables == EXPECTED_TABLES + + def test_idempotent(self, tmp_config: Config): + """Running init_db twice should not raise or corrupt.""" + init_db(tmp_config.db_path) + init_db(tmp_config.db_path) + + with get_connection() as conn: + rows = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'" + ).fetchall() + tables = {row["name"] for row in rows} + + assert tables == EXPECTED_TABLES + + def test_creates_indexes(self, tmp_config: Config): + with get_connection() as conn: + rows = conn.execute( + "SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'idx_%'" + ).fetchall() + indexes = {row["name"] for row in rows} + + expected_indexes = { + "idx_interactions_arxiv", + "idx_interactions_type", + "idx_notes_arxiv", + "idx_list_papers_list", + "idx_translations_arxiv", + "idx_papers_cached_at", + } + assert indexes == expected_indexes + + +class TestGetConnection: + """Tests for get_connection().""" + + def test_row_factory(self, tmp_config: Config): + """Connection should use sqlite3.Row for dict-like access.""" + with get_connection() as conn: + assert conn.row_factory is sqlite3.Row + + def test_connection_closes(self, tmp_config: Config): + """Connection should be closed after context manager exits.""" + with get_connection() as conn: + pass + # Attempting to use a closed connection raises ProgrammingError + with pytest.raises(sqlite3.ProgrammingError): + conn.execute("SELECT 1") + + def test_data_persists(self, tmp_config: Config): + """Data written in one connection should be readable in another.""" + with get_connection() as conn: + conn.execute( + "INSERT INTO preferred_categories (category, priority) VALUES (?, ?)", + ("cs.AI", 1), + ) + conn.commit() + + with get_connection() as conn: + row = conn.execute( + "SELECT * FROM preferred_categories WHERE category = 'cs.AI'" + ).fetchone() + assert row is not None + assert row["category"] == "cs.AI" diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..36330d5 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,96 @@ +"""Tests for core data models.""" + +from datetime import datetime + +from arxiv_explorer.core.models import ( + InteractionType, + KeywordInterest, + Language, + NoteType, + Paper, + PaperSummary, + ReadingListPaper, + ReadingStatus, + RecommendedPaper, +) + + +class TestEnums: + """Verify enum values match the strings stored in SQLite.""" + + def test_interaction_type_values(self): + assert InteractionType.INTERESTING.value == "interesting" + assert InteractionType.NOT_INTERESTING.value == "not_interesting" + + def test_reading_status_values(self): + assert ReadingStatus.UNREAD.value == "unread" + assert ReadingStatus.READING.value == "reading" + assert ReadingStatus.COMPLETED.value == "completed" + + def test_note_type_values(self): + assert NoteType.GENERAL.value == "general" + assert NoteType.QUESTION.value == "question" + assert NoteType.INSIGHT.value == "insight" + assert NoteType.TODO.value == "todo" + + def test_language_values(self): + assert Language.EN.value == "en" + assert Language.KO.value == "ko" + + def test_enum_from_string(self): + """Enums can be constructed from stored string values.""" + assert InteractionType("interesting") is InteractionType.INTERESTING + assert ReadingStatus("completed") is ReadingStatus.COMPLETED + + +class TestPaper: + """Tests for the Paper dataclass.""" + + def test_primary_category(self, sample_paper: Paper): + assert sample_paper.primary_category == "hep-ph" + + def test_primary_category_empty(self): + paper = Paper( + arxiv_id="0000.00000", + title="t", + abstract="a", + authors=[], + categories=[], + published=datetime(2024, 1, 1), + ) + assert paper.primary_category == "" + + def test_optional_fields_default_none(self): + paper = Paper( + arxiv_id="0000.00000", + title="t", + abstract="a", + authors=[], + categories=[], + published=datetime(2024, 1, 1), + ) + assert paper.updated is None + assert paper.pdf_url is None + + +class TestDataclassDefaults: + """Verify that dataclass default factories work correctly.""" + + def test_paper_summary_defaults(self): + summary = PaperSummary(id=1, arxiv_id="0000.00000", summary_short="Short") + assert summary.key_findings == [] + assert summary.summary_detailed is None + + def test_reading_list_paper_defaults(self): + rlp = ReadingListPaper(id=1, list_id=1, arxiv_id="0000.00000") + assert rlp.status == ReadingStatus.UNREAD + assert rlp.position == 0 + + def test_keyword_interest_defaults(self): + ki = KeywordInterest(id=1, keyword="test") + assert ki.weight == 1.0 + assert ki.source == "explicit" + + def test_recommended_paper_defaults(self, sample_paper: Paper): + rp = RecommendedPaper(paper=sample_paper, score=0.75) + assert rp.summary is None diff --git a/tests/test_preference_service.py b/tests/test_preference_service.py new file mode 100644 index 0000000..a44931f --- /dev/null +++ b/tests/test_preference_service.py @@ -0,0 +1,136 @@ +"""Tests for the preference service.""" + +from arxiv_explorer.core.config import Config +from arxiv_explorer.core.models import InteractionType +from arxiv_explorer.services.preference_service import PreferenceService + + +class TestCategoryManagement: + """CRUD for preferred categories.""" + + def test_add_category(self, tmp_config: Config): + service = PreferenceService() + cat = service.add_category("hep-ph", priority=2) + + assert cat.category == "hep-ph" + assert cat.priority == 2 + + def test_add_category_update_priority(self, tmp_config: Config): + """Adding the same category again updates its priority.""" + service = PreferenceService() + service.add_category("cs.AI", priority=1) + updated = service.add_category("cs.AI", priority=3) + + assert updated.priority == 3 + + def test_get_categories_ordered_by_priority(self, tmp_config: Config): + service = PreferenceService() + service.add_category("cs.AI", priority=1) + service.add_category("hep-ph", priority=3) + service.add_category("quant-ph", priority=2) + + cats = service.get_categories() + priorities = [c.priority for c in cats] + assert priorities == sorted(priorities, reverse=True) + + def test_remove_category(self, tmp_config: Config): + service = PreferenceService() + service.add_category("cs.AI") + + assert service.remove_category("cs.AI") is True + assert service.get_categories() == [] + + def test_remove_nonexistent_category(self, tmp_config: Config): + service = PreferenceService() + assert service.remove_category("nonexistent") is False + + +class TestPaperInteractions: + """Like/dislike paper interactions.""" + + def test_mark_interesting(self, tmp_config: Config): + service = PreferenceService() + service.mark_interesting("2401.00001") + + assert service.get_interaction("2401.00001") == InteractionType.INTERESTING + assert "2401.00001" in service.get_interesting_papers() + + def test_mark_not_interesting(self, tmp_config: Config): + service = PreferenceService() + service.mark_not_interesting("2401.00001") + + assert service.get_interaction("2401.00001") == InteractionType.NOT_INTERESTING + assert "2401.00001" not in service.get_interesting_papers() + + def test_like_replaces_dislike(self, tmp_config: Config): + """Liking a paper removes any previous dislike.""" + service = PreferenceService() + service.mark_not_interesting("2401.00001") + service.mark_interesting("2401.00001") + + assert service.get_interaction("2401.00001") == InteractionType.INTERESTING + + def test_dislike_replaces_like(self, tmp_config: Config): + """Disliking a paper removes any previous like.""" + service = PreferenceService() + service.mark_interesting("2401.00001") + service.mark_not_interesting("2401.00001") + + assert service.get_interaction("2401.00001") == InteractionType.NOT_INTERESTING + assert "2401.00001" not in service.get_interesting_papers() + + def test_no_interaction_returns_none(self, tmp_config: Config): + service = PreferenceService() + assert service.get_interaction("9999.99999") is None + + +class TestKeywordManagement: + """CRUD for keyword interests.""" + + def test_add_keyword(self, tmp_config: Config): + service = PreferenceService() + service.add_keyword("machine learning", weight=1.5) + + keywords = service.get_keywords() + assert len(keywords) == 1 + assert keywords[0].keyword == "machine learning" + assert keywords[0].weight == 1.5 + + def test_keyword_lowercased(self, tmp_config: Config): + """Keywords are stored in lowercase.""" + service = PreferenceService() + service.add_keyword("Deep Learning") + + keywords = service.get_keywords() + assert keywords[0].keyword == "deep learning" + + def test_add_keyword_update_weight(self, tmp_config: Config): + """Adding the same keyword again updates its weight.""" + service = PreferenceService() + service.add_keyword("quantum", weight=1.0) + service.add_keyword("quantum", weight=2.5) + + keywords = service.get_keywords() + assert len(keywords) == 1 + assert keywords[0].weight == 2.5 + + def test_get_keywords_ordered_by_weight(self, tmp_config: Config): + service = PreferenceService() + service.add_keyword("low", weight=0.5) + service.add_keyword("high", weight=2.0) + service.add_keyword("mid", weight=1.0) + + keywords = service.get_keywords() + weights = [k.weight for k in keywords] + assert weights == sorted(weights, reverse=True) + + def test_remove_keyword(self, tmp_config: Config): + service = PreferenceService() + service.add_keyword("test") + + assert service.remove_keyword("test") is True + assert service.get_keywords() == [] + + def test_remove_nonexistent_keyword(self, tmp_config: Config): + service = PreferenceService() + assert service.remove_keyword("nonexistent") is False diff --git a/tests/test_recommendation.py b/tests/test_recommendation.py new file mode 100644 index 0000000..818f28c --- /dev/null +++ b/tests/test_recommendation.py @@ -0,0 +1,163 @@ +"""Tests for the recommendation engine.""" + +from datetime import datetime, timedelta + +from arxiv_explorer.core.config import Config +from arxiv_explorer.core.models import KeywordInterest, Paper, PreferredCategory +from arxiv_explorer.services.recommendation import RecommendationEngine + + +class TestCategoryScoring: + """Category matching contributes to the paper score.""" + + def test_matching_category_increases_score( + self, + tmp_config: Config, + sample_papers: list[Paper], + sample_categories: list[PreferredCategory], + ): + engine = RecommendationEngine() + results = engine.score_papers(sample_papers, None, sample_categories, []) + + # Paper with hep-ph (priority 2) should rank above paper with cs.AI (priority 1) + scores = {r.paper.arxiv_id: r.score for r in results} + assert scores["2401.00001"] > scores["2401.00003"] + + def test_no_category_match_gives_zero_category_score( + self, tmp_config: Config, sample_categories: list[PreferredCategory] + ): + engine = RecommendationEngine() + paper = Paper( + arxiv_id="9999.00001", + title="Unrelated", + abstract="Nothing relevant.", + authors=["X"], + categories=["math.AG"], + published=datetime(2024, 1, 1), + ) + results = engine.score_papers([paper], None, sample_categories, []) + # Score should be very small (only recency if applicable, no category) + assert results[0].score < tmp_config.category_weight + + +class TestKeywordScoring: + """Keyword matching contributes to the paper score.""" + + def test_keyword_match_increases_score( + self, tmp_config: Config, sample_papers: list[Paper], sample_keywords: list[KeywordInterest] + ): + engine = RecommendationEngine() + results = engine.score_papers(sample_papers, None, [], sample_keywords) + scores = {r.paper.arxiv_id: r.score for r in results} + + # "deep learning" matches paper 1, "quantum" matches paper 2 + assert scores["2401.00001"] > 0 + assert scores["2401.00002"] > 0 + + def test_keyword_weight_affects_score(self, tmp_config: Config): + engine = RecommendationEngine() + paper = Paper( + arxiv_id="0001", + title="Deep learning methods", + abstract="Using deep learning.", + authors=[], + categories=[], + published=datetime(2024, 1, 1), + ) + low_weight = [KeywordInterest(id=1, keyword="deep learning", weight=0.5)] + high_weight = [KeywordInterest(id=1, keyword="deep learning", weight=2.0)] + + score_low = engine.score_papers([paper], None, [], low_weight)[0].score + score_high = engine.score_papers([paper], None, [], high_weight)[0].score + assert score_high > score_low + + +class TestRecencyScoring: + """Recent papers get a bonus.""" + + def test_recent_paper_gets_bonus(self, tmp_config: Config): + engine = RecommendationEngine() + recent = Paper( + arxiv_id="new", + title="X", + abstract="Y", + authors=[], + categories=[], + published=datetime.now() - timedelta(days=1), + ) + old = Paper( + arxiv_id="old", + title="X", + abstract="Y", + authors=[], + categories=[], + published=datetime.now() - timedelta(days=60), + ) + results = engine.score_papers([recent, old], None, [], []) + scores = {r.paper.arxiv_id: r.score for r in results} + assert scores["new"] > scores["old"] + + +class TestSortOrder: + """Results are sorted by score descending.""" + + def test_results_sorted_descending( + self, + tmp_config: Config, + sample_papers: list[Paper], + sample_categories: list[PreferredCategory], + ): + engine = RecommendationEngine() + results = engine.score_papers(sample_papers, None, sample_categories, []) + scores = [r.score for r in results] + assert scores == sorted(scores, reverse=True) + + +class TestUserProfile: + """TF-IDF user profile building.""" + + def test_empty_likes_returns_none(self): + engine = RecommendationEngine() + assert engine.build_user_profile([]) is None + + def test_profile_from_papers(self, sample_papers: list[Paper]): + engine = RecommendationEngine() + profile = engine.build_user_profile(sample_papers) + assert profile is not None + assert profile.shape[0] > 0 + + def test_content_similarity_affects_score(self, tmp_config: Config): + engine = RecommendationEngine() + + liked = [ + Paper( + arxiv_id="liked1", + title="Neural network optimization", + abstract="Gradient descent methods for training deep neural networks.", + authors=[], + categories=[], + published=datetime(2024, 1, 1), + ), + ] + profile = engine.build_user_profile(liked) + + similar = Paper( + arxiv_id="sim", + title="Neural network training", + abstract="New optimization techniques for deep neural networks.", + authors=[], + categories=[], + published=datetime(2024, 1, 1), + ) + different = Paper( + arxiv_id="diff", + title="Galaxy formation", + abstract="Cosmological simulations of galaxy formation in dark matter halos.", + authors=[], + categories=[], + published=datetime(2024, 1, 1), + ) + + results = engine.score_papers([similar, different], profile, [], []) + scores = {r.paper.arxiv_id: r.score for r in results} + assert scores["sim"] > scores["diff"] From a477d91ad2892b7303be07d5b5c59e5193185a5f Mon Sep 17 00:00:00 2001 From: Axect Date: Sat, 7 Feb 2026 14:51:08 +0800 Subject: [PATCH 4/6] Add CI workflow for linting and testing - Lint job: ruff check + format check on Python 3.13 - Test job: pytest with coverage on Python 3.11, 3.12, 3.13 matrix - Uses astral-sh/setup-uv@v4 with caching for fast builds - Triggers on push to main/dev and PRs to main Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 52 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a09d5e9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: CI + +on: + push: + branches: [main, dev] + pull_request: + branches: [main] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install dependencies + run: uv sync --locked + + - name: Ruff check + run: uv run ruff check src/ tests/ + + - name: Ruff format + run: uv run ruff format --check src/ tests/ + + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync --locked + + - name: Run tests + run: uv run pytest --cov=arxiv_explorer --cov-report=term-missing From 7310fd24229ad4ee885b6b0b6185ab895026dff7 Mon Sep 17 00:00:00 2001 From: Axect Date: Sat, 7 Feb 2026 14:51:13 +0800 Subject: [PATCH 5/6] Enhance README with badges, demo section, and comparison table - Add CI, license, Python version, and GitHub stars badges - Add tagline and 'Why arXiv Explorer?' section - Expand Quick Start into Quick Demo with full workflow - Add shell completion instructions - Add comparison table with arxiv-sanity-lite (respectful framing) - Add Contributing section linking to CONTRIBUTING.md Co-Authored-By: Claude Opus 4.6 --- README.md | 68 +++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 59 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 46c3a99..e0c93a9 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,22 @@ # arXiv Explorer -Personalized arXiv paper recommendation and management system with CLI and TUI interfaces. +[![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/) +[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) +[![CI](https://github.com/Axect/arXiv_explorer/actions/workflows/ci.yml/badge.svg)](https://github.com/Axect/arXiv_explorer/actions/workflows/ci.yml) +[![GitHub stars](https://img.shields.io/github/stars/Axect/arXiv_explorer)](https://github.com/Axect/arXiv_explorer/stargazers) + +> Your personal research assistant for arXiv — discover, organize, and annotate papers from the terminal. ![arXiv Explorer TUI](tui.png) +## Why arXiv Explorer? + +- **Learns from you** — The recommendation engine improves every time you like or dislike a paper. No manual tuning required. +- **No API keys needed** — Fetches papers directly from the public arXiv API. AI features use your locally installed CLI tools. +- **Fully local** — All data lives in a single SQLite file on your machine. No accounts, no cloud sync, no tracking. +- **Terminal-native** — A rich CLI and a full TUI, built with Typer and Textual. Works over SSH. +- **Composable** — Pipe exports to other tools, integrate with arxivterminal or arxiv-doc-builder, or build your own workflow. + ## Features - **Personalized Recommendations** — TF-IDF content similarity + category/keyword/recency scoring @@ -28,23 +41,41 @@ cd arXiv_explorer uv sync ``` -## Quick Start +### Shell Completion + +```bash +# fish +axp --install-completion fish + +# bash +axp --install-completion bash + +# zsh +axp --install-completion zsh +``` + +## Quick Demo ```bash -# Set up preferred categories +# 1. Tell arXiv Explorer what you're interested in axp prefs add-category hep-ph --priority 2 axp prefs add-category cs.AI +axp prefs add-keyword "deep learning" --weight 1.5 -# Fetch and rank recent papers +# 2. Fetch and rank the last week's papers axp daily --days 7 --limit 10 -# Search arXiv -axp search "quantum computing" - -# Mark a paper as interesting (improves future recommendations) +# 3. Found something interesting? Like it — this trains the recommender axp like 2501.12345 -# Launch the TUI +# 4. Get an AI summary (uses your configured provider) +axp show 2501.12345 --summary + +# 5. Organize into a reading list +axp list create "GNN papers" +axp list add "GNN papers" 2501.12345 + +# 6. Or just launch the TUI for a full interactive experience axp tui ``` @@ -160,6 +191,21 @@ axp config test Summarization and translation are available in both CLI (`axp show -s`, `axp translate`) and TUI (`s` / `t` keys in paper detail). +## Comparison + +Different tools serve different workflows. [arxiv-sanity-lite](https://github.com/karpathy/arxiv-sanity-lite) pioneered TF-IDF-based paper recommendations and remains the gold standard for web-based discovery. arXiv Explorer brings a similar approach to the terminal. + +| | arxiv-sanity-lite | arXiv Explorer | +|---|---|---| +| **Interface** | Web UI | CLI + TUI (works over SSH) | +| **Recommendation** | TF-IDF (web) | TF-IDF (local, learns per session) | +| **Setup** | Server deployment | `uv sync` and go | +| **Data storage** | PostgreSQL + S3 | Single SQLite file | +| **AI summaries** | No | Yes (pluggable providers) | +| **Reading lists & notes** | No | Yes | +| **Export** | No | Markdown, JSON, CSV | +| **Best for** | Browsing with a team | Solo terminal workflow | + ## Integration - **[arxivterminal](https://github.com/Axect/arxivterminal)** — Reads from its local paper database (read-only) @@ -169,6 +215,10 @@ Summarization and translation are available in both CLI (`axp show -s`, `axp tra All data is stored locally in SQLite at `~/.config/arxiv-explorer/explorer.db`. No cloud sync. +## Contributing + +Contributions are welcome! See [CONTRIBUTING.md](.github/CONTRIBUTING.md) for development setup and guidelines. + ## License [MIT](LICENSE) From 97c01cda09a4c7b4231039e5d9b75d8d01000f39 Mon Sep 17 00:00:00 2001 From: Axect Date: Sat, 7 Feb 2026 14:51:19 +0800 Subject: [PATCH 6/6] Add GitHub community files for contributor experience - CONTRIBUTING.md: dev setup, code style, testing, gitflow PR process - ISSUE_TEMPLATE/bug_report.yml: structured bug report form - ISSUE_TEMPLATE/feature_request.yml: feature request form - PULL_REQUEST_TEMPLATE.md: PR checklist (tests, lint, docs) - CODE_OF_CONDUCT.md: Contributor Covenant v2.1 - SECURITY.md: vulnerability reporting procedure Co-Authored-By: Claude Opus 4.6 --- .github/CODE_OF_CONDUCT.md | 154 +++++++++++++++++++++ .github/CONTRIBUTING.md | 94 +++++++++++++ .github/ISSUE_TEMPLATE/bug_report.yml | 84 +++++++++++ .github/ISSUE_TEMPLATE/feature_request.yml | 45 ++++++ .github/PULL_REQUEST_TEMPLATE.md | 17 +++ .github/SECURITY.md | 12 ++ 6 files changed, 406 insertions(+) create mode 100644 .github/CODE_OF_CONDUCT.md create mode 100644 .github/CONTRIBUTING.md create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/SECURITY.md diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..56e9b0b --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,154 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +national origin, race, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open and welcoming +environment, and to demonstrate an empathy and regard for our fellow community +members. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other community members +* Being respectful of differing opinions, viewpoints, and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members who may be less experienced + than you +* Being considerate of those who may not be as familiar with the conventions + used in our community +* Learning from and being open to constructive feedback + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards +of acceptable behavior and will take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned with this Code of Conduct, and will communicate reasons for moderation +decisions in a way that respects this community. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project leaders. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement by contacting +axect.tg@proton.me. + +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Harmful Conduct + +1.1. **Severity Levels**: + +* **1.0 - Minor**: Verbal or written comments that are considered slight but + hurtful or exclusionary. Minor offenses are those that are part of a pattern + of behavior or that would reasonably cause others to feel uncomfortable. + +* **2.0 - Significant**: Verbal or written comments that are considered + inappropriate, including those that are offensive or harmful. Significant + offenses include the presence of mild-to-moderate sexual content (including + references to sexual preferences or kinks, but not non-consensual sexual + content) that would make others uncomfortable. + +* **3.0 - Severe**: Actions that create hostile, threatening, or abusive + environments. Severe offenses include the presence of explicit sexual content, + hate speech, threats, or encouragement of self-harm. + +1.2. **Consequences**: + +* **Level 1.0**: A private note from a community leader expressing concern + regarding the behavior and inviting the individual to discuss what changes + they would like to make. + +* **Level 2.0**: A private note from a community leader with an explanation of + the unacceptable behavior, why it was unacceptable, and a request to + communicate more respectfully and considerately. A community leader may also + request the individual to stop engaging with the project until the issue is + resolved. + +* **Level 3.0**: Immediate, permanent exclusion from the project community and + any associated events, with zero tolerance for repetition. + +### 2. Unacceptable Conduct + +* **Sexual Content**: Comments or content that are explicitly sexual, including + detailed descriptions of sexual acts, genitalia, or sexual preferences and + kinks, even when consensual. + +* **Hate Speech**: Comments that target or dehumanize individuals based on + characteristics such as race, ethnicity, religion, disability, age, sex + characteristics, gender identity, sexual orientation, or other protected + status. + +* **Harassment**: Verbal or written comments that are directed at an individual + with the intent to annoy, intimidate, or demean, or that would reasonably + cause a reasonable person to feel uncomfortable in the community. + +* **Personal Attacks**: Offensive comments or actions that target individuals + or groups based on their identity, beliefs, or affiliations. + +* **Targeted Offenses**: Conduct targeted at specific individuals or groups, + such as repeated harassment or exclusion of particular members of the + community. + +* **Unwelcome Physical Contact**: Any unwanted physical contact, including + touching, hugging, or other forms of physical contact that make someone + uncomfortable. + +* **Incitement to Harm**: Encouraging others to engage in harmful behavior, + including the promotion of illegal acts or violence. + +### 3. Attribution + +This Code of Conduct is adapted from the Contributor Covenant version 2.1, +available at https://www.contributor-covenant.org/version/2/1/code_of_conduct.html. +The enforcement guidelines have been adapted for our community's needs but +maintain the spirit and intent of the original document. + +## Attribution + +This Code of Conduct and its enforcement guidelines are adapted from the +Contributor Covenant version 2.1 (https://www.contributor-covenant.org) and +the Python Software Foundation's Code of Conduct (https://www.python.org/psf/conduct/). + +## Contact + +For questions or concerns about this Code of Conduct, please contact +axect.tg@proton.me. diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..301c836 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,94 @@ +# Contributing to arXiv Explorer + +Thank you for your interest in contributing to arXiv Explorer! This guide will help you get started. + +## Prerequisites + +- **Python 3.11+** +- **[uv](https://docs.astral.sh/uv/)** - Fast Python package manager +- **git** + +## Getting Started + +1. Fork the repository and clone your fork: + + ```bash + git clone https://github.com//arXiv_explorer.git + cd arXiv_explorer + ``` + +2. Install dependencies: + + ```bash + uv sync + ``` + +3. Verify the installation: + + ```bash + uv run axp --help + ``` + +## Development Workflow + +### Git Workflow + +This project follows **gitflow**: + +- **`main`** contains stable releases only. +- **`dev`** is the active development branch. +- All feature branches should be created from `dev`. +- All pull requests should target `dev`. + +To start working on a change: + +```bash +git checkout dev +git pull origin dev +git checkout -b feature/your-feature-name +``` + +### Code Style + +We use [Ruff](https://docs.astral.sh/ruff/) for both linting and formatting. Before submitting a pull request, make sure your code passes both checks: + +```bash +uv run ruff check src/ tests/ +uv run ruff format src/ tests/ +``` + +### Testing + +Run the full test suite with coverage: + +```bash +uv run pytest --cov +``` + +To run a specific test file: + +```bash +uv run pytest tests/test_recommendation.py +``` + +All new features should include tests. Aim to maintain or improve code coverage. + +### Commit Messages + +Write clear, descriptive commit messages that explain **why** the change was made, not just what was changed. Use the imperative mood in the subject line (e.g., "Add keyword weighting to recommendation engine" rather than "Added keyword weighting"). + +## Pull Request Process + +1. **Ensure all tests pass** before submitting your PR. +2. **Ensure linting passes** with `uv run ruff check src/ tests/`. +3. **Describe your changes** clearly in the PR description --- what was changed, why, and how to test it. +4. **Target the `dev` branch** --- PRs to `main` will not be accepted unless they are release merges. +5. **Keep PRs focused** --- one feature or fix per pull request makes review easier. + +## Reporting Issues + +If you find a bug or have a feature request, please open an issue using the appropriate template. Provide as much detail as possible to help us understand and reproduce the problem. + +## Questions? + +If you have questions about contributing, feel free to open a discussion or reach out to the maintainer at axect.tg@proton.me. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..b595d9a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,84 @@ +name: Bug Report +description: Report a bug in arXiv Explorer +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thank you for reporting a bug. Please fill out the information below to help us investigate and fix the issue. + + - type: textarea + id: description + attributes: + label: Description + description: A clear and concise description of the bug. + placeholder: Describe the bug... + validations: + required: true + + - type: textarea + id: steps-to-reproduce + attributes: + label: Steps to Reproduce + description: Steps to reproduce the behavior. + placeholder: | + 1. Run `uv run axp ...` + 2. ... + 3. See error + validations: + required: true + + - type: textarea + id: expected-behavior + attributes: + label: Expected Behavior + description: What you expected to happen. + placeholder: Describe what you expected... + validations: + required: true + + - type: textarea + id: actual-behavior + attributes: + label: Actual Behavior + description: What actually happened. Include any error messages or tracebacks. + placeholder: Describe what actually happened... + validations: + required: true + + - type: input + id: python-version + attributes: + label: Python Version + description: Output of `python --version`. + placeholder: "e.g., 3.12.0" + validations: + required: false + + - type: input + id: os + attributes: + label: Operating System + description: Your operating system and version. + placeholder: "e.g., Ubuntu 24.04, macOS 15.2, Windows 11" + validations: + required: false + + - type: input + id: version + attributes: + label: arXiv Explorer Version + description: Output of `uv run axp --version` or the version in pyproject.toml. + placeholder: "e.g., 0.1.0" + validations: + required: false + + - type: textarea + id: additional-context + attributes: + label: Additional Context + description: Any other context about the problem (logs, screenshots, configuration, etc.). + placeholder: Add any other context here... + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..a2175e1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,45 @@ +name: Feature Request +description: Suggest a new feature or improvement for arXiv Explorer +title: "[Feature]: " +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Thank you for suggesting a feature! Please provide as much detail as possible so we can evaluate and prioritize your request. + + - type: textarea + id: problem-description + attributes: + label: Problem Description + description: Describe the problem or limitation you are experiencing. What is frustrating or missing? + placeholder: "I'm always frustrated when..." + validations: + required: true + + - type: textarea + id: proposed-solution + attributes: + label: Proposed Solution + description: Describe the solution you would like to see. How should this feature work? + placeholder: Describe your ideal solution... + validations: + required: true + + - type: textarea + id: alternatives-considered + attributes: + label: Alternatives Considered + description: Describe any alternative solutions or workarounds you have considered. + placeholder: List any alternatives you've thought about... + validations: + required: false + + - type: textarea + id: additional-context + attributes: + label: Additional Context + description: Any other context, screenshots, or examples that help explain the feature request. + placeholder: Add any other context here... + validations: + required: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..65b2026 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,17 @@ +## Summary + + + +## Changes + + + +- +- +- + +## Checklist + +- [ ] All tests pass (`uv run pytest --cov`) +- [ ] Linting passes (`uv run ruff check src/ tests/`) +- [ ] Documentation updated (if applicable) diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..eab7021 --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,12 @@ +# Security Policy + +## Supported Versions + +| Version | Status | +|---------|--------| +| 0.1.x | ✅ Supported | +| < 0.1.x | ❌ Not Supported | + +## Reporting a Vulnerability + +To report a security vulnerability, please email `axect.tg@proton.me`. We will acknowledge receipt of your report within 48 hours. Please do not open public issues for security vulnerabilities; instead, use this dedicated reporting channel.