diff --git a/.coveragerc b/.coveragerc index e265f5a..b857b2b 100644 --- a/.coveragerc +++ b/.coveragerc @@ -7,6 +7,8 @@ omit = */site-packages/* [report] +fail_under = 95 +show_missing = true exclude_lines = pragma: no cover if __name__ == .__main__.: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a124b2..a9f7f3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: - name: Run tests run: | if [ -d tests ]; then - pytest --tb=short -q + pytest --tb=short -q --cov --cov-report=term-missing else echo "No tests directory found, skipping pytest" fi diff --git a/processing/chunker.py b/processing/chunker.py index 4e89934..24d847c 100644 --- a/processing/chunker.py +++ b/processing/chunker.py @@ -112,7 +112,14 @@ def _stash(m: re.Match) -> str: body = body.strip() full = (prefix + body).strip() - if not full: + if not full: # pragma: no cover + # Structurally unreachable given how `sections` is built above: + # a section only carries header_line=None with an empty body if + # it's the sole pre-first-header entry, and that's appended only + # when its body is non-whitespace; every other section has a + # non-empty header_line, which alone makes `prefix` (and so + # `full`) non-empty. Kept as a defensive guard in case that + # invariant ever changes. continue if len(full) <= MAX_CHARS: @@ -128,8 +135,14 @@ def _stash(m: re.Match) -> str: chunk = (prefix + sb).strip() if chunk: all_chunks.append(chunk) - else: - all_chunks.append(full) # fallback: emit as-is + else: # pragma: no cover + # Structurally unreachable: `body` is always non-empty here + # (guarded by the `if not full` check above), and both branches + # of _split_at_paragraphs return at least one element for + # non-empty input -- verified empirically, including with a + # negative `budget`. Kept as a defensive fallback in case that + # invariant ever changes. + all_chunks.append(full) # Restore fenced code blocks in every chunk restored: list[str] = [] diff --git a/tests/test_ingestion.py b/tests/test_ingestion.py index 62da900..2064dbe 100644 --- a/tests/test_ingestion.py +++ b/tests/test_ingestion.py @@ -17,3 +17,39 @@ def test_load_documents_not_found(): with patch("os.path.exists", return_value=False): docs = load_documents(["/repo"]) assert len(docs) == 0 + + +def test_load_documents_skips_work_subtree(): + # omnibioai/work/ holds UUID- and wftest_*/sweep_*-named runtime copies + # of bundle READMEs that would otherwise shadow the canonical + # omnibioai-workflow-bundles/ paths -- see doc_loader.py's module + # docstring. Any .md under a "work" path segment must never be loaded. + fake_walk = [ + ("/repo", [], ["README.md"]), + ("/repo/work/run-1234", [], ["shadowed.md"]), + ] + with patch("os.path.exists", return_value=True), \ + patch("os.walk", return_value=iter(fake_walk)), \ + patch("builtins.open", mock_open(read_data="real content")): + docs = load_documents(["/repo"]) + assert len(docs) == 1 + assert docs[0]["source"] == "/repo/README.md" + + +def test_load_documents_continues_past_unreadable_file(): + fake_walk = [("/repo", [], ["bad.md", "good.md"])] + call_count = {"n": 0} + + def _open(path, *args, **kwargs): + call_count["n"] += 1 + if "bad.md" in path: + raise OSError("permission denied") + return mock_open(read_data="readable content").return_value + + with patch("os.path.exists", return_value=True), \ + patch("os.walk", return_value=iter(fake_walk)), \ + patch("builtins.open", side_effect=_open): + docs = load_documents(["/repo"]) + + assert len(docs) == 1 + assert docs[0]["source"] == "/repo/good.md" diff --git a/tests/test_main_api.py b/tests/test_main_api.py index 7ba25e7..8949252 100644 --- a/tests/test_main_api.py +++ b/tests/test_main_api.py @@ -1,3 +1,5 @@ +import importlib +import logging import sys from unittest.mock import AsyncMock, MagicMock, patch @@ -36,6 +38,44 @@ def test_status_endpoint(): assert response.json()["graph_edges"] == 4 +def test_status_endpoint_counts_distinct_omnibioai_repos(): + with ( + patch("api.main.CONTROL_PLANE.status", return_value={"status": "READY"}), + patch("api.main.graph_store") as mock_gs, + patch("api.main.vector_store") as mock_vs, + ): + mock_gs.size.return_value = {"nodes": 1, "edges": 2} + mock_vs.index = None + mock_vs.metadata = [ + {"source": "/data/omnibioai-workbench/README.md"}, + {"source": "/data/omnibioai-workbench/docs/notes.md"}, # same repo, deduped + {"source": "/data/omnibioai-auth/README.md"}, + {"source": "/data/unrelated/notes.md"}, # no "omnibioai*" segment at all + ] + response = client.get("/status") + assert response.status_code == 200 + assert response.json()["repos_loaded"] == 2 + + +def test_warns_when_no_persisted_faiss_index_is_found(caplog): + import api.main as main_module + from index.vector_store import VectorStore + + try: + with ( + patch.object(VectorStore, "load", return_value=False), + caplog.at_level(logging.WARNING, logger="api.main"), + ): + importlib.reload(main_module) + assert any( + "No persisted FAISS index found" in record.message for record in caplog.records + ) + finally: + # Restore api.main to a normal (unpatched) import for any test that + # runs after this one and reaches for its module-level state. + importlib.reload(main_module) + + def test_guard_requests_middleware_ready(): with ( patch("api.main.CONTROL_PLANE.status", return_value={"status": "READY"}), diff --git a/tests/test_rag_engine.py b/tests/test_rag_engine.py index 3304cf9..f7a617f 100644 --- a/tests/test_rag_engine.py +++ b/tests/test_rag_engine.py @@ -2,9 +2,10 @@ import numpy as np import pytest +import requests import rag.engine as _engine_mod -from rag.engine import RAGEngine, cosine, ollama_embed, ollama_generate +from rag.engine import RAGEngine, _load_llm_model, cosine, ollama_embed, ollama_generate # ========================================================= # UNIT TESTS FOR ollama_embed @@ -399,3 +400,103 @@ def test_retrieve_without_rerank_fetches_exact_top_k(mock_embed, engine, mock_ve call_args = mock_index.search.call_args assert call_args[0][1] == 5 # exact top_k, no multiplier + + +# ========================================================= +# UNIT TESTS FOR _load_llm_model +# ========================================================= + +def test_load_llm_model_defaults_when_file_missing(tmp_path): + missing = tmp_path / "does-not-exist.yaml" + assert _load_llm_model(default="fallback-model", path=str(missing)) == "fallback-model" + + +def test_load_llm_model_defaults_on_malformed_yaml(tmp_path): + bad = tmp_path / "bad.yaml" + bad.write_text("llm_model: [unterminated") + assert _load_llm_model(default="fallback-model", path=str(bad)) == "fallback-model" + + +def test_load_llm_model_defaults_when_yaml_is_not_a_mapping(tmp_path): + not_a_map = tmp_path / "list.yaml" + not_a_map.write_text("- one\n- two\n") + assert _load_llm_model(default="fallback-model", path=str(not_a_map)) == "fallback-model" + + +def test_load_llm_model_defaults_when_key_is_absent(tmp_path): + no_key = tmp_path / "no_key.yaml" + no_key.write_text("other_setting: true\n") + assert _load_llm_model(default="fallback-model", path=str(no_key)) == "fallback-model" + + +def test_load_llm_model_reads_configured_value(tmp_path): + configured = tmp_path / "config.yaml" + configured.write_text("llm_model: mixtral\n") + assert _load_llm_model(default="fallback-model", path=str(configured)) == "mixtral" + + +# ========================================================= +# UNIT TESTS FOR ollama_embed RETRY BEHAVIOR +# ========================================================= + +@patch("rag.engine.time.sleep") +@patch("rag.engine.requests.post") +def test_ollama_embed_retries_then_succeeds(mock_post, mock_sleep): + failure = requests.exceptions.ConnectionError("transient CUDA context failure") + success = MagicMock() + success.json.return_value = {"embedding": [0.1] * 768} + success.raise_for_status.return_value = None + mock_post.side_effect = [failure, success] + + vec = ollama_embed("test text") + + assert vec.shape == (768,) + assert mock_post.call_count == 2 + mock_sleep.assert_called_once_with(2) # 2 * attempt(1) + + +@patch("rag.engine.time.sleep") +@patch("rag.engine.requests.post") +def test_ollama_embed_raises_after_exhausting_all_retries(mock_post, mock_sleep): + mock_post.side_effect = requests.exceptions.ConnectionError("still down") + + with pytest.raises(requests.exceptions.ConnectionError): + ollama_embed("test text") + + assert mock_post.call_count == 3 + assert mock_sleep.call_count == 2 # slept between attempts 1->2 and 2->3, not after the last + + +# ========================================================= +# UNIT TESTS FOR RAGEngine.stream_llm +# ========================================================= + +@patch("rag.engine.requests.post") +def test_stream_llm_yields_tokens_and_stops_on_done(mock_post, engine): + response = MagicMock() + response.raise_for_status.return_value = None + response.iter_lines.return_value = [ + b'{"response": "Hello"}', + b"", # blank keep-alive line -- must be skipped, not yielded + b'{"response": " world"}', + b'{"response": "", "done": true}', + b'{"response": "unreachable after done"}', + ] + mock_post.return_value = response + + tokens = list(engine.stream_llm("query", "context")) + + assert tokens == ["Hello", " world"] + _, kwargs = mock_post.call_args + assert kwargs["stream"] is True + + +@patch("rag.engine.requests.post") +def test_stream_llm_yields_inline_error_token_on_failure(mock_post, engine): + mock_post.side_effect = requests.exceptions.RequestException("ollama unreachable") + + tokens = list(engine.stream_llm("query", "context")) + + assert len(tokens) == 1 + assert tokens[0].startswith("[LLM_ERROR]") + assert "ollama unreachable" in tokens[0]