mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
* fix(explorer): repair /api/enrich/extract and the /api/decisions routes Two Explorer API endpoints fail on every install. /api/enrich/extract imported extract_entities and extract_relations from semantic_extract.methods, where neither name is defined — that module ships only the per-strategy variants (extract_entities_ml, extract_relations_regex, ...), and nothing re-exports a plain facade. The resulting ImportError was caught and reported as "semantic_extract module not available. Ensure spacy and transformers are installed.", so a wiring bug looked like a missing dependency. The route now calls NamedEntityRecognizer and RelationExtractor directly, the classes the README documents, and feeds the extracted entities into relation extraction rather than re-deriving them. The 503 branch stays for a genuinely absent module. Every /api/decisions* route returned 500 once the graph held a decision: record_decision() stores timestamp as datetime.now().timestamp(), a float, while DecisionResponse types the field as str, so pydantic rejected the value the library itself wrote. A before-mode field validator on DecisionResponse normalizes float, int and datetime inputs to ISO-8601, covering every route that builds the model instead of only the list endpoint. The existing tests missed both: test_extract accepted 503 as a pass, and the decision fixtures are hand-built nodes carrying no timestamp at all. Both are tightened, and a TestRecordedDecisions class exercises the routes against decisions created through record_decision(). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * perf(semantic_extract): cache spaCy models instead of loading one per call extract_entities_ml(), extract_relations_similarity() and extract_relations_dependency() called spacy.load() on every invocation, so the model was re-read from disk and re-initialized per call. On a short sentence that is ~120 ms of loading around ~2 ms of work, and successive calls never got cheaper. The path is reachable from the CLI, the MCP extract_entities tool, the pipeline ner_extract step and POST /api/enrich/extract, and process_batch() multiplies it by the number of documents. The module already had a cached loader for one code path — get_nlp_model() and its _nlp_cache global — but the extraction functions bypassed it. Adds load_spacy_model(), a process-level cache keyed by model name behind a lock so concurrent callers do not each start a load, and routes the five call sites through it. Errors are left uncached and propagate unchanged, so the existing OSError fallbacks to pattern extraction still fire. get_nlp_model() keeps its own entry: it loads with disable=["parser", "ner", "lemmatizer"] for similarity work, so its model is not interchangeable with the NER one. Cache entries record the spacy module object they came from. Several tests patch methods.spacy with a mock and assert on load calls; without that guard a name-keyed cache would hand a previous test's mock to a later one. Measured on the same sentence, Python 3.12.13 / spacy 3.8.15 / en_core_web_sm: extract_entities_ml() median 132 ms before, 2.1 ms after, identical entities. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(explorer): harden extraction and timestamp handling * fix(explorer): catch OverflowError/OSError in decision timestamp validator DecisionResponse._normalize_timestamp only guarded against NaN/inf via math.isfinite(), but datetime.fromtimestamp() raises OverflowError or OSError for finite epoch values outside the platform's representable range (e.g. milliseconds stored where seconds were expected). Those exceptions escaped the pydantic validator unhandled, reintroducing an unhandled 500 on /api/decisions* for exactly the bug class this PR closes. Also exclude bool from the numeric branch, since bool is an int subclass and was being silently coerced to epoch 0/1. * docs: add changelog entry for PR #886 (explorer extract/decisions fixes) Documents the extraction 503, decisions timestamp 500, and folded-in spaCy caching fixes, plus the review-round hardening from Sameer6305 and the timestamp overflow/bool fix from this follow-up commit. --------- Co-authored-by: joseedson18jc <joseedson18jc@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Sameer Kadam <sskadam6305@gmail.com> Co-authored-by: KaifAhmad1 <kaifahmad087@gmail.com> Co-authored-by: Mohd Kaif <98801504+KaifAhmad1@users.noreply.github.com>
115 lines
3.6 KiB
Python
115 lines
3.6 KiB
Python
"""Tests for the process-level spaCy model cache in semantic_extract.methods.
|
|
|
|
Before this cache existed, extract_entities_ml(), extract_relations_similarity()
|
|
and extract_relations_dependency() called spacy.load() on every invocation, so a
|
|
short sentence cost ~120 ms of model loading on top of ~2 ms of actual work.
|
|
"""
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from semantica.semantic_extract import methods
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clear_cache():
|
|
methods.clear_spacy_model_cache()
|
|
yield
|
|
methods.clear_spacy_model_cache()
|
|
|
|
|
|
def _fake_spacy(load):
|
|
return SimpleNamespace(load=load, util=SimpleNamespace(is_package=lambda name: True))
|
|
|
|
|
|
def test_model_loaded_once_across_calls(monkeypatch):
|
|
calls = []
|
|
|
|
def fake_load(name, **kwargs):
|
|
calls.append(name)
|
|
return MagicMock()
|
|
|
|
monkeypatch.setattr(methods, "spacy", _fake_spacy(fake_load))
|
|
|
|
methods.load_spacy_model("en_core_web_sm")
|
|
methods.load_spacy_model("en_core_web_sm")
|
|
methods.load_spacy_model("en_core_web_sm")
|
|
|
|
assert calls == ["en_core_web_sm"], "spacy.load should run once per model name"
|
|
|
|
|
|
def test_same_object_returned(monkeypatch):
|
|
sentinel = MagicMock()
|
|
monkeypatch.setattr(methods, "spacy", _fake_spacy(lambda name, **kw: sentinel))
|
|
|
|
assert methods.load_spacy_model("en_core_web_sm") is sentinel
|
|
assert methods.load_spacy_model("en_core_web_sm") is sentinel
|
|
|
|
|
|
def test_distinct_models_cached_separately(monkeypatch):
|
|
calls = []
|
|
monkeypatch.setattr(
|
|
methods,
|
|
"spacy",
|
|
_fake_spacy(lambda name, **kw: (calls.append(name), MagicMock())[1]),
|
|
)
|
|
|
|
methods.load_spacy_model("en_core_web_sm")
|
|
methods.load_spacy_model("en_core_web_lg")
|
|
methods.load_spacy_model("en_core_web_sm")
|
|
|
|
assert calls == ["en_core_web_sm", "en_core_web_lg"]
|
|
|
|
|
|
def test_load_errors_propagate_and_are_not_cached(monkeypatch):
|
|
"""Callers rely on OSError to trigger their fallback path."""
|
|
attempts = []
|
|
|
|
def failing_load(name, **kwargs):
|
|
attempts.append(name)
|
|
raise OSError(f"Can't find model '{name}'")
|
|
|
|
monkeypatch.setattr(methods, "spacy", _fake_spacy(failing_load))
|
|
|
|
with pytest.raises(OSError):
|
|
methods.load_spacy_model("en_core_web_missing")
|
|
with pytest.raises(OSError):
|
|
methods.load_spacy_model("en_core_web_missing")
|
|
|
|
assert len(attempts) == 2, "a failed load must not populate the cache"
|
|
|
|
|
|
def test_cache_ignores_entries_from_a_replaced_spacy_module(monkeypatch):
|
|
"""Patching methods.spacy must not hand back a model from the old module.
|
|
|
|
Existing tests patch this attribute with a mock and assert on load calls, so
|
|
a cache keyed on model name alone would leak objects across those tests.
|
|
"""
|
|
first = MagicMock()
|
|
monkeypatch.setattr(methods, "spacy", _fake_spacy(lambda name, **kw: first))
|
|
assert methods.load_spacy_model("en_core_web_sm") is first
|
|
|
|
second = MagicMock()
|
|
monkeypatch.setattr(methods, "spacy", _fake_spacy(lambda name, **kw: second))
|
|
assert methods.load_spacy_model("en_core_web_sm") is second
|
|
|
|
|
|
def test_extract_entities_ml_reuses_the_cached_model(monkeypatch):
|
|
calls = []
|
|
|
|
def fake_load(name, **kwargs):
|
|
calls.append(name)
|
|
nlp = MagicMock()
|
|
nlp.return_value = SimpleNamespace(ents=[])
|
|
return nlp
|
|
|
|
monkeypatch.setattr(methods, "spacy", _fake_spacy(fake_load))
|
|
monkeypatch.setattr(methods, "SPACY_AVAILABLE", True)
|
|
|
|
methods.extract_entities_ml("Alice works at Acme Corp.")
|
|
methods.extract_entities_ml("Bob works at Globex.")
|
|
|
|
assert len(calls) == 1, "the model should be loaded once, not once per call"
|