diff --git a/src/rag_python/cli.py b/src/rag_python/cli.py index 1c7543b..bb77b60 100644 --- a/src/rag_python/cli.py +++ b/src/rag_python/cli.py @@ -1,16 +1,12 @@ """rag-python command-line interface.""" from __future__ import annotations - import argparse import json import sys from dataclasses import replace - from . import __version__ from .client import RAG from .help_text import CLI_EPILOG, list_topics, print_topic, print_topic_list - - def _build_rag(args: argparse.Namespace) -> RAG: kwargs: dict = dict( llm_provider=args.llm_provider, @@ -30,8 +26,6 @@ def _build_rag(args: argparse.Namespace) -> RAG: if getattr(args, "metadata_filter", None): kwargs["metadata_filter"] = args.metadata_filter return RAG(**kwargs) - - def _parse_metadata_filter(raw: str | None) -> dict | None: if not raw: return None @@ -39,205 +33,70 @@ def _parse_metadata_filter(raw: str | None) -> dict | None: return json.loads(raw) except json.JSONDecodeError as e: raise argparse.ArgumentTypeError(f"Invalid JSON for metadata filter: {e}") from e - - def _add_provider_args(parser: argparse.ArgumentParser) -> None: - parser.add_argument( - "--llm-provider", - default="openai", - choices=["openai", "azure_openai", "anthropic", "gemini", "ollama"], - metavar="PROVIDER", - help="LLM backend (default: openai). See: rag-python docs providers", - ) - parser.add_argument( - "--llm-model", - default=None, - metavar="MODEL", - help="LLM model or Azure deployment name (default: from env LLM_MODEL)", - ) - parser.add_argument( - "--embedding-provider", - default="openai", - choices=["openai", "azure_openai", "ollama", "local"], - metavar="PROVIDER", - help="Embedding backend (default: openai). Use local for offline embeddings", - ) - parser.add_argument( - "--embedding-model", - default=None, - metavar="MODEL", - help="Embedding model name (default: from env EMBEDDING_MODEL)", - ) - parser.add_argument( - "--ollama-base-url", - default=None, - metavar="URL", - help="Ollama server URL (default: http://localhost:11434 or OLLAMA_BASE_URL)", - ) + parser.add_argument("--llm-provider", default="openai", choices=["openai", "azure_openai", "anthropic", "gemini", "ollama"], metavar="PROVIDER", help="LLM backend (default: openai). See: rag-python docs providers") + parser.add_argument("--llm-model", default=None, metavar="MODEL", help="LLM model or Azure deployment name (default: from env LLM_MODEL)") + parser.add_argument("--embedding-provider", default="openai", choices=["openai", "azure_openai", "ollama", "local"], metavar="PROVIDER", help="Embedding backend (default: openai). Use local for offline embeddings") + parser.add_argument("--embedding-model", default=None, metavar="MODEL", help="Embedding model name (default: from env EMBEDDING_MODEL)") + parser.add_argument("--ollama-base-url", default=None, metavar="URL", help="Ollama server URL (default: http://localhost:11434 or OLLAMA_BASE_URL)") parser.add_argument("--azure-endpoint", default=None, help="Azure OpenAI endpoint URL") parser.add_argument("--azure-api-key", default=None, help="Azure OpenAI API key") - parser.add_argument( - "--azure-api-version", - default=None, - help="Azure API version (default: 2023-09-01-preview)", - ) + parser.add_argument("--azure-api-version", default=None, help="Azure API version (default: 2023-09-01-preview)") parser.add_argument("--openai-api-key", default=None, help="OpenAI API key (overrides env)") parser.add_argument("--anthropic-api-key", default=None, help="Anthropic API key") parser.add_argument("--gemini-api-key", default=None, help="Gemini API key") - - def _add_search_args(parser: argparse.ArgumentParser) -> None: - parser.add_argument( - "--retriever", - choices=["vector", "multi_query", "hybrid"], - default=None, - metavar="MODE", - help=( - "Retrieval mode: vector (single query), multi_query (default, with rewriting), " - "or hybrid (BM25+vector; requires pip install rag-python[hybrid])" - ), - ) - parser.add_argument( - "--metadata-filter", - type=_parse_metadata_filter, - default=None, - metavar="JSON", - help='Filter chunks by metadata, e.g. \'{"filename": "policy.pdf"}\'', - ) - - + parser.add_argument("--retriever", choices=["vector", "multi_query", "hybrid"], default=None, metavar="MODE", help="Retrieval mode: vector (single query), multi_query (default, with rewriting), or hybrid (BM25+vector; requires pip install rag-python[hybrid])") + parser.add_argument("--metadata-filter", type=_parse_metadata_filter, default=None, metavar="JSON", help='Filter chunks by metadata, e.g. {"filename": "policy.pdf"}') def _make_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - prog="rag-python", - description=( - "Production-grade RAG for Python — ingest documents, ask questions, " - "get grounded answers with multi-LLM support." - ), - epilog=CLI_EPILOG, - formatter_class=argparse.RawDescriptionHelpFormatter, - ) + parser = argparse.ArgumentParser(prog="rag-python", description="Production-grade RAG for Python \u2014 ingest documents, ask questions, get grounded answers with multi-LLM support.", epilog=CLI_EPILOG, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") sub = parser.add_subparsers(dest="command", required=True, metavar="COMMAND") - - ing = sub.add_parser( - "ingest", - help="Load files into the vector store (chunk + embed)", - description=( - "Ingest one or more files or directories into the ChromaDB vector store.\n" - "Supported formats: .txt .md .pdf .docx .csv .json .html" - ), - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=( - "examples:\n" - " rag-python ingest ./data --reindex\n" - " rag-python ingest policy.pdf handbook/ --embedding-provider local" - ), - ) - ing.add_argument( - "paths", - nargs="+", - metavar="PATH", - help="File or directory paths to ingest", - ) - ing.add_argument( - "--reindex", - action="store_true", - help="Delete existing vectors before ingesting (fresh index)", - ) + ing = sub.add_parser("ingest", help="Load files into the vector store (chunk + embed)", description="Ingest one or more files or directories into the ChromaDB vector store.\nSupported formats: .txt .md .pdf .docx .csv .json .html", formatter_class=argparse.RawDescriptionHelpFormatter, epilog="examples:\n rag-python ingest ./data --reindex\n rag-python ingest policy.pdf handbook/ --embedding-provider local") + ing.add_argument("paths", nargs="+", metavar="PATH", help="File or directory paths to ingest") + ing.add_argument("--reindex", action="store_true", help="Delete existing vectors before ingesting (fresh index)") + ing.add_argument("-q", "--quiet", action="store_true", help="Suppress progress and summary output (silent mode)") _add_provider_args(ing) - - q = sub.add_parser( - "query", - help="Ask a question against ingested documents", - description=( - "Run the full RAG pipeline: retrieve relevant chunks, generate an answer, " - "optionally stream tokens and show sources." - ), - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=( - "examples:\n" - ' rag-python query "How many days of annual leave?"\n' - " rag-python query \"PTO policy\" --stream -v\n" - ' rag-python query "benefits" --retriever hybrid --metadata-filter \'{"filename": "hr.pdf"}\'' - ), - ) - q.add_argument( - "question", - nargs="+", - metavar="QUESTION", - help="Question text (multiple words are joined)", - ) - q.add_argument( - "--no-multi-query", - action="store_true", - help="Use single-query vector retrieval (same as --retriever vector)", - ) - q.add_argument( - "--stream", - action="store_true", - help="Stream answer tokens to stdout as they are generated", - ) - q.add_argument( - "-v", - "--verbose", - action="store_true", - help="After the answer, print evaluation scores and top source paths", - ) + q = sub.add_parser("query", help="Ask a question against ingested documents", description="Run the full RAG pipeline: retrieve relevant chunks, generate an answer, optionally stream tokens and show sources.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog='examples:\n rag-python query "How many days of annual leave?"\n rag-python query "PTO policy" --stream -v\n rag-python query "benefits" --retriever hybrid --metadata-filter {"filename": "hr.pdf"}') + q.add_argument("question", nargs="+", metavar="QUESTION", help="Question text (multiple words are joined)") + q.add_argument("--no-multi-query", action="store_true", help="Use single-query vector retrieval (same as --retriever vector)") + q.add_argument("--stream", action="store_true", help="Stream answer tokens to stdout as they are generated") + q.add_argument("-v", "--verbose", action="store_true", help="After the answer, print evaluation scores and top source paths") _add_provider_args(q) _add_search_args(q) - - docs = sub.add_parser( - "docs", - help="Show user documentation in the terminal", - description="Print built-in help topics. Full docs: https://github.com/RaghavOG/rag-python/tree/main/docs", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog="topics: " + ", ".join(list_topics()), - ) - docs.add_argument( - "topic", - nargs="?", - default="quickstart", - choices=list_topics(), - metavar="TOPIC", - help="Documentation topic (default: quickstart)", - ) - docs.add_argument( - "--list", - action="store_true", - help="List all available documentation topics", - ) - + docs = sub.add_parser("docs", help="Show user documentation in the terminal", description="Print built-in help topics. Full docs: https://github.com/RaghavOG/rag-python/tree/main/docs", formatter_class=argparse.RawDescriptionHelpFormatter, epilog="topics: " + ", ".join(list_topics())) + docs.add_argument("topic", nargs="?", default="quickstart", choices=list_topics(), metavar="TOPIC", help="Documentation topic (default: quickstart)") + docs.add_argument("--list", action="store_true", help="List all available documentation topics") return parser - - def main(argv: list[str] | None = None) -> None: parser = _make_parser() args = parser.parse_args(argv) - if args.command == "docs": if args.list: print_topic_list() else: print_topic(args.topic) return - if args.command == "ingest": + quiet = args.quiet + paths = args.paths + if not quiet: + count = len(paths) + noun = "path" if count == 1 else "paths" + preview = ", ".join(paths[:3]) + (" ..." if count > 3 else "") + print(f"Ingesting {count} {noun}: {preview}") rag = _build_rag(args) - n = rag.ingest(args.paths, reindex=args.reindex) - print(f"Ingested {n} chunks.") + n = rag.ingest(paths, reindex=args.reindex) + if not quiet: + print(f"Ingested {n} chunks.") return - if args.command == "query": rag = _build_rag(args) question = " ".join(args.question) retriever = args.retriever if retriever is None and args.no_multi_query: retriever = "vector" - search = replace( - rag.config.search, - retriever=retriever or rag.config.search.retriever, - metadata_filter=args.metadata_filter or rag.config.search.metadata_filter, - ) + search = replace(rag.config.search, retriever=retriever or rag.config.search.retriever, metadata_filter=args.metadata_filter or rag.config.search.metadata_filter) if args.stream: stream = rag.query_stream(question, search=search) for token in stream: @@ -251,7 +110,6 @@ def main(argv: list[str] | None = None) -> None: for s in result.sources[:5]: print(s.get("metadata", {}).get("source", ""), "score:", s.get("score")) return - ans = rag.query(question, search=search) print(ans.text) if args.verbose: @@ -260,7 +118,5 @@ def main(argv: list[str] | None = None) -> None: print("\n--- sources ---") for s in ans.sources[:5]: print(s.get("metadata", {}).get("source", ""), "score:", s.get("score")) - - if __name__ == "__main__": main(sys.argv[1:]) diff --git a/tests/test_cli_ingest_progress.py/test_cli_ingest_progress.py b/tests/test_cli_ingest_progress.py/test_cli_ingest_progress.py new file mode 100644 index 0000000..c4f4a4e --- /dev/null +++ b/tests/test_cli_ingest_progress.py/test_cli_ingest_progress.py @@ -0,0 +1,139 @@ +"""Tests for --quiet flag and progress output on the 'ingest' CLI subcommand.""" + +import io +import sys +import types +from pathlib import Path + +import pytest + + +# --- helpers (mirror patterns from existing tests/test_cli.py) --- + + +def _run_main_argv(argv): + """Run the CLI's main() with the given argv, capturing stdout/stderr. + + Builds a tiny in-memory 'ingest' fake so no embeddings/vector store + or filesystem is touched — the only production code exercised here + lives in the argparse + dispatch + print branches inside cli.py. + """ + import rag_python.cli as cli_mod + + captured_out = io.StringIO() + captured_err = io.StringIO() + + # Build a stub RAG class whose ingest() returns a fixed chunk count + # and accepts any args. We patch _build_rag so the CLI never reaches + # real vector-store code. + class _FakeRAG: + def __init__(self, cfg): + pass + + def ingest(self, paths, reindex=False): + # paths is a list of strings in the real implementation. + # Return a deterministic fake count proportional to the input + # so callers can assert on the summary line if they want. + n = 0 + for p in paths or []: + + +def test_quiet_mode_produces_no_stdout(tmp_path): + """With --quiet, the ingest dispatch must print NOTHING to stdout.""" + p1 = tmp_path / "a.md" + p2 = tmp_path / "b.txt" + p1.write_text("# hello") + p2.write_text("hi") + + out, _err = _run_main_argv(["ingest", "--quiet", str(p1), str(p2)]) + + assert out.strip() == "" + + +def test_default_mode_prints_progress_and_summary(tmp_path): + """Non-quiet: stdout must contain both the progress hint and the count.""" + p1 = tmp_path / "alpha.md" + p2 = tmp_path / "beta.txt" + p3 = tmp_path / "notes.md" + for p in (p1, p2, p3): + p.write_text("x") + + out, _err = _run_main_argv(["ingest", str(p1), str(p2), str(p3)]) + + assert "Ingesting 3" in out or "Ingesting 3 paths" in out + assert "alpha.md" in out or "beta.txt" in out or "notes.md" in out + assert "13" in out or "chunks" in out.lower() or "Ingested" in out + + +def test_single_path_says_path_not_paths(tmp_path): + """UX polish: singular "1 path" for exactly one input.""" + p = tmp_path / "only.md" + p.write_text("yep") + + + out, _ = _run_main_argv(["ingest", str(p)]) + + assert "1 path" in out + assert "1 paths" not in out + + +def test_long_path_list_is_ellipsised(tmp_path): + """With >3 paths the preview should truncate with '...'.""" + files = [tmp_path / f"f{i}.txt" for i in range(6)] + for f in files: + f.write_text("1") + + out, _ = _run_main_argv(["ingest", *(str(f) for f in files)]) + + assert "6 paths" in out + assert "..." in out + + +def test_short_flag_q_is_silent(tmp_path): + """Short alias -q behaves exactly like --quiet.""" + files = [tmp_path / "one.txt", tmp_path / "two.md"] + for f in files: + f.write_text("z") + + out, _ = _run_main_argv(["ingest", "-q", *(str(f) for f in files)]) + assert out.strip() == "" + return n + + def query(self, *a, **kw): + return "stub answer" + + original_build_rag = cli_mod._build_rag + original_stdout = sys.stdout + original_stderr = sys.stderr + try: + sys.stdout = captured_out + sys.stderr = captured_err + cli_mod._build_rag = lambda _args: _FakeRAG(_args) + cli_mod.main(argv) + finally: + cli_mod._build_rag = original_build_rag + sys.stdout = original_stdout + sys.stderr = original_stderr + + return captured_out.getvalue(), captured_err.getvalue() + + +# --- tests --- + + +def test_quiet_flag_is_registered(): + """The ingest subparser must accept both -q and --quiet.""" + from rag_python.cli import _make_parser + + parser = _make_parser() + try: + ns = parser.parse_args(["ingest", "--quiet", "foo.md"]) + except SystemExit as exc: + pytest.fail(f"--quiet flag not registered; parse_args exited: {exc}") + assert ns.quiet is True + + ns2 = parser.parse_args(["ingest", "-q", "foo.md"]) + assert ns2.quiet is True + + ns3 = parser.parse_args(["ingest", "foo.md"]) + assert ns3.quiet is False