Files
hkuds--lightrag/tests/extraction/test_extract_entities.py
T
2026-07-13 12:08:54 +08:00

179 lines
5.7 KiB
Python

"""Tests for entity extraction gleaning token limit guard."""
import logging
from unittest.mock import AsyncMock
import pytest
from lightrag.utils import Tokenizer, TokenizerInterface
@pytest.fixture
def _propagate_lightrag_logger(monkeypatch):
"""``lightrag.utils.logger`` sets ``propagate = False`` to avoid noisy
test output; restore propagation locally so ``caplog`` can capture
WARNING records emitted from inside ``lightrag.operate``."""
monkeypatch.setattr(logging.getLogger("lightrag"), "propagate", True)
class DummyTokenizer(TokenizerInterface):
"""Simple 1:1 character-to-token mapping for testing."""
def encode(self, content: str):
return [ord(ch) for ch in content]
def decode(self, tokens):
return "".join(chr(token) for token in tokens)
def _make_global_config(
entity_extract_max_gleaning: int = 1,
) -> dict:
"""Build a minimal global_config dict for extract_entities."""
tokenizer = Tokenizer("dummy", DummyTokenizer())
extract_func = AsyncMock(return_value="")
return {
"llm_model_func": extract_func,
"role_llm_funcs": {
"extract": extract_func,
"keyword": extract_func,
"query": extract_func,
"vlm": extract_func,
},
"entity_extract_max_gleaning": entity_extract_max_gleaning,
"entity_extract_max_records": 100,
"entity_extract_max_entities": 40,
"addon_params": {},
"tokenizer": tokenizer,
"llm_model_max_async": 1,
}
# Minimal valid extraction result that _process_extraction_result can parse
_EXTRACTION_RESULT = (
"(entity<|#|>TEST_ENTITY<|#|>CONCEPT<|#|>A test entity)<|COMPLETE|>"
)
def _make_chunks(content: str = "Test content.") -> dict[str, dict]:
return {
"chunk-001": {
"tokens": len(content),
"content": content,
"full_doc_id": "doc-001",
"chunk_order_index": 0,
}
}
@pytest.mark.offline
@pytest.mark.asyncio
async def test_gleaning_skipped_when_tokens_exceed_limit(
monkeypatch, caplog, _propagate_lightrag_logger
):
"""Gleaning must be skipped (with a WARNING) when the projected
gleaning input — system + history(user+assistant) + continue prompt —
exceeds ``MAX_EXTRACT_INPUT_TOKENS``. This prevents
``context_length_exceeded`` errors from the LLM provider on the second
round when the initial response was long.
"""
from lightrag.operate import extract_entities
# 10 tokens cannot fit any realistic prompt — guard must trip.
monkeypatch.setenv("MAX_EXTRACT_INPUT_TOKENS", "10")
global_config = _make_global_config(entity_extract_max_gleaning=1)
llm_func = global_config["llm_model_func"]
llm_func.return_value = _EXTRACTION_RESULT
with caplog.at_level("WARNING", logger="lightrag"):
await extract_entities(
chunks=_make_chunks(),
global_config=global_config,
)
# Only the initial extraction round ran; gleaning was skipped.
assert llm_func.await_count == 1
warnings_emitted = [
rec.getMessage()
for rec in caplog.records
if rec.levelname == "WARNING"
and rec.getMessage().startswith("Gleaning stopped for chunk chunk-001:")
]
assert warnings_emitted, (
"expected a WARNING log explaining gleaning was skipped due to "
"token limit; got: "
f"{[r.getMessage() for r in caplog.records]}"
)
# Message must surface both the measured token count and the limit so
# operators can size MAX_EXTRACT_INPUT_TOKENS appropriately.
msg = warnings_emitted[0]
assert "exceeded limit (10)" in msg
assert "Input tokens (" in msg
@pytest.mark.offline
@pytest.mark.asyncio
async def test_gleaning_proceeds_when_tokens_within_limit(monkeypatch):
"""Gleaning runs normally when the projected input fits the cap."""
from lightrag.operate import extract_entities
monkeypatch.setenv("MAX_EXTRACT_INPUT_TOKENS", "999999")
global_config = _make_global_config(entity_extract_max_gleaning=1)
llm_func = global_config["llm_model_func"]
llm_func.return_value = _EXTRACTION_RESULT
await extract_entities(
chunks=_make_chunks(),
global_config=global_config,
)
# Both rounds run: initial extraction + one gleaning pass.
assert llm_func.await_count == 2
@pytest.mark.offline
@pytest.mark.asyncio
async def test_no_gleaning_when_max_gleaning_zero(monkeypatch):
"""``entity_extract_max_gleaning=0`` disables gleaning regardless of
token budget — the guard is downstream of the feature flag."""
from lightrag.operate import extract_entities
monkeypatch.setenv("MAX_EXTRACT_INPUT_TOKENS", "999999")
global_config = _make_global_config(entity_extract_max_gleaning=0)
llm_func = global_config["llm_model_func"]
llm_func.return_value = _EXTRACTION_RESULT
await extract_entities(
chunks=_make_chunks(),
global_config=global_config,
)
assert llm_func.await_count == 1
@pytest.mark.offline
@pytest.mark.asyncio
async def test_gleaning_guard_disabled_when_max_tokens_zero(monkeypatch):
"""Setting ``MAX_EXTRACT_INPUT_TOKENS=0`` opts out of the guard so
gleaning always runs regardless of input size — useful for callers
whose provider has no hard input ceiling."""
from lightrag.operate import extract_entities
monkeypatch.setenv("MAX_EXTRACT_INPUT_TOKENS", "0")
global_config = _make_global_config(entity_extract_max_gleaning=1)
llm_func = global_config["llm_model_func"]
llm_func.return_value = _EXTRACTION_RESULT
await extract_entities(
chunks=_make_chunks(),
global_config=global_config,
)
# Guard disabled → gleaning still runs even with tight projected input.
assert llm_func.await_count == 2