diff --git a/codex/cli.py b/codex/cli.py index 3acac41..a68fff4 100644 --- a/codex/cli.py +++ b/codex/cli.py @@ -188,6 +188,24 @@ def discover_leads( typer.echo(f"{item['pull']:>4}× {item['cited_id']}") +@discover_app.command("recommend") +def discover_recommend( + paper_id: str = typer.Argument( + ..., help="Semantic Scholar paper ID (or arXiv:/DOI: prefixed)." + ), + limit: int = typer.Option(20, "--limit", "-n", help="Number of recommendations."), +) -> None: + """Recommended papers for PAPER_ID via Semantic Scholar.""" + from codex.sources.semanticscholar import fetch_recommendations + + rec = fetch_recommendations(paper_id, limit=limit) + if not rec: + typer.echo(f"No recommendations found for {paper_id}.") + return + for pid in rec: + typer.echo(pid) + + @discover_app.command("citing") def discover_citing(paper_id: str = typer.Argument(...)) -> None: """List papers that cite PAPER_ID.""" @@ -582,7 +600,9 @@ def graph_report( settings = get_settings() with get_conn() as conn: graph = build_citation_graph(conn) - known_ids = {row["id"] for row in conn.execute("SELECT id FROM papers").fetchall()} + paper_rows = conn.execute("SELECT id, title FROM papers").fetchall() + known_ids = {row["id"] for row in paper_rows} + titles = {row["id"]: row["title"] for row in paper_rows} if graph.number_of_nodes() == 0: typer.echo("Citation graph is empty — ingest some papers first.") @@ -592,6 +612,13 @@ def graph_report( pr = citation_pagerank(graph) hubs = sorted(pr.items(), key=lambda x: -x[1])[:top_n] dangling = sorted(dangling_citations(graph, known_ids)) + small_corpus = n_papers < settings.graph_min_corpus_size + warning = ( + f"only {n_papers} ingested papers; recommended >= {settings.graph_min_corpus_size} " + "for meaningful ranking" + if small_corpus + else None + ) if output_json: typer.echo( @@ -599,7 +626,11 @@ def graph_report( { "nodes": graph.number_of_nodes(), "edges": graph.number_of_edges(), - "hubs": [{"paper_id": pid, "pagerank": score} for pid, score in hubs], + "small_corpus_warning": warning, # null unless below threshold (audit U-3) + "hubs": [ + {"paper_id": pid, "title": titles.get(pid), "pagerank": score} + for pid, score in hubs + ], "dangling_count": len(dangling), "dangling": dangling, }, @@ -608,18 +639,16 @@ def graph_report( ) return - if n_papers < settings.graph_min_corpus_size: - typer.echo( - f"Warning: only {n_papers} ingested papers " - f"(recommended ≥ {settings.graph_min_corpus_size} for meaningful ranking).", - err=True, - ) + if warning: + typer.echo(f"Warning: {warning}.", err=True) typer.echo(f"Graph: {graph.number_of_nodes()} nodes, {graph.number_of_edges()} edges") typer.echo("") typer.echo(f"TOP-{top_n} HUB PAPERS (PageRank)") typer.echo("-" * 48) for pid, score in hubs: - typer.echo(f" {score:.4f} {pid}") + # in-KB hubs show their title; dangling (OpenAlex-id) hubs are flagged (U-1) + label = titles.get(pid) or "(dangling — not ingested)" + typer.echo(f" {score:.4f} {pid} {label}") typer.echo("") typer.echo(f"DANGLING CITATIONS ({len(dangling)} cited but not ingested)") typer.echo("-" * 48) @@ -652,3 +681,23 @@ def graph_related( ) for pid in related: typer.echo(f" {pid}") + + +@graph_app.command("cocited") +def graph_cocited( + paper_id: str = typer.Argument(..., help="Paper ID to find co-cited papers for."), +) -> None: + """List papers frequently co-cited with PAPER_ID (graph co-citation).""" + from codex.db import get_conn + from codex.graph import build_citation_graph, find_co_cited + + with get_conn() as conn: + graph = build_citation_graph(conn) + + results = find_co_cited(paper_id, graph) + if not results: + typer.echo(f"No papers co-cited with {paper_id}.") + return + typer.echo(f"Papers co-cited with {paper_id} (count desc):") + for pid, count in results: + typer.echo(f" {count:>3}× {pid}") diff --git a/tests/graph/test_cli.py b/tests/graph/test_cli.py index 32ee60c..e99535b 100644 --- a/tests/graph/test_cli.py +++ b/tests/graph/test_cli.py @@ -28,9 +28,11 @@ def _make_conn_cm(conn: MagicMock): def _make_conn_with_paper_ids(*paper_ids: str) -> MagicMock: - """Mock conn that returns paper rows for SELECT id FROM papers.""" + """Mock conn that returns paper rows for SELECT id, title FROM papers.""" conn = MagicMock() - conn.execute.return_value.fetchall.return_value = [{"id": pid} for pid in paper_ids] + conn.execute.return_value.fetchall.return_value = [ + {"id": pid, "title": f"Title of {pid}"} for pid in paper_ids + ] return conn