diff --git a/PR_DESCRIPTION_SEMANTIC_EXTRACT.md b/PR_DESCRIPTION_SEMANTIC_EXTRACT.md new file mode 100644 index 00000000..26964ccd --- /dev/null +++ b/PR_DESCRIPTION_SEMANTIC_EXTRACT.md @@ -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 Semantica’s 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` diff --git a/semantica/semantic_extract/methods.py b/semantica/semantic_extract/methods.py index c2177977..2f638bce 100644 --- a/semantica/semantic_extract/methods.py +++ b/semantica/semantic_extract/methods.py @@ -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) diff --git a/semantica/semantic_extract/ner_extractor.py b/semantica/semantic_extract/ner_extractor.py index ac6fea43..11e399a7 100644 --- a/semantica/semantic_extract/ner_extractor.py +++ b/semantica/semantic_extract/ner_extractor.py @@ -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]: diff --git a/tests/test_ner_configurations.py b/tests/test_ner_configurations.py index 58d6b62f..2fead463 100644 --- a/tests/test_ner_configurations.py +++ b/tests/test_ner_configurations.py @@ -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"""