Files
codex-py/codex/cli.py
Tarik Moussa b61d84d4bd feat(F-16): chunk quality gate + section classification
- codex/quality.py: 3-signal filter (length / alpha-ratio / bib-score)
  + rule-based section classifier + run_quality_pass retroactive DB pass
- codex/ingest.py: promote quality imports to module level; apply
  filter_chunks before embedding; store section column in chunks INSERT
- codex/config.py: CHUNK_MIN_CHARS / CHUNK_MIN_ALPHA_RATIO / CHUNK_MAX_BIB_SCORE
- infra/schema.sql: ALTER TABLE chunks ADD COLUMN IF NOT EXISTS section TEXT
- .env.example: document F-16 quality thresholds
- codex/cli.py: quality run sub-command (scope by --paper-id or all papers)
- tests/quality/: 35 new tests covering all quality functions
- tests/ingest/: patch filter_chunks in source-path tests to isolate ingest

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 03:22:11 +02:00

499 lines
17 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).")
# 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(search_app, name="search")
@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"),
) -> 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']}")
@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. Without it only connection + topic gaps run."
),
),
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)
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"
)