feat(cli): graph-report titles + JSON warning; wire recommend/cocited (U-1,U-3,U-4,C-9)
- U-1: 'graph report' shows the paper title next to each in-KB hub (dangling OpenAlex-id hubs are flagged), instead of bare ids. - U-3: '--json' now includes a 'small_corpus_warning' field (null unless below graph_min_corpus_size) instead of silently dropping the warning. - C-9: new 'codex discover recommend <id>' wires semanticscholar.fetch_recommendations (was implemented + tested but unreachable). - U-4: new 'codex graph cocited <id>' wires graph.find_co_cited (likewise). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
67
codex/cli.py
67
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}")
|
||||
|
||||
Reference in New Issue
Block a user