Files
codex-py/codex/cli.py
Tarik Moussa c4106b0f51 fix(synthesis): make coverage-map gaps opt-in (audit R-8)
find_gaps defaulted its probe topics to paper titles, so a title — which
retrieves mostly its own single bibkey — fell below synthesis_gap_min_coverage
and flagged almost every paper as a 'gap'. Coverage-map gaps now run only for
explicitly-requested topics; the CLI gains a repeatable --topic option. The
code-vs-corpus gap path (precise) is unchanged and still default.

Tests: the two cases that relied on default-title coverage gaps now pass explicit
topics; added a regression that no coverage gaps appear without --topic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 16:32:01 +02:00

655 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""codex CLI — commands wired to domain modules."""
from __future__ import annotations
import json
from datetime import UTC, datetime
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.")
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.",
invoke_without_command=True,
)
app.add_typer(discover_app, name="discover")
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")
@app.command()
def migrate() -> None:
"""Apply infra/schema.sql to bring the database schema up to date (idempotent).
Must connect as a role that can run DDL (owns / can CREATE + ALTER the tables).
The application's DATABASE_URL is usually a least-privilege DML role and will
fail with InsufficientPrivilege; set MIGRATION_DATABASE_URL to a privileged
(owner/superuser) connection (audit M-1).
"""
import psycopg
import psycopg.rows
from codex.config import get_settings
from codex.db import apply_schema
settings = get_settings()
url = settings.migration_database_url or settings.database_url
try:
with psycopg.connect(url, row_factory=psycopg.rows.dict_row) as conn:
apply_schema(conn)
except psycopg.errors.InsufficientPrivilege as exc:
typer.echo(
"ERROR: schema migration needs a role that owns the tables "
"(CREATE/ALTER). Set MIGRATION_DATABASE_URL to a privileged connection "
f"and retry. ({exc})",
err=True,
)
raise typer.Exit(1) from exc
typer.echo("Schema applied — database is up to date.")
@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
rich: bool = typer.Option( # noqa: A002
False,
"--rich",
help="Extract formulas and figures (requires PDF source, F-09).",
),
) -> None:
"""Ingest a paper into the knowledge base."""
from codex.ingest import ingest_paper
result = ingest_paper(paper_id, source_path=source, rich=rich)
typer.echo(
f"Ingested {result.paper_id}: {result.chunks_upserted} chunks, "
f"{result.citations_upserted} citations, "
f"{result.formulas_upserted} formulas, "
f"{result.figures_upserted} figures"
)
# ---------------------------------------------------------------------------
# F-09: Search command group
# ---------------------------------------------------------------------------
@search_app.callback(invoke_without_command=True)
def search_callback(ctx: typer.Context) -> None:
"""Search subcommands: ``paper`` for semantic search, ``formula`` for LaTeX FTS."""
if ctx.invoked_subcommand is None:
typer.echo(ctx.get_help())
raise typer.Exit(0)
@search_app.command("paper")
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
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()
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)
# distance is lower=better; divide to reduce distance for high-PR papers
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']}")
@search_app.command("formula")
def search_formula(
query: str = typer.Argument(..., help="LaTeX snippet or keyword to search in formulas"),
limit: int = typer.Option(10, "--limit", "-n", help="Number of results"),
) -> None:
"""Full-text search over extracted LaTeX formulas (F-09)."""
from codex.db import get_conn
with get_conn() as conn:
rows = conn.execute(
"""
SELECT paper_id, page, raw_latex, context,
ts_rank(to_tsvector('english', raw_latex || ' ' || coalesce(context, '')),
plainto_tsquery('english', %(query)s)) AS rank
FROM formulas
WHERE to_tsvector('english', raw_latex || ' ' || coalesce(context, ''))
@@ plainto_tsquery('english', %(query)s)
ORDER BY rank DESC
LIMIT %(limit)s
""",
{"query": query, "limit": limit},
).fetchall()
if not rows:
typer.echo("No matching formulas found.")
return
for row in rows:
typer.echo(
f"[{row['rank']:.3f}] {row['paper_id']} p.{row['page']} {row['raw_latex'][:80]}"
)
@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)
# ---------------------------------------------------------------------------
# F-12: Wiki command group
# ---------------------------------------------------------------------------
@wiki_app.command("compile")
def wiki_compile(
concept: Optional[str] = typer.Option( # noqa: UP045
None, "--concept", help="Compile only this concept slug."
),
all_concepts: bool = typer.Option(
False, "--all", help="Force full recompile (ignore change detection)."
),
top_k: int = typer.Option(0, "--top-k", help="Override config.wiki_top_k (0 = use config)."),
output_dir: Optional[str] = typer.Option( # noqa: UP045
None, "--output-dir", help="Override config.wiki_dir."
),
) -> None:
"""Compile concept pages from retrieved chunks via local LLM.
By default only recompiles concepts whose source chunks have changed
(hash-based incremental). Use --all to force full recompile.
"""
from codex.wiki import compile_all
report = compile_all(
changed_only=not all_concepts,
top_k=top_k if top_k > 0 else None,
concept_filter=concept,
output_dir=output_dir,
)
if report.compiled:
typer.echo(f"Compiled: {', '.join(report.compiled)}")
if report.skipped:
typer.echo(f"Skipped (unchanged): {', '.join(report.skipped)}")
if report.ungrounded:
typer.echo(f"⚠ Ungrounded claims: {len(report.ungrounded)}", err=True)
for slug, text in report.ungrounded:
typer.echo(f" [{slug}] {text[:100]}", err=True)
@wiki_app.command("list")
def wiki_list(
output_dir: Optional[str] = typer.Option( # noqa: UP045
None, "--output-dir", help="Override config.wiki_dir."
),
) -> None:
"""List compiled concept pages with freshness information."""
import json as _json
from pathlib import Path
from codex.config import get_settings
settings = get_settings()
wiki_dir = Path(output_dir or settings.wiki_dir)
state_path = wiki_dir / ".compile-state.json"
state: dict[str, str] = {}
if state_path.exists():
try:
state = _json.loads(state_path.read_text(encoding="utf-8"))
except (_json.JSONDecodeError, OSError):
state = {}
pages = sorted(wiki_dir.glob("*.md"))
if not pages:
typer.echo("No compiled pages found.")
return
for page in pages:
if page.name in ("index.md", "log.md"):
continue
slug = page.stem
hash_val = state.get(slug, "")[:8]
content = page.read_text(encoding="utf-8")
n_claims = content.count("[")
n_ungrounded = content.count("")
mtime = datetime.fromtimestamp(page.stat().st_mtime, tz=UTC).strftime("%Y-%m-%d %H:%M")
typer.echo(
f"{slug:40s} last={mtime} hash={hash_val} claims≈{n_claims} ⚠={n_ungrounded}"
)
@wiki_app.command("check")
def wiki_check(
output_dir: Optional[str] = typer.Option( # noqa: UP045
None, "--output-dir", help="Override config.wiki_dir."
),
) -> None:
"""Check all compiled pages for ungrounded claims.
Exits with code 0 if all claims are grounded, 1 if any are ungrounded.
Suitable for CI pipelines.
"""
from pathlib import Path
from codex.config import get_settings
settings = get_settings()
wiki_dir = Path(output_dir or settings.wiki_dir)
pages = sorted(wiki_dir.glob("*.md"))
found_ungrounded = False
for page in pages:
if page.name in ("index.md", "log.md"):
continue
content = page.read_text(encoding="utf-8")
if "" in content:
typer.echo(f"⚠ Ungrounded claim(s) in: {page.name}", err=True)
found_ungrounded = True
# Check for quarantined pages in wiki/draft/
draft_dir = wiki_dir / "draft"
draft_pages = sorted(draft_dir.glob("*.md")) if draft_dir.exists() else []
if draft_pages:
n = len(draft_pages)
typer.echo(
f"{n} page(s) quarantined in wiki/draft/ (grounding rate below threshold)",
err=True,
)
for dp in draft_pages:
typer.echo(f" - {dp.name}", err=True)
raise typer.Exit(2)
if found_ungrounded:
raise typer.Exit(1)
else:
typer.echo("All claims grounded.")
# ---------------------------------------------------------------------------
# F-13: Synthesis command group
# ---------------------------------------------------------------------------
@synth_app.command("leads")
def synthesis_leads(
lib_path: Optional[str] = typer.Option( # noqa: UP045
None,
"--lib-path",
help=(
"Path to a C++ source tree. Enables improvement leads (@cite-aware) and "
"code-vs-corpus gap detection."
),
),
topic: list[str] | None = typer.Option( # noqa: B008
None,
"--topic",
help=(
"Topic to check for coverage gaps (repeatable). Opt-in: without --topic "
"no coverage-map gaps run (avoids per-paper false positives, audit R-8)."
),
),
output_dir: Optional[str] = typer.Option( # noqa: UP045
None, "--output-dir", help="Override config.leads_dir."
),
) -> None:
"""Generate grounded leads (stages 2-4) and write them to leads/grounded/."""
from codex.config import get_settings
from codex.synthesis import (
default_llm_client,
find_connections,
find_gaps,
find_improvements,
write_leads,
)
settings = get_settings()
leads_dir = output_dir or settings.leads_dir
llm = default_llm_client()
connections = find_connections(top_k=settings.synthesis_top_k, llm=llm)
gaps = find_gaps(lib_path=lib_path, llm=llm, topics=topic or None)
improvements = (
find_improvements(lib_path, top_k=settings.synthesis_top_k, llm=llm) if lib_path else []
)
all_leads = connections + gaps + improvements
write_leads(all_leads, leads_dir)
typer.echo(
f"Grounded leads written to {leads_dir}/grounded/: "
f"{len(connections)} connection · {len(gaps)} gap · {len(improvements)} improvement"
)
@synth_app.command("conjectures")
def synthesis_conjectures(
output_dir: Optional[str] = typer.Option( # noqa: UP045
None, "--output-dir", help="Override config.leads_dir."
),
) -> None:
"""Generate stage-5 conjecture leads — quarantined to leads/conjectures/.
INVARIANT (F-13 HARD): conjectures are NEVER written to wiki/. They
require a ``suggested_validation`` spike before they may be promoted.
"""
from codex.config import get_settings
from codex.synthesis import default_llm_client, propose_conjectures, write_leads
settings = get_settings()
leads_dir = output_dir or settings.leads_dir
llm = default_llm_client()
conjectures = propose_conjectures(top_k=settings.synthesis_top_k, llm=llm)
write_leads(conjectures, leads_dir)
typer.echo(f"Conjectures (UNVERIFIED) written to {leads_dir}/conjectures/: {len(conjectures)}")
@synth_app.command("report")
def synthesis_report(
output_dir: Optional[str] = typer.Option( # noqa: UP045
None, "--output-dir", help="Override config.leads_dir."
),
) -> None:
"""Show grounded leads and conjectures — visibly separated."""
from pathlib import Path
from codex.config import get_settings
settings = get_settings()
leads_dir = Path(output_dir or settings.leads_dir)
grounded_dir = leads_dir / "grounded"
conj_dir = leads_dir / "conjectures"
grounded_files = sorted(grounded_dir.glob("*.md")) if grounded_dir.exists() else []
conj_files = sorted(conj_dir.glob("*.md")) if conj_dir.exists() else []
typer.echo("=" * 72)
typer.echo(f"GROUNDED LEADS ({len(grounded_files)}) → {grounded_dir}")
typer.echo("=" * 72)
if not grounded_files:
typer.echo("(none)")
for f in grounded_files:
first = f.read_text(encoding="utf-8").splitlines()[0]
typer.echo(f" {first}")
typer.echo("")
typer.echo("=" * 72)
typer.echo(f"CONJECTURES ({len(conj_files)}) → {conj_dir} [UNVERIFIED — never in wiki/]")
typer.echo("=" * 72)
if not conj_files:
typer.echo("(none)")
for f in conj_files:
first = f.read_text(encoding="utf-8").splitlines()[0]
typer.echo(f" {first}")
# ---------------------------------------------------------------------------
# F-16 — quality sub-commands
# ---------------------------------------------------------------------------
@quality_app.command("run")
def quality_run(
paper_id: Optional[str] = typer.Option( # noqa: UP045
None,
"--paper-id",
help="Restrict pass to one paper (omit to process all papers).",
),
) -> None:
"""Apply quality gate + section classification to existing chunks.
Iterates over chunks in the database, removes those that fail the
quality thresholds, and back-fills the ``section`` column for the rest.
"""
from codex.config import get_settings
from codex.db import get_conn
from codex.quality import run_quality_pass
settings = get_settings()
with get_conn() as conn:
stats = run_quality_pass(paper_id=paper_id, conn=conn, settings=settings)
scope = f"paper {paper_id}" if paper_id else "all papers"
typer.echo(
f"Quality pass ({scope}): "
f"{stats['kept']} kept, "
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.config import get_settings
from codex.db import get_conn
from codex.graph import build_citation_graph, citation_pagerank, dangling_citations
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()}
if graph.number_of_nodes() == 0:
typer.echo("Citation graph is empty — ingest some papers first.")
return
n_papers = len(known_ids)
pr = citation_pagerank(graph)
hubs = sorted(pr.items(), key=lambda x: -x[1])[:top_n]
dangling = sorted(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
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,
)
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}")