fix: audit remediation Wave 3 — M-1, MED/LOW sweep, ingest robustness + D-1 close-out #14

Merged
user2595 merged 15 commits from fix/audit-wave-3 into main 2026-06-16 04:54:00 +00:00
2 changed files with 62 additions and 11 deletions
Showing only changes of commit 92928bab99 - Show all commits

View File

@@ -188,6 +188,24 @@ def discover_leads(
typer.echo(f"{item['pull']:>4}× {item['cited_id']}") 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") @discover_app.command("citing")
def discover_citing(paper_id: str = typer.Argument(...)) -> None: def discover_citing(paper_id: str = typer.Argument(...)) -> None:
"""List papers that cite PAPER_ID.""" """List papers that cite PAPER_ID."""
@@ -582,7 +600,9 @@ def graph_report(
settings = get_settings() settings = get_settings()
with get_conn() as conn: with get_conn() as conn:
graph = build_citation_graph(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: if graph.number_of_nodes() == 0:
typer.echo("Citation graph is empty — ingest some papers first.") typer.echo("Citation graph is empty — ingest some papers first.")
@@ -592,6 +612,13 @@ def graph_report(
pr = citation_pagerank(graph) pr = citation_pagerank(graph)
hubs = sorted(pr.items(), key=lambda x: -x[1])[:top_n] hubs = sorted(pr.items(), key=lambda x: -x[1])[:top_n]
dangling = sorted(dangling_citations(graph, known_ids)) 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: if output_json:
typer.echo( typer.echo(
@@ -599,7 +626,11 @@ def graph_report(
{ {
"nodes": graph.number_of_nodes(), "nodes": graph.number_of_nodes(),
"edges": graph.number_of_edges(), "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_count": len(dangling),
"dangling": dangling, "dangling": dangling,
}, },
@@ -608,18 +639,16 @@ def graph_report(
) )
return return
if n_papers < settings.graph_min_corpus_size: if warning:
typer.echo( typer.echo(f"Warning: {warning}.", err=True)
f"Warning: only {n_papers} ingested papers "
f"(recommended ≥ {settings.graph_min_corpus_size} for meaningful ranking).",
err=True,
)
typer.echo(f"Graph: {graph.number_of_nodes()} nodes, {graph.number_of_edges()} edges") typer.echo(f"Graph: {graph.number_of_nodes()} nodes, {graph.number_of_edges()} edges")
typer.echo("") typer.echo("")
typer.echo(f"TOP-{top_n} HUB PAPERS (PageRank)") typer.echo(f"TOP-{top_n} HUB PAPERS (PageRank)")
typer.echo("-" * 48) typer.echo("-" * 48)
for pid, score in hubs: 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("")
typer.echo(f"DANGLING CITATIONS ({len(dangling)} cited but not ingested)") typer.echo(f"DANGLING CITATIONS ({len(dangling)} cited but not ingested)")
typer.echo("-" * 48) typer.echo("-" * 48)
@@ -652,3 +681,23 @@ def graph_related(
) )
for pid in related: for pid in related:
typer.echo(f" {pid}") 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}")

View File

@@ -28,9 +28,11 @@ def _make_conn_cm(conn: MagicMock):
def _make_conn_with_paper_ids(*paper_ids: str) -> 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 = 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 return conn