mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-03 20:12:55 +00:00
``canonical_name`` answers "" for a punctuation-only name, which callers are meant to read as "no entity" -- ``_resolve_endpoint`` already does. Entity extraction did not, and nodes merge on that key, so every such entity in a source collapsed onto one shared node that belonged to none of them.
1165 lines
42 KiB
Python
1165 lines
42 KiB
Python
"""Tests for the GraphRAG extraction pipeline (D28).
|
|
|
|
The LLM and the embeddings model are mocked in every test so the suite makes no
|
|
real model or network calls. A live ``GraphStore`` is exercised against the
|
|
ephemeral pytest-postgresql cluster (never the operator's dev DB) with a unique
|
|
temp ``source_id``; if pgvector is unavailable there the live tests skip.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import uuid
|
|
|
|
import pytest
|
|
|
|
import docsgpt.graphrag.extraction as extraction_module
|
|
from docsgpt.graphrag.store import GraphStore
|
|
from docsgpt.storage.db.source_config import SourceConfig
|
|
from docsgpt.vectorstore import pgconn
|
|
|
|
extract_graph_for_source = extraction_module.extract_graph_for_source
|
|
|
|
TEST_EMBEDDING_DIM = 8
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _close_pools():
|
|
"""Never leak a pool into another test; an ephemeral DSN dies with its DB."""
|
|
yield
|
|
for dsn, pool in list(pgconn._POOLS.items()):
|
|
try:
|
|
pool.close()
|
|
except Exception:
|
|
pass
|
|
pgconn._POOLS.pop(dsn, None)
|
|
|
|
|
|
def _ephemeral_dsn(info) -> str:
|
|
"""libpq DSN for the ephemeral pytest-postgresql database."""
|
|
password = f":{info.password}" if info.password else ""
|
|
return (
|
|
f"postgresql://{info.user}{password}@{info.host}:{info.port}/{info.dbname}"
|
|
)
|
|
|
|
|
|
def _live_store(monkeypatch, info):
|
|
"""Graph store on a fresh ephemeral database, schema created up front.
|
|
|
|
Construction runs no DDL any more (boot owns the schema), so the tables are
|
|
created explicitly here — what ``ensure_vector_schema`` does in production.
|
|
"""
|
|
monkeypatch.setattr(
|
|
GraphStore, "_embedding_dim", lambda self: TEST_EMBEDDING_DIM
|
|
)
|
|
dsn = _ephemeral_dsn(info)
|
|
# The pipeline builds its own GraphStore() from settings, so point those at
|
|
# the ephemeral cluster too — never at the operator's configured DB.
|
|
from docsgpt.core import settings as settings_module
|
|
|
|
monkeypatch.setattr(
|
|
settings_module.settings, "PGVECTOR_CONNECTION_STRING", dsn, raising=False
|
|
)
|
|
store = GraphStore(connection_string=dsn)
|
|
try:
|
|
store._ensure_tables()
|
|
except Exception as exc:
|
|
pytest.skip(f"pgvector extension unavailable: {exc}")
|
|
return store
|
|
|
|
|
|
class _StubLLM:
|
|
"""Stub LLM whose ``.gen`` returns crafted responses in order."""
|
|
|
|
def __init__(self, responses):
|
|
self._responses = list(responses)
|
|
self.model_id = "stub-model"
|
|
self.gen_calls = []
|
|
self._token_usage_source = None
|
|
self._request_id = None
|
|
|
|
def gen(self, model=None, messages=None, **kwargs):
|
|
self.gen_calls.append({"model": model, "messages": messages})
|
|
if not self._responses:
|
|
raise AssertionError("gen called more times than crafted responses")
|
|
response = self._responses.pop(0)
|
|
if isinstance(response, Exception):
|
|
raise response
|
|
return response
|
|
|
|
|
|
class _ScriptedLLM:
|
|
"""Stub LLM answering per chunk, so results do not depend on call order.
|
|
|
|
The extraction pool runs calls concurrently, so a stub that hands out
|
|
responses in call order gives each chunk whichever response its thread
|
|
happened to grab first. ``script`` maps a chunk's text to the responses
|
|
for that chunk, consumed one per call.
|
|
"""
|
|
|
|
def __init__(self, script):
|
|
self._script = {text: list(responses) for text, responses in script.items()}
|
|
self.model_id = "stub-model"
|
|
self.calls = []
|
|
self._token_usage_source = None
|
|
self._request_id = None
|
|
|
|
def gen(self, model=None, messages=None, **kwargs):
|
|
text = messages[-1]["content"].removeprefix("<chunk>\n").removesuffix("\n</chunk>")
|
|
self.calls.append(text)
|
|
responses = self._script.get(text)
|
|
if not responses:
|
|
raise AssertionError(f"unexpected extraction call for {text!r}")
|
|
response = responses.pop(0)
|
|
if isinstance(response, Exception):
|
|
raise response
|
|
return response
|
|
|
|
def calls_for(self, text):
|
|
return self.calls.count(text)
|
|
|
|
|
|
class _StubEmbedding:
|
|
"""Stub embeddings model producing deterministic fixed-dim vectors."""
|
|
|
|
def __init__(self):
|
|
self.dimension = TEST_EMBEDDING_DIM
|
|
|
|
def embed_documents(self, documents):
|
|
return [
|
|
[float(len(d) % 7)] + [0.0] * (TEST_EMBEDDING_DIM - 1)
|
|
for d in documents
|
|
]
|
|
|
|
|
|
@pytest.fixture
|
|
def stub_embedding(monkeypatch):
|
|
from docsgpt.core.settings import settings
|
|
|
|
# The resolver short-circuits to the remote API when this is configured,
|
|
# which would bypass the stub on a dev machine that sets it.
|
|
monkeypatch.setattr(settings, "EMBEDDINGS_BASE_URL", None)
|
|
embedding = _StubEmbedding()
|
|
monkeypatch.setattr(
|
|
extraction_module.EmbeddingsSingleton,
|
|
"get_instance",
|
|
staticmethod(lambda *a, **k: embedding),
|
|
)
|
|
return embedding
|
|
|
|
|
|
def _install_stub_llm(monkeypatch, llm):
|
|
captured = {}
|
|
|
|
def _create(*args, **kwargs):
|
|
captured["model_id"] = kwargs.get("model_id")
|
|
return llm
|
|
|
|
monkeypatch.setattr(
|
|
extraction_module.LLMCreator, "create_llm", staticmethod(_create)
|
|
)
|
|
return captured
|
|
|
|
|
|
def _chunk(doc_id, text):
|
|
return {"doc_id": doc_id, "text": text}
|
|
|
|
|
|
def _extraction_json(entities, relationships):
|
|
return json.dumps({"entities": entities, "relationships": relationships})
|
|
|
|
|
|
class TestFactText:
|
|
"""A relationship rendered as the sentence it asserts.
|
|
|
|
This is what fact seeding matches a question against, so it has to read as
|
|
a claim rather than as three fields concatenated.
|
|
"""
|
|
|
|
def test_renders_the_relationship_as_a_sentence(self):
|
|
text = extraction_module._fact_text(
|
|
{
|
|
"source": "Alder",
|
|
"target": "Quill",
|
|
"type": "streams_to",
|
|
"description": "Alder streams audit events to Quill.",
|
|
}
|
|
)
|
|
|
|
assert text == "Alder streams_to Quill: Alder streams audit events to Quill."
|
|
|
|
def test_omits_an_absent_description(self):
|
|
text = extraction_module._fact_text(
|
|
{"source": "Alder", "target": "Quill", "type": "streams_to"}
|
|
)
|
|
|
|
assert text == "Alder streams_to Quill"
|
|
|
|
def test_defaults_a_missing_relation(self):
|
|
text = extraction_module._fact_text({"source": "Alder", "target": "Quill"})
|
|
|
|
assert text == "Alder related to Quill"
|
|
|
|
@pytest.mark.parametrize(
|
|
"rel",
|
|
[
|
|
{"source": "Alder", "target": ""},
|
|
{"source": "", "target": "Quill"},
|
|
{},
|
|
],
|
|
)
|
|
def test_an_edge_without_both_endpoints_has_no_fact(self, rel):
|
|
assert extraction_module._fact_text(rel) == ""
|
|
|
|
|
|
class TestEmbedFacts:
|
|
"""Fact embeddings are always recorded, so a source can switch to
|
|
relationship seeding at query time without being rebuilt."""
|
|
|
|
def _relationships(self):
|
|
return [{"source": "Alder", "target": "Quill", "type": "streams_to"}]
|
|
|
|
def test_attaches_one_embedding_per_fact_in_a_single_call(self):
|
|
relationships = self._relationships() + [{"source": "", "target": "Nowhere"}]
|
|
calls = []
|
|
|
|
class _Embedding:
|
|
def embed_documents(self, texts):
|
|
calls.append(texts)
|
|
return [[0.5] * 4 for _ in texts]
|
|
|
|
extraction_module._embed_facts(_Embedding(), relationships)
|
|
|
|
# One batched call, and the endpoint-less relationship is skipped
|
|
# rather than embedded as an empty string.
|
|
assert calls == [["Alder streams_to Quill"]]
|
|
assert relationships[0]["fact_embedding"] == [0.5] * 4
|
|
assert "fact_embedding" not in relationships[1]
|
|
|
|
def test_survives_an_embedding_failure(self):
|
|
"""The graph is still correct without fact embeddings — only
|
|
relationship seeding degrades, and it falls back to entities — so a
|
|
failure here must not fail the chunk."""
|
|
relationships = self._relationships()
|
|
|
|
class _Embedding:
|
|
def embed_documents(self, texts):
|
|
raise RuntimeError("embeddings down")
|
|
|
|
extraction_module._embed_facts(_Embedding(), relationships)
|
|
|
|
assert "fact_embedding" not in relationships[0]
|
|
|
|
|
|
class TestSeedText:
|
|
"""What a node's embedding is computed from.
|
|
|
|
Retrieval matches a whole question against these embeddings, so what goes
|
|
into them decides what the graph walk can start from.
|
|
"""
|
|
|
|
def _entity(self):
|
|
return {
|
|
"name": "Quill",
|
|
"normalized_name": "quill",
|
|
"type": "store",
|
|
"description": "A write-ahead store.",
|
|
}
|
|
|
|
def test_includes_type_and_description(self):
|
|
assert (
|
|
extraction_module._seed_text(self._entity())
|
|
== "Quill (store): A write-ahead store."
|
|
)
|
|
|
|
def test_falls_back_to_the_name_when_fields_are_missing(self):
|
|
assert extraction_module._seed_text({"name": "Quill"}) == "Quill"
|
|
|
|
def test_embedded_text_is_keyed_by_the_normalized_name(self):
|
|
"""The richer text must reach ``embed_documents``, keyed by the same
|
|
normalized name the store resolves nodes by — otherwise the embedding
|
|
is computed for a node it never reaches."""
|
|
captured = {}
|
|
|
|
class _Embedding:
|
|
def embed_documents(self, texts):
|
|
captured["texts"] = texts
|
|
return [[0.0] * 4 for _ in texts]
|
|
|
|
result = extraction_module._embed_names(_Embedding(), [self._entity()], [])
|
|
|
|
assert captured["texts"] == ["Quill (store): A write-ahead store."]
|
|
assert set(result) == {"quill"}
|
|
|
|
|
|
@pytest.mark.integration
|
|
class TestExtractionLive:
|
|
@pytest.fixture
|
|
def store(self, monkeypatch, postgresql):
|
|
store = _live_store(monkeypatch, postgresql.info)
|
|
yield store
|
|
store.close()
|
|
|
|
@pytest.fixture
|
|
def source_id(self):
|
|
return str(uuid.uuid4())
|
|
|
|
def test_entities_and_relationships_written(
|
|
self, store, source_id, monkeypatch, stub_embedding
|
|
):
|
|
try:
|
|
payload = _extraction_json(
|
|
entities=[
|
|
{"name": "Ada Lovelace", "type": "person", "description": "A mathematician."},
|
|
{"name": "Analytical Engine", "type": "machine", "description": "Early computer."},
|
|
],
|
|
relationships=[
|
|
{
|
|
"source": "Ada Lovelace",
|
|
"target": "Analytical Engine",
|
|
"type": "worked_on",
|
|
"description": "wrote algorithms for it",
|
|
"weight": 3.0,
|
|
}
|
|
],
|
|
)
|
|
llm = _StubLLM([payload])
|
|
_install_stub_llm(monkeypatch, llm)
|
|
|
|
summary = extract_graph_for_source(
|
|
source_id,
|
|
user="owner-1",
|
|
chunks=[_chunk("c1", "Ada Lovelace worked on the Analytical Engine.")],
|
|
config=SourceConfig(),
|
|
request_id="req-1",
|
|
)
|
|
|
|
assert summary["nodes"] == 2
|
|
assert summary["edges"] == 1
|
|
assert summary["chunks_processed"] == 1
|
|
assert summary["failed_chunks"] == 0
|
|
assert store.count_nodes(source_id) == 2
|
|
|
|
node = store.get_node_by_normalized(source_id, "ada lovelace")
|
|
assert node is not None
|
|
mapping = store.get_chunk_ids_for_nodes(source_id, [node["id"]])
|
|
assert mapping[node["id"]] == ["c1"]
|
|
finally:
|
|
store.delete_by_source(source_id)
|
|
|
|
def test_parallel_workers_process_every_chunk_once(
|
|
self, store, source_id, monkeypatch, stub_embedding
|
|
):
|
|
"""Running the model calls concurrently must not change what gets written.
|
|
|
|
Extraction spends nearly all of a chunk's time waiting on the model, so
|
|
the calls run in a pool while every graph write stays on the calling
|
|
thread. Six chunks share one entity here: whatever order the pool
|
|
finishes in, that entity is upserted once, each chunk is linked, and all
|
|
six are marked processed.
|
|
"""
|
|
from docsgpt.core.settings import settings
|
|
|
|
try:
|
|
payload = _extraction_json(
|
|
entities=[{"name": "Ada", "type": "person", "description": "d"}],
|
|
relationships=[],
|
|
)
|
|
llm = _StubLLM([payload] * 6)
|
|
_install_stub_llm(monkeypatch, llm)
|
|
monkeypatch.setattr(settings, "GRAPHRAG_EXTRACTION_WORKERS", 4)
|
|
|
|
summary = extract_graph_for_source(
|
|
source_id,
|
|
user="owner-1",
|
|
chunks=[
|
|
_chunk(f"c{i}", f"Ada appears here, take {i}.") for i in range(6)
|
|
],
|
|
config=SourceConfig(),
|
|
request_id="req-parallel",
|
|
)
|
|
|
|
assert summary["chunks_processed"] == 6
|
|
assert summary["failed_chunks"] == 0
|
|
assert summary["nodes"] == 1
|
|
assert len(llm.gen_calls) == 6
|
|
|
|
node = store.get_node_by_normalized(source_id, "ada")
|
|
assert node is not None
|
|
mapping = store.get_chunk_ids_for_nodes(source_id, [node["id"]])
|
|
assert sorted(mapping[node["id"]]) == [f"c{i}" for i in range(6)]
|
|
finally:
|
|
store.delete_by_source(source_id)
|
|
|
|
def test_embedding_runs_on_the_calling_thread(
|
|
self, store, source_id, monkeypatch, stub_embedding
|
|
):
|
|
"""Only the LLM call may run in the extraction pool, never embedding.
|
|
|
|
Embedding from the pool is what broke every graph build inside a
|
|
worker: the embeddings client used to decide "embed locally" from the
|
|
task on the *current thread's* stack, so a pool thread dispatched to
|
|
the worker instead and Celery refused the wait. ``in_worker`` is
|
|
process-wide now, but the pool still has no reason to touch the
|
|
embeddings client — it exists to overlap model latency.
|
|
"""
|
|
import threading
|
|
|
|
from docsgpt.core.settings import settings
|
|
|
|
caller = threading.current_thread()
|
|
seen = []
|
|
real_embed_names = extraction_module._embed_names
|
|
|
|
def _recording_embed_names(*args, **kwargs):
|
|
seen.append(threading.current_thread())
|
|
return real_embed_names(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(extraction_module, "_embed_names", _recording_embed_names)
|
|
try:
|
|
payload = _extraction_json(
|
|
entities=[{"name": "Ada", "type": "person", "description": "d"}],
|
|
relationships=[],
|
|
)
|
|
_install_stub_llm(monkeypatch, _StubLLM([payload] * 4))
|
|
monkeypatch.setattr(settings, "GRAPHRAG_EXTRACTION_WORKERS", 4)
|
|
|
|
summary = extract_graph_for_source(
|
|
source_id,
|
|
user="owner-1",
|
|
chunks=[_chunk(f"c{i}", f"Ada, take {i}.") for i in range(4)],
|
|
config=SourceConfig(),
|
|
request_id="req-thread",
|
|
)
|
|
|
|
assert summary["failed_chunks"] == 0
|
|
assert len(seen) == 4
|
|
assert all(thread is caller for thread in seen)
|
|
finally:
|
|
store.delete_by_source(source_id)
|
|
|
|
def test_same_entity_across_chunks_merges(
|
|
self, store, source_id, monkeypatch, stub_embedding
|
|
):
|
|
try:
|
|
payload_a = _extraction_json(
|
|
entities=[{"name": "Ada", "type": "person", "description": "first"}],
|
|
relationships=[],
|
|
)
|
|
payload_b = _extraction_json(
|
|
entities=[{"name": "Ada", "type": "person", "description": "second"}],
|
|
relationships=[],
|
|
)
|
|
llm = _StubLLM([payload_a, payload_b])
|
|
_install_stub_llm(monkeypatch, llm)
|
|
|
|
summary = extract_graph_for_source(
|
|
source_id,
|
|
user="owner-1",
|
|
chunks=[_chunk("c1", "Ada one."), _chunk("c2", "Ada two.")],
|
|
config=SourceConfig(),
|
|
request_id="req-1",
|
|
)
|
|
|
|
assert summary["chunks_processed"] == 2
|
|
assert store.count_nodes(source_id) == 1
|
|
node = store.get_node_by_normalized(source_id, "ada")
|
|
assert node["doc_freq"] == 2
|
|
assert "first" in node["description"]
|
|
assert "second" in node["description"]
|
|
finally:
|
|
store.delete_by_source(source_id)
|
|
|
|
def test_checkpoint_skips_done_chunks(
|
|
self, store, source_id, monkeypatch, stub_embedding
|
|
):
|
|
try:
|
|
payload = _extraction_json(
|
|
entities=[{"name": "Ada", "type": "person", "description": "d"}],
|
|
relationships=[],
|
|
)
|
|
first_llm = _StubLLM([payload])
|
|
_install_stub_llm(monkeypatch, first_llm)
|
|
extract_graph_for_source(
|
|
source_id,
|
|
user="owner-1",
|
|
chunks=[_chunk("c1", "Ada.")],
|
|
config=SourceConfig(),
|
|
request_id="req-1",
|
|
)
|
|
assert len(first_llm.gen_calls) == 1
|
|
|
|
second_llm = _StubLLM([])
|
|
_install_stub_llm(monkeypatch, second_llm)
|
|
summary = extract_graph_for_source(
|
|
source_id,
|
|
user="owner-1",
|
|
chunks=[_chunk("c1", "Ada.")],
|
|
config=SourceConfig(),
|
|
request_id="req-2",
|
|
)
|
|
assert len(second_llm.gen_calls) == 0
|
|
assert summary["chunks_processed"] == 0
|
|
finally:
|
|
store.delete_by_source(source_id)
|
|
|
|
def test_cap_limits_processing(
|
|
self, store, source_id, monkeypatch, stub_embedding
|
|
):
|
|
try:
|
|
payload = _extraction_json(
|
|
entities=[{"name": "X", "type": "t", "description": "d"}],
|
|
relationships=[],
|
|
)
|
|
llm = _StubLLM([payload, payload])
|
|
_install_stub_llm(monkeypatch, llm)
|
|
|
|
config = SourceConfig.model_validate({"graph": {"max_chunks": 2}})
|
|
summary = extract_graph_for_source(
|
|
source_id,
|
|
user="owner-1",
|
|
chunks=[_chunk(f"c{i}", f"text {i}") for i in range(5)],
|
|
config=config,
|
|
request_id="req-1",
|
|
)
|
|
|
|
assert len(llm.gen_calls) == 2
|
|
assert summary["chunks_processed"] == 2
|
|
assert summary["skipped_over_cap"] == 3
|
|
finally:
|
|
store.delete_by_source(source_id)
|
|
|
|
def test_malformed_and_error_chunks_are_skipped(
|
|
self, store, source_id, monkeypatch, stub_embedding
|
|
):
|
|
try:
|
|
good = _extraction_json(
|
|
entities=[{"name": "Ada", "type": "person", "description": "d"}],
|
|
relationships=[],
|
|
)
|
|
# Each failing chunk fails its retry too; one that recovers on
|
|
# retry is covered in ``TestFailedChunksAreRetried``.
|
|
llm = _ScriptedLLM({
|
|
"garbage": ["not json at all", "still not json"],
|
|
"boom": [RuntimeError("model exploded"), RuntimeError("model exploded again")],
|
|
"Ada.": [good],
|
|
})
|
|
_install_stub_llm(monkeypatch, llm)
|
|
|
|
summary = extract_graph_for_source(
|
|
source_id,
|
|
user="owner-1",
|
|
chunks=[
|
|
_chunk("c1", "garbage"),
|
|
_chunk("c2", "boom"),
|
|
_chunk("c3", "Ada."),
|
|
],
|
|
config=SourceConfig(),
|
|
request_id="req-1",
|
|
)
|
|
|
|
assert summary["failed_chunks"] == 2
|
|
assert summary["chunks_processed"] == 1
|
|
assert store.count_nodes(source_id) == 1
|
|
progress = store.get_progress(source_id)
|
|
assert progress["c1"] == "failed"
|
|
assert progress["c2"] == "failed"
|
|
assert progress["c3"] == "done"
|
|
finally:
|
|
store.delete_by_source(source_id)
|
|
|
|
def test_exactly_one_gen_per_chunk(
|
|
self, store, source_id, monkeypatch, stub_embedding
|
|
):
|
|
try:
|
|
payload = _extraction_json(
|
|
entities=[{"name": "A", "type": "t", "description": "d"}],
|
|
relationships=[],
|
|
)
|
|
llm = _StubLLM([payload, payload, payload])
|
|
_install_stub_llm(monkeypatch, llm)
|
|
|
|
extract_graph_for_source(
|
|
source_id,
|
|
user="owner-1",
|
|
chunks=[_chunk(f"c{i}", f"text {i}") for i in range(3)],
|
|
config=SourceConfig(),
|
|
request_id="req-1",
|
|
)
|
|
assert len(llm.gen_calls) == 3
|
|
finally:
|
|
store.delete_by_source(source_id)
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestExtractionTokenUsage:
|
|
def test_llm_tagged_for_token_usage(self, monkeypatch):
|
|
llm = _StubLLM([])
|
|
captured = _install_stub_llm(monkeypatch, llm)
|
|
|
|
built = extraction_module._build_extraction_llm(
|
|
"stub-model", user="owner-1", request_id="req-99"
|
|
)
|
|
|
|
assert built is llm
|
|
assert built._token_usage_source == "graph_extraction"
|
|
assert built._request_id == "req-99"
|
|
assert captured["model_id"] == "stub-model"
|
|
|
|
def test_concurrent_extraction_calls_never_share_an_llm(self, monkeypatch, stub_embedding):
|
|
"""Provider usage is recorded on the LLM instance (``_last_usage``) and
|
|
claimed by whichever call finishes next, so two calls in flight on one
|
|
instance can bill each other's tokens. Each extraction thread needs its
|
|
own instance."""
|
|
import threading
|
|
import time
|
|
from unittest.mock import MagicMock
|
|
|
|
from docsgpt.core.settings import settings
|
|
|
|
payload = _extraction_json(
|
|
entities=[{"name": "Ada", "type": "person", "description": "d"}],
|
|
relationships=[],
|
|
)
|
|
|
|
class _ThreadRecordingLLM:
|
|
model_id = "stub-model"
|
|
|
|
def __init__(self):
|
|
self.threads = set()
|
|
|
|
def gen(self, model=None, messages=None, **kwargs):
|
|
self.threads.add(threading.get_ident())
|
|
time.sleep(0.01) # keep calls overlapping
|
|
return payload
|
|
|
|
built = []
|
|
|
|
def _create(*args, **kwargs):
|
|
llm = _ThreadRecordingLLM()
|
|
built.append(llm)
|
|
return llm
|
|
|
|
monkeypatch.setattr(extraction_module.LLMCreator, "create_llm", staticmethod(_create))
|
|
monkeypatch.setattr(settings, "GRAPHRAG_EXTRACTION_WORKERS", 4)
|
|
store = MagicMock(name="GraphStore")
|
|
store.pending_chunks.return_value = [f"c{i}" for i in range(8)]
|
|
store.apply_chunk.return_value = (1, 0)
|
|
store.count_nodes.return_value = 1
|
|
monkeypatch.setattr("docsgpt.graphrag.store.GraphStore", lambda *a, **k: store)
|
|
|
|
summary = extract_graph_for_source(
|
|
str(uuid.uuid4()),
|
|
user="owner-1",
|
|
chunks=[_chunk(f"c{i}", f"Ada, take {i}.") for i in range(8)],
|
|
config=SourceConfig(),
|
|
request_id="req-threads",
|
|
)
|
|
|
|
assert summary["chunks_processed"] == 8
|
|
used = [llm for llm in built if llm.threads]
|
|
assert len(used) > 1, "calls did not run concurrently"
|
|
assert all(len(llm.threads) == 1 for llm in used)
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestModelResolution:
|
|
def test_per_source_override_wins(self, monkeypatch):
|
|
monkeypatch.setattr(
|
|
extraction_module.settings, "GRAPHRAG_EXTRACTION_MODEL", "setting-model"
|
|
)
|
|
monkeypatch.setattr(extraction_module.settings, "LLM_NAME", "instance-model")
|
|
config = SourceConfig.model_validate(
|
|
{"graph": {"extraction_model": "override-model"}}
|
|
)
|
|
assert (
|
|
extraction_module._resolve_extraction_model(config) == "override-model"
|
|
)
|
|
|
|
def test_setting_then_instance_default(self, monkeypatch):
|
|
monkeypatch.setattr(
|
|
extraction_module.settings, "GRAPHRAG_EXTRACTION_MODEL", "setting-model"
|
|
)
|
|
monkeypatch.setattr(extraction_module.settings, "LLM_NAME", "instance-model")
|
|
assert (
|
|
extraction_module._resolve_extraction_model(SourceConfig())
|
|
== "setting-model"
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
extraction_module.settings, "GRAPHRAG_EXTRACTION_MODEL", None
|
|
)
|
|
assert (
|
|
extraction_module._resolve_extraction_model(SourceConfig())
|
|
== "instance-model"
|
|
)
|
|
|
|
def test_max_chunks_resolution(self, monkeypatch):
|
|
monkeypatch.setattr(
|
|
extraction_module.settings,
|
|
"GRAPHRAG_MAX_CHUNKS_FOR_EXTRACTION",
|
|
2000,
|
|
)
|
|
assert extraction_module._resolve_max_chunks(SourceConfig()) == 2000
|
|
config = SourceConfig.model_validate({"graph": {"max_chunks": 5}})
|
|
assert extraction_module._resolve_max_chunks(config) == 5
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestExtractionProviderResolution:
|
|
"""The extraction model decides the provider, not ``LLM_PROVIDER``.
|
|
|
|
``settings.LLM_PROVIDER`` is the deployment default (``docsgpt`` out of the
|
|
box, i.e. the hosted public endpoint). Dispatching the resolved extraction
|
|
model through it sends the call to a provider that never serves that model:
|
|
the request is rejected, the shared fallback answers instead, and the graph
|
|
is quietly built by a different model than the one configured.
|
|
"""
|
|
|
|
def _capture_create_llm(self, monkeypatch, llm=None):
|
|
captured = {}
|
|
|
|
def _create(provider, *args, **kwargs):
|
|
captured["provider"] = provider
|
|
captured["args"] = args
|
|
captured["kwargs"] = kwargs
|
|
return llm or _StubLLM([])
|
|
|
|
monkeypatch.setattr(
|
|
extraction_module.LLMCreator, "create_llm", staticmethod(_create)
|
|
)
|
|
return captured
|
|
|
|
def test_provider_comes_from_the_model_registry(self, monkeypatch):
|
|
monkeypatch.setattr(extraction_module.settings, "LLM_PROVIDER", "docsgpt")
|
|
monkeypatch.setattr(
|
|
extraction_module, "get_provider_from_model_id", lambda *a, **k: "openai"
|
|
)
|
|
monkeypatch.setattr(
|
|
extraction_module, "get_api_key_for_provider", lambda provider: "sk-openai"
|
|
)
|
|
captured = self._capture_create_llm(monkeypatch)
|
|
|
|
extraction_module._build_extraction_llm("gpt-4o-mini", "owner-1", "req-1")
|
|
|
|
assert captured["provider"] == "openai"
|
|
assert captured["kwargs"]["api_key"] == "sk-openai"
|
|
assert captured["kwargs"]["model_id"] == "gpt-4o-mini"
|
|
|
|
def test_owner_scopes_the_registry_lookup(self, monkeypatch):
|
|
"""A per-user (BYOM) model only resolves when the owner is passed."""
|
|
seen = {}
|
|
|
|
def _resolve(model_id, user_id=None):
|
|
seen["model_id"] = model_id
|
|
seen["user_id"] = user_id
|
|
return "anthropic"
|
|
|
|
monkeypatch.setattr(
|
|
extraction_module, "get_provider_from_model_id", _resolve
|
|
)
|
|
monkeypatch.setattr(
|
|
extraction_module, "get_api_key_for_provider", lambda provider: "k"
|
|
)
|
|
self._capture_create_llm(monkeypatch)
|
|
|
|
extraction_module._build_extraction_llm("byom-uuid", "owner-7", "req-1")
|
|
|
|
assert seen == {"model_id": "byom-uuid", "user_id": "owner-7"}
|
|
|
|
def test_unknown_model_falls_back_to_the_configured_provider(self, monkeypatch):
|
|
monkeypatch.setattr(extraction_module.settings, "LLM_PROVIDER", "docsgpt")
|
|
monkeypatch.setattr(
|
|
extraction_module, "get_provider_from_model_id", lambda *a, **k: None
|
|
)
|
|
monkeypatch.setattr(
|
|
extraction_module, "get_api_key_for_provider", lambda provider: "fallback-key"
|
|
)
|
|
captured = self._capture_create_llm(monkeypatch)
|
|
|
|
extraction_module._build_extraction_llm("mystery-model", "owner-1", "req-1")
|
|
|
|
assert captured["provider"] == "docsgpt"
|
|
assert captured["kwargs"]["api_key"] == "fallback-key"
|
|
|
|
def test_no_model_id_skips_the_lookup(self, monkeypatch):
|
|
monkeypatch.setattr(extraction_module.settings, "LLM_PROVIDER", "openai")
|
|
calls = []
|
|
monkeypatch.setattr(
|
|
extraction_module,
|
|
"get_provider_from_model_id",
|
|
lambda *a, **k: calls.append(a) or "anthropic",
|
|
)
|
|
monkeypatch.setattr(
|
|
extraction_module, "get_api_key_for_provider", lambda provider: "k"
|
|
)
|
|
captured = self._capture_create_llm(monkeypatch)
|
|
|
|
extraction_module._build_extraction_llm(None, "owner-1", "req-1")
|
|
|
|
assert calls == []
|
|
assert captured["provider"] == "openai"
|
|
|
|
def test_api_key_follows_the_resolved_provider(self, monkeypatch):
|
|
"""The key must match the provider actually dispatched to."""
|
|
monkeypatch.setattr(extraction_module.settings, "LLM_PROVIDER", "docsgpt")
|
|
monkeypatch.setattr(extraction_module.settings, "API_KEY", "generic-key")
|
|
monkeypatch.setattr(
|
|
extraction_module, "get_provider_from_model_id", lambda *a, **k: "anthropic"
|
|
)
|
|
keyed_for = {}
|
|
|
|
def _key(provider):
|
|
keyed_for["provider"] = provider
|
|
return "sk-anthropic"
|
|
|
|
monkeypatch.setattr(extraction_module, "get_api_key_for_provider", _key)
|
|
captured = self._capture_create_llm(monkeypatch)
|
|
|
|
extraction_module._build_extraction_llm("claude-x", "owner-1", "req-1")
|
|
|
|
assert keyed_for["provider"] == "anthropic"
|
|
assert captured["kwargs"]["api_key"] == "sk-anthropic"
|
|
assert captured["kwargs"]["api_key"] != "generic-key"
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestFailedChunksAreReported:
|
|
"""Every dropped chunk has to leave a trace.
|
|
|
|
A chunk whose extraction cannot be parsed is marked ``failed`` and skipped.
|
|
That path logged nothing at all, so a graph could come back short with the
|
|
summary's ``failed_chunks`` count as the only hint and no way to tell which
|
|
chunk, or why, from the logs.
|
|
"""
|
|
|
|
def _fake_store(self, monkeypatch, chunk_ids):
|
|
from unittest.mock import MagicMock
|
|
|
|
store = MagicMock(name="GraphStore")
|
|
store.pending_chunks.return_value = list(chunk_ids)
|
|
store.apply_chunk.return_value = (1, 0)
|
|
store.count_nodes.return_value = 1
|
|
monkeypatch.setattr(
|
|
"docsgpt.graphrag.store.GraphStore", lambda *a, **k: store
|
|
)
|
|
return store
|
|
|
|
def test_unparseable_output_is_logged_with_the_chunk_id(
|
|
self, monkeypatch, caplog, stub_embedding
|
|
):
|
|
import logging
|
|
|
|
store = self._fake_store(monkeypatch, ["c1"])
|
|
_install_stub_llm(monkeypatch, _StubLLM(["not json at all", "still not json"]))
|
|
|
|
with caplog.at_level(logging.WARNING, logger="docsgpt.graphrag.extraction"):
|
|
summary = extract_graph_for_source(
|
|
str(uuid.uuid4()),
|
|
user="owner-1",
|
|
chunks=[_chunk("c1", "some text")],
|
|
config=SourceConfig(),
|
|
request_id="req-1",
|
|
)
|
|
|
|
assert summary["failed_chunks"] == 1
|
|
store.mark_chunk.assert_called_once()
|
|
assert store.mark_chunk.call_args.args[2] == "failed"
|
|
messages = [r.getMessage() for r in caplog.records if r.levelno >= logging.WARNING]
|
|
assert any("c1" in message for message in messages), messages
|
|
|
|
def test_llm_errors_still_name_the_chunk(
|
|
self, monkeypatch, caplog, stub_embedding
|
|
):
|
|
import logging
|
|
|
|
self._fake_store(monkeypatch, ["c7"])
|
|
_install_stub_llm(
|
|
monkeypatch,
|
|
_StubLLM([RuntimeError("model exploded"), RuntimeError("model exploded again")]),
|
|
)
|
|
|
|
with caplog.at_level(logging.WARNING, logger="docsgpt.graphrag.extraction"):
|
|
extract_graph_for_source(
|
|
str(uuid.uuid4()),
|
|
user="owner-1",
|
|
chunks=[_chunk("c7", "some text")],
|
|
config=SourceConfig(),
|
|
request_id="req-1",
|
|
)
|
|
|
|
messages = [r.getMessage() for r in caplog.records if r.levelno >= logging.WARNING]
|
|
assert any("c7" in message for message in messages), messages
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestFailedChunksAreRetried:
|
|
"""A chunk that fails once gets one more attempt before the build ends.
|
|
|
|
Failures are recorded as ``failed`` and the checkpoint treats them as
|
|
pending, but nothing ever ran the build again, so a single transient error
|
|
— a provider hiccup, one response that did not parse — left a permanent
|
|
hole in the graph until someone rebuilt the whole source. Retries run
|
|
after the rest of the build, which gives a burst of rate limiting time to
|
|
pass, and are bounded at one per chunk so a chunk that can never be
|
|
extracted costs at most two calls.
|
|
"""
|
|
|
|
GOOD = _extraction_json(
|
|
entities=[{"name": "Ada", "type": "person", "description": "d"}],
|
|
relationships=[],
|
|
)
|
|
|
|
def _fake_store(self, monkeypatch, chunk_ids):
|
|
from unittest.mock import MagicMock
|
|
|
|
store = MagicMock(name="GraphStore")
|
|
store.pending_chunks.return_value = list(chunk_ids)
|
|
store.apply_chunk.return_value = (1, 0)
|
|
store.count_nodes.return_value = 1
|
|
monkeypatch.setattr(
|
|
"docsgpt.graphrag.store.GraphStore", lambda *a, **k: store
|
|
)
|
|
return store
|
|
|
|
def _run(self, chunks, progress=None):
|
|
return extract_graph_for_source(
|
|
str(uuid.uuid4()),
|
|
user="owner-1",
|
|
chunks=chunks,
|
|
config=SourceConfig(),
|
|
request_id="req-retry",
|
|
progress_cb=progress,
|
|
)
|
|
|
|
@staticmethod
|
|
def _marked_failed(store):
|
|
return [c.args[1] for c in store.mark_chunk.call_args_list if c.args[2] == "failed"]
|
|
|
|
def test_a_transient_failure_is_retried_and_written(self, monkeypatch, stub_embedding):
|
|
store = self._fake_store(monkeypatch, ["c1"])
|
|
llm = _ScriptedLLM({"flaky": [RuntimeError("rate limited"), self.GOOD]})
|
|
_install_stub_llm(monkeypatch, llm)
|
|
|
|
summary = self._run([_chunk("c1", "flaky")])
|
|
|
|
assert summary["failed_chunks"] == 0
|
|
assert summary["chunks_processed"] == 1
|
|
assert store.apply_chunk.call_args.args[1] == "c1"
|
|
assert self._marked_failed(store) == []
|
|
|
|
def test_an_unparseable_response_is_retried(self, monkeypatch, stub_embedding):
|
|
store = self._fake_store(monkeypatch, ["c1"])
|
|
_install_stub_llm(monkeypatch, _ScriptedLLM({"odd": ["not json", self.GOOD]}))
|
|
|
|
summary = self._run([_chunk("c1", "odd")])
|
|
|
|
assert summary["failed_chunks"] == 0
|
|
assert self._marked_failed(store) == []
|
|
|
|
def test_a_chunk_that_fails_again_is_marked_failed_once(self, monkeypatch, stub_embedding):
|
|
store = self._fake_store(monkeypatch, ["c1"])
|
|
llm = _ScriptedLLM({"broken": ["not json", "still not json"]})
|
|
_install_stub_llm(monkeypatch, llm)
|
|
|
|
summary = self._run([_chunk("c1", "broken")])
|
|
|
|
assert summary["failed_chunks"] == 1
|
|
assert summary["chunks_processed"] == 0
|
|
assert llm.calls_for("broken") == 2
|
|
assert self._marked_failed(store) == ["c1"]
|
|
|
|
def test_only_failed_chunks_are_retried(self, monkeypatch, stub_embedding):
|
|
from docsgpt.core.settings import settings
|
|
|
|
monkeypatch.setattr(settings, "GRAPHRAG_EXTRACTION_WORKERS", 4)
|
|
self._fake_store(monkeypatch, ["c1", "c2", "c3"])
|
|
llm = _ScriptedLLM({
|
|
"one": [self.GOOD],
|
|
"two": [RuntimeError("timeout"), self.GOOD],
|
|
"three": [self.GOOD],
|
|
})
|
|
_install_stub_llm(monkeypatch, llm)
|
|
|
|
summary = self._run([_chunk("c1", "one"), _chunk("c2", "two"), _chunk("c3", "three")])
|
|
|
|
assert summary["chunks_processed"] == 3
|
|
assert summary["failed_chunks"] == 0
|
|
assert (llm.calls_for("one"), llm.calls_for("two"), llm.calls_for("three")) == (1, 2, 1)
|
|
|
|
def test_a_failed_write_is_retried(self, monkeypatch, stub_embedding):
|
|
store = self._fake_store(monkeypatch, ["c1"])
|
|
store.apply_chunk.side_effect = [RuntimeError("write failed"), (1, 0)]
|
|
_install_stub_llm(monkeypatch, _ScriptedLLM({"text": [self.GOOD, self.GOOD]}))
|
|
|
|
summary = self._run([_chunk("c1", "text")])
|
|
|
|
assert summary["failed_chunks"] == 0
|
|
assert summary["chunks_processed"] == 1
|
|
assert self._marked_failed(store) == []
|
|
|
|
def test_progress_ends_at_the_total(self, monkeypatch, stub_embedding):
|
|
self._fake_store(monkeypatch, ["c1", "c2"])
|
|
_install_stub_llm(monkeypatch, _ScriptedLLM({
|
|
"fine": [self.GOOD],
|
|
"broken": ["not json", "still not json"],
|
|
}))
|
|
events = []
|
|
|
|
self._run([_chunk("c1", "fine"), _chunk("c2", "broken")], progress=events.append)
|
|
|
|
assert all(e["current"] <= e["total"] for e in events)
|
|
assert events[-1]["current"] == events[-1]["total"] == 2
|
|
|
|
|
|
@pytest.mark.integration
|
|
class TestSummaryNodeCount:
|
|
"""``nodes`` must describe the graph, not the number of upserts."""
|
|
|
|
@pytest.fixture
|
|
def store(self, monkeypatch, postgresql):
|
|
store = _live_store(monkeypatch, postgresql.info)
|
|
yield store
|
|
store.close()
|
|
|
|
def test_repeated_entity_counts_once(
|
|
self, store, monkeypatch, stub_embedding
|
|
):
|
|
source_id = str(uuid.uuid4())
|
|
try:
|
|
payload = _extraction_json(
|
|
entities=[{"name": "Ada", "type": "person", "description": "d"}],
|
|
relationships=[],
|
|
)
|
|
_install_stub_llm(monkeypatch, _StubLLM([payload, payload]))
|
|
|
|
summary = extract_graph_for_source(
|
|
source_id,
|
|
user="owner-1",
|
|
chunks=[_chunk("c1", "Ada one."), _chunk("c2", "Ada two.")],
|
|
config=SourceConfig(),
|
|
request_id="req-1",
|
|
)
|
|
|
|
# Two chunks upserted the same entity: one node in the graph.
|
|
assert store.count_nodes(source_id) == 1
|
|
assert summary["nodes"] == 1
|
|
assert summary["chunks_processed"] == 2
|
|
finally:
|
|
store.delete_by_source(source_id)
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestSummaryCountFailure:
|
|
"""A broken count query must not be reported as an empty graph."""
|
|
|
|
def test_a_failed_count_reports_the_write_count(
|
|
self, monkeypatch, stub_embedding
|
|
):
|
|
from unittest.mock import MagicMock
|
|
|
|
store = MagicMock(name="GraphStore")
|
|
store.pending_chunks.return_value = ["c1"]
|
|
store.apply_chunk.return_value = (2, 1)
|
|
store.count_nodes.side_effect = RuntimeError("count query failed")
|
|
monkeypatch.setattr(
|
|
"docsgpt.graphrag.store.GraphStore", lambda *a, **k: store
|
|
)
|
|
_install_stub_llm(
|
|
monkeypatch,
|
|
_StubLLM([_extraction_json([{"name": "Ada"}], [])]),
|
|
)
|
|
|
|
summary = extract_graph_for_source(
|
|
str(uuid.uuid4()),
|
|
user="owner-1",
|
|
chunks=[_chunk("c1", "Ada.")],
|
|
config=SourceConfig(),
|
|
request_id="req-1",
|
|
)
|
|
|
|
# Falls back to what was actually written, not to zero.
|
|
assert summary["nodes"] == 2
|
|
# And it asked for a count that raises rather than one that returns 0,
|
|
# or the fallback above could never run.
|
|
assert store.count_nodes.call_args.kwargs.get("strict") is True
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestEntityNormalization:
|
|
"""An entity whose name normalizes to nothing is not an entity.
|
|
|
|
``canonical_name`` answers "" for a punctuation-only name, and nodes are
|
|
merged on that key, so keeping them collapses every such entity onto one
|
|
shared node. ``_resolve_endpoint`` already drops them on the relationship
|
|
side.
|
|
"""
|
|
|
|
@pytest.mark.parametrize("name", ["!!!", "--", "?", " *** "])
|
|
def test_a_name_that_normalizes_to_nothing_is_dropped(self, name):
|
|
assert extraction_module._build_entities([{"name": name}]) == []
|
|
|
|
def test_real_names_survive(self):
|
|
built = extraction_module._build_entities(
|
|
[{"name": "Quill Store"}, {"name": "!!!"}, {"name": "Alder"}]
|
|
)
|
|
assert [e["normalized_name"] for e in built] == ["quill store", "alder"]
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestParsing:
|
|
def test_parses_embedded_json(self):
|
|
raw = 'sure!\n{"entities": [{"name": "A"}], "relationships": []}\nthanks'
|
|
parsed = extraction_module._parse_extraction(raw)
|
|
assert parsed["entities"] == [{"name": "A"}]
|
|
assert parsed["relationships"] == []
|
|
|
|
def test_garbage_returns_none(self):
|
|
assert extraction_module._parse_extraction("no json here") is None
|
|
assert extraction_module._parse_extraction("{bad json}") is None
|
|
assert extraction_module._parse_extraction(None) is None
|
|
|
|
def test_missing_keys_default_empty(self):
|
|
parsed = extraction_module._parse_extraction('{"foo": 1}')
|
|
assert parsed == {"entities": [], "relationships": []}
|
|
|
|
def test_chunk_id_prefers_doc_id(self):
|
|
assert extraction_module._chunk_id({"doc_id": "7"}) == "7"
|
|
assert extraction_module._chunk_id({"chunk_id": "abc"}) == "abc"
|
|
assert extraction_module._chunk_id({"id": 9}) == "9"
|
|
assert extraction_module._chunk_id({"text": "no id"}) is None
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestEmbeddingsResolution:
|
|
def test_extraction_uses_shared_resolver(self, monkeypatch):
|
|
"""Extraction must resolve embeddings through ``get_embeddings``."""
|
|
from unittest.mock import MagicMock
|
|
|
|
fake_store = MagicMock()
|
|
fake_store.pending_chunks.return_value = []
|
|
monkeypatch.setattr(
|
|
"docsgpt.graphrag.store.GraphStore", lambda *a, **k: fake_store
|
|
)
|
|
_install_stub_llm(monkeypatch, _StubLLM([]))
|
|
|
|
calls = []
|
|
fake_embedding = MagicMock()
|
|
|
|
def _resolver(*args, **kwargs):
|
|
calls.append((args, kwargs))
|
|
return fake_embedding
|
|
|
|
monkeypatch.setattr(extraction_module, "get_embeddings", _resolver)
|
|
|
|
summary = extract_graph_for_source(
|
|
str(uuid.uuid4()),
|
|
user="owner-1",
|
|
chunks=[],
|
|
config=SourceConfig(),
|
|
request_id="req-1",
|
|
)
|
|
|
|
assert calls == [((), {})]
|
|
assert summary["chunks_processed"] == 0
|