mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
Consolidates the remaining #994 fixes into this PR so it can fully close the issue, per maintainer request. From #1005 (yzxcj797): - EmbeddingGeneratorWithProvenance.__getattr__ self-recursion guard: accessing self._generator via attribute syntax re-entered __getattr__ forever when _generator was absent (failed __init__, pickle/copy probes like __deepcopy__). Private-name lookups now raise AttributeError. - 4 regression tests in TestMethodDispatchRecursion: default dispatch no longer self-recurses for generation/text, a user-registered custom method still takes precedence, and a bare provenance wrapper raises AttributeError instead of RecursionError. (The methods.py identity guards from #1005 are already present here.) From #1006 (yzxcj797): - doctor gains two embedding backend checks, "Embeddings (sentence-transformers)" and "Embeddings (fastembed)". Default is a cheap import+version check (uninstalled backend now reports fail with a pip hint instead of invisible). --deep-embeddings (or SEMANTICA_DOCTOR_DEEP_EMBEDDINGS=1) instantiates via TextEmbedder and embeds a probe, catching backends that import cleanly but cannot load (the #994 failure mode) via the hash-fallback-active signal. _DeepEmbeddingFailure marks post-import runtime/model-load failures so they get a remediation hint instead of a misleading pip-install hint. - 7 tests in TestDoctorEmbeddings and TestDoctorEmbeddingHintsAndEnv. Validation: - tests/test_cli_commands.py: 237 passed (7 new) - tests/test_embedding_providers.py: 9 passed (4 new) - AST parse + import of all four modules OK
This commit is contained in:
+67
-1
@@ -773,10 +773,22 @@ def changelog(cli_ctx: CLIContext, local_json: bool) -> None:
|
||||
_run_with_error_handling(_action)
|
||||
|
||||
|
||||
class _DeepEmbeddingFailure(Exception):
|
||||
"""A deep-probe failure from doctor's embedding checks.
|
||||
|
||||
Marks failures that happened AFTER the backend imported cleanly — model
|
||||
load, probe, or runtime problems — so the check's hint can point at the
|
||||
real remediation instead of `pip install`.
|
||||
"""
|
||||
|
||||
|
||||
@main.command()
|
||||
@click.option("--json", "local_json", is_flag=True, default=False)
|
||||
@click.option("--deep-embeddings", "deep_embeddings", is_flag=True, default=False,
|
||||
help="Also instantiate the local embedding backends and embed a probe "
|
||||
"text (catches backends that import cleanly but cannot load).")
|
||||
@click.pass_obj
|
||||
def doctor(cli_ctx: CLIContext, local_json: bool) -> None:
|
||||
def doctor(cli_ctx: CLIContext, local_json: bool, deep_embeddings: bool) -> None:
|
||||
"""Run a health check on all Semantica components and backends."""
|
||||
import importlib.metadata
|
||||
cli_ctx = _require_ctx(cli_ctx)
|
||||
@@ -787,6 +799,16 @@ def doctor(cli_ctx: CLIContext, local_json: bool) -> None:
|
||||
try:
|
||||
note = fn()
|
||||
return label, "ok", note, None
|
||||
except _DeepEmbeddingFailure as exc:
|
||||
# A deep-probe failure means the package IMPORTED fine: the pip
|
||||
# hint would be the wrong remediation for what is actually a
|
||||
# runtime/model-load problem (broken torch, failed model
|
||||
# download, missing shared libs).
|
||||
return label, "fail", str(exc), (
|
||||
"runtime/model-load failure — reinstalling the package usually "
|
||||
"does not help; check the warnings above (torch install, model "
|
||||
"download, disk space)"
|
||||
)
|
||||
except Exception as exc:
|
||||
return label, "fail", str(exc), hint
|
||||
|
||||
@@ -827,6 +849,50 @@ def doctor(cli_ctx: CLIContext, local_json: bool) -> None:
|
||||
return f"{backend} importable"
|
||||
checks.append(_check("Vector store", _vector, hint="pip install semantica[vectorstore-…]"))
|
||||
|
||||
# Embedding backends (#994): `doctor` used to report all green while
|
||||
# every local embedding backend was non-functional — import success
|
||||
# says nothing about model loading. Default checks stay cheap
|
||||
# (import + version); --deep-embeddings (or
|
||||
# SEMANTICA_DOCTOR_DEEP_EMBEDDINGS=1) instantiates the backend through
|
||||
# TextEmbedder and embeds a probe, which is the only level that
|
||||
# catches a backend that imports cleanly but cannot actually load.
|
||||
deep = deep_embeddings or os.environ.get("SEMANTICA_DOCTOR_DEEP_EMBEDDINGS", "").strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
def _embedding_backend(method: str) -> str:
|
||||
if method == "sentence_transformers":
|
||||
import sentence_transformers # noqa: F401
|
||||
note = f"importable ({importlib.metadata.version('sentence-transformers')})"
|
||||
else:
|
||||
import fastembed # noqa: F401
|
||||
note = f"importable ({importlib.metadata.version('fastembed')})"
|
||||
if not deep:
|
||||
return note
|
||||
try:
|
||||
from .embeddings import TextEmbedder
|
||||
embedder = TextEmbedder(method=method)
|
||||
if embedder.model is None and embedder.fastembed_model is None:
|
||||
raise RuntimeError(
|
||||
"model failed to load — the hash fallback is active "
|
||||
"(see warnings above); embedding quality is degraded"
|
||||
)
|
||||
probe = embedder.embed_text("semantica doctor embedding probe")
|
||||
except _DeepEmbeddingFailure:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise _DeepEmbeddingFailure(str(exc)) from exc
|
||||
return f"{note}; deep probe ok ({len(probe)}-dim)"
|
||||
|
||||
checks.append(_check(
|
||||
"Embeddings (sentence-transformers)",
|
||||
lambda: _embedding_backend("sentence_transformers"),
|
||||
hint="pip install sentence-transformers",
|
||||
))
|
||||
checks.append(_check(
|
||||
"Embeddings (fastembed)",
|
||||
lambda: _embedding_backend("fastembed"),
|
||||
hint="pip install fastembed",
|
||||
))
|
||||
|
||||
# LLM provider keys
|
||||
for provider, var in [("OpenAI", "OPENAI_API_KEY"), ("Anthropic", "ANTHROPIC_API_KEY"),
|
||||
("Groq", "GROQ_API_KEY")]:
|
||||
|
||||
@@ -69,6 +69,15 @@ class EmbeddingGeneratorWithProvenance:
|
||||
return embeddings
|
||||
|
||||
def __getattr__(self, name):
|
||||
# __getattr__ only runs when normal lookup fails. Accessing
|
||||
# self._generator by attribute syntax HERE would re-enter
|
||||
# __getattr__ for ever when _generator itself is missing — the shape
|
||||
# pickle/copy protocol probes hit when __init__ never completed
|
||||
# (#994's RecursionError family). Fail fast on private probes.
|
||||
if name.startswith("_"):
|
||||
raise AttributeError(
|
||||
f"{type(self).__name__!r} object has no attribute {name!r}"
|
||||
)
|
||||
return getattr(self._generator, name)
|
||||
|
||||
|
||||
|
||||
@@ -1851,3 +1851,118 @@ class TestExitCodes:
|
||||
assert "Traceback" not in result.output, (
|
||||
f"Traceback found for {argv}: {result.output}"
|
||||
)
|
||||
|
||||
|
||||
class TestDoctorEmbeddings:
|
||||
"""#994: doctor must surface non-functional embedding backends instead of
|
||||
reporting all green. Default = import-level check; --deep-embeddings (or
|
||||
SEMANTICA_DOCTOR_DEEP_EMBEDDINGS=1) instantiates via TextEmbedder."""
|
||||
|
||||
def _doctor_checks(self, runner, *extra):
|
||||
result = runner.invoke(cli_module.main, ["doctor", "--json", *extra])
|
||||
_ok(result)
|
||||
import json as _json
|
||||
return {c["check"]: c for c in _json.loads(result.output)}
|
||||
|
||||
def _with_fake_st(self, monkeypatch, **embedder_attrs):
|
||||
fake_st = _fake_module(
|
||||
__version__="9.9.9",
|
||||
SentenceTransformer=object,
|
||||
)
|
||||
monkeypatch.setitem(__import__("sys").modules, "sentence_transformers", fake_st)
|
||||
|
||||
def test_doctor_reports_embedding_checks(self, runner):
|
||||
checks = self._doctor_checks(runner)
|
||||
assert "Embeddings (sentence-transformers)" in checks
|
||||
assert "Embeddings (fastembed)" in checks
|
||||
|
||||
def test_import_failure_is_fail_status_with_hint(self, runner):
|
||||
checks = self._doctor_checks(runner)
|
||||
st = checks["Embeddings (sentence-transformers)"]
|
||||
if st["status"] == "fail":
|
||||
assert st["hint"] == "pip install sentence-transformers"
|
||||
|
||||
def test_deep_probe_detects_fallback_active(self, runner, monkeypatch):
|
||||
self._with_fake_st(monkeypatch)
|
||||
fake_embedder = types.SimpleNamespace(model=None, fastembed_model=None)
|
||||
|
||||
fake_emb_mod = _fake_module(TextEmbedder=lambda **k: fake_embedder)
|
||||
monkeypatch.setitem(__import__("sys").modules, "semantica.embeddings", fake_emb_mod)
|
||||
|
||||
checks = self._doctor_checks(runner, "--deep-embeddings")
|
||||
st = checks["Embeddings (sentence-transformers)"]
|
||||
assert st["status"] == "fail"
|
||||
assert "hash fallback" in st["note"]
|
||||
|
||||
def test_deep_probe_ok_when_model_loads(self, runner, monkeypatch):
|
||||
self._with_fake_st(monkeypatch)
|
||||
import numpy as np
|
||||
fake_embedder = types.SimpleNamespace(
|
||||
model=object(),
|
||||
fastembed_model=None,
|
||||
embed_text=lambda text: np.zeros(384, dtype=np.float32),
|
||||
)
|
||||
fake_emb_mod = _fake_module(TextEmbedder=lambda **k: fake_embedder)
|
||||
monkeypatch.setitem(__import__("sys").modules, "semantica.embeddings", fake_emb_mod)
|
||||
|
||||
checks = self._doctor_checks(runner, "--deep-embeddings")
|
||||
st = checks["Embeddings (sentence-transformers)"]
|
||||
assert st["status"] == "ok"
|
||||
assert "384-dim" in st["note"]
|
||||
|
||||
def test_env_var_enables_deep_mode(self, runner, monkeypatch):
|
||||
monkeypatch.setenv("SEMANTICA_DOCTOR_DEEP_EMBEDDINGS", "1")
|
||||
self._with_fake_st(monkeypatch)
|
||||
fake_embedder = types.SimpleNamespace(model=None, fastembed_model=None)
|
||||
fake_emb_mod = _fake_module(TextEmbedder=lambda **k: fake_embedder)
|
||||
monkeypatch.setitem(__import__("sys").modules, "semantica.embeddings", fake_emb_mod)
|
||||
|
||||
checks = self._doctor_checks(runner)
|
||||
st = checks["Embeddings (sentence-transformers)"]
|
||||
assert st["status"] == "fail"
|
||||
assert "hash fallback" in st["note"]
|
||||
|
||||
|
||||
class TestDoctorEmbeddingHintsAndEnv:
|
||||
"""Review follow-ups: deep failures must not carry the pip-install hint,
|
||||
and the env toggle tolerates case/whitespace variants."""
|
||||
|
||||
def _doctor_checks(self, runner, *extra):
|
||||
result = runner.invoke(cli_module.main, ["doctor", "--json", *extra])
|
||||
_ok(result)
|
||||
import json as _json
|
||||
return {c["check"]: c for c in _json.loads(result.output)}
|
||||
|
||||
def _with_fake_st(self, monkeypatch):
|
||||
fake_st = _fake_module(
|
||||
__version__="9.9.9",
|
||||
SentenceTransformer=object,
|
||||
)
|
||||
monkeypatch.setitem(__import__("sys").modules, "sentence_transformers", fake_st)
|
||||
|
||||
def test_deep_failure_hint_is_not_pip_install(self, runner, monkeypatch):
|
||||
self._with_fake_st(monkeypatch)
|
||||
fake_embedder = types.SimpleNamespace(model=None, fastembed_model=None)
|
||||
fake_emb_mod = _fake_module(TextEmbedder=lambda **k: fake_embedder)
|
||||
monkeypatch.setitem(__import__("sys").modules, "semantica.embeddings", fake_emb_mod)
|
||||
|
||||
checks = self._doctor_checks(runner, "--deep-embeddings")
|
||||
st = checks["Embeddings (sentence-transformers)"]
|
||||
assert st["status"] == "fail"
|
||||
assert "pip install" not in (st["hint"] or ""), (
|
||||
"a deep probe failure means the package imported fine — pointing "
|
||||
"users at pip sends them to reinstall for a runtime/model problem"
|
||||
)
|
||||
assert "runtime/model-load" in st["hint"]
|
||||
|
||||
def test_env_var_tolerates_case_and_whitespace(self, runner, monkeypatch):
|
||||
monkeypatch.setenv("SEMANTICA_DOCTOR_DEEP_EMBEDDINGS", " TRUE ")
|
||||
self._with_fake_st(monkeypatch)
|
||||
fake_embedder = types.SimpleNamespace(model=None, fastembed_model=None)
|
||||
fake_emb_mod = _fake_module(TextEmbedder=lambda **k: fake_embedder)
|
||||
monkeypatch.setitem(__import__("sys").modules, "semantica.embeddings", fake_emb_mod)
|
||||
|
||||
checks = self._doctor_checks(runner)
|
||||
st = checks["Embeddings (sentence-transformers)"]
|
||||
assert st["status"] == "fail"
|
||||
assert "hash fallback" in st["note"], "padded/caps env value must enable deep mode"
|
||||
|
||||
@@ -85,3 +85,49 @@ if __name__ == '__main__':
|
||||
runner = unittest.TextTestRunner(stream=f, verbosity=2)
|
||||
unittest.main(testRunner=runner, exit=False)
|
||||
|
||||
|
||||
class TestMethodDispatchRecursion(unittest.TestCase):
|
||||
"""#994: built-in aliases are registered in the method registry onto the
|
||||
wrapper functions themselves, so dispatching through the registry called a
|
||||
wrapper back into itself with the same default method — a recursion storm
|
||||
that surfaced as `maximum recursion depth exceeded` during model loading."""
|
||||
|
||||
def test_generate_embeddings_default_does_not_self_recurse(self):
|
||||
from semantica.embeddings.methods import generate_embeddings
|
||||
emb = generate_embeddings("recursion probe")
|
||||
self.assertIsNotNone(emb)
|
||||
|
||||
def test_embed_text_default_does_not_self_recurse(self):
|
||||
from semantica.embeddings.methods import embed_text
|
||||
emb = embed_text("recursion probe", method="sentence_transformers")
|
||||
self.assertIsNotNone(emb)
|
||||
|
||||
def test_custom_registered_method_still_wins(self):
|
||||
from semantica.embeddings.methods import method_registry
|
||||
calls = []
|
||||
|
||||
def spy(data, *a, **k):
|
||||
calls.append(data)
|
||||
return {"custom": True}
|
||||
|
||||
method_registry.register("generation", "my_custom_gen", spy)
|
||||
try:
|
||||
from semantica.embeddings.methods import generate_embeddings
|
||||
out = generate_embeddings("payload", method="my_custom_gen")
|
||||
self.assertEqual(out, {"custom": True})
|
||||
self.assertEqual(calls, ["payload"])
|
||||
finally:
|
||||
method_registry.unregister("generation", "my_custom_gen")
|
||||
|
||||
def test_provenance_wrapper_missing_generator_raises_attribute_error(self):
|
||||
# Partially-initialised wrappers (failed __init__, pickle/copy probes)
|
||||
# must raise AttributeError, not RecursionError via __getattr__.
|
||||
from semantica.embeddings.embeddings_provenance import (
|
||||
EmbeddingGeneratorWithProvenance,
|
||||
)
|
||||
bare = EmbeddingGeneratorWithProvenance.__new__(
|
||||
EmbeddingGeneratorWithProvenance
|
||||
)
|
||||
with self.assertRaises(AttributeError):
|
||||
getattr(bare, "model")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user