diff --git a/codex/cli.py b/codex/cli.py index 7d005dd..ede4f8d 100644 --- a/codex/cli.py +++ b/codex/cli.py @@ -1,8 +1,157 @@ +"""codex CLI — commands wired to domain modules.""" + +from __future__ import annotations + +import json +from typing import Optional + import typer app = typer.Typer(help="codex — personal knowledge base for scientific papers.") +discover_app = typer.Typer(help="Discovery queries over the citation graph.") +prov_app = typer.Typer(help="Provenance: @cite scan, code_links, bib export.") +app.add_typer(discover_app, name="discover") +app.add_typer(prov_app, name="provenance") -@app.callback() -def _main() -> None: - """codex CLI — commands land here in F-07.""" +@app.command() +def ingest( + paper_id: str = typer.Argument(..., help="arXiv ID, DOI, or OpenAlex W-ID"), + source: Optional[str] = typer.Option(None, "--source", "-s", help="Path to .tex or .pdf"), # noqa: UP045 +) -> None: + """Ingest a paper into the knowledge base.""" + from codex.ingest import ingest_paper + + result = ingest_paper(paper_id, source_path=source) + typer.echo( + f"Ingested {result.paper_id}: {result.chunks_upserted} chunks, " + f"{result.citations_upserted} citations" + ) + + +@app.command() +def search( + query: str = typer.Argument(..., help="Natural-language search query"), + limit: int = typer.Option(10, "--limit", "-n", help="Number of results"), +) -> None: + """Semantic similarity search over paper abstracts.""" + from codex.db import get_conn + from codex.embed import get_embedder + + emb = get_embedder().encode_dense([query])[0].tolist() + with get_conn() as conn: + rows = conn.execute( + """ + SELECT id, title, year, + abstract_emb <-> %(emb)s AS distance + FROM papers + WHERE abstract_emb IS NOT NULL + ORDER BY abstract_emb <-> %(emb)s + LIMIT %(limit)s + """, + {"emb": emb, "limit": limit}, + ).fetchall() + for row in rows: + typer.echo(f"[{row['distance']:.3f}] {row['id']} ({row['year']}) — {row['title']}") + + +@discover_app.command("leads") +def discover_leads( + limit: int = typer.Option(20, "--limit", "-n"), +) -> None: + """Show discovery leads: cited papers not yet ingested, ranked by pull.""" + from codex.discover import discovery_leads + + for item in discovery_leads(limit=limit): + typer.echo(f"{item['pull']:>4}× {item['cited_id']}") + + +@discover_app.command("citing") +def discover_citing(paper_id: str = typer.Argument(...)) -> None: + """List papers that cite PAPER_ID.""" + from codex.discover import citing_papers + + for pid in citing_papers(paper_id): + typer.echo(pid) + + +@discover_app.command("cited-by") +def discover_cited_by(paper_id: str = typer.Argument(...)) -> None: + """List papers that PAPER_ID cites.""" + from codex.discover import cited_by + + for pid in cited_by(paper_id): + typer.echo(pid) + + +@discover_app.command("cocited") +def discover_cocited( + paper_id: str = typer.Argument(...), + limit: int = typer.Option(10, "--limit", "-n"), +) -> None: + """List papers frequently co-cited with PAPER_ID.""" + from codex.discover import cocited_papers + + for item in cocited_papers(paper_id, limit=limit): + typer.echo(f"{item['co_citations']:>4}× {item['paper_id']}") + + +@prov_app.command("scan") +def prov_scan(root_dir: str = typer.Argument(..., help="Root directory to scan")) -> None: + """Scan C++ sources for @cite tags.""" + from codex.provenance import scan_cite_tags + + hits = scan_cite_tags(root_dir) + typer.echo(json.dumps(hits, indent=2)) + + +@prov_app.command("add-link") +def prov_add_link( + symbol: str = typer.Argument(...), + paper_id: Optional[str] = typer.Option(None, "--paper-id"), # noqa: UP045 + role: Optional[str] = typer.Option(None, "--role"), # noqa: UP045 + note: Optional[str] = typer.Option(None, "--note"), # noqa: UP045 +) -> None: + """Add a code → paper link.""" + from codex.provenance import add_code_link + + link = add_code_link(symbol, paper_id=paper_id, role=role, note=note) + typer.echo(f"Created code_link id={link.id}: {symbol} → {paper_id}") + + +@prov_app.command("links") +def prov_links( + paper_id: Optional[str] = typer.Option(None, "--paper-id"), # noqa: UP045 +) -> None: + """List code → paper links.""" + from codex.provenance import list_code_links + + for link in list_code_links(paper_id=paper_id): + typer.echo(f"[{link.id}] {link.symbol} → {link.paper_id} ({link.role})") + + +@prov_app.command("bib") +def prov_bib( + paper_ids: Optional[list[str]] = typer.Argument(None), # noqa: UP045, B008 + output: Optional[str] = typer.Option( # noqa: UP045 + None, "--output", "-o", help="Write to file instead of stdout" + ), +) -> None: + """Export papers as BibTeX.""" + from codex.provenance import export_bib + + bib = export_bib(paper_ids if paper_ids else None) + if output: + from pathlib import Path + + Path(output).write_text(bib, encoding="utf-8") + typer.echo(f"Wrote {output}") + else: + typer.echo(bib) + + +@app.command() +def ask(question: str = typer.Argument(..., help="Question to answer")) -> None: + """Ask a question (RAG — not yet implemented).""" + typer.echo("ask: not yet implemented", err=True) + raise typer.Exit(1) diff --git a/tests/cli/__init__.py b/tests/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py new file mode 100644 index 0000000..670b873 --- /dev/null +++ b/tests/cli/test_cli.py @@ -0,0 +1,291 @@ +"""Tests for codex.cli — Typer command-line interface. + +All external calls (ingest, DB, embed, discovery, provenance) are mocked +so the suite runs offline without real API calls. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock, patch + +import numpy as np +from typer.testing import CliRunner + +from codex.cli import app +from codex.ingest import IngestResult +from codex.models import CodeLink + +runner = CliRunner() + + +class TestIngest: + """Tests for `codex ingest` command.""" + + def test_ingest_command(self) -> None: + """Test basic ingest command with mocked ingest_paper.""" + with patch("codex.ingest.ingest_paper") as mock_ingest: + mock_ingest.return_value = IngestResult( + paper_id="2301.07041", + chunks_upserted=5, + citations_upserted=3, + ) + + result = runner.invoke(app, ["ingest", "2301.07041"]) + + assert result.exit_code == 0 + assert "2301.07041" in result.stdout + assert "5 chunks" in result.stdout + assert "3 citations" in result.stdout + + +class TestSearch: + """Tests for `codex search` command.""" + + def test_search_command(self) -> None: + """Test search command with mocked embedder and DB.""" + with ( + patch("codex.embed.get_embedder") as mock_embedder, + patch("codex.db.get_conn") as mock_get_conn, + ): + # Mock embedder - return numpy array with one row + mock_emb_instance = MagicMock() + mock_emb_instance.encode_dense.return_value = np.array([[0.1, 0.2, 0.3]]) + mock_embedder.return_value = mock_emb_instance + + # Mock DB connection + mock_conn = MagicMock() + mock_conn.__enter__.return_value = mock_conn + mock_conn.__exit__.return_value = None + mock_conn.execute.return_value.fetchall.return_value = [ + { + "id": "2301.07041", + "title": "Test Paper", + "year": 2023, + "distance": 0.123, + }, + { + "id": "2301.07042", + "title": "Another Paper", + "year": 2023, + "distance": 0.456, + }, + ] + mock_get_conn.return_value = mock_conn + + result = runner.invoke(app, ["search", "machine learning", "--limit", "2"]) + + assert result.exit_code == 0 + assert "2301.07041" in result.stdout + assert "2301.07042" in result.stdout + assert "Test Paper" in result.stdout + assert "0.123" in result.stdout + + +class TestDiscoverLeads: + """Tests for `codex discover leads` command.""" + + def test_discover_leads_command(self) -> None: + """Test discover leads command with mocked discovery_leads.""" + with patch("codex.discover.discovery_leads") as mock_leads: + mock_leads.return_value = [ + {"cited_id": "2301.07041", "pull": 5}, + {"cited_id": "2301.07042", "pull": 3}, + ] + + result = runner.invoke(app, ["discover", "leads", "--limit", "2"]) + + assert result.exit_code == 0 + assert "2301.07041" in result.stdout + assert "2301.07042" in result.stdout + assert "5×" in result.stdout + assert "3×" in result.stdout + + +class TestDiscoverCiting: + """Tests for `codex discover citing` command.""" + + def test_discover_citing_command(self) -> None: + """Test discover citing command with mocked citing_papers.""" + with patch("codex.discover.citing_papers") as mock_citing: + mock_citing.return_value = ["2301.07041", "2301.07042"] + + result = runner.invoke(app, ["discover", "citing", "2301.07043"]) + + assert result.exit_code == 0 + assert "2301.07041" in result.stdout + assert "2301.07042" in result.stdout + + +class TestDiscoverCitedBy: + """Tests for `codex discover cited-by` command.""" + + def test_discover_cited_by_command(self) -> None: + """Test discover cited-by command with mocked cited_by.""" + with patch("codex.discover.cited_by") as mock_cited: + mock_cited.return_value = ["2301.07041", "2301.07042"] + + result = runner.invoke(app, ["discover", "cited-by", "2301.07043"]) + + assert result.exit_code == 0 + assert "2301.07041" in result.stdout + assert "2301.07042" in result.stdout + + +class TestDiscoverCocited: + """Tests for `codex discover cocited` command.""" + + def test_discover_cocited_command(self) -> None: + """Test discover cocited command with mocked cocited_papers.""" + with patch("codex.discover.cocited_papers") as mock_cocited: + mock_cocited.return_value = [ + {"paper_id": "2301.07041", "co_citations": 5}, + {"paper_id": "2301.07042", "co_citations": 3}, + ] + + result = runner.invoke(app, ["discover", "cocited", "2301.07043"]) + + assert result.exit_code == 0 + assert "2301.07041" in result.stdout + assert "2301.07042" in result.stdout + assert "5×" in result.stdout + assert "3×" in result.stdout + + +class TestProvScan: + """Tests for `codex provenance scan` command.""" + + def test_prov_scan_command(self) -> None: + """Test provenance scan command with mocked scan_cite_tags.""" + with patch("codex.provenance.scan_cite_tags") as mock_scan: + mock_scan.return_value = [ + { + "file": "src/main.cpp", + "line": "42", + "symbol": "compute_embedding", + "bibkey": "2301.07041", + }, + { + "file": "src/utils.h", + "line": "10", + "symbol": "helper_func", + "bibkey": "2301.07042", + }, + ] + + result = runner.invoke(app, ["provenance", "scan", "/path/to/src"]) + + assert result.exit_code == 0 + assert "src/main.cpp" in result.stdout + assert "2301.07041" in result.stdout + assert "compute_embedding" in result.stdout + + +class TestProvAddLink: + """Tests for `codex provenance add-link` command.""" + + def test_prov_add_link_command(self) -> None: + """Test provenance add-link command with mocked add_code_link.""" + with patch("codex.provenance.add_code_link") as mock_add: + mock_link = CodeLink( + id=1, + symbol="my_func", + paper_id="2301.07041", + role="core_algorithm", + note="Primary reference", + added_at="2023-01-01T00:00:00Z", + ) + mock_add.return_value = mock_link + + result = runner.invoke( + app, + [ + "provenance", + "add-link", + "my_func", + "--paper-id", + "2301.07041", + "--role", + "core_algorithm", + "--note", + "Primary reference", + ], + ) + + assert result.exit_code == 0 + assert "Created code_link" in result.stdout + assert "my_func" in result.stdout + assert "2301.07041" in result.stdout + + +class TestProvLinks: + """Tests for `codex provenance links` command.""" + + def test_prov_links_command(self) -> None: + """Test provenance links command with mocked list_code_links.""" + with patch("codex.provenance.list_code_links") as mock_links: + mock_links.return_value = [ + CodeLink( + id=1, + symbol="my_func", + paper_id="2301.07041", + role="core", + note=None, + added_at="2023-01-01T00:00:00Z", + ), + CodeLink( + id=2, + symbol="another_func", + paper_id="2301.07042", + role="utility", + note=None, + added_at="2023-01-02T00:00:00Z", + ), + ] + + result = runner.invoke(app, ["provenance", "links"]) + + assert result.exit_code == 0 + assert "[1]" in result.stdout + assert "[2]" in result.stdout + assert "my_func" in result.stdout + assert "another_func" in result.stdout + + +class TestProvBib: + """Tests for `codex provenance bib` command.""" + + def test_prov_bib_command(self) -> None: + """Test provenance bib command with mocked export_bib.""" + with patch("codex.provenance.export_bib") as mock_export: + mock_export.return_value = "@article{2301.07041,\n title = {Test},\n}" + + result = runner.invoke(app, ["provenance", "bib"]) + + assert result.exit_code == 0 + assert "@article" in result.stdout + assert "2301.07041" in result.stdout + + def test_prov_bib_with_output_file(self, tmp_path: Any) -> None: + """Test provenance bib command writing to file.""" + with patch("codex.provenance.export_bib") as mock_export: + mock_export.return_value = "@article{2301.07041,\n title = {Test},\n}" + + output_file = tmp_path / "refs.bib" + result = runner.invoke(app, ["provenance", "bib", "--output", str(output_file)]) + + assert result.exit_code == 0 + assert "Wrote" in result.stdout + assert output_file.exists() + assert "@article" in output_file.read_text() + + +class TestAsk: + """Tests for `codex ask` command.""" + + def test_ask_command_exits_1(self) -> None: + """Test that ask command exits with code 1 (not yet implemented).""" + result = runner.invoke(app, ["ask", "what is machine learning?"]) + + assert result.exit_code == 1 + assert "not yet implemented" in result.stdout or "not yet implemented" in result.stderr