diff --git a/CHANGELOG.md b/CHANGELOG.md index b121e0700..db442d825 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **`MemoryRoot.default()` → `MemoryRoot.resolve()`** — the classmethod that + resolves the memory root from `--root` / `EVEROS_ROOT` / default was + renamed to make its behavior explicit (`resolve` walks the precedence + chain; `default` was ambiguous with "default location"). `MemoryRoot` + is publicly exported from `everos.core.persistence`; callers outside + the repo may have used the old name. **A `default()` alias is kept** + as a backward-compatibility shim that forwards to `resolve()` and + emits a `DeprecationWarning`. The alias will be removed in a future + major release — update call sites when convenient. - **Uncalibrated recall scores moved to their own name** — `KEYWORD` and single-route `VECTOR` searches now report their top score as `recall_top_score_raw`; `recall_top_score` is reserved for the calibrated diff --git a/docs/openapi.json b/docs/openapi.json index e19ad97ec..ff8deb1c0 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -12,7 +12,7 @@ "health" ], "summary": "Health", - "description": "Liveness probe — returns ``{\"status\": \"ok\"}`` with HTTP 200.", + "description": "Liveness probe with capabilities and disabled features.", "operationId": "health_health_get", "responses": { "200": { @@ -20,11 +20,7 @@ "content": { "application/json": { "schema": { - "additionalProperties": { - "type": "string" - }, - "type": "object", - "title": "Response Health Health Get" + "$ref": "#/components/schemas/HealthResponse" } } } @@ -2719,6 +2715,71 @@ "type": "object", "title": "HTTPValidationError" }, + "HealthCapabilities": { + "properties": { + "llm": { + "type": "boolean", + "title": "Llm" + }, + "embed": { + "type": "boolean", + "title": "Embed" + }, + "rerank": { + "type": "boolean", + "title": "Rerank" + }, + "multimodal_llm": { + "type": "boolean", + "title": "Multimodal Llm" + }, + "parser": { + "type": "boolean", + "title": "Parser" + } + }, + "type": "object", + "required": [ + "llm", + "embed", + "rerank", + "multimodal_llm", + "parser" + ], + "title": "HealthCapabilities", + "description": "Availability flags for the five capability probes.\n\nField order matches the health-endpoint payload contract; clients\nkey off these names to decide whether to expose optional features." + }, + "HealthResponse": { + "properties": { + "status": { + "type": "string", + "title": "Status" + }, + "version": { + "type": "string", + "title": "Version" + }, + "capabilities": { + "$ref": "#/components/schemas/HealthCapabilities" + }, + "disabled_features": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Disabled Features" + } + }, + "type": "object", + "required": [ + "status", + "version", + "capabilities", + "disabled_features" + ], + "title": "HealthResponse", + "description": "Response schema for ``GET /health``.\n\nDeclared as a Pydantic model (not ``dict``) so the generated\nOpenAPI schema carries the full field shape — ``capabilities`` and\n``disabled_features`` are typed. A bare ``-> dict`` return type\ndegrades the OpenAPI response to ``additionalProperties: true``,\nwhich robs clients (and codegen) of any structure to lean on." + }, "KnowledgeSearchRequest": { "properties": { "query": { diff --git a/pyproject.toml b/pyproject.toml index 10b88fade..dc6f21cf6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,14 @@ dependencies = [ # CLI + TUI "typer>=0.12.0", + # click is a transitive dep of uvicorn today, but our CLI raises + # ``click.exceptions.Abort`` directly (e.g. tests that inject aborts) + # and typer 0.15+ vendored a copy of click under ``typer._click`` — so + # the two classes are DIFFERENT even though users only ever see + # ``typer.Abort``. We depend on the standalone click package so + # ``click.exceptions.Abort`` stays importable and both classes are + # covered in the Ctrl-C catch (see backfill exit-130 path). + "click>=8.1", "textual>=8.2.7", # Tokenization (BM25 Chinese support) diff --git a/scripts/e2e_memorize/run.py b/scripts/e2e_memorize/run.py index 86a4d51fa..032c58a89 100644 --- a/scripts/e2e_memorize/run.py +++ b/scripts/e2e_memorize/run.py @@ -51,7 +51,7 @@ def _print_header(mode: str, fixture_path: Path, session_id: str) -> None: print(f" everos e2e memorize · mode={mode}") print(f" fixture : {fixture_path.name}") print(f" session_id : {session_id}") - print(f" memory root : {MemoryRoot.default().root}") + print(f" memory root : {MemoryRoot.resolve().root}") llm_state = "" if get_llm_client() else "" print(f" llm_client : {llm_state}") print("=" * 72) @@ -59,7 +59,7 @@ def _print_header(mode: str, fixture_path: Path, session_id: str) -> None: def _list_written_files(session_id: str, mode: str) -> None: """Walk memory root and print files touched in this run.""" - root = MemoryRoot.default().root + root = MemoryRoot.resolve().root cutoff = time.time() - 600 # files modified in the last 10 min print() print("─── files modified within the last 10 minutes under memory root ───") diff --git a/src/everos/component/capabilities.py b/src/everos/component/capabilities.py new file mode 100644 index 000000000..fda706455 --- /dev/null +++ b/src/everos/component/capabilities.py @@ -0,0 +1,74 @@ +"""Capabilities inference and feature availability logic. + +Compute which features are disabled based on available capabilities. +Used by the health endpoint, startup banner, and related diagnostics. + +Two distinct feature-name vocabularies exist in the refactored codebase. +They deliberately do NOT share a source of truth: + +- ``ProviderNotConfiguredError.feature`` (raised by + ``SearchManager._validate_components`` and error handlers): per-request + granular tag identifying the specific search mode or endpoint that failed — + ``"vector"`` | ``"user_hybrid"`` | ``"agent_hybrid"`` | ``"agentic_search"`` + | ``"knowledge"`` | ``"skill_extraction_backfill"``. Appears in HTTP 422 + response ``message``. Naming mirrors the ``SearchMethod`` enum values plus + endpoint-scoped tags. + +- ``compute_disabled_features()`` (below): capability-level tag identifying + a whole feature category disabled by the current tier — + ``"vector_search"`` | ``"hybrid_search"`` | ``"agentic_search"`` + | ``"reflection"`` | ``"skill_extraction"`` | ``"knowledge"`` + | ``"multimodal_upload"``. Appears in ``GET /health`` response + ``disabled_features``. + +Callers of either surface should treat these vocabularies as stable client +contracts — do not unify them without a coordinated migration on the client side. +""" + +from __future__ import annotations + + +def compute_disabled_features(caps: dict[str, bool]) -> list[str]: + """Derive the list of disabled features from capability availability. + + Args: + caps: Dictionary with keys "llm", "embed", "rerank", "multimodal_llm", "parser" + and boolean availability values. Note: ``caps["llm"]`` is + accepted for shape symmetry but NOT read here — LLM is a + Tier-1 hard requirement enforced at server startup + (``LLMLifespanProvider``), so any process reaching this + function is guaranteed to have LLM available. If LLM ever + becomes soft, add an ``if not caps["llm"]`` branch here + covering the LLM-dependent features. + + Returns: + List of feature names that are disabled due to missing capabilities. + Possible values: "vector_search", "hybrid_search", "agentic_search", + "reflection", "skill_extraction", "knowledge", "multimodal_upload". + """ + disabled: list[str] = [] + + # Embedding-dependent features + if not caps["embed"]: + disabled.extend( + [ + "vector_search", + "hybrid_search", + "reflection", + "skill_extraction", + ] + ) + + # Rerank-dependent feature + if not caps["rerank"]: + disabled.append("agentic_search") + + # Knowledge requires both embedding and rerank + if not (caps["embed"] and caps["rerank"]): + disabled.append("knowledge") + + # Multimodal upload requires both multimodal_llm and parser + if not (caps["multimodal_llm"] and caps["parser"]): + disabled.append("multimodal_upload") + + return disabled diff --git a/src/everos/component/embedding/__init__.py b/src/everos/component/embedding/__init__.py index f1ec9878b..9a1d4f4e9 100644 --- a/src/everos/component/embedding/__init__.py +++ b/src/everos/component/embedding/__init__.py @@ -6,9 +6,18 @@ - :class:`EmbeddingProvider` — Protocol every provider satisfies. - :class:`EmbeddingServiceError` — provider-side failure. - :class:`EmbeddingError` — backward-compat alias for ``EmbeddingServiceError``. +- :class:`EmbeddingCapability` — soft-dependency wrapper around an + optional :class:`EmbeddingProvider` (``available`` / ``embed_or_none`` + / ``require``). - :class:`OpenAIEmbeddingProvider` — concrete provider for any OpenAI-protocol embeddings endpoint (DeepInfra, vLLM, OpenAI, …). - :func:`build_embedding_provider` — settings-driven factory. +- :func:`get_embedding_capability` — process-wide lazy singleton + accessor for :class:`EmbeddingCapability`. There is no separate + ``get_embedder`` accessor: consumers that need a provider call + ``get_embedding_capability().require()``, which routes every caller + through a single shared provider (and its single ``AsyncOpenAI`` + client + ``asyncio.Semaphore``). External usage:: @@ -19,19 +28,19 @@ from everos.core.errors import EmbeddingServiceError as EmbeddingServiceError -from .accessor import EmbeddingNotConfiguredError as EmbeddingNotConfiguredError -from .accessor import get_embedder as get_embedder +from .accessor import get_embedding_capability as get_embedding_capability +from .capability import EmbeddingCapability as EmbeddingCapability from .factory import build_embedding_provider as build_embedding_provider from .openai_provider import OpenAIEmbeddingProvider as OpenAIEmbeddingProvider from .protocol import EmbeddingError as EmbeddingError from .protocol import EmbeddingProvider as EmbeddingProvider __all__ = [ + "EmbeddingCapability", "EmbeddingError", - "EmbeddingNotConfiguredError", "EmbeddingProvider", "EmbeddingServiceError", "OpenAIEmbeddingProvider", "build_embedding_provider", - "get_embedder", + "get_embedding_capability", ] diff --git a/src/everos/component/embedding/accessor.py b/src/everos/component/embedding/accessor.py index cbe4d1102..aad795d51 100644 --- a/src/everos/component/embedding/accessor.py +++ b/src/everos/component/embedding/accessor.py @@ -1,14 +1,16 @@ -"""Process-wide embedding provider accessor. - -Lazy singleton mirror of :func:`everos.component.llm.get_llm_client`: -first call reads settings and builds the OpenAI-protocol embedding -client; subsequent calls return the cached instance. Strategies and -other components that need a process-wide embedder import this rather -than threading the provider through their constructors. - -Raises :class:`EmbeddingNotConfiguredError` when credentials are missing -so misconfiguration surfaces at the call site (or at app startup via a -lifespan provider) instead of silently degrading. +"""Process-wide embedding capability accessor. + +Lazy singleton for :class:`EmbeddingCapability`. The first call reads +settings and attempts to build an OpenAI-protocol embedding provider; +subsequent calls return the cached wrapper. Consumers that must have an +embedder call ``get_embedding_capability().require()``; consumers that +can degrade gracefully use ``.embed_or_none`` or check ``.available``. + +There is deliberately no separate ``get_embedder()`` accessor: routing +every consumer through the capability keeps a single provider (and its +underlying ``AsyncOpenAI`` client + ``asyncio.Semaphore``) per process, +so the configured ``max_concurrent`` bound holds instead of silently +doubling. """ from __future__ import annotations @@ -16,33 +18,41 @@ from everos.config import load_settings from everos.core.observability.logging import get_logger +from .capability import EmbeddingCapability from .factory import build_embedding_provider -from .protocol import EmbeddingProvider logger = get_logger(__name__) -class EmbeddingNotConfiguredError(RuntimeError): - """Raised when ``settings.embedding`` lacks ``model``/``api_key``/``base_url``.""" - +_capability: EmbeddingCapability | None = None -_embedder: EmbeddingProvider | None = None +def get_embedding_capability() -> EmbeddingCapability: + """Return the process-wide :class:`EmbeddingCapability`. Never raises. -def get_embedder() -> EmbeddingProvider: - """Return the singleton :class:`EmbeddingProvider`. + On the first call, builds and caches a capability from current + settings — ``available`` is ``False`` when the provider cannot be + built (missing fields, unsupported provider name, malformed URL, …). + The build outcome is cached, so a later settings change requires a + process restart to take effect. - Raises: - EmbeddingNotConfiguredError: When required settings fields are - unset. See :func:`build_embedding_provider` for the exact - keys. + Configuration failures (:class:`ValueError` from + :func:`build_embedding_provider`) are logged at ``warning`` level: + the downstream :class:`ProviderNotConfiguredError` message maps both + "user hasn't configured it" and "user configured it wrong" onto the + same HTTP 422, so the log line is the only place an operator can + tell those two states apart. """ - global _embedder - if _embedder is not None: - return _embedder + global _capability + if _capability is not None: + return _capability try: - _embedder = build_embedding_provider(load_settings().embedding) + provider = build_embedding_provider(load_settings().embedding) except ValueError as exc: - raise EmbeddingNotConfiguredError(str(exc)) from exc - logger.info("embedder_built") - return _embedder + logger.warning( + "embedding_capability_build_failed", + reason=str(exc), + ) + provider = None + _capability = EmbeddingCapability(provider=provider) + return _capability diff --git a/src/everos/component/embedding/capability.py b/src/everos/component/embedding/capability.py new file mode 100644 index 000000000..0826bc924 --- /dev/null +++ b/src/everos/component/embedding/capability.py @@ -0,0 +1,59 @@ +"""EmbeddingCapability — soft-dependency wrapper for EmbeddingProvider. + +The wrapper encapsulates ``Optional[EmbeddingProvider]`` so consumers +never see ``None`` directly. Two consumption modes: + +- :meth:`embed_or_none` for soft-degrade write paths (cascade handlers + that write ``vector=NULL`` when embedding is unavailable). +- :meth:`require` for hard-required paths (search VECTOR/HYBRID/AGENTIC) + that raise :class:`ProviderNotConfiguredError` -> HTTP 422 when missing. + +Design rationale: EverOS has multiple code paths that need to know +whether embedding is available. Rather than each callsite reading +settings and re-implementing the check, the check lives in +:func:`everos.component.embedding.accessor.get_embedding_capability` +(module-level singleton); every consumer asks the capability. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from everos.core.errors import ProviderNotConfiguredError + +from .protocol import EmbeddingProvider + + +@dataclass(frozen=True) +class EmbeddingCapability: + """Wraps an optional EmbeddingProvider with soft / strict consumption modes.""" + + provider: EmbeddingProvider | None + + @property + def available(self) -> bool: + """True iff a provider was successfully constructed.""" + return self.provider is not None + + async def embed_or_none(self, text: str) -> list[float] | None: + """Embed ``text`` if available, else return ``None``. + + For write paths that can proceed without a vector (cascade + handlers write ``vector=NULL`` on the LanceDB row). + """ + if self.provider is None: + return None + return list(await self.provider.embed(text)) + + def require(self) -> EmbeddingProvider: + """Return the provider, or raise :class:`ProviderNotConfiguredError`. + + For paths that cannot proceed without embedding (search + VECTOR/HYBRID/AGENTIC methods). Caller supplies its own + ``feature``/``alternative_hint`` context by catching and + re-raising if needed; this base call only identifies the + missing provider as ``"embedding"``. + """ + if self.provider is None: + raise ProviderNotConfiguredError(provider="embedding") + return self.provider diff --git a/src/everos/component/embedding/factory.py b/src/everos/component/embedding/factory.py index f32e7446f..1b1814790 100644 --- a/src/everos/component/embedding/factory.py +++ b/src/everos/component/embedding/factory.py @@ -2,6 +2,7 @@ from __future__ import annotations +from everos.component.utils.config_hints import missing_config_error from everos.config import EmbeddingSettings from .openai_provider import OpenAIEmbeddingProvider @@ -32,18 +33,11 @@ def build_embedding_provider( ValueError: If ``model``, ``api_key`` or ``base_url`` is unset. """ if not settings.model: - raise ValueError( - "Embedding model is not configured " - "(set EVEROS_EMBEDDING__MODEL or [embedding] model in user toml)" - ) + raise ValueError(missing_config_error("Embedding model", "embedding")) if not settings.api_key or not settings.api_key.get_secret_value(): - raise ValueError( - "Embedding api_key is not configured (set EVEROS_EMBEDDING__API_KEY)" - ) + raise ValueError(missing_config_error("Embedding api_key", "embedding")) if not settings.base_url: - raise ValueError( - "Embedding base_url is not configured (set EVEROS_EMBEDDING__BASE_URL)" - ) + raise ValueError(missing_config_error("Embedding base_url", "embedding")) return OpenAIEmbeddingProvider( model=settings.model, api_key=settings.api_key.get_secret_value(), diff --git a/src/everos/component/llm/client.py b/src/everos/component/llm/client.py index dfcbcaff2..6d48804fb 100644 --- a/src/everos/component/llm/client.py +++ b/src/everos/component/llm/client.py @@ -17,6 +17,7 @@ from everalgo.llm.types import ChatMessage, ChatResponse from pydantic import BaseModel +from everos.component.utils.config_hints import missing_config_error from everos.config import load_settings from everos.core.observability.logging import get_logger @@ -93,7 +94,7 @@ def get_llm_client() -> LLMClient: ) if not api_key or not llm_cfg.base_url: raise LLMNotConfiguredError( - "LLM is required; set EVEROS_LLM__API_KEY + EVEROS_LLM__BASE_URL" + missing_config_error("LLM api_key and base_url", "llm") ) client: LLMClient = build_client( LLMConfig( @@ -133,8 +134,7 @@ def get_multimodal_llm_client() -> LLMClient: api_key = cfg.api_key.get_secret_value() if cfg.api_key is not None else None if not api_key or not cfg.base_url: raise LLMNotConfiguredError( - "Multimodal LLM is required for parsing; set " - "EVEROS_MULTIMODAL__API_KEY + EVEROS_MULTIMODAL__BASE_URL" + missing_config_error("Multimodal LLM api_key and base_url", "multimodal") ) _multimodal_client = build_client( LLMConfig( diff --git a/src/everos/component/llm/factory.py b/src/everos/component/llm/factory.py index 5dff064aa..71c08b8c3 100644 --- a/src/everos/component/llm/factory.py +++ b/src/everos/component/llm/factory.py @@ -2,6 +2,7 @@ from __future__ import annotations +from everos.component.utils.config_hints import missing_config_error from everos.config import LLMSettings from .openai_provider import OpenAIProvider @@ -29,15 +30,9 @@ def build_llm_provider(settings: LLMSettings) -> LLMClient: ValueError: If ``api_key`` or ``base_url`` is unset. """ if not settings.api_key or not settings.api_key.get_secret_value(): - raise ValueError( - "LLM api_key is not configured " - "(set EVEROS_LLM__API_KEY or [llm] api_key in user toml)" - ) + raise ValueError(missing_config_error("LLM api_key", "llm")) if not settings.base_url: - raise ValueError( - "LLM base_url is not configured " - "(set EVEROS_LLM__BASE_URL or [llm] base_url in user toml)" - ) + raise ValueError(missing_config_error("LLM base_url", "llm")) return OpenAIProvider( model=settings.model, api_key=settings.api_key.get_secret_value(), diff --git a/src/everos/component/multimodal/__init__.py b/src/everos/component/multimodal/__init__.py new file mode 100644 index 000000000..0c7953a99 --- /dev/null +++ b/src/everos/component/multimodal/__init__.py @@ -0,0 +1,27 @@ +"""Multimodal LLM capability — optional vision/audio support for parsing. + +Public surface: + +- :class:`MultimodalLLMCapability` — soft-dependency wrapper around an + optional multimodal LLMClient (``available`` / ``require``; no soft-degrade + accessor — multimodal parsing is entirely optional). +- :func:`get_multimodal_llm_capability` — process-wide lazy singleton accessor + for :class:`MultimodalLLMCapability`. + +External usage:: + + from everos.component.multimodal import get_multimodal_llm_capability + cap = get_multimodal_llm_capability() + if cap.available: + client = cap.require() +""" + +from __future__ import annotations + +from .accessor import get_multimodal_llm_capability as get_multimodal_llm_capability +from .capability import MultimodalLLMCapability as MultimodalLLMCapability + +__all__ = [ + "MultimodalLLMCapability", + "get_multimodal_llm_capability", +] diff --git a/src/everos/component/multimodal/accessor.py b/src/everos/component/multimodal/accessor.py new file mode 100644 index 000000000..e4966c453 --- /dev/null +++ b/src/everos/component/multimodal/accessor.py @@ -0,0 +1,55 @@ +"""Process-wide multimodal LLM capability accessor. + +Lazy singleton — first call reads settings and attempts to build a multimodal +LLM client. Unlike :func:`everos.component.llm.client.get_multimodal_llm_client` +(which raises when misconfigured), this wraps the outcome in a capability that +reports ``available=False`` when the client cannot be built. + +Subsequent calls return the cached instance. +""" + +from __future__ import annotations + +from everos.component.llm.client import ( + LLMNotConfiguredError, + get_multimodal_llm_client, +) +from everos.core.observability.logging import get_logger + +from .capability import MultimodalLLMCapability + +logger = get_logger(__name__) + +_capability: MultimodalLLMCapability | None = None + + +def get_multimodal_llm_capability() -> MultimodalLLMCapability: + """Return the process-wide :class:`MultimodalLLMCapability`. Never raises. + + Lazy singleton: the first call attempts to build a multimodal client from + current settings — ``available`` is ``False`` when the client cannot be + built (e.g. missing model/base_url/api_key). Use this from upload + endpoints, the health endpoint, the startup banner, and any other caller + that needs to check "is multimodal parsing available?" without a hard + dependency. + + Build failures (``ValueError`` from the factory / ``LLMNotConfiguredError`` + from missing settings) are logged as + ``multimodal_llm_capability_build_failed`` so an operator can distinguish + "not configured" from "misconfigured" — mirrors the equivalent warning on + the embedding and rerank accessors so all three optional providers surface + the same signal. + """ + global _capability + if _capability is not None: + return _capability + try: + provider = get_multimodal_llm_client() + except (ValueError, LLMNotConfiguredError) as exc: + logger.warning( + "multimodal_llm_capability_build_failed", + reason=str(exc), + ) + provider = None + _capability = MultimodalLLMCapability(provider=provider) + return _capability diff --git a/src/everos/component/multimodal/capability.py b/src/everos/component/multimodal/capability.py new file mode 100644 index 000000000..0805a2c03 --- /dev/null +++ b/src/everos/component/multimodal/capability.py @@ -0,0 +1,40 @@ +"""MultimodalLLMCapability — soft-dependency wrapper for multimodal LLM client. + +Parallel structure to :class:`everos.component.rerank.RerankCapability`, but +for the multimodal LLM used by ``everalgo.parser``. A caller either requires +multimodal (raising :class:`ProviderNotConfiguredError` -> HTTP 422 when +missing) or checks ``available`` to skip parsing when unavailable. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from everos.core.errors import ProviderNotConfiguredError + +if TYPE_CHECKING: + from everalgo.llm.protocols import LLMClient + + +@dataclass(frozen=True) +class MultimodalLLMCapability: + """Wraps an optional multimodal LLMClient with a hard-require API.""" + + provider: LLMClient | None + + @property + def available(self) -> bool: + """True iff a provider was successfully constructed.""" + return self.provider is not None + + def require(self) -> LLMClient: + """Return the provider, or raise :class:`ProviderNotConfiguredError`. + + Caller supplies its own ``feature``/``alternative_hint`` context by + catching and re-raising if needed; this base call only identifies + the missing provider as ``"multimodal_llm"``. + """ + if self.provider is None: + raise ProviderNotConfiguredError(provider="multimodal_llm") + return self.provider diff --git a/src/everos/component/parser/_core.py b/src/everos/component/parser/_core.py index a93e57d65..d30a35d4f 100644 --- a/src/everos/component/parser/_core.py +++ b/src/everos/component/parser/_core.py @@ -6,6 +6,7 @@ from __future__ import annotations +from functools import lru_cache from typing import TYPE_CHECKING from everos.core.errors import MultimodalNotEnabledError, UnsupportedModalityError @@ -14,8 +15,22 @@ from everalgo.types import ParsedContent, RawFile +@lru_cache(maxsize=1) def parser_available() -> bool: - """Whether ``everalgo.parser`` is importable.""" + """Whether ``everalgo.parser`` is importable. + + Memoised: the underlying ``import everalgo.parser`` pulls in heavy + PDF/Office dependencies (``pypdf``, ``python-docx``, ...) that would + block the event loop for hundreds of ms — unacceptable inside + ``async def health()`` on a liveness probe. First call at startup + (via :class:`ParserLifespanProvider`) pays the import cost off the + request path; subsequent calls hit the cache instantly. + + The cache is process-wide. Tests that patch ``sys.modules`` to + swap the ``everalgo.parser`` module in or out must invoke + :meth:`parser_available.cache_clear` between assertions to avoid + cross-test contamination. + """ try: import everalgo.parser # noqa: F401 except ImportError: diff --git a/src/everos/component/rerank/__init__.py b/src/everos/component/rerank/__init__.py index 1ce9ca7c8..ebc83b282 100644 --- a/src/everos/component/rerank/__init__.py +++ b/src/everos/component/rerank/__init__.py @@ -5,6 +5,9 @@ - :class:`RerankProvider` — Protocol every provider satisfies. - :class:`RerankResult` / :class:`RerankServiceError` — value type + error. - :class:`RerankError` — backward-compat alias for :class:`RerankServiceError`. +- :class:`RerankCapability` — soft-dependency wrapper around an optional + :class:`RerankProvider` (``available`` / ``require``; no soft-degrade + accessor — rerank has no write path to degrade). - :class:`DeepInfraRerankProvider` — DeepInfra inference-API rerank. - :class:`VllmRerankProvider` — OpenAI-compat ``/v1/rerank`` (vLLM, self-hosted, other compatible servers). @@ -12,6 +15,8 @@ ``gte-rerank-v2`` native text-rerank endpoint. - :func:`build_rerank_provider` — settings-driven factory that picks the concrete provider via ``settings.rerank.provider``. +- :func:`get_rerank_capability` — process-wide lazy singleton accessor + for :class:`RerankCapability`. External usage:: @@ -22,6 +27,8 @@ from everos.core.errors import RerankServiceError as RerankServiceError +from .accessor import get_rerank_capability as get_rerank_capability +from .capability import RerankCapability as RerankCapability from .dashscope_provider import DashScopeRerankProvider as DashScopeRerankProvider from .deepinfra_provider import DeepInfraRerankProvider as DeepInfraRerankProvider from .factory import build_rerank_provider as build_rerank_provider @@ -33,10 +40,12 @@ __all__ = [ "DashScopeRerankProvider", "DeepInfraRerankProvider", + "RerankCapability", "RerankError", "RerankProvider", "RerankResult", "RerankServiceError", "VllmRerankProvider", "build_rerank_provider", + "get_rerank_capability", ] diff --git a/src/everos/component/rerank/accessor.py b/src/everos/component/rerank/accessor.py new file mode 100644 index 000000000..f8e0adb40 --- /dev/null +++ b/src/everos/component/rerank/accessor.py @@ -0,0 +1,57 @@ +"""Process-wide rerank capability accessor. + +Lazy singleton mirror of +:func:`everos.component.embedding.accessor.get_embedding_capability`: first +call reads settings, attempts to build a rerank provider, and wraps the +outcome (provider or ``None``) in a :class:`RerankCapability`. Subsequent +calls return the cached instance. + +Rerank is a Tier-3 optional provider — call sites either go through +:func:`get_rerank_capability` and call ``.require()`` when rerank is +mandatory, or check ``.available`` and skip reranking when it is not. +""" + +from __future__ import annotations + +from everos.config import load_settings +from everos.core.observability.logging import get_logger + +from .capability import RerankCapability +from .factory import build_rerank_provider + +logger = get_logger(__name__) + + +_capability: RerankCapability | None = None + + +def get_rerank_capability() -> RerankCapability: + """Return the process-wide :class:`RerankCapability`. Never raises. + + Lazy singleton: the first call builds and caches a capability from + current settings — ``available`` is ``False`` when the provider cannot + be built (e.g. missing model/base_url/api_key, unsupported provider + name, malformed URL, …). Use this from search strategies, the health + endpoint, the startup banner, and any other caller that needs to + check "is rerank available?" without a hard dependency. + + Configuration failures (:class:`ValueError` from + :func:`build_rerank_provider`) are logged at ``warning`` level: the + downstream :class:`ProviderNotConfiguredError` message maps both + "user hasn't configured it" and "user configured it wrong" onto the + same HTTP 422, so the log line is the only place an operator can + tell those two states apart. + """ + global _capability + if _capability is not None: + return _capability + try: + provider = build_rerank_provider(load_settings().rerank) + except ValueError as exc: + logger.warning( + "rerank_capability_build_failed", + reason=str(exc), + ) + provider = None + _capability = RerankCapability(provider=provider) + return _capability diff --git a/src/everos/component/rerank/capability.py b/src/everos/component/rerank/capability.py new file mode 100644 index 000000000..a3d7c7115 --- /dev/null +++ b/src/everos/component/rerank/capability.py @@ -0,0 +1,39 @@ +"""RerankCapability — soft-dependency wrapper for RerankProvider. + +Parallel structure to :class:`everos.component.embedding.EmbeddingCapability`, +but without a soft-degrade accessor: rerank is a query-time enhancement, +not a write path. A caller either hard-requires rerank (raising +:class:`ProviderNotConfiguredError` -> HTTP 422 when missing) or chooses to +skip reranking entirely — there is no equivalent of ``embed_or_none``. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from everos.core.errors import ProviderNotConfiguredError + +from .protocol import RerankProvider + + +@dataclass(frozen=True) +class RerankCapability: + """Wraps an optional RerankProvider with a hard-require API.""" + + provider: RerankProvider | None + + @property + def available(self) -> bool: + """True iff a provider was successfully constructed.""" + return self.provider is not None + + def require(self) -> RerankProvider: + """Return the provider, or raise :class:`ProviderNotConfiguredError`. + + Caller supplies its own ``feature``/``alternative_hint`` context by + catching and re-raising if needed; this base call only identifies + the missing provider as ``"rerank"``. + """ + if self.provider is None: + raise ProviderNotConfiguredError(provider="rerank") + return self.provider diff --git a/src/everos/component/rerank/factory.py b/src/everos/component/rerank/factory.py index 1cf4c1a0f..ce2b8b57a 100644 --- a/src/everos/component/rerank/factory.py +++ b/src/everos/component/rerank/factory.py @@ -13,6 +13,7 @@ from __future__ import annotations +from everos.component.utils.config_hints import missing_config_error from everos.config import RerankSettings from .dashscope_provider import DashScopeRerankProvider @@ -38,22 +39,14 @@ def build_rerank_provider(settings: RerankSettings) -> RerankProvider: string) for ``vllm`` self-hosted endpoints. """ if not settings.model: - raise ValueError( - "Rerank model is not configured " - "(set EVEROS_RERANK__MODEL or [rerank] model in user toml)" - ) + raise ValueError(missing_config_error("Rerank model", "rerank")) if not settings.base_url: - raise ValueError( - "Rerank base_url is not configured (set EVEROS_RERANK__BASE_URL)" - ) + raise ValueError(missing_config_error("Rerank base_url", "rerank")) api_key = settings.api_key.get_secret_value() if settings.api_key else "" if settings.provider == "deepinfra": if not api_key: - raise ValueError( - "DeepInfra rerank api_key is not configured " - "(set EVEROS_RERANK__API_KEY)" - ) + raise ValueError(missing_config_error("DeepInfra rerank api_key", "rerank")) return DeepInfraRerankProvider( model=settings.model, api_key=api_key, @@ -75,10 +68,7 @@ def build_rerank_provider(settings: RerankSettings) -> RerankProvider: ) if settings.provider == "dashscope": if not api_key: - raise ValueError( - "DashScope rerank api_key is not configured " - "(set EVEROS_RERANK__API_KEY)" - ) + raise ValueError(missing_config_error("DashScope rerank api_key", "rerank")) return DashScopeRerankProvider( model=settings.model, api_key=api_key, diff --git a/src/everos/component/utils/config_hints.py b/src/everos/component/utils/config_hints.py new file mode 100644 index 000000000..ef22590bc --- /dev/null +++ b/src/everos/component/utils/config_hints.py @@ -0,0 +1,30 @@ +"""User-facing error message helpers for missing provider configuration. + +Guidance points users at the ``/everos.toml`` file, not at +environment variables. Env vars still work as an override mechanism +(see ``pydantic-settings`` precedence in ``config/settings.py``) but +are not surfaced in onboarding-facing text. +""" + +from __future__ import annotations + +from everos.core.persistence import MemoryRoot + + +def missing_config_error(field_label: str, toml_section: str) -> str: + """Return a uniform error message for a missing config field. + + Args: + field_label: Human-readable label (e.g. ``"LLM api_key"``). + toml_section: TOML section name without brackets (e.g. ``"llm"``). + + Returns: + A single-line message including the resolved memory-root path + and a hint to run ``everos init``. Never mentions env vars. + """ + root = MemoryRoot.resolve().root + return ( + f"{field_label} is not configured. " + f"Edit {root}/everos.toml (run `everos init` to scaffold), " + f"section [{toml_section}]." + ) diff --git a/src/everos/core/errors.py b/src/everos/core/errors.py index ba34c7ed8..4d24aedd6 100644 --- a/src/everos/core/errors.py +++ b/src/everos/core/errors.py @@ -36,6 +36,7 @@ class ErrorCode(StrEnum): CAPABILITY_UNAVAILABLE = "CAPABILITY_UNAVAILABLE" CONFIGURATION_ERROR = "CONFIGURATION_ERROR" INTERNAL_ERROR = "INTERNAL_ERROR" + PROVIDER_NOT_CONFIGURED = "PROVIDER_NOT_CONFIGURED" # --------------------------------------------------------------------------- @@ -175,6 +176,56 @@ class ConfigurationError(AppError): """ +# TOML section name for each provider kind, keyed by the `provider` argument +# passed to `ProviderNotConfiguredError`. +_PROVIDER_SECTIONS: dict[str, str] = { + "llm": "llm", + "embedding": "embedding", + "rerank": "rerank", + "multimodal_llm": "multimodal", +} + + +class ProviderNotConfiguredError(ConfigurationError): + """Raised when a runtime path requires a provider that is not configured. + + Maps to HTTP 422 via the FastAPI exception handler; the message is + directly user-facing and points at the toml file for remediation. + + Args: + provider: Which provider is missing. One of ``"llm"``, + ``"embedding"``, ``"rerank"``, ``"multimodal_llm"``. + feature: Optional user-facing feature that required this + provider (e.g. ``"knowledge"``, ``"agent_hybrid"``). + alternative_hint: Optional alternative workaround the user can + take without configuring the missing provider (e.g. flip + ``enable_llm_rerank=true`` to use the LLM lane). + """ + + def __init__( + self, + provider: str, + feature: str | None = None, + alternative_hint: str | None = None, + ) -> None: + # Lazy import: `config_hints` -> `core.persistence` -> `core.persistence + # .markdown.writer` imports `PathTraversalError` from this module, so a + # module-level import here would be circular. + from everos.component.utils.config_hints import missing_config_error + + self.provider = provider + self.feature = feature + self.alternative_hint = alternative_hint + section = _PROVIDER_SECTIONS.get(provider, provider) + field_label = f"Provider '{provider}'" + if feature: + field_label += f" (required by {feature})" + message = missing_config_error(field_label, section) + if alternative_hint: + message += f" Alternative: {alternative_hint}" + super().__init__(message) + + # --------------------------------------------------------------------------- # Backward compatibility aliases # --------------------------------------------------------------------------- diff --git a/src/everos/core/persistence/memory_root.py b/src/everos/core/persistence/memory_root.py index 7a96bd2f9..701ff0012 100644 --- a/src/everos/core/persistence/memory_root.py +++ b/src/everos/core/persistence/memory_root.py @@ -23,11 +23,12 @@ The default location and tunables come from :class:`everos.config.Settings` (loaded from ``config/default.toml`` + ``EVEROS_*`` environment variables); -:meth:`MemoryRoot.default` resolves the configured path. +:meth:`MemoryRoot.resolve` resolves the configured path. """ from __future__ import annotations +import warnings from dataclasses import dataclass from pathlib import Path @@ -92,7 +93,7 @@ def __init__(self, root: Path | str) -> None: object.__setattr__(self, "root", resolved) @classmethod - def default(cls, *, explicit_root: str | None = None) -> MemoryRoot: + def resolve(cls, *, explicit_root: str | None = None) -> MemoryRoot: """Return the memory-root resolved from CLI / env / default. Resolution: ``explicit_root`` > ``EVEROS_ROOT`` env > ``~/.everos``. @@ -105,6 +106,25 @@ def default(cls, *, explicit_root: str | None = None) -> MemoryRoot: return cls(resolve_root(explicit_root)) + @classmethod + def default(cls, *, explicit_root: str | None = None) -> MemoryRoot: + """Deprecated alias for :meth:`resolve`. Removed in a future major. + + Kept as a backward-compatibility shim for v1.2.0 callers who used + the old ``default()`` name (renamed to ``resolve()`` in this + release). Emits :class:`DeprecationWarning` on every call so + existing callers surface in test / CI runs; the constructor + semantics are identical. + """ + warnings.warn( + "MemoryRoot.default() is deprecated and renamed to " + "MemoryRoot.resolve(); the alias will be removed in a " + "future major release. Update call sites.", + DeprecationWarning, + stacklevel=2, + ) + return cls.resolve(explicit_root=explicit_root) + # ── User-visible (partitioned by app / project) ────────────────────────── # # These take ``(app_id, project_id)`` because the scope dirs hang off the diff --git a/src/everos/entrypoints/api/app.py b/src/everos/entrypoints/api/app.py index 5d28ba036..c174abac1 100644 --- a/src/everos/entrypoints/api/app.py +++ b/src/everos/entrypoints/api/app.py @@ -34,6 +34,7 @@ LanceDBLifespanProvider, LLMLifespanProvider, OmeLifespanProvider, + ParserLifespanProvider, SqliteLifespanProvider, ) from .routes import ( @@ -71,9 +72,9 @@ def create_app( cors_allow_headers: Allowed CORS headers (default: ``["*"]``). lifespan_providers: Optional list of LifespanProvider; defaults to ``[TracingLifespanProvider(), MetricsLifespanProvider(), - LLMLifespanProvider(), SqliteLifespanProvider(), - LanceDBLifespanProvider(), CascadeLifespanProvider(), - OmeLifespanProvider()]``. + LLMLifespanProvider(), ParserLifespanProvider(), + SqliteLifespanProvider(), LanceDBLifespanProvider(), + CascadeLifespanProvider(), OmeLifespanProvider()]``. Returns: FastAPI: Configured application instance. @@ -85,6 +86,7 @@ def create_app( TracingLifespanProvider(), MetricsLifespanProvider(), LLMLifespanProvider(), + ParserLifespanProvider(), SqliteLifespanProvider(), LanceDBLifespanProvider(), CascadeLifespanProvider(), diff --git a/src/everos/entrypoints/api/exception_handlers.py b/src/everos/entrypoints/api/exception_handlers.py index da7675a2a..0e3142815 100644 --- a/src/everos/entrypoints/api/exception_handlers.py +++ b/src/everos/entrypoints/api/exception_handlers.py @@ -43,6 +43,7 @@ InvalidInputError, NotFoundError, PathTraversalError, + ProviderNotConfiguredError, UnsupportedModalityError, ) from everos.core.observability.logging import get_logger @@ -241,6 +242,31 @@ async def configuration_handler( ) +async def provider_not_configured_handler( + request: Request, + exc: ProviderNotConfiguredError, +) -> JSONResponse: + """ProviderNotConfiguredError -> 422 (client-actionable, unlike its parent). + + Unlike a generic ``ConfigurationError`` (500 -- an operator bug), a + missing provider is something the caller can fix by editing + ``everos.toml``, so this more-specific subclass gets its own 422 + mapping instead of falling through to ``configuration_handler``. + """ + logger.warning( + "provider_not_configured_error", + path=str(request.url.path), + provider=exc.provider, + feature=exc.feature, + ) + return _error_response( + request, + HTTP_422_UNPROCESSABLE_CONTENT, + ErrorCode.PROVIDER_NOT_CONFIGURED, + str(exc), + ) + + # --------------------------------------------------------------------------- # Pydantic / FastAPI built-in exceptions # --------------------------------------------------------------------------- @@ -350,7 +376,10 @@ def register_handlers(app: FastAPI) -> None: app.add_exception_handler(InfrastructureError, infrastructure_handler) # Capability errors (permanent, not retryable) app.add_exception_handler(CapabilityError, capability_handler) - # Configuration errors + # Configuration errors (specific before parent) + app.add_exception_handler( + ProviderNotConfiguredError, provider_not_configured_handler + ) app.add_exception_handler(ConfigurationError, configuration_handler) # FastAPI built-in exceptions app.add_exception_handler(HTTPException, http_exception_handler) diff --git a/src/everos/entrypoints/api/lifespans/__init__.py b/src/everos/entrypoints/api/lifespans/__init__.py index 262106d35..70fd1cb73 100644 --- a/src/everos/entrypoints/api/lifespans/__init__.py +++ b/src/everos/entrypoints/api/lifespans/__init__.py @@ -13,6 +13,7 @@ from everos.entrypoints.api.lifespans import ( LLMLifespanProvider, + ParserLifespanProvider, SqliteLifespanProvider, LanceDBLifespanProvider, CascadeLifespanProvider, @@ -24,6 +25,7 @@ from .lancedb import LanceDBLifespanProvider as LanceDBLifespanProvider from .llm import LLMLifespanProvider as LLMLifespanProvider from .ome import OmeLifespanProvider as OmeLifespanProvider +from .parser import ParserLifespanProvider as ParserLifespanProvider from .sqlite import SqliteLifespanProvider as SqliteLifespanProvider __all__ = [ @@ -31,5 +33,6 @@ "LLMLifespanProvider", "LanceDBLifespanProvider", "OmeLifespanProvider", + "ParserLifespanProvider", "SqliteLifespanProvider", ] diff --git a/src/everos/entrypoints/api/lifespans/cascade.py b/src/everos/entrypoints/api/lifespans/cascade.py index aa3cf5c52..fc6528920 100644 --- a/src/everos/entrypoints/api/lifespans/cascade.py +++ b/src/everos/entrypoints/api/lifespans/cascade.py @@ -4,9 +4,12 @@ depends on both stores being ready before its watcher / scanner / worker tasks can take the first row. -Construction reads the live :class:`Settings` to build the embedding + -tokenizer providers. If either is misconfigured the lifespan fails -fast — the daemon would be useless without them anyway. +Construction reads the live :class:`Settings` to build the tokenizer +provider, which fails fast if misconfigured. Embedding is a soft +dependency: startup warms the process-wide +:class:`~everos.component.embedding.EmbeddingCapability` singleton via +:func:`get_embedding_capability`, which never raises — the daemon +runs in keyword-only mode when embedding is unavailable. """ from __future__ import annotations @@ -15,9 +18,8 @@ from fastapi import FastAPI -from everos.component.embedding import build_embedding_provider +from everos.component.embedding import get_embedding_capability from everos.component.tokenizer import build_tokenizer -from everos.config import load_settings from everos.core.lifespan import LifespanProvider from everos.core.observability.logging import get_logger from everos.core.persistence import MemoryRoot @@ -34,15 +36,22 @@ def __init__(self, order: int = 12) -> None: self._orchestrator: CascadeOrchestrator | None = None async def startup(self, app: FastAPI) -> Any: - settings = load_settings() - memory_root = MemoryRoot.default() + memory_root = MemoryRoot.resolve() memory_root.ensure() - embedder = build_embedding_provider(settings.embedding) tokenizer = build_tokenizer() + + capability = get_embedding_capability() + if capability.available: + logger.info("cascade_startup_embed_available") + else: + logger.info( + "cascade_startup_embed_unavailable", + reason="embedding not configured; keyword-only mode", + ) + self._orchestrator = CascadeOrchestrator( memory_root=memory_root, - embedder=embedder, tokenizer=tokenizer, ) await self._orchestrator.start() diff --git a/src/everos/entrypoints/api/lifespans/lancedb.py b/src/everos/entrypoints/api/lifespans/lancedb.py index b2a030bdc..57b0f0f0d 100644 --- a/src/everos/entrypoints/api/lifespans/lancedb.py +++ b/src/everos/entrypoints/api/lifespans/lancedb.py @@ -5,9 +5,22 @@ Importing :mod:`everos.infra.persistence.lancedb` also triggers the side-effect import of ``tables`` so business schemas are loaded (future: preflight registration). + Log hint if unbackfilled (vector IS NULL) rows exist. Shutdown: Close the connection (also clears the table cache). + +Unbackfilled hint: + The informational "you have unbackfilled memory rows" banner runs + an unconditional ``count_rows(filter='vector IS NULL')`` against + every business table on startup. An earlier "marker + limit(1) + probe" amortisation was reverted (round-3 finding #3): the vector + column has no scalar index, so ``limit(1)`` on ``vector IS NULL`` + costs the same full scan as ``count_rows``. On a clean state the + probe scanned the entire empty tail before returning, matching the + cost it was meant to avoid; on a dirty state the probe hit early + and then the full ``count_rows`` ran anyway, doubling the scan. + The marker's ``last_seen_count`` field was written but never read. """ from __future__ import annotations @@ -19,19 +32,60 @@ from everos.core.lifespan import LifespanProvider from everos.core.observability.logging import get_logger from everos.infra.persistence.lancedb import ( + BUSINESS_SCHEMAS_WITH_VECTOR, dispose_connection, ensure_business_indexes, get_connection, + get_table, verify_business_schemas, ) logger = get_logger(__name__) +async def _log_unbackfilled_hint() -> None: + """Warn at startup if there are unbackfilled memory rows. + + Runs an unconditional ``count_rows(filter="vector IS NULL")`` per + business table. The vector column has no scalar index, so + ``count_rows`` and any ``limit(1)`` probe cost the same full scan + — a previous "marker + probe" optimisation (removed here) turned + out to be net-zero on clean state and net-negative on dirty state + (probe hits early, then the full count runs anyway = twice the + scan). + + Per-table failures are logged as warnings and don't interrupt + startup. + """ + total_null = 0 + for schema in BUSINESS_SCHEMAS_WITH_VECTOR: + try: + table = await get_table(schema.TABLE_NAME, schema) + count = await table.count_rows(filter="vector IS NULL") + except Exception as exc: + logger.warning( + "unbackfilled_check_failed", + schema=schema.__name__, + error=repr(exc), + ) + continue + if count > 0: + total_null += count + + if total_null > 0: + banner_logger = get_logger("everos.cli.server") + banner_logger.warning( + "unbackfilled_memory_rows", + count=total_null, + hint="Run `everos cascade backfill` to include them in " + "vector/hybrid search (optional).", + ) + + class LanceDBLifespanProvider(LifespanProvider): """Manage the LanceDB connection + table cache for the app lifecycle. - Startup runs three steps: + Startup runs four steps: 1. ``get_connection`` — lazy-open the async connection. 2. ``verify_business_schemas`` — fail loud if an on-disk table's @@ -39,6 +93,7 @@ class LanceDBLifespanProvider(LifespanProvider): online migration; cascade is rebuildable from md so the recovery is documented as ``rm -rf ~/.everos/.index/lancedb``. 3. ``ensure_business_indexes`` — idempotent FTS index creation. + 4. ``_log_unbackfilled_hint`` — warn if unbackfilled rows exist. """ def __init__(self, order: int = 11) -> None: @@ -48,6 +103,7 @@ async def startup(self, app: FastAPI) -> Any: conn = await get_connection() await verify_business_schemas() await ensure_business_indexes() + await _log_unbackfilled_hint() logger.info("lancedb_ready", uri=conn.uri) return conn diff --git a/src/everos/entrypoints/api/lifespans/parser.py b/src/everos/entrypoints/api/lifespans/parser.py new file mode 100644 index 000000000..1a695cddc --- /dev/null +++ b/src/everos/entrypoints/api/lifespans/parser.py @@ -0,0 +1,60 @@ +"""Parser lifespan provider — warms the optional ``everalgo.parser`` import. + +``everalgo.parser`` is an optional dependency (``everos[multimodal]``). +When installed it pulls in ``pypdf`` / ``python-docx`` / ... on first +import — hundreds of milliseconds to seconds of blocking work. That +cost is fine at startup but unacceptable on the request path, where +:func:`everos.entrypoints.api.routes.health.health` calls +:func:`parser_available` from inside ``async def`` — an event-loop +block there stalls liveness probes and any in-flight requests behind +it. + +This provider resolves :func:`parser_available` once at startup, +priming both Python's ``sys.modules`` cache and the +:func:`functools.lru_cache` wrapping ``parser_available`` itself. If +the extra is not installed, the import fails, the cache stores +``False``, and every subsequent probe is a hot dict lookup. + +Ordered between :class:`LLMLifespanProvider` (``order=8``, hard +Tier-1 requirement) and :class:`SqliteLifespanProvider` (``order=10``) +— the warm is best-effort chassis hygiene that must not delay the +storage stack coming up. +""" + +from __future__ import annotations + +from typing import Any + +from fastapi import FastAPI + +from everos.component.parser import parser_available +from everos.core.lifespan import LifespanProvider +from everos.core.observability.logging import get_logger + +logger = get_logger(__name__) + + +class ParserLifespanProvider(LifespanProvider): + """Warm the ``everalgo.parser`` import once at startup. + + Never fails startup: when the optional extra is not installed, + :func:`parser_available` returns ``False`` and this provider logs + the fact at INFO so operators see a single line rather than the + hidden per-request block that the pre-warm eliminates. + """ + + def __init__(self, order: int = 9) -> None: + # Slot picked to sit between LLM (order=8, hard requirement) and + # sqlite (order=10) — verified against every existing provider's + # `order=` default (see PR #361 review notes on M-c). + super().__init__(name="parser", order=order) + + async def startup(self, app: FastAPI) -> Any: + available = parser_available() + logger.info("parser_lifespan_ready", available=available) + return None + + async def shutdown(self, app: FastAPI) -> None: + # Nothing to tear down — the import is process-scoped and lives + # in `sys.modules` for the process lifetime. + return None diff --git a/src/everos/entrypoints/api/routes/health.py b/src/everos/entrypoints/api/routes/health.py index 6a7eeda22..2d78f27f1 100644 --- a/src/everos/entrypoints/api/routes/health.py +++ b/src/everos/entrypoints/api/routes/health.py @@ -3,11 +3,71 @@ from __future__ import annotations from fastapi import APIRouter +from pydantic import BaseModel + +from everos import __version__ +from everos.component.capabilities import compute_disabled_features +from everos.component.embedding import get_embedding_capability +from everos.component.multimodal import get_multimodal_llm_capability +from everos.component.parser import parser_available +from everos.component.rerank import get_rerank_capability router = APIRouter(tags=["health"]) -@router.get("/health") -async def health() -> dict[str, str]: - """Liveness probe — returns ``{"status": "ok"}`` with HTTP 200.""" - return {"status": "ok"} +class HealthCapabilities(BaseModel): + """Availability flags for the five capability probes. + + Field order matches the health-endpoint payload contract; clients + key off these names to decide whether to expose optional features. + """ + + llm: bool + embed: bool + rerank: bool + multimodal_llm: bool + parser: bool + + +class HealthResponse(BaseModel): + """Response schema for ``GET /health``. + + Declared as a Pydantic model (not ``dict``) so the generated + OpenAPI schema carries the full field shape — ``capabilities`` and + ``disabled_features`` are typed. A bare ``-> dict`` return type + degrades the OpenAPI response to ``additionalProperties: true``, + which robs clients (and codegen) of any structure to lean on. + """ + + status: str + version: str + capabilities: HealthCapabilities + disabled_features: list[str] + + +@router.get("/health", response_model=HealthResponse) +async def health() -> HealthResponse: + """Liveness probe with capabilities and disabled features.""" + # ``llm`` is hardcoded ``True`` — kept for symmetry with the caps + # dict rather than probed live. Rationale: LLM is a Tier-1 hard + # requirement enforced at startup by ``LLMLifespanProvider`` + # (lifespans/llm.py), which eagerly calls ``get_llm_client()`` and + # raises ``LLMNotConfiguredError`` if credentials are missing — + # FastAPI startup then fails, so ``/health`` is unreachable + # without a working LLM. Any code path that reaches this handler + # therefore has ``get_llm_client()`` returning a real client. If + # the LLM capability is ever downgraded to soft (like embed / + # rerank), swap this literal for a real probe. + caps = HealthCapabilities( + llm=True, + embed=get_embedding_capability().available, + rerank=get_rerank_capability().available, + multimodal_llm=get_multimodal_llm_capability().available, + parser=parser_available(), + ) + return HealthResponse( + status="ok", + version=__version__, + capabilities=caps, + disabled_features=compute_disabled_features(caps.model_dump()), + ) diff --git a/src/everos/entrypoints/api/routes/knowledge.py b/src/everos/entrypoints/api/routes/knowledge.py index 58e74f60e..d64d4998c 100644 --- a/src/everos/entrypoints/api/routes/knowledge.py +++ b/src/everos/entrypoints/api/routes/knowledge.py @@ -25,15 +25,18 @@ from everos.service.knowledge import KnowledgeExtractor from everalgo.types import ParsedContent -from fastapi import APIRouter, Path, Query, Request, Response, UploadFile +from fastapi import APIRouter, Depends, Path, Query, Request, Response, UploadFile from fastapi.params import Form from pydantic import BaseModel, Field +from everos.component.embedding import get_embedding_capability from everos.component.llm import get_llm_client +from everos.component.rerank import get_rerank_capability from everos.component.utils.datetime import to_display_tz from everos.config import load_settings from everos.core.errors import ( InvalidInputError, + ProviderNotConfiguredError, UnsupportedModalityError, ) from everos.core.persistence import MemoryRoot @@ -59,6 +62,61 @@ # a shared module would be cleaner but is out of scope for this PR. from .memorize import PathSafeId, SuccessEnvelope +_KNOWLEDGE_FEATURE = "knowledge" + + +def _require_knowledge_capabilities() -> None: + """Per-endpoint gate: knowledge writes/search require embed + rerank. + + **Why the gate lives at the HTTP layer, not at cascade registry**: + cascade handlers register **unconditionally** now (see + ``memory/cascade/registry.py:177-194`` — the earlier atomic-pair + gate that lived there was removed because it broke the delete + path: a Tier-3 → Tier-2/1 downgrade would strand existing knowledge + documents with no handler to process their deletion). Handlers + body-guard the embed/rerank branches internally instead, so the + delete path stays reachable across tier changes. + + Because the cascade layer no longer refuses the write, the HTTP + layer is now the *only* place that enforces "knowledge features + require Tier 3". Without this gate a Tier-1/2 upload would: + + - accept the multipart request → md write succeeds + - enqueue for cascade → cascade handler body-guards on + ``get_embedding_capability().available`` and no-ops the + index write + - client sees 201 but the doc is permanently keyword-only / + unsearchable + + Returning 422 up front is the honest answer. + + **Attached per-endpoint (not router-wide)** so read / list / + delete / metadata-patch routes stay reachable after a Tier-3 → + Tier-2/1 downgrade: users can still inspect and clean up their + existing docs (rename, recategorize, delete) even when the + providers that would embed or rerank new content are no longer + configured. Title / category patches only rewrite md frontmatter + (and move the doc directory when category changes) — no embed or + rerank code runs on that path. + + Checks both capabilities (not just embedding) up front so a client + missing only rerank gets a rerank-specific message rather than + passing this gate and failing later inside search. + """ + if not get_embedding_capability().available: + raise ProviderNotConfiguredError( + provider="embedding", + feature=_KNOWLEDGE_FEATURE, + ) + if not get_rerank_capability().available: + raise ProviderNotConfiguredError( + provider="rerank", + feature=_KNOWLEDGE_FEATURE, + ) + + +# Router prefix is /knowledge; app.py mounts it under both /api/v1 and +# /api/v2 (see create_app — v1 retained as a permanent alias). router = APIRouter(prefix="/knowledge", tags=["knowledge"]) @@ -276,6 +334,61 @@ def _reject_oversized_upload(file: UploadFile) -> None: raise InvalidInputError(f"Uploaded file exceeds the {limit_mib:.1f} MiB limit.") +_PLAIN_TEXT_EXTENSIONS: frozenset[str] = frozenset( + {"md", "txt", "rst", "markdown", "text"} +) +# Explicit mime allowlist. ``text/*`` prefix matching would let +# ``text/html`` (and any future ``text/xml`` etc.) bypass the parser and +# feed raw markup — script tags, HTML comments, style blocks — straight +# into the knowledge-extraction LLM. everalgo's HTML parser runs +# ``clean_html_for_llm`` (strips ``