Files
DocsGPT/tests/graphrag/test_graph_search_tool.py
T
Alex 692edcdd4e test(agents): give the graph tool tests the vector store they need
The tool now asks graphrag_available(), which wants pgvector as well as the
flag. One case set only the flag and passed locally off a dev .env, then
failed on CI's faiss default. An autouse fixture sets it for the module, so a
case that forgets fails for its own reason; the two about a different vector
store override it themselves.
2026-09-20 10:54:55 +01:00

362 lines
14 KiB
Python

"""The graph exposed to an agent as callable tools.
Ranking with a graph never beat vector search in measurement; letting a model
*follow* an edge did, on content where the answer is two documents away. These
tests cover the contract that makes that possible — the tool must return the
relationships verbatim enough for the model to read a name out of them, and
must refuse clearly rather than silently when it has nothing to offer.
"""
from __future__ import annotations
import pytest
from docsgpt.agents.tools.graph_search import (
GRAPH_TOOL_ID,
GraphSearchTool,
add_graph_search_tool,
build_graph_tool_entry,
)
from docsgpt.core.settings import settings
SOURCE = {"active_docs": ["src-1"]}
@pytest.fixture(autouse=True)
def _graph_store_is_pgvector(monkeypatch):
"""The graph only exists under pgvector, and CI's default is faiss.
Set here rather than per test so a case that forgets it fails for its own
reason instead of the gate; the cases about a different vector store
override it in the test body.
"""
monkeypatch.setattr(settings, "VECTOR_STORE", "pgvector")
class _StubStore:
def __init__(self, nodes=None, relationships=None, pages=None):
self._nodes = nodes or []
self._relationships = relationships or []
self._pages = pages or []
def search_nodes_by_embedding(self, source_id, embedding, k=10):
return self._nodes[:k]
def entity_relationships(self, source_id, name, limit=25):
return self._relationships
def entity_pages(self, source_id, name, limit=4):
return self._pages
def _tool(monkeypatch, store, enabled=True):
monkeypatch.setattr(settings, "GRAPHRAG_ENABLED", enabled)
monkeypatch.setattr(settings, "VECTOR_STORE", "pgvector")
tool = GraphSearchTool({"source": SOURCE})
tool._store = store
monkeypatch.setattr(tool, "_embed", lambda text: [0.0, 0.1])
return tool
class TestGating:
def test_reports_when_graphs_are_disabled(self, monkeypatch):
tool = _tool(monkeypatch, _StubStore(), enabled=False)
assert "not enabled" in tool.execute_action("search_entities", query="x")
def test_reports_when_no_sources_are_configured(self, monkeypatch):
monkeypatch.setattr(settings, "GRAPHRAG_ENABLED", True)
monkeypatch.setattr(settings, "VECTOR_STORE", "pgvector")
tool = GraphSearchTool({"source": {"active_docs": []}})
assert "No graph-backed sources" in tool.execute_action(
"search_entities", query="x"
)
def test_unknown_action_is_named(self, monkeypatch):
tool = _tool(monkeypatch, _StubStore())
assert "Unknown action" in tool.execute_action("wander")
class TestActions:
def test_search_entities_lists_names_with_match_strength(self, monkeypatch):
tool = _tool(
monkeypatch,
_StubStore(nodes=[{"name": "Quill", "distance": 0.2, "description": "A store."}]),
)
result = tool.execute_action("search_entities", query="quill")
assert "Quill" in result
assert "0.80" in result
def test_search_entities_requires_a_query(self, monkeypatch):
tool = _tool(monkeypatch, _StubStore())
assert "required" in tool.execute_action("search_entities", query=" ")
def test_relationships_are_rendered_as_triples(self, monkeypatch):
"""The model reads the *target* out of this line to take its next step,
so the target name has to survive rendering intact."""
tool = _tool(
monkeypatch,
_StubStore(
relationships=[
{"source": "Alder", "type": "streams_to", "target": "Quill", "description": ""}
]
),
)
result = tool.execute_action("get_relationships", entity="Alder")
assert "Alder --streams_to--> Quill" in result
def test_missing_relationships_suggest_the_next_step(self, monkeypatch):
"""A dead end should point at search_entities rather than stop the agent."""
tool = _tool(monkeypatch, _StubStore(relationships=[]))
result = tool.execute_action("get_relationships", entity="Nope")
assert "search_entities" in result
def test_pages_are_titled_truncated_and_recorded(self, monkeypatch):
tool = _tool(
monkeypatch,
_StubStore(
pages=[
{
"metadata": {"title": "quill-store.md", "source": "quill-store.md"},
"text": "x" * 5000,
}
]
),
)
result = tool.execute_action("read_entity_pages", entity="Quill")
assert "--- quill-store.md ---" in result
# Only what the model reads is truncated.
assert len(result) < 3000
# Accumulated so the answer can cite what the walk actually read.
assert tool.retrieved_docs[0]["title"] == "quill-store.md"
assert len(tool.retrieved_docs[0]["text"]) == 5000
def test_page_labels_match_what_the_retrievers_record(self, monkeypatch):
"""A page read here and the same chunk retrieved by internal_search are
one document. The citation manager keys on (source, title), so labels
derived differently give the same document two citation numbers."""
from docsgpt.retriever.labels import labels_from_metadata
metadata = {"title": "Quill Store", "source": "quill-store.md"}
text = "Quill is a write-ahead store."
tool = _tool(monkeypatch, _StubStore(pages=[{"metadata": metadata, "text": text}]))
tool.execute_action("read_entity_pages", entity="Quill")
expected = labels_from_metadata(metadata, text, "src-1")
doc = tool.retrieved_docs[0]
assert {k: doc[k] for k in ("title", "source", "filename")} == expected
# The full chunk text, so the doc dedupes against the retriever's copy;
# only what the model reads is truncated.
assert doc["text"] == text
def test_a_page_with_no_metadata_falls_back_to_its_source_id(self, monkeypatch):
tool = _tool(monkeypatch, _StubStore(pages=[{"metadata": {}, "text": "body"}]))
tool.execute_action("read_entity_pages", entity="Quill")
assert tool.retrieved_docs[0]["source"] == "src-1"
def test_pages_absent_is_stated_plainly(self, monkeypatch):
tool = _tool(monkeypatch, _StubStore(pages=[]))
assert "No documents" in tool.execute_action("read_entity_pages", entity="Quill")
class TestWiring:
def test_entry_exposes_every_action(self):
entry = build_graph_tool_entry()
assert {a["name"] for a in entry["actions"]} == {
"search_entities",
"get_relationships",
"read_entity_pages",
}
assert all(action["active"] for action in entry["actions"])
def test_not_added_when_graphs_are_disabled(self, monkeypatch):
monkeypatch.setattr(settings, "GRAPHRAG_ENABLED", False)
monkeypatch.setattr(
"docsgpt.agents.tools.graph_search.sources_have_graph", lambda source: True
)
tools: dict = {}
add_graph_search_tool(tools, {"source": SOURCE})
assert tools == {}
def test_not_added_when_the_sources_have_no_graph(self, monkeypatch):
monkeypatch.setattr(settings, "GRAPHRAG_ENABLED", True)
monkeypatch.setattr(settings, "VECTOR_STORE", "pgvector")
monkeypatch.setattr(
"docsgpt.agents.tools.graph_search.sources_have_graph", lambda source: False
)
tools: dict = {}
add_graph_search_tool(tools, {"source": SOURCE})
assert tools == {}
def test_added_with_its_sentinel_id_and_source_config(self, monkeypatch):
monkeypatch.setattr(settings, "GRAPHRAG_ENABLED", True)
monkeypatch.setattr(settings, "VECTOR_STORE", "pgvector")
monkeypatch.setattr(
"docsgpt.agents.tools.graph_search.sources_have_graph", lambda source: True
)
tools: dict = {}
add_graph_search_tool(tools, {"source": SOURCE})
assert tools[GRAPH_TOOL_ID]["id"] == GRAPH_TOOL_ID
assert tools[GRAPH_TOOL_ID]["config"]["source"] == SOURCE
class TestPlumbing:
def test_a_single_source_id_and_empty_entries_are_accepted(self):
tool = GraphSearchTool({"source": {"active_docs": "src-1"}})
assert tool._sources() == ["src-1"]
tool = GraphSearchTool({"source": {"active_docs": ["src-1", "", None]}})
assert tool._sources() == ["src-1"]
def test_the_store_is_built_once_and_reused(self, monkeypatch):
built = []
monkeypatch.setattr(
"docsgpt.graphrag.store.GraphStore", lambda: built.append(object()) or built[-1]
)
tool = GraphSearchTool({"source": SOURCE})
assert tool._get_store() is tool._get_store()
assert len(built) == 1
def test_an_embedding_failure_makes_entity_search_unavailable(self, monkeypatch):
def _broken_embeddings():
raise RuntimeError("no model")
monkeypatch.setattr("docsgpt.vectorstore.base.get_embeddings", _broken_embeddings)
monkeypatch.setattr(settings, "GRAPHRAG_ENABLED", True)
tool = GraphSearchTool({"source": SOURCE})
tool._store = _StubStore()
assert tool.execute_action("search_entities", query="quill") == "Entity search is unavailable."
def test_no_matching_entities_is_stated_plainly(self, monkeypatch):
tool = _tool(monkeypatch, _StubStore())
assert "No entities found" in tool.execute_action("search_entities", query="quill")
def test_a_failing_store_is_reported_not_raised(self, monkeypatch):
class _BrokenStore(_StubStore):
def entity_relationships(self, source_id, name, limit=25):
raise RuntimeError("connection lost")
tool = _tool(monkeypatch, _BrokenStore())
assert tool.execute_action("get_relationships", entity="Quill") == "The graph lookup failed."
class TestSourcesHaveGraph:
"""Whether to offer the tool at all: only when some source has a graph."""
def _patch_counts(self, monkeypatch, counts=None, error=None):
class _Store:
def count_nodes_many(self, source_ids):
if error:
raise error
return {s: counts.get(s, 0) for s in source_ids}
monkeypatch.setattr("docsgpt.graphrag.store.GraphStore", _Store)
def test_true_when_any_source_has_nodes(self, monkeypatch):
from docsgpt.agents.tools.graph_search import sources_have_graph
self._patch_counts(monkeypatch, {"b": 12})
assert sources_have_graph({"active_docs": ["a", "b"]}) is True
def test_false_when_no_source_has_nodes(self, monkeypatch):
from docsgpt.agents.tools.graph_search import sources_have_graph
self._patch_counts(monkeypatch, {})
assert sources_have_graph({"active_docs": "a"}) is False
def test_false_without_sources_or_when_the_check_fails(self, monkeypatch):
from docsgpt.agents.tools.graph_search import sources_have_graph
assert sources_have_graph({"active_docs": []}) is False
self._patch_counts(monkeypatch, error=RuntimeError("no pgvector"))
assert sources_have_graph({"active_docs": ["a"]}) is False
class TestGraphsMustBeAvailable:
"""The graph lives in the pgvector store, so the flag alone is not enough.
With another vector store configured the graph tables are not the ones the
sources were ingested into; everything else in the app asks
``graphrag_available()``, which requires both.
"""
def test_the_tool_is_not_offered_without_pgvector(self, monkeypatch):
monkeypatch.setattr(settings, "GRAPHRAG_ENABLED", True)
monkeypatch.setattr(settings, "VECTOR_STORE", "faiss")
monkeypatch.setattr(
"docsgpt.agents.tools.graph_search.sources_have_graph",
lambda source: pytest.fail("must not reach the database"),
)
tools = {}
add_graph_search_tool(tools, {"source": SOURCE})
assert tools == {}
def test_actions_report_it_rather_than_querying(self, monkeypatch):
monkeypatch.setattr(settings, "GRAPHRAG_ENABLED", True)
monkeypatch.setattr(settings, "VECTOR_STORE", "faiss")
tool = GraphSearchTool({"source": SOURCE})
tool._store = _StubStore(nodes=[{"name": "Quill", "distance": 0.1}])
assert "not enabled" in tool.execute_action("search_entities", query="quill")
class TestPooledConnection:
"""The tool is cached for the whole agent run; its connection must not be."""
class _ClosingStore(_StubStore):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.closed = 0
def close(self):
self.closed += 1
def test_the_connection_goes_back_after_each_action(self, monkeypatch):
store = self._ClosingStore(relationships=[{"source": "A", "target": "B", "type": "r"}])
tool = _tool(monkeypatch, store)
tool.execute_action("get_relationships", entity="A")
# Held open, one pooled connection would be pinned across every LLM
# round trip of the run.
assert store.closed == 1
assert tool._store is None
def test_a_failing_action_still_releases_it(self, monkeypatch):
class _Broken(self._ClosingStore):
def entity_relationships(self, source_id, name, limit=25):
raise RuntimeError("connection lost")
store = _Broken()
tool = _tool(monkeypatch, store)
assert tool.execute_action("get_relationships", entity="A") == "The graph lookup failed."
assert store.closed == 1
assert tool._store is None