Files
semantica/tests/split/test_spacy_model_cache.py
T
Yunare MaiaandSameer Kadam e12eec40a1 refactor(ner): remove dead _extract_with_spacy method and unused self.nlp (#1220)
* test(ner): fix NER configuration tests for the typed LLM extraction API

Two of the three failing tests tracked in #1059 were still red after
#1070 was closed because the mocks targeted the pre-typed provider API:

- test_ner_llm_config mocked generate_structured, but the LLM path now
  goes through generate_typed with a Pydantic schema. Mock the typed
  response (namespace items with .text/.label/.start/.end/.confidence)
  and expect extraction_method 'llm_typed'.
- test_ner_pattern_config asserted 'Apple Inc' without the trailing
  dot, but the ORG pattern captures it via (?:\.|\b). Assert 'Apple
  Inc.' to match current production behavior.

Verified locally: 8/8 pass in test_ner_configurations.py; the
performance-test failures in tests/semantic_extract/ reproduce on a
clean main checkout and are unrelated.

Fixes #1059

Signed-off-by: Yunare Maia <yunare@gmail.com>

* refactor(ner): remove dead _extract_with_spacy method and unused self.nlp

_extract_with_spacy() had no callers: the ML dispatch path goes through
get_entity_method('ml') -> extract_entities_ml(), which loads the spaCy
model lazily via the process-level cache in methods.py. The instance
attribute self.nlp was only read by that dead method, so __init__ now
just validates the runtime (keeping the _ml_runtime_usable gate) instead
of eagerly loading a model that was never used.

Fixes #1058

Signed-off-by: Yunare Maia <yunare@gmail.com>

* test(split): rewrite NERExtractor cache tests to not rely on removed .nlp attribute

NERExtractor.nlp was removed in this PR as part of dead-code cleanup
(the attribute was only used by the equally-dead _extract_with_spacy()).
The three affected tests in TestNERExtractorSpacyModelCache previously
verified cache behavior through .nlp identity comparisons; rewrite them
to use load-call counts and direct se_methods.load_spacy_model() cache
queries instead:

- test_ner_extractor_reuses_cached_model_across_instances: drop the
  e1.nlp is e2.nlp is e3.nlp assertion; len(calls)==1 already proves
  reuse; add a cache query to confirm the cached object is non-None.

- test_ner_extractor_distinct_model_names_load_separately: store each
  mock nlp in a dict keyed by name, then query the cache to assert
  sm_cached is loaded['en_core_web_sm'] and sm_cached is not lg_cached.

- test_ner_extractor_failed_load_not_cached_and_retried: replace
  extractor.nlp is None/not None with is-not-None construction checks
  and a final cache query that verifies the recovered model is the
  exact object returned by working_load.

All three tests still exercise the original behavioral contract (no
crash on missing model, failures not cached / retried, successful load
shared across instances); they just no longer rely on a private
instance attribute that no longer exists.

---------

Signed-off-by: Yunare Maia <yunare@gmail.com>
Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
2026-08-27 17:56:03 +05:30

326 lines
12 KiB
Python

from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from semantica.semantic_extract import methods as se_methods
from semantica.split import methods as split_methods
from semantica.split import semantic_chunker
from semantica.semantic_extract import ner_extractor as ner_extractor_module
from semantica.semantic_extract.ner_extractor import NERExtractor
@pytest.fixture(autouse=True)
def clear_cache():
se_methods.clear_spacy_model_cache()
yield
se_methods.clear_spacy_model_cache()
@pytest.fixture(autouse=True)
def force_spacy_available(monkeypatch):
# split.methods, split.semantic_chunker, and ner_extractor each compute
# their own SPACY_AVAILABLE flag from the real environment at import time;
# force all true so these tests exercise the spaCy branch regardless of
# whether spaCy is actually installed where they run.
monkeypatch.setattr(split_methods, "SPACY_AVAILABLE", True)
monkeypatch.setattr(semantic_chunker, "SPACY_AVAILABLE", True)
monkeypatch.setattr(ner_extractor_module, "SPACY_AVAILABLE", True)
def _fake_spacy(load):
return SimpleNamespace(
load=load,
util=SimpleNamespace(is_package=lambda _name: True),
)
def _nlp_mock(sentences=("Hello world.",)):
"""A stand-in spaCy Language object: callable, returns a doc with .sents."""
nlp = MagicMock()
nlp.return_value = SimpleNamespace(
sents=[SimpleNamespace(text=s) for s in sentences]
)
return nlp
class TestSpacyModelCache:
"""split.methods and split.semantic_chunker must share the cached model
defined in semantic_extract.methods instead of each calling spacy.load()
independently.
"""
def test_split_by_sentences_reuses_cached_model(self, monkeypatch):
calls = []
def fake_load(name, **kwargs):
calls.append((name, kwargs))
return _nlp_mock()
monkeypatch.setattr(se_methods, "spacy", _fake_spacy(fake_load))
split_methods.split_by_sentences("Hello world. Bye world.")
split_methods.split_by_sentences("Another sentence here.")
split_methods.split_by_sentences("A third call.")
assert len(calls) == 1, "spacy.load should run once, not once per call"
assert calls[0][0] == "en_core_web_sm"
def test_semantic_chunker_reuses_cached_model_across_instances(self, monkeypatch):
calls = []
def fake_load(name, **kwargs):
calls.append((name, kwargs))
return _nlp_mock()
monkeypatch.setattr(se_methods, "spacy", _fake_spacy(fake_load))
chunker1 = semantic_chunker.SemanticChunker()
chunker2 = semantic_chunker.SemanticChunker()
assert len(calls) == 1, "each new SemanticChunker should not reload the model"
assert chunker1.nlp is chunker2.nlp
def test_split_methods_and_semantic_chunker_share_the_cache(self, monkeypatch):
calls = []
def fake_load(name, **kwargs):
calls.append((name, kwargs))
return _nlp_mock()
monkeypatch.setattr(se_methods, "spacy", _fake_spacy(fake_load))
split_methods.split_by_sentences("Test sentence for split.methods.")
semantic_chunker.SemanticChunker()
assert len(calls) == 1, (
"split.methods and split.semantic_chunker must share one cached "
"model instead of each loading their own"
)
def test_distinct_model_names_load_separately(self, monkeypatch):
calls = []
def fake_load(name, **kwargs):
calls.append((name, kwargs))
return _nlp_mock()
monkeypatch.setattr(se_methods, "spacy", _fake_spacy(fake_load))
sm_chunker = semantic_chunker.SemanticChunker(model="en_core_web_sm")
lg_chunker = semantic_chunker.SemanticChunker(model="en_core_web_lg")
sm_chunker_again = semantic_chunker.SemanticChunker(model="en_core_web_sm")
assert [name for name, _ in calls] == ["en_core_web_sm", "en_core_web_lg"]
assert sm_chunker.nlp is sm_chunker_again.nlp
assert sm_chunker.nlp is not lg_chunker.nlp
def test_no_disable_kwarg_requested(self, monkeypatch):
"""split.methods and split.semantic_chunker both want the full
pipeline (they need .sents, which requires the parser/senter). If
either one later starts requesting a trimmed pipeline (e.g.
disable=["ner"]), the name-only cache key in load_spacy_model would
silently hand back a cached model built for a different config --
this test should catch that the moment it happens.
"""
calls = []
def fake_load(_name, **kwargs):
calls.append(kwargs)
return _nlp_mock()
monkeypatch.setattr(se_methods, "spacy", _fake_spacy(fake_load))
split_methods.split_by_sentences("Hello world.")
se_methods.clear_spacy_model_cache()
semantic_chunker.SemanticChunker()
assert len(calls) == 2
assert all(kwargs == {} for kwargs in calls), (
"neither caller should pass any pipeline-configuration kwargs; "
"the name-only cache key in load_spacy_model cannot distinguish "
"models loaded with different component configs"
)
def test_missing_model_falls_back_without_poisoning_cache(self, monkeypatch):
attempts = []
def failing_load(name, **_kwargs):
attempts.append(name)
raise OSError(f"Can't find model '{name}'")
monkeypatch.setattr(se_methods, "spacy", _fake_spacy(failing_load))
# split_by_sentences should fall back to regex splitting, not raise
chunks = split_methods.split_by_sentences("Hello world. Bye world.")
assert chunks, "fallback splitting should still produce chunks"
# SemanticChunker should leave .nlp as None rather than propagate
chunker = semantic_chunker.SemanticChunker()
assert chunker.nlp is None
assert len(attempts) == 2, "a failed load must not be cached"
# Once the model is available, both callers should now get it, and
# share a single successful load.
def working_load(name, **_kwargs):
attempts.append(name)
return _nlp_mock()
monkeypatch.setattr(se_methods, "spacy", _fake_spacy(working_load))
chunker2 = semantic_chunker.SemanticChunker()
split_methods.split_by_sentences("One more sentence.")
assert len(attempts) == 3, (
"the model should load once after it becomes available"
)
assert chunker2.nlp is not None
def test_semantic_chunker_falls_back_when_spacy_runtime_is_broken(
self, monkeypatch
):
"""A spaCy model that is installed but unusable at runtime (e.g. a
config incompatible with the installed spaCy version) must degrade
SemanticChunker to fallback chunking, not crash __init__ -- mirrors
TestNERExtractorSpacyModelCache's equivalent broken-runtime test.
"""
def broken_load(name, **_kwargs):
raise RuntimeError("ConfigSchemaNlp is not fully defined")
monkeypatch.setattr(se_methods, "spacy", _fake_spacy(broken_load))
chunker = semantic_chunker.SemanticChunker()
assert chunker.nlp is None
class TestNERExtractorSpacyModelCache:
"""NERExtractor(method="ml") must reuse the centralized cache in
semantic_extract.methods, not call spacy.load() on every construction.
These tests mirror TestSpacyModelCache but focus on the NERExtractor path,
confirming that all three callers (split_by_sentences, SemanticChunker, and
NERExtractor) draw from the same process-level cache.
"""
def test_ner_extractor_reuses_cached_model_across_instances(self, monkeypatch):
"""Two NERExtractor(method='ml') constructions with the same model name
must cause exactly one underlying spacy.load() call."""
calls = []
def fake_load(name, **kwargs):
calls.append(name)
return _nlp_mock()
monkeypatch.setattr(se_methods, "spacy", _fake_spacy(fake_load))
NERExtractor(method="ml")
NERExtractor(method="ml")
NERExtractor(method="ml", model="en_core_web_sm")
assert len(calls) == 1, (
"repeated NERExtractor constructions should not reload the model"
)
def test_ner_extractor_and_split_callers_share_one_cached_model(self, monkeypatch):
"""NERExtractor, SemanticChunker, and split_by_sentences must all use
the same cached Language object for the same model name."""
calls = []
def fake_load(name, **kwargs):
calls.append(name)
return _nlp_mock()
monkeypatch.setattr(se_methods, "spacy", _fake_spacy(fake_load))
split_methods.split_by_sentences("First sentence.")
semantic_chunker.SemanticChunker()
NERExtractor(method="ml")
assert len(calls) == 1, (
"split_by_sentences, SemanticChunker, and NERExtractor must share "
"one cached model instead of each loading their own"
)
def test_ner_extractor_distinct_model_names_load_separately(self, monkeypatch):
"""Different model names must produce separate cache entries."""
calls = []
loaded = {}
def fake_load(name, **kwargs):
calls.append(name)
nlp = _nlp_mock()
loaded[name] = nlp
return nlp
monkeypatch.setattr(se_methods, "spacy", _fake_spacy(fake_load))
NERExtractor(method="ml", model="en_core_web_sm")
NERExtractor(method="ml", model="en_core_web_lg")
NERExtractor(method="ml", model="en_core_web_sm")
assert calls == ["en_core_web_sm", "en_core_web_lg"]
# Same model name -> same cached Language object; different names -> different objects.
assert loaded["en_core_web_sm"] is not loaded["en_core_web_lg"]
def test_ner_extractor_failed_load_not_cached_and_retried(self, monkeypatch):
"""A missing model must not poison the cache. A subsequent construction
after the model becomes available must succeed and share the loaded model."""
attempts = []
def failing_load(name, **_kwargs):
attempts.append(name)
raise OSError(f"Can't find model '{name}'")
monkeypatch.setattr(se_methods, "spacy", _fake_spacy(failing_load))
# Construction with missing model: must not raise
NERExtractor(method="ml")
assert len(attempts) == 1, "one load attempt expected for the missing model"
# Second construction: must retry (cache must not hold the failure)
NERExtractor(method="ml")
assert len(attempts) == 2, "a failed load must not be cached"
# Now install a working model and verify recovery
loaded_models = {}
def working_load(name, **_kwargs):
attempts.append(name)
nlp = _nlp_mock()
loaded_models[name] = nlp
return nlp
monkeypatch.setattr(se_methods, "spacy", _fake_spacy(working_load))
NERExtractor(method="ml")
NERExtractor(method="ml")
assert len(attempts) == 3, (
"exactly one successful load expected after the model becomes available"
)
# Confirm the recovered model is cached and shared across callers.
assert se_methods.load_spacy_model("en_core_web_sm") is loaded_models["en_core_web_sm"]
def test_ner_extractor_non_ml_method_does_not_load_model(self, monkeypatch):
"""NERExtractor with a non-ml method must not touch the spaCy cache."""
calls = []
def fake_load(name, **kwargs):
calls.append(name)
return _nlp_mock()
monkeypatch.setattr(se_methods, "spacy", _fake_spacy(fake_load))
NERExtractor(method="pattern")
NERExtractor(method="llm")
NERExtractor(method="regex")
assert calls == [], "non-ml methods must not trigger any spacy.load()"
if __name__ == "__main__":
pytest.main([__file__])