Fix semantic extract spaCy fallback review issues

This commit is contained in:
KaifAhmad1
2026-03-23 23:31:01 +05:30
parent eeda5f5b80
commit a3a0848577
4 changed files with 119 additions and 3 deletions
+64
View File
@@ -0,0 +1,64 @@
# PR Title
Harden spaCy NER Fallback in Semantic Extract
## Summary
This PR fixes a runtime failure in Semantic Extract when spaCy is installed but not actually usable at runtime, such as Python 3.12 / Pydantic v2 environments where `spacy.load("en_core_web_sm")` fails during config validation.
Instead of crashing during `NERExtractor(method="ml")` initialization or ML entity extraction, Semantica now logs the failure and falls back cleanly to non-ML extraction behavior.
## What Changed
### spaCy Initialization Hardening
Updated `semantica/semantic_extract/ner_extractor.py` so `NERExtractor` no longer crashes if:
- spaCy is importable
- the configured model exists
- but `spacy.load(...)` fails at runtime for reasons other than missing files
This now degrades gracefully by leaving `self.nlp = None` and allowing fallback behavior.
### ML Extraction Fallback Hardening
Updated `semantica/semantic_extract/methods.py` so `extract_entities_ml()` now catches:
- missing spaCy model errors
- generic spaCy runtime initialization failures
If spaCy cannot initialize, extraction falls back to pattern-based extraction instead of raising.
### Regression Test
Added a regression test in `tests/test_ner_configurations.py` covering the case where:
- spaCy is available
- `spacy.load(...)` raises a runtime exception
- `NERExtractor(method="ml")` still initializes safely
### Changelog
Added an `Unreleased` changelog entry documenting the spaCy runtime fallback fix.
## Why This Matters
This fixes benchmark and CI instability caused by spaCy runtime incompatibilities outside Semanticas control.
It ensures Semantic Extract remains resilient when spaCy is present in the environment but broken due to dependency mismatches.
## Validation
Tested with:
```bash
pytest tests/test_ner_configurations.py -q -k "spacy_runtime_is_broken"
```
Result:
```bash
1 passed
```
Note:
The benchmark failure path was fixed directly, but the local `semantic-extract` branch did not contain the benchmark file path used in CI, so only the targeted regression path was verified locally.
## Files Changed
- `semantica/semantic_extract/ner_extractor.py`
- `semantica/semantic_extract/methods.py`
- `tests/test_ner_configurations.py`
- `CHANGELOG.md`
+5 -2
View File
@@ -685,12 +685,15 @@ def extract_entities_ml(
return extract_entities_pattern(text, **kwargs)
except Exception as exc:
logger.warning(
f"spaCy model failed to initialize ({exc}), falling back to pattern extraction"
"spaCy fallback triggered because the default model failed to initialize. Falling back to pattern extraction.",
exc_info=True,
)
return extract_entities_pattern(text, **kwargs)
except Exception as exc:
logger.warning(
f"spaCy model {model} failed to initialize ({exc}), falling back to pattern extraction"
"spaCy model %s failed to initialize, falling back to pattern extraction.",
model,
exc_info=True,
)
return extract_entities_pattern(text, **kwargs)
+24 -1
View File
@@ -147,6 +147,7 @@ class NERExtractor:
# Initialize spaCy model if ML method is used
self.nlp = None
self._ml_runtime_usable = True
if "ml" in self.method and SPACY_AVAILABLE:
try:
self.nlp = spacy.load(self.model_name)
@@ -155,8 +156,11 @@ class NERExtractor:
f"spaCy model {self.model_name} not found. ML method will fallback."
)
except Exception as exc:
self._ml_runtime_usable = False
self.logger.warning(
f"spaCy model {self.model_name} failed to initialize ({exc}). ML method will fallback."
"spaCy model %s failed to initialize and will be disabled for this extractor instance. ML method will fallback.",
self.model_name,
exc_info=True,
)
def extract(self, text: Union[str, List[Dict[str, Any]], List[str]], pipeline_id: Optional[str] = None, **kwargs) -> Union[List[Entity], List[List[Entity]]]:
@@ -346,6 +350,7 @@ class NERExtractor:
methods = options.get("method", self.method)
if isinstance(methods, str):
methods = [methods]
methods = self._filter_unusable_methods(methods)
min_confidence = options.get("min_confidence", self.min_confidence)
entity_types = options.get("entity_types", self.entity_types)
@@ -461,6 +466,24 @@ class NERExtractor:
)
raise
def _filter_unusable_methods(self, methods: List[str]) -> List[str]:
"""Skip ML dispatch after a known spaCy runtime initialization failure."""
filtered = []
skipped_ml = False
for method_name in methods:
if method_name in {"ml", "spacy"} and not self._ml_runtime_usable:
skipped_ml = True
continue
filtered.append(method_name)
if skipped_ml:
self.logger.debug(
"Skipping ML entity extraction because spaCy runtime initialization previously failed for this extractor."
)
return filtered
def _vote_entities(
self, results: List[List[Entity]], threshold: float = 0.5
) -> List[Entity]:
+26
View File
@@ -110,6 +110,32 @@ class TestNERConfigurations(unittest.TestCase):
extractor = NERExtractor(method="ml", model="en_core_web_sm")
self.assertIsNone(extractor.nlp)
self.assertFalse(extractor._ml_runtime_usable)
@patch('semantica.semantic_extract.ner_extractor.spacy')
@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,
mock_init_spacy,
):
"""Test degraded ML mode skips repeated spaCy load attempts after init failure."""
mock_init_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)
self.assertEqual(mock_init_spacy.load.call_count, 1)
self.assertEqual(mock_methods_spacy.load.call_count, 0)
self.assertEqual(mock_ml_method.call_count, 0)
self.assertIsInstance(entities, list)
def test_ner_regex_config(self):
"""Test NER with Regex configuration"""