feat: merge F-07 CLI (Typer commands: ingest, search, discover, provenance, ask-stub)
This commit is contained in:
155
codex/cli.py
155
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)
|
||||
|
||||
0
tests/cli/__init__.py
Normal file
0
tests/cli/__init__.py
Normal file
292
tests/cli/test_cli.py
Normal file
292
tests/cli/test_cli.py
Normal file
@@ -0,0 +1,292 @@
|
||||
"""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 datetime import datetime
|
||||
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=datetime(2023, 1, 1),
|
||||
)
|
||||
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=datetime(2023, 1, 1),
|
||||
),
|
||||
CodeLink(
|
||||
id=2,
|
||||
symbol="another_func",
|
||||
paper_id="2301.07042",
|
||||
role="utility",
|
||||
note=None,
|
||||
added_at=datetime(2023, 1, 2),
|
||||
),
|
||||
]
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user