open-notebook/open_notebook/utils/runtime_capabilities.py
Luis Novo 3b7243d216
Some checks are pending
Development Build / extract-version (push) Waiting to run
Development Build / changes (push) Waiting to run
Tests / Frontend Lint (push) Waiting to run
Tests / Backend Tests (push) Waiting to run
Tests / Backend Lint (push) Waiting to run
Tests / Backend Typecheck (push) Waiting to run
Development Build / build-regular (push) Blocked by required conditions
Development Build / build-single (push) Blocked by required conditions
Development Build / summary (push) Blocked by required conditions
Tests / Frontend Tests (push) Waiting to run
Tests / Frontend Build (push) Waiting to run
fix(sources): fall back to auto when a selected engine's runtime is absent (#1194)
* fix(sources): fall back to auto when a selected engine's runtime is absent

The content-processing engine choice is persisted in the database; the
runtime that serves it (Docling, local Crawl4AI) is installed on demand
from environment flags evaluated at boot. The two therefore drift: a
redeploy that drops OPEN_NOTEBOOK_ENABLE_CRAWL4AI/_DOCLING, a volume
moved to a new deployment, or a failed on-demand install all leave a
stored selection pointing at a runtime that is not there.

The source graph passed that selection straight to content-core, so
every affected extraction failed with "Could not extract any text
content from this source" - no mention of the engine, the runtime, or
the flag that would fix it. For a URL engine set to crawl4ai this breaks
URL ingestion entirely.

The graph now checks runtime availability before honoring the stored
engine and degrades to content-core's "auto" chain, logging a WARNING
that names the engine and the env var that would enable it. Engines with
no opt-in runtime (auto/simple/firecrawl/jina) are passed through
untouched.

The availability probes moved from api/routers/capabilities.py to
open_notebook/utils/runtime_capabilities.py so the graph can use them
without importing from the API layer; the capabilities endpoint keeps
identical behavior and its tests follow the probes to their new home.

Found by the smoke-e2e agent during v1.14.0 release testing, on a dev
environment that was in exactly this state. Pre-existing since v1.13.0
(#1122 made the runtimes opt-in, #432 made the stored selection take
effect), not a v1.14.0 regression.

* docs(changelog): record the unavailable-engine fallback fix
2026-07-20 18:09:00 -03:00

122 lines
4.9 KiB
Python

"""
Runtime availability probes for the opt-in heavy extraction engines.
Docling and local Crawl4AI are installed on demand at container startup (see
scripts/docker-entrypoint.sh and
docs/7-DEVELOPMENT/decisions/ADR-007-optin-runtimes.md), so availability is a
*runtime* property: it cannot be read off the enable flags, and it does not
survive a redeploy that drops them.
This lives in open_notebook.utils (not api/) because both layers need it: the
capabilities router reports it to the frontend, and the source-processing graph
must not hand content-core an engine whose runtime is absent — the engine
choice is persisted in the database and therefore outlives the environment that
made it available.
"""
import importlib.util
import os
import sys
from loguru import logger
def docling_available() -> bool:
"""True when Docling is installed (its document engine, OCR and image sources work)."""
try:
# content-core's own routing gate — the authoritative signal, not just a spec check.
from content_core.extraction import DOCLING_AVAILABLE
return bool(DOCLING_AVAILABLE)
except (ImportError, AttributeError):
# Content-core absent or its API moved — fall back to a plain spec check.
return importlib.util.find_spec("docling") is not None
except Exception:
# An unexpected failure shouldn't be silently masked as "unavailable".
logger.opt(exception=True).warning(
"Unexpected error probing Docling availability; reporting unavailable"
)
return False
def crawl4ai_remote_configured() -> bool:
"""True when a remote Crawl4AI server is configured (CRAWL4AI_API_URL)."""
try:
from content_core.config import get_crawl4ai_api_url
return bool(get_crawl4ai_api_url())
except (ImportError, AttributeError):
return bool(os.environ.get("CRAWL4AI_API_URL"))
except Exception:
logger.opt(exception=True).warning(
"Unexpected error probing Crawl4AI remote config; falling back to env var"
)
return bool(os.environ.get("CRAWL4AI_API_URL"))
def _default_playwright_cache() -> str | None:
"""Playwright's default browser download directory when PLAYWRIGHT_BROWSERS_PATH is unset."""
if sys.platform == "darwin":
return os.path.expanduser("~/Library/Caches/ms-playwright")
if sys.platform == "win32":
local = os.environ.get("LOCALAPPDATA")
return os.path.join(local, "ms-playwright") if local else None
return os.path.expanduser("~/.cache/ms-playwright") # linux and others
def _chromium_browser_present() -> bool:
"""True when a Playwright Chromium browser is installed on disk.
Local Crawl4AI needs both the package AND a Chromium browser. The startup
installer downloads them in separate steps and degrades gracefully, so the
package can be present while the browser download failed — checking the
browser here keeps this an honest "usable capability" signal.
Playwright installs browsers into PLAYWRIGHT_BROWSERS_PATH (Docker) or, when
that's unset, its per-user default cache (dev). Resolving the path does not
download anything, so we must confirm a chromium build actually exists in
whichever directory applies before reporting local Crawl4AI available.
"""
base = os.environ.get("PLAYWRIGHT_BROWSERS_PATH") or _default_playwright_cache()
if not base or not os.path.isdir(base):
return False
try:
return any("chromium" in name for name in os.listdir(base))
except OSError:
return False
def crawl4ai_local_ready() -> bool:
"""True when local Crawl4AI can actually render: package installed + Chromium present."""
if importlib.util.find_spec("crawl4ai") is None:
return False
return _chromium_browser_present()
def crawl4ai_available() -> bool:
"""True when Crawl4AI can run at all — locally installed or offloaded to a server."""
return crawl4ai_local_ready() or crawl4ai_remote_configured()
# Engine name -> (availability probe, env var that enables it). Engines absent
# from this map need no runtime and are always usable.
_ENGINE_RUNTIMES: dict[str, tuple[str, str]] = {
"crawl4ai": ("crawl4ai_available", "OPEN_NOTEBOOK_ENABLE_CRAWL4AI"),
"docling": ("docling_available", "OPEN_NOTEBOOK_ENABLE_DOCLING"),
}
def engine_runtime_missing(engine: str | None) -> str | None:
"""Return the env var that would enable ``engine``, or None if it is usable.
Used to avoid passing content-core an engine whose runtime is absent, which
fails extraction outright with no indication of the real cause.
"""
if not engine:
return None
entry = _ENGINE_RUNTIMES.get(engine.strip().lower())
if entry is None:
return None # Engine needs no opt-in runtime (auto/simple/firecrawl/jina).
probe_name, env_var = entry
probe = globals()[probe_name]
return None if probe() else env_var