feat(F-15): citation graph — PageRank, coupling, co-citation, CLI
- codex/graph.py: build_citation_graph (DiGraph from DB), citation_pagerank (graceful uniform fallback < 5 nodes), find_related (bibliographic coupling), find_co_cited, dangling_citations - codex/config.py: graph_cite_boost_alpha=0.3, graph_min_corpus_size=15 - codex/cli.py: graph report [--top-n] [--json], graph related <paper-id> [--min-shared]; search paper --cite-boost (PageRank score weighting) - tests/graph/: 39 tests (graph functions + CLI) — 326 total green Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
107
codex/cli.py
107
codex/cli.py
@@ -14,6 +14,7 @@ prov_app = typer.Typer(help="Provenance: @cite scan, code_links, bib export.")
|
||||
wiki_app = typer.Typer(help="Wiki-compile: grounded concept pages over the RAG substrate.")
|
||||
synth_app = typer.Typer(help="Synthesis: grounded leads and quarantined conjectures (F-13).")
|
||||
quality_app = typer.Typer(help="Chunk quality gate and section classification (F-16).")
|
||||
graph_app = typer.Typer(help="Citation graph analytics: PageRank, coupling, dangling leads (F-15).")
|
||||
# F-09: search sub-app with paper (semantic) and formula (FTS) subcommands
|
||||
search_app = typer.Typer(
|
||||
help="Search: semantic paper search and formula full-text search.",
|
||||
@@ -24,6 +25,7 @@ app.add_typer(prov_app, name="provenance")
|
||||
app.add_typer(wiki_app, name="wiki")
|
||||
app.add_typer(synth_app, name="synthesis")
|
||||
app.add_typer(quality_app, name="quality")
|
||||
app.add_typer(graph_app, name="graph")
|
||||
app.add_typer(search_app, name="search")
|
||||
|
||||
|
||||
@@ -66,8 +68,14 @@ def search_callback(ctx: typer.Context) -> None:
|
||||
def search_paper(
|
||||
query: str = typer.Argument(..., help="Natural-language search query"),
|
||||
limit: int = typer.Option(10, "--limit", "-n", help="Number of results"),
|
||||
cite_boost: bool = typer.Option(
|
||||
False,
|
||||
"--cite-boost",
|
||||
help="Weight results by citation PageRank (F-15). Graceful when corpus < 5 papers.",
|
||||
),
|
||||
) -> None:
|
||||
"""Semantic similarity search over paper abstracts."""
|
||||
from codex.config import get_settings
|
||||
from codex.db import get_conn
|
||||
from codex.embed import get_embedder
|
||||
|
||||
@@ -84,6 +92,24 @@ def search_paper(
|
||||
""",
|
||||
{"emb": emb, "limit": limit},
|
||||
).fetchall()
|
||||
|
||||
if cite_boost and rows:
|
||||
from codex.graph import build_citation_graph, citation_pagerank
|
||||
|
||||
settings = get_settings()
|
||||
graph = build_citation_graph(conn)
|
||||
pr = citation_pagerank(graph)
|
||||
alpha = settings.graph_cite_boost_alpha
|
||||
results = []
|
||||
for row in rows:
|
||||
pr_score = pr.get(row["id"], 0.0)
|
||||
boosted = row["distance"] * (1.0 + alpha * pr_score)
|
||||
results.append((boosted, row))
|
||||
results.sort(key=lambda x: x[0])
|
||||
for boosted_dist, row in results:
|
||||
typer.echo(f"[{boosted_dist:.3f}] {row['id']} ({row['year']}) — {row['title']}")
|
||||
return
|
||||
|
||||
for row in rows:
|
||||
typer.echo(f"[{row['distance']:.3f}] {row['id']} ({row['year']}) — {row['title']}")
|
||||
|
||||
@@ -496,3 +522,84 @@ def quality_run(
|
||||
f"{stats['removed']} removed, "
|
||||
f"{stats['tagged']} section-tagged"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# F-15 — graph sub-commands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@graph_app.command("report")
|
||||
def graph_report(
|
||||
top_n: int = typer.Option(10, "--top-n", "-n", help="Number of hub papers to show."),
|
||||
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
||||
) -> None:
|
||||
"""Show citation graph report: top hub papers, dangling citations, cluster summary."""
|
||||
from codex.db import get_conn
|
||||
from codex.graph import build_citation_graph, citation_pagerank, dangling_citations
|
||||
|
||||
with get_conn() as conn:
|
||||
graph = build_citation_graph(conn)
|
||||
known_ids = {row["id"] for row in conn.execute("SELECT id FROM papers").fetchall()}
|
||||
|
||||
if graph.number_of_nodes() == 0:
|
||||
typer.echo("Citation graph is empty — ingest some papers first.")
|
||||
raise typer.Exit(0)
|
||||
|
||||
pr = citation_pagerank(graph)
|
||||
hubs = sorted(pr.items(), key=lambda x: -x[1])[:top_n]
|
||||
dangling = dangling_citations(graph, known_ids)
|
||||
|
||||
if output_json:
|
||||
typer.echo(
|
||||
json.dumps(
|
||||
{
|
||||
"nodes": graph.number_of_nodes(),
|
||||
"edges": graph.number_of_edges(),
|
||||
"hubs": [{"paper_id": pid, "pagerank": score} for pid, score in hubs],
|
||||
"dangling_count": len(dangling),
|
||||
"dangling": dangling,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
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}")
|
||||
typer.echo("")
|
||||
typer.echo(f"DANGLING CITATIONS ({len(dangling)} cited but not ingested)")
|
||||
typer.echo("-" * 48)
|
||||
for pid in dangling[:top_n]:
|
||||
typer.echo(f" {pid}")
|
||||
if len(dangling) > top_n:
|
||||
typer.echo(f" … and {len(dangling) - top_n} more")
|
||||
|
||||
|
||||
@graph_app.command("related")
|
||||
def graph_related(
|
||||
paper_id: str = typer.Argument(..., help="Paper ID to find related papers for."),
|
||||
min_shared: int = typer.Option(
|
||||
2, "--min-shared", help="Minimum shared references for bibliographic coupling."
|
||||
),
|
||||
) -> None:
|
||||
"""List bibliographically related papers (shared references ≥ min-shared)."""
|
||||
from codex.db import get_conn
|
||||
from codex.graph import build_citation_graph, find_related
|
||||
|
||||
with get_conn() as conn:
|
||||
graph = build_citation_graph(conn)
|
||||
|
||||
related = find_related(paper_id, graph, min_shared=min_shared)
|
||||
if not related:
|
||||
typer.echo(f"No papers with ≥ {min_shared} shared references found for {paper_id}.")
|
||||
return
|
||||
typer.echo(
|
||||
f"Papers related to {paper_id} (bibliographic coupling, ≥ {min_shared} shared refs):"
|
||||
)
|
||||
for pid in related:
|
||||
typer.echo(f" {pid}")
|
||||
|
||||
Reference in New Issue
Block a user