"""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.") app.add_typer(discover_app, name="discover") app.add_typer(prov_app, name="provenance") app.add_typer(wiki_app, name="wiki") @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 ) -> None: """Ingest a paper into the knowledge base.""" from codex.ingest import ingest_paper result = ingest_paper(paper_id, source_path=source) typer.echo( f"Ingested {result.paper_id}: {result.chunks_upserted} chunks, " f"{result.citations_upserted} citations" ) @app.command() def search( query: str = typer.Argument(..., help="Natural-language search query"), limit: int = typer.Option(10, "--limit", "-n", help="Number of results"), ) -> None: """Semantic similarity search over paper abstracts.""" 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() for row in rows: typer.echo(f"[{row['distance']:.3f}] {row['id']} ({row['year']}) — {row['title']}") @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 if found_ungrounded: raise typer.Exit(1) else: typer.echo("All claims grounded.")