Files
semantica/tests/test_ner_configurations.py
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

278 lines
11 KiB
Python

import unittest
import sys
import os
from unittest.mock import MagicMock, patch
from dataclasses import asdict
# Add project root to path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from semantica.semantic_extract.ner_extractor import NERExtractor, Entity
from semantica.semantic_extract.named_entity_recognizer import NamedEntityRecognizer
from semantica.semantic_extract.methods import get_entity_method
class TestNERConfigurations(unittest.TestCase):
"""
Test suite to verify NER with different configurations:
- LLM
- ML (spaCy)
- Regex
- Pattern
- Fallbacks and Ensemble
"""
def setUp(self):
self.text = "Apple Inc. was founded by Steve Jobs."
@patch('semantica.semantic_extract.methods.create_provider')
def test_ner_llm_config(self, mock_create_provider):
"""Test NER with LLM configuration"""
print("\nTesting NER with LLM configuration...")
# Mock LLM provider
mock_provider = MagicMock()
mock_provider.is_available.return_value = True
# The LLM path uses generate_typed (Pydantic schema validation), not
# generate_structured. Build a typed response object whose .entities
# carries simple namespace-like items.
def _entity(text, label, start, end, confidence):
item = MagicMock()
item.text = text
item.label = label
item.start = start
item.end = end
item.confidence = confidence
return item
typed_response = MagicMock()
typed_response.entities = [
_entity("Apple Inc.", "ORG", 0, 10, 0.95),
_entity("Steve Jobs", "PERSON", 26, 36, 0.98),
]
mock_provider.generate_typed.return_value = typed_response
mock_create_provider.return_value = mock_provider
# Initialize extractor with LLM method
extractor = NERExtractor(
method="llm",
provider="openai",
model="gpt-4",
temperature=0.1
)
entities = extractor.extract_entities(self.text)
# Verify provider creation args
mock_create_provider.assert_called_with("openai", model="gpt-4", temperature=0.1)
# Verify extraction
self.assertEqual(len(entities), 2)
self.assertEqual(entities[0].text, "Apple Inc.")
self.assertEqual(entities[0].label, "ORG")
self.assertEqual(entities[0].metadata["extraction_method"], "llm_typed")
self.assertEqual(entities[0].metadata["model"], "gpt-4")
@patch('semantica.semantic_extract.methods.spacy')
def test_ner_ml_config_spacy_available(self, mock_spacy):
"""Test NER with ML (spaCy) configuration when spaCy is available"""
print("\nTesting NER with ML (spaCy) configuration...")
# Mock spaCy nlp model
mock_nlp = MagicMock()
mock_doc = MagicMock()
# Mock entities
ent1 = MagicMock()
ent1.text = "Apple Inc."
ent1.label_ = "ORG"
ent1.start_char = 0
ent1.end_char = 10
ent1.confidence = 1.0 # Optional attribute
ent2 = MagicMock()
ent2.text = "Steve Jobs"
ent2.label_ = "PERSON"
ent2.start_char = 26
ent2.end_char = 36
ent2.confidence = 0.99
mock_doc.ents = [ent1, ent2]
mock_nlp.return_value = mock_doc
mock_spacy.load.return_value = mock_nlp
# Patch SPACY_AVAILABLE in methods module
with patch('semantica.semantic_extract.methods.SPACY_AVAILABLE', True):
extractor = NERExtractor(method="ml", model="en_core_web_trf")
entities = extractor.extract_entities(self.text)
# Verify spacy load called with correct model
mock_spacy.load.assert_called_with("en_core_web_trf")
self.assertEqual(len(entities), 2)
self.assertEqual(entities[0].text, "Apple Inc.")
self.assertEqual(entities[0].label, "ORG")
self.assertEqual(entities[0].metadata["extraction_method"], "ml")
self.assertEqual(entities[0].metadata["model"], "en_core_web_trf")
@patch('semantica.semantic_extract.methods.spacy')
def test_ner_ml_init_falls_back_when_spacy_runtime_is_broken(self, mock_spacy):
"""Test NER init does not crash when spaCy is installed but unusable at runtime.
The model load now goes through load_spacy_model() in semantic_extract.methods,
so we patch methods.spacy (not ner_extractor.spacy) to inject the failure.
"""
from semantica.semantic_extract.methods import clear_spacy_model_cache
clear_spacy_model_cache()
mock_spacy.load.side_effect = RuntimeError("ConfigSchemaNlp is not fully defined")
with patch('semantica.semantic_extract.ner_extractor.SPACY_AVAILABLE', True):
extractor = NERExtractor(method="ml", model="en_core_web_sm")
self.assertFalse(extractor._ml_runtime_usable)
@patch('semantica.semantic_extract.methods.get_entity_method')
@patch('semantica.semantic_extract.methods.spacy')
def test_ner_ml_runtime_failure_disables_repeated_ml_load_attempts(
self,
mock_methods_spacy,
mock_get_method,
):
"""Test degraded ML mode skips repeated spaCy load attempts after init failure.
The model load at construction time now goes through load_spacy_model() in
semantic_extract.methods, so methods.spacy is the single mock target for the
init-time failure. After the RuntimeError is raised, _ml_runtime_usable is
False and no further spacy.load (or extract_entities_ml) calls are made.
"""
from semantica.semantic_extract.methods import clear_spacy_model_cache
clear_spacy_model_cache()
mock_methods_spacy.load.side_effect = RuntimeError("ConfigSchemaNlp is not fully defined")
mock_ml_method = MagicMock(return_value=[])
mock_get_method.side_effect = lambda name: mock_ml_method if name == "ml" else (lambda *_args, **_kwargs: [])
with patch('semantica.semantic_extract.ner_extractor.SPACY_AVAILABLE', True):
extractor = NERExtractor(method=["ml", "pattern"], model="en_core_web_sm")
entities = extractor.extract_entities(self.text)
self.assertFalse(extractor._ml_runtime_usable)
# methods.spacy.load called once during __init__ (the RuntimeError); not again
# during extract_entities because _filter_unusable_methods removes "ml".
self.assertEqual(mock_methods_spacy.load.call_count, 1)
self.assertEqual(mock_ml_method.call_count, 0)
self.assertIsInstance(entities, list)
def test_ner_regex_config(self):
"""Test NER with Regex configuration"""
print("\nTesting NER with Regex configuration...")
custom_patterns = {
"COMPANY": r"Apple Inc\.",
"FOUNDER": r"Steve Jobs"
}
extractor = NERExtractor(method="regex", patterns=custom_patterns)
entities = extractor.extract_entities(self.text)
self.assertEqual(len(entities), 2)
# Check if labels match custom keys
labels = sorted([e.label for e in entities])
self.assertEqual(labels, ["COMPANY", "FOUNDER"])
# Check metadata
self.assertEqual(entities[0].metadata["extraction_method"], "regex")
def test_ner_pattern_config(self):
"""Test NER with default Pattern configuration"""
print("\nTesting NER with Pattern configuration...")
# Default patterns in methods.py match "Apple Inc" (ORG) and "Steve Jobs" (PERSON)
# Note: The pattern for ORG in methods.py expects "Inc|Corp..."
extractor = NERExtractor(method="pattern")
entities = extractor.extract_entities(self.text)
self.assertTrue(len(entities) >= 2)
texts = [e.text for e in entities]
self.assertIn("Apple Inc.", texts) # The ORG pattern includes the trailing dot via (?:\.|\b)
# Actually methods.py regex: r"\b([A-Z][a-zA-Z]+(?:\s+[A-Z][a-zA-Z]+)*\s+(?:Inc|Corp|LLC|Ltd|Company))\b"
# "Apple Inc." -> "Apple Inc" (dot is outside \b if not matched?)
# Let's check the result strictly
@patch('semantica.semantic_extract.methods.create_provider')
@patch('semantica.semantic_extract.methods.spacy')
def test_ner_ensemble_config(self, mock_spacy, mock_create_provider):
"""Test NER with Ensemble (Multiple Methods)"""
print("\nTesting NER with Ensemble configuration...")
# Setup mocks
# LLM returns 1 entity
mock_provider = MagicMock()
mock_provider.is_available.return_value = True
mock_provider.generate_structured.return_value = [
{"text": "Apple Inc.", "label": "ORG", "start": 0, "end": 10, "confidence": 0.95}
]
mock_create_provider.return_value = mock_provider
# ML returns 2 entities
mock_nlp = MagicMock()
mock_doc = MagicMock()
ent1 = MagicMock()
ent1.text = "Apple Inc."
ent1.label_ = "ORG"
ent1.start_char = 0
ent1.end_char = 10
ent1.confidence = 0.95
ent2 = MagicMock()
ent2.text = "Steve Jobs"
ent2.label_ = "PERSON"
ent2.start_char = 26
ent2.end_char = 36
ent2.confidence = 0.99
mock_doc.ents = [ent1, ent2]
mock_nlp.return_value = mock_doc
mock_spacy.load.return_value = mock_nlp
with patch('semantica.semantic_extract.methods.SPACY_AVAILABLE', True):
# Init extractor with list of methods
extractor = NERExtractor(method=["llm", "ml"], ensemble_voting=True)
entities = extractor.extract_entities(self.text)
# Since ensemble_voting=True (implied merge), we expect unique entities
# Apple Inc (from both) + Steve Jobs (from ML)
texts = [e.text for e in entities]
self.assertIn("Apple Inc.", texts)
self.assertIn("Steve Jobs", texts)
@patch('semantica.semantic_extract.methods.HuggingFaceModelLoader')
def test_ner_huggingface_config(self, mock_loader_cls):
"""Test NER with HuggingFace configuration"""
print("\nTesting NER with HuggingFace configuration...")
mock_loader = MagicMock()
mock_loader_cls.return_value = mock_loader
# Mock extract_entities return
# HuggingFace loader typically returns list of dicts or objects
mock_loader.extract_entities.return_value = [
{"word": "Apple Inc.", "entity_group": "ORG", "score": 0.99, "start": 0, "end": 10}
]
extractor = NERExtractor(
method="huggingface",
huggingface_model="dslim/bert-base-NER",
device="cpu"
)
entities = extractor.extract_entities(self.text)
mock_loader.load_ner_model.assert_called_with("dslim/bert-base-NER")
self.assertEqual(len(entities), 1)
self.assertEqual(entities[0].text, "Apple Inc.")
self.assertEqual(entities[0].label, "ORG")
if __name__ == '__main__':
unittest.main()