Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

524 lines
18 KiB
Python

"""Unit tests for the LightRAG RAG pipeline + provider routing.
RAG-Anything / LightRAG is an optional dependency that is NOT installed in CI,
so these tests exercise everything that does not require the package (factory
routing, config bridge, storage, lifecycle gating, parse-layer consumption)
directly, and stub the thin ``engine`` adapter + the parse service to cover the
index/search orchestration without the heavy deps.
"""
from __future__ import annotations
import asyncio
import json
from pathlib import Path
import sys
import types
import pytest
from deeptutor.services.rag.factory import (
LIGHTRAG_PROVIDER,
get_pipeline,
list_pipelines,
normalize_provider_name,
)
from deeptutor.services.rag.index_versioning import resolve_storage_dir_for_read
from deeptutor.services.rag.pipelines.lightrag import config as lr_config
from deeptutor.services.rag.pipelines.lightrag import engine, storage
from deeptutor.services.rag.pipelines.lightrag.pipeline import LightRagPipeline
# --------------------------------------------------------------------------- #
# factory routing + config
# --------------------------------------------------------------------------- #
def test_factory_dispatches_lightrag_lazily(tmp_path) -> None:
pipe = get_pipeline("lightrag", kb_base_dir=str(tmp_path))
assert type(pipe).__name__ == "LightRagPipeline"
# Building the pipeline must NOT import the heavy optional dependency.
assert "raganything" not in sys.modules
def test_list_pipelines_includes_lightrag(monkeypatch) -> None:
monkeypatch.setattr(lr_config, "is_lightrag_available", lambda: False)
entry = next(p for p in list_pipelines() if p["id"] == LIGHTRAG_PROVIDER)
assert entry["requires_api_key"] is False
assert entry["configured"] is False
def test_normalize_provider_keeps_lightrag() -> None:
assert normalize_provider_name("lightrag") == "lightrag"
assert normalize_provider_name("LightRAG") == "lightrag"
@pytest.mark.parametrize(
"given,expected",
[
("hybrid", "hybrid"),
("MIX", "mix"),
("naive", "naive"),
("local", "local"),
("global", "global"),
("", "hybrid"),
(None, "hybrid"),
("bogus", "hybrid"),
],
)
def test_normalize_mode(given, expected) -> None:
assert lr_config.normalize_mode(given) == expected
def test_is_lightrag_available_false_when_dependency_missing(monkeypatch) -> None:
def fake_find_spec(name):
return None if name == "raganything" else object()
monkeypatch.setattr(lr_config.importlib.util, "find_spec", fake_find_spec)
assert lr_config.is_lightrag_available() is False
# --------------------------------------------------------------------------- #
# storage
# --------------------------------------------------------------------------- #
def test_storage_meta_and_has_output(tmp_path) -> None:
root = tmp_path / "version-1"
root.mkdir()
assert storage.has_output(root) is False
assert storage.has_output(None) is False
(root / "vdb_chunks.json").write_text("{}", encoding="utf-8")
assert storage.has_output(root) is False
(root / "graph_chunk_entity_relation.graphml").write_text("<graph/>", encoding="utf-8")
assert storage.has_output(root) is False
(root / "kv_store_doc_status.json").write_text(
json.dumps(
{
"doc-1": {
"status": "failed",
"file_path": "bad.docx",
"error_msg": "embedding failed",
"chunks_list": [],
}
}
),
encoding="utf-8",
)
assert storage.has_output(root) is False
assert storage.failure_summary(root) == "bad.docx: embedding failed"
assert storage.document_error(root, "doc-1") == "embedding failed"
(root / "kv_store_doc_status.json").write_text(
json.dumps(
{
"doc-1": {
"status": "processed",
"file_path": "good.docx",
"chunks_list": ["chunk-1"],
}
}
),
encoding="utf-8",
)
assert storage.has_output(root) is True
storage.write_meta(root)
meta = json.loads((root / storage.META_FILENAME).read_text())
assert meta["signature"] == "lightrag"
assert meta["provider"] == "lightrag"
def test_embedding_func_returns_numpy_array(monkeypatch) -> None:
class _FakeEmbeddingFunc:
def __init__(self, *, embedding_dim, max_token_size, func) -> None:
self.embedding_dim = embedding_dim
self.max_token_size = max_token_size
self.func = func
fake_lightrag = types.ModuleType("lightrag")
fake_utils = types.ModuleType("lightrag.utils")
fake_utils.EmbeddingFunc = _FakeEmbeddingFunc
monkeypatch.setitem(sys.modules, "lightrag", fake_lightrag)
monkeypatch.setitem(sys.modules, "lightrag.utils", fake_utils)
class _Config:
dim = 3
max_tokens = 99
class _Client:
def get_embedding_func(self):
async def embed(texts):
return [[1, 2, 3] for _ in texts]
return embed
monkeypatch.setattr("deeptutor.services.embedding.get_embedding_config", lambda: _Config())
monkeypatch.setattr("deeptutor.services.embedding.get_embedding_client", lambda: _Client())
embedding = lr_config.build_embedding_func()
vectors = asyncio.run(embedding.func(["a", "b"]))
assert embedding.embedding_dim == 3
assert embedding.max_token_size == 99
assert vectors.shape == (2, 3)
assert hasattr(vectors, "size")
def test_lightrag_llm_adapter_preserves_messages_and_drops_extra_kwargs(
monkeypatch,
) -> None:
captured: dict[str, object] = {}
class _Client:
def get_model_func(self):
async def model_func(prompt, **kwargs):
captured["prompt"] = prompt
captured.update(kwargs)
return "ok"
return model_func
monkeypatch.setattr("deeptutor.services.llm.get_llm_client", lambda: _Client())
func = lr_config.build_llm_model_func()
result = asyncio.run(
func(
"",
system_prompt="sys",
messages=[{"role": "user", "content": "from messages"}],
response_format={"type": "json_object"},
hashing_kv=object(),
keyword_extraction=True,
)
)
assert result == "ok"
assert captured["prompt"] == ""
assert captured["system_prompt"] == "sys"
assert captured["history_messages"] == []
assert captured["messages"] == [{"role": "user", "content": "from messages"}]
assert "response_format" not in captured
assert "hashing_kv" not in captured
assert "keyword_extraction" not in captured
def test_lightrag_vision_adapter_preserves_messages(monkeypatch) -> None:
captured: dict[str, object] = {}
class _Client:
def get_vision_model_func(self):
async def model_func(prompt, **kwargs):
captured["prompt"] = prompt
captured.update(kwargs)
return "ok"
return model_func
monkeypatch.setattr("deeptutor.services.llm.get_llm_client", lambda: _Client())
func = lr_config.build_vision_model_func()
result = asyncio.run(
func(
"",
image_data="abc123",
messages=[{"role": "user", "content": [{"type": "text", "text": "hi"}]}],
)
)
assert result == "ok"
assert captured["prompt"] == ""
assert captured["image_data"] == "abc123"
assert captured["messages"] == [{"role": "user", "content": [{"type": "text", "text": "hi"}]}]
def test_build_rag_skips_raganything_parser_install_check(monkeypatch) -> None:
"""Regression for issue #594.
RAG-Anything validates its *default* parser (``mineru``) at LightRAG-init
time, even though DeepTutor only ever inserts a pre-parsed ``content_list``
and never uses RAG-Anything's parser. ``build_rag`` must pre-satisfy that
check so indexing with a different parse engine (e.g. pymupdf4llm) doesn't
hard-fail when MinerU is absent.
"""
captured: dict[str, object] = {}
class _FakeConfig:
def __init__(self, *, working_dir) -> None:
self.working_dir = working_dir
self.parser = "mineru" # RAG-Anything's default
class _FakeRagAnything:
def __init__(self, *, config, llm_model_func, vision_model_func, embedding_func) -> None:
# Mirror the real constructor: the install check starts unsatisfied.
self._parser_installation_checked = False
captured["config"] = config
fake_module = types.ModuleType("raganything")
fake_module.RAGAnything = _FakeRagAnything
fake_module.RAGAnythingConfig = _FakeConfig
monkeypatch.setitem(sys.modules, "raganything", fake_module)
monkeypatch.setattr(engine, "build_llm_model_func", lambda: "llm")
monkeypatch.setattr(engine, "build_vision_model_func", lambda: "vision")
monkeypatch.setattr(engine, "build_embedding_func", lambda: "embed")
rag = engine.build_rag(Path("/tmp/kb-wd")) # noqa: S108
assert rag._parser_installation_checked is True
assert captured["config"].working_dir == "/tmp/kb-wd"
def test_lightrag_query_initializes_raganything_before_aquery(monkeypatch) -> None:
calls: list[str] = []
class _Rag:
lightrag = None
async def _ensure_lightrag_initialized(self):
calls.append("ensure")
self.lightrag = object()
return {"success": True}
async def aquery(self, question, mode=None, **kwargs):
calls.append("aquery")
assert self.lightrag is not None
assert question == "hello"
assert mode == "hybrid"
assert kwargs == {}
return "answer"
monkeypatch.setattr(engine, "query_kwargs_from_settings", lambda: {})
result = asyncio.run(engine.query(_Rag(), "hello", "hybrid"))
assert result == "answer"
assert calls == ["ensure", "aquery"]
def test_lightrag_query_surfaces_raganything_initialization_failure() -> None:
class _Rag:
lightrag = None
async def _ensure_lightrag_initialized(self):
return {"success": False, "error": "storage failed"}
async def aquery(self, question, mode=None, **kwargs): # pragma: no cover
raise AssertionError("aquery should not run")
with pytest.raises(RuntimeError, match="storage failed"):
asyncio.run(engine.query(_Rag(), "hello", "hybrid"))
# --------------------------------------------------------------------------- #
# pipeline lifecycle (engine + parse service stubbed)
# --------------------------------------------------------------------------- #
class _FakeRag:
def __init__(self, working_dir) -> None:
self.working_dir = Path(working_dir)
def _force_available(monkeypatch, available: bool = True) -> None:
monkeypatch.setattr(lr_config, "is_lightrag_available", lambda: available)
def _stub_engine(monkeypatch, answer: str = "ANSWER") -> list[dict]:
"""Stub the engine so insert writes a readiness marker and query echoes."""
inserts: list[dict] = []
monkeypatch.setattr(engine, "build_rag", lambda wd: _FakeRag(wd))
async def fake_insert(rag, content_list, *, file_name, doc_id):
inserts.append({"file": file_name, "doc_id": doc_id, "blocks": content_list})
(rag.working_dir / "vdb_chunks.json").write_text(
json.dumps({"vectors": [[1.0]]}), encoding="utf-8"
)
(rag.working_dir / "kv_store_doc_status.json").write_text(
json.dumps(
{
doc_id: {
"status": "processed",
"file_path": file_name,
"chunks_list": ["chunk-1"],
}
}
),
encoding="utf-8",
)
async def fake_query(rag, question, mode):
return f"{answer}|{mode}"
monkeypatch.setattr(engine, "insert", fake_insert)
monkeypatch.setattr(engine, "query", fake_query)
return inserts
def _stub_parse(monkeypatch, *, blocks=None, markdown: str = "# md") -> None:
from deeptutor.services.parsing.types import ParsedDocument
class _Service:
def parse(self, path, **_):
return ParsedDocument(
markdown=markdown,
blocks=blocks,
source_hash="h_" + Path(path).stem,
engine="fake",
)
monkeypatch.setattr("deeptutor.services.parsing.get_parse_service", lambda: _Service())
def test_initialize_requires_lightrag(tmp_path, monkeypatch) -> None:
_force_available(monkeypatch, False)
pipe = LightRagPipeline(kb_base_dir=str(tmp_path))
pdf = tmp_path / "a.pdf"
pdf.write_bytes(b"%PDF")
with pytest.raises(lr_config.LightRagNotAvailableError):
asyncio.run(pipe.initialize("kb", [str(pdf)]))
def test_initialize_orchestrates_index_and_uses_blocks(tmp_path, monkeypatch) -> None:
_force_available(monkeypatch, True)
inserts = _stub_engine(monkeypatch)
_stub_parse(monkeypatch, blocks=[{"type": "text", "text": "hi", "page_idx": 0}])
pipe = LightRagPipeline(kb_base_dir=str(tmp_path))
pdf = tmp_path / "exam.pdf"
pdf.write_bytes(b"%PDF")
ok = asyncio.run(pipe.initialize("kb", [str(pdf)]))
assert ok is True
assert len(inserts) == 1
assert inserts[0]["file"] == "exam.pdf"
# blocks from the parse layer are passed through verbatim (multimodal path).
assert inserts[0]["blocks"] == [{"type": "text", "text": "hi", "page_idx": 0}]
# version dir is marked ready.
root = resolve_storage_dir_for_read(tmp_path / "kb", None)
assert storage.has_output(root) is True
def test_ingest_falls_back_to_markdown_when_no_blocks(tmp_path, monkeypatch) -> None:
_force_available(monkeypatch, True)
inserts = _stub_engine(monkeypatch)
_stub_parse(monkeypatch, blocks=None, markdown="# only markdown")
pipe = LightRagPipeline(kb_base_dir=str(tmp_path))
pdf = tmp_path / "notes.pdf"
pdf.write_bytes(b"%PDF")
asyncio.run(pipe.initialize("kb", [str(pdf)]))
assert inserts[0]["blocks"] == [{"type": "text", "text": "# only markdown", "page_idx": 0}]
def test_initialize_no_content_returns_false(tmp_path, monkeypatch) -> None:
_force_available(monkeypatch, True)
inserts = _stub_engine(monkeypatch)
_stub_parse(monkeypatch, blocks=None, markdown="") # empty parse
pipe = LightRagPipeline(kb_base_dir=str(tmp_path))
pdf = tmp_path / "blank.pdf"
pdf.write_bytes(b"%PDF")
ok = asyncio.run(pipe.initialize("kb", [str(pdf)]))
assert ok is False
assert inserts == []
def test_initialize_fails_when_lightrag_records_doc_failure(tmp_path, monkeypatch) -> None:
_force_available(monkeypatch, True)
monkeypatch.setattr(engine, "build_rag", lambda wd: _FakeRag(wd))
async def fake_insert(rag, content_list, *, file_name, doc_id):
(rag.working_dir / "kv_store_doc_status.json").write_text(
json.dumps(
{
doc_id: {
"status": "failed",
"file_path": file_name,
"error_msg": "'list' object has no attribute 'size'",
"chunks_list": [],
}
}
),
encoding="utf-8",
)
monkeypatch.setattr(engine, "insert", fake_insert)
_stub_parse(monkeypatch, blocks=[{"type": "text", "text": "hi", "page_idx": 0}])
pipe = LightRagPipeline(kb_base_dir=str(tmp_path))
docx = tmp_path / "bad.docx"
docx.write_bytes(b"docx")
with pytest.raises(RuntimeError, match="list.*size"):
asyncio.run(pipe.initialize("kb", [str(docx)]))
assert resolve_storage_dir_for_read(tmp_path / "kb", None) is None
def test_search_needs_reindex_without_output(tmp_path) -> None:
res = asyncio.run(LightRagPipeline(kb_base_dir=str(tmp_path)).search("q", "missing"))
assert res["needs_reindex"] is True
assert res["provider"] == "lightrag"
def test_search_not_configured_when_unavailable(tmp_path, monkeypatch) -> None:
_force_available(monkeypatch, True)
_stub_engine(monkeypatch)
_stub_parse(monkeypatch, blocks=[{"type": "text", "text": "x", "page_idx": 0}])
pipe = LightRagPipeline(kb_base_dir=str(tmp_path))
pdf = tmp_path / "a.pdf"
pdf.write_bytes(b"%PDF")
asyncio.run(pipe.initialize("kb", [str(pdf)]))
_force_available(monkeypatch, False)
res = asyncio.run(pipe.search("q", "kb"))
assert res["error_type"] == "not_configured"
def test_search_happy_path_resolves_mode(tmp_path, monkeypatch) -> None:
_force_available(monkeypatch, True)
_stub_engine(monkeypatch, answer="GROUNDED")
_stub_parse(monkeypatch, blocks=[{"type": "text", "text": "x", "page_idx": 0}])
pipe = LightRagPipeline(kb_base_dir=str(tmp_path))
pdf = tmp_path / "a.pdf"
pdf.write_bytes(b"%PDF")
asyncio.run(pipe.initialize("kb", [str(pdf)]))
# Per-KB search_mode is read from kb_config.json next to the store.
(tmp_path / "kb_config.json").write_text(
json.dumps({"knowledge_bases": {"kb": {"search_mode": "local"}}}), encoding="utf-8"
)
res = asyncio.run(pipe.search("question?", "kb"))
assert res["answer"] == "GROUNDED|local"
assert res["mode"] == "local"
assert res["provider"] == "lightrag"
def test_explicit_mode_overrides_kb_config(tmp_path, monkeypatch) -> None:
_force_available(monkeypatch, True)
_stub_engine(monkeypatch, answer="A")
_stub_parse(monkeypatch, blocks=[{"type": "text", "text": "x", "page_idx": 0}])
pipe = LightRagPipeline(kb_base_dir=str(tmp_path))
pdf = tmp_path / "a.pdf"
pdf.write_bytes(b"%PDF")
asyncio.run(pipe.initialize("kb", [str(pdf)]))
res = asyncio.run(pipe.search("q", "kb", mode="global"))
assert res["mode"] == "global"
def test_global_provider_mode_used_when_kb_has_none(tmp_path, monkeypatch) -> None:
_force_available(monkeypatch, True)
_stub_engine(monkeypatch, answer="A")
_stub_parse(monkeypatch, blocks=[{"type": "text", "text": "x", "page_idx": 0}])
pipe = LightRagPipeline(kb_base_dir=str(tmp_path))
pdf = tmp_path / "a.pdf"
pdf.write_bytes(b"%PDF")
asyncio.run(pipe.initialize("kb", [str(pdf)]))
# No per-KB search_mode, but a global default mode set from the engine card.
(tmp_path / "kb_config.json").write_text(
json.dumps({"defaults": {"provider_modes": {"lightrag": "naive"}}}), encoding="utf-8"
)
res = asyncio.run(pipe.search("q", "kb"))
assert res["mode"] == "naive"