"""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).get_secret_value() 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("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.""" 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) 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.") 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)) 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( json.dumps( { "nodes": graph.number_of_nodes(), "edges": graph.number_of_edges(), "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, }, indent=2, ) ) return 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: # 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) 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}") @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}")