Spaces:
Sleeping
Sleeping
| """Tests for ragqa.cli. | |
| The CLI wires modules together. Tests use a real chunker (with the | |
| `make_pdf` fixture) but mock the embedder (no torch load) and LLM | |
| (no HTTP). That keeps each test < 100ms but exercises the actual | |
| file paths and command parsing. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import os | |
| from pathlib import Path | |
| from unittest.mock import patch, MagicMock | |
| import numpy as np | |
| import pytest | |
| from ragqa.cli import main | |
| # βββββββββββββββββββββββββ fakes βββββββββββββββββββββββββββββββββββββββββββ | |
| def _patched_embedder(dim: int = 4): | |
| """Patch ragqa.embedding.SentenceTransformer so Embedder doesn't load | |
| PyTorch + the 80MB model in tests.""" | |
| mock_st = MagicMock() | |
| mock_st.get_sentence_embedding_dimension.return_value = dim | |
| def encode_side_effect(texts, normalize_embeddings=True, **_): | |
| rng = np.random.RandomState(0) | |
| arr = rng.randn(len(texts), dim).astype(np.float32) | |
| if normalize_embeddings: | |
| arr /= np.linalg.norm(arr, axis=1, keepdims=True) | |
| return arr | |
| mock_st.encode.side_effect = encode_side_effect | |
| return mock_st | |
| # βββββββββββββββββββββββββ ingest ββββββββββββββββββββββββββββββββββββββββββ | |
| def test_ingest_creates_index_from_single_pdf(make_pdf, tmp_path, capsys): | |
| pdf = make_pdf(["The cat sat on the mat. " * 20]) | |
| out_dir = tmp_path / "myindex" | |
| with patch("ragqa.embedding.SentenceTransformer", | |
| return_value=_patched_embedder()): | |
| rc = main(["ingest", "-i", str(pdf), "-o", str(out_dir)]) | |
| assert rc == 0 | |
| assert (out_dir / "index.faiss").is_file() | |
| assert (out_dir / "chunks.json").is_file() | |
| assert (out_dir / "meta.json").is_file() | |
| meta = json.loads((out_dir / "meta.json").read_text()) | |
| assert meta["n_chunks"] >= 1 | |
| def test_ingest_creates_index_from_directory(make_pdf, tmp_path): | |
| pdf_dir = tmp_path / "pdfs" | |
| pdf_dir.mkdir() | |
| # Put two PDFs in a directory; the ingester should pick up both. | |
| p1 = make_pdf(["Doc one content. " * 20]) | |
| p2 = make_pdf(["Doc two content. " * 20]) | |
| (pdf_dir / "a.pdf").write_bytes(p1.read_bytes()) | |
| (pdf_dir / "b.pdf").write_bytes(p2.read_bytes()) | |
| out_dir = tmp_path / "idx" | |
| with patch("ragqa.embedding.SentenceTransformer", | |
| return_value=_patched_embedder()): | |
| rc = main(["ingest", "-i", str(pdf_dir), "-o", str(out_dir)]) | |
| assert rc == 0 | |
| chunks = json.loads((out_dir / "chunks.json").read_text()) | |
| sources = {c["source_file"] for c in chunks} | |
| assert sources == {"a.pdf", "b.pdf"} | |
| def test_ingest_with_no_pdfs_exits_error(tmp_path, capsys): | |
| empty_dir = tmp_path / "empty" | |
| empty_dir.mkdir() | |
| out_dir = tmp_path / "idx" | |
| rc = main(["ingest", "-i", str(empty_dir), "-o", str(out_dir)]) | |
| assert rc != 0 | |
| err = capsys.readouterr().err.lower() | |
| assert "no pdf" in err or "not found" in err | |
| # βββββββββββββββββββββββββ ask βββββββββββββββββββββββββββββββββββββββββββββ | |
| def _build_index_for_ask(make_pdf, tmp_path): | |
| """Helper: ingest a tiny PDF so the ask tests have something to load.""" | |
| pdf = make_pdf(["The capital of France is Paris. " * 5]) | |
| out = tmp_path / "idx" | |
| with patch("ragqa.embedding.SentenceTransformer", | |
| return_value=_patched_embedder()): | |
| rc = main(["ingest", "-i", str(pdf), "-o", str(out)]) | |
| assert rc == 0 | |
| return out | |
| def test_ask_without_api_key_exits_error(make_pdf, tmp_path, capsys, monkeypatch): | |
| monkeypatch.delenv("GROQ_API_KEY", raising=False) | |
| idx = _build_index_for_ask(make_pdf, tmp_path) | |
| rc = main(["ask", "--index", str(idx), "What is the capital?"]) | |
| assert rc != 0 | |
| err = capsys.readouterr().err.lower() | |
| assert "groq_api_key" in err | |
| def test_ask_loads_index_and_prints_answer(make_pdf, tmp_path, capsys, monkeypatch): | |
| idx = _build_index_for_ask(make_pdf, tmp_path) | |
| monkeypatch.setenv("GROQ_API_KEY", "fake") | |
| # Mock both the embedder (for query encode) and the LLM HTTP layer. | |
| with patch("ragqa.embedding.SentenceTransformer", | |
| return_value=_patched_embedder()): | |
| mock_resp = MagicMock(status_code=200) | |
| mock_resp.json.return_value = { | |
| "choices": [{"message": {"content": "Paris [1]."}}] | |
| } | |
| with patch("ragqa.llm.requests.post", return_value=mock_resp): | |
| # Force min_score = 0 so the random-vector fake retrieval | |
| # actually returns chunks. | |
| rc = main([ | |
| "ask", "--index", str(idx), "--min-score", "0", | |
| "What is the capital?", | |
| ]) | |
| assert rc == 0 | |
| out = capsys.readouterr().out | |
| assert "Paris" in out | |
| def test_ask_prints_sources_section_when_citations_present( | |
| make_pdf, tmp_path, capsys, monkeypatch, | |
| ): | |
| idx = _build_index_for_ask(make_pdf, tmp_path) | |
| monkeypatch.setenv("GROQ_API_KEY", "fake") | |
| with patch("ragqa.embedding.SentenceTransformer", | |
| return_value=_patched_embedder()): | |
| mock_resp = MagicMock(status_code=200) | |
| mock_resp.json.return_value = { | |
| "choices": [{"message": {"content": "Paris [1]."}}] | |
| } | |
| with patch("ragqa.llm.requests.post", return_value=mock_resp): | |
| main([ | |
| "ask", "--index", str(idx), "--min-score", "0", | |
| "What is the capital?", | |
| ]) | |
| out = capsys.readouterr().out | |
| # Citation block should reference the source PDF and a page number. | |
| assert "Sources" in out or "sources" in out | |
| assert "page" in out.lower() | |
| # βββββββββββββββββββββββββ help + parser βββββββββββββββββββββββββββββββββββ | |
| def test_help_exits_zero(capsys): | |
| """`ragqa --help` should exit 0 with usage info.""" | |
| with pytest.raises(SystemExit) as exc: | |
| main(["--help"]) | |
| assert exc.value.code == 0 | |
| def test_no_subcommand_exits_nonzero(capsys): | |
| """Calling `ragqa` with no subcommand should error, not silently do nothing.""" | |
| with pytest.raises(SystemExit) as exc: | |
| main([]) | |
| assert exc.value.code != 0 | |